Files
MikrotikManager/backend/src/db/migration-schema-inventory.test.ts
T

339 lines
29 KiB
TypeScript

import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { captureMigrationSchema, compareMigrationSchemas, MIGRATION_SCHEMA_QUERIES, MIGRATION_SCHEMA_PARAMETERS, MigrationSchemaInventoryError, type SchemaSection } from "./migration-schema-inventory.js"
type Row = Record<string, unknown>
type Data = Record<SchemaSection, Row[]>
const metadata = { backend_pid: "71", transaction_started: "2026-10-25 10:00:00+00", read_only: "on", isolation: "repeatable read", search_path: "pg_catalog", timezone: "UTC", date_style: "ISO, YMD", standard_strings: "on", server_version: "180000", statement_timeout: "5s" }
const relation = (name: string, kind = "r", extra: Row = {}): Row => ({ namespace: "public", name, kind, persistence: "p", owner: "postgres", acl: null, options: null, access_method: ["r", "p"].includes(kind) ? "heap" : kind === "i" ? "btree" : null, tablespace: null, toast_options: null, of_type: null, row_security: false, force_row_security: false, replica_identity: "d", is_partition: false, populated: true, partition_key: null, partition_bound: null, ...extra })
const column = (relation = "servers", name = "id", extra: Row = {}): Row => ({ namespace: "public", relation, position: 1, name, dropped: false, type: "bigint", type_modifier: -1, dimensions: 0, not_null: true, identity: "a", generated: "", storage: "p", compression: "", statistics_target: null, has_missing: false, missing_value: null, acl: null, options: null, comment: null, collation: null, default_expression: null, is_local: true, inherited_count: 0, ...extra })
function fixture(): Data {
const data = Object.fromEntries(Object.keys(MIGRATION_SCHEMA_QUERIES).map(key => [key, []])) as unknown as Data
data.database = [{ encoding: "UTF8", collate: "C.UTF-8", ctype: "C.UTF-8", locale_provider: "c", locale: null, locale_rules: null, collation_version: null, default_tablespace: "pg_default", owner: "postgres", acl: null }]
data.namespaces = [{ namespace: "public", owner: "pg_database_owner", acl: ["=U/pg_database_owner", "pg_database_owner=UC/pg_database_owner"] }]
data.relations = [relation("servers"), relation("servers_id_seq", "S"), relation("servers_pkey", "i"), relation("server_view", "v"), relation("traffic_samples", "p", { partition_key: "RANGE (sampled_at)" }), relation("traffic_samples_d_20261025", "r", { is_partition: true, partition_bound: "FOR VALUES FROM ('2026-10-25') TO ('2026-10-26')" })]
data.columns = [column(), column("servers", "computed", { position: 2, identity: "", generated: "s", default_expression: "(id + 1)" })]
data.constraints = [{ namespace: "public", name: "servers_pkey", relation: "servers", domain: null, kind: "p", deferrable: false, deferred: false, validated: true, is_local: true, inherited_count: 0, no_inherit: true, enforced: true, period: false, definition: "PRIMARY KEY (id)", parent_namespace: null, parent_relation: null, parent_constraint: null, foreign_namespace: null, foreign_relation: null }]
data.indexes = [{ namespace: "public", name: "servers_pkey", relation: "servers", definition: "CREATE UNIQUE INDEX servers_pkey ON public.servers USING btree (id)", unique_index: true, primary_index: true, exclusion: false, immediate: true, clustered: false, valid: true, ready: true, live: true, replica_identity: false, check_xmin: false, nulls_not_distinct: false, attributes: 1, key_attributes: 1, keys: "1", key_options: "0", operator_classes: ["pg_catalog.int8_ops"], collations: ["<none>"], expressions: null, predicate: null }]
data.inheritance = [{ namespace: "public", child: "traffic_samples_d_20261025", parent_namespace: "public", parent: "traffic_samples", position: 1, detach_pending: false }]
data.sequences = [{ namespace: "public", name: "servers_id_seq", type: "bigint", start: "1", increment: "1", maximum: "9223372036854775807", minimum: "1", cache: "1", cycle: false, owner_namespace: "public", owner_relation: "servers", owner_column: "id", owner_dependency: "i" }]
data.views = [{ namespace: "public", name: "server_view", kind: "v", definition: "SELECT id FROM public.servers;" }]
data.types = [{ namespace: "public", name: "servers", kind: "c", category: "C", owner: "postgres", acl: null, length: -1, by_value: false, alignment: "d", storage: "x", not_null: false, preferred: false, defined: true, delimiter: ",", dimensions: 0, input_function: "record_in(cstring,oid,integer)", output_function: "record_out(record)", receive_function: "record_recv(internal,oid,integer)", send_function: "record_send(record)", modifier_input: "-", modifier_output: "-", analyze_function: "-", subscript_function: "-", base_type: null, element_type: null, relation_namespace: "public", relation: "servers", default_expression: null, default_literal: null, collation: null, enum_labels: [], range_definition: null }]
data.functions = [{ namespace: "public", name: "guard", arguments: "", kind: "f", language: "plpgsql", owner: "postgres", acl: null, definition: "CREATE OR REPLACE FUNCTION public.guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$", external_library: null, security_definer: false, leakproof: false, strict: false, volatility: "v", parallel: "u", cost: "100", rows: "0", support: "-", configuration: null }]
data.triggers = [{ namespace: "public", relation: "servers", name: "guard", internal: false, enabled: "A", type: 19, definition: "CREATE TRIGGER guard BEFORE UPDATE ON public.servers FOR EACH ROW EXECUTE FUNCTION public.guard()", function_namespace: "public", function_name: "guard", function_arguments: "", arguments: "", condition: null, deferrable: false, initially_deferred: false, old_table: null, new_table: null, parent_relation: null, parent_trigger: null, constraint_name: null }]
for (const section of ["database", "namespaces", "relations", "columns", "constraints", "types", "functions", "triggers"] as const) for (const row of data[section]) row.comment = null
for (const row of data.constraints) row.restore_definition = row.kind === "c" ? row.definition : null
return data
}
function client(data: Data, transform: (envelope: Row, section: SchemaSection, index: number) => void = () => {}) {
let count = 0
const calls: string[] = []
return { calls, query: async (sql: string, values?: unknown[]) => {
const section = Object.entries(MIGRATION_SCHEMA_QUERIES).find(([, query]) => query === sql)?.[0] as SchemaSection | undefined
assert(section, "Only exact fixed catalog SQL may execute")
assert.deepEqual(values, ["database", "namespaces"].includes(section) ? [] : [["public"]]); calls.push(section)
const envelope: Row = { ...metadata, objects: structuredClone(data[section]) }
transform(envelope, section, count++); return { rows: [envelope] }
} }
}
const snapshot = (data = fixture()) => captureMigrationSchema(client(data))
let cases = 0
async function test(name: string, work: () => Promise<void> | void) { await work(); cases++; console.log("ok", name) }
const expected = await snapshot()
function constraintTriggerFixture(): Data {
const data = fixture()
data.constraints.push({ ...data.constraints[0], name: "guard", kind: "t", deferrable: true, deferred: true, definition: "TRIGGER DEFERRABLE INITIALLY DEFERRED" })
Object.assign(data.triggers[0], { type: 21, constraint_name: "guard", deferrable: true, initially_deferred: true, definition: "CREATE CONSTRAINT TRIGGER guard AFTER INSERT OR UPDATE ON public.servers DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.guard()" })
return data
}
await test("user constraint triggers require a complete portable matching trigger and preserve definition evidence", async () => {
const before = await snapshot(constraintTriggerFixture())
const data = constraintTriggerFixture(); data.triggers[0].definition += " /* changed */"
const after = await snapshot(data)
assert.throws(() => compareMigrationSchemas(before, after), MigrationSchemaInventoryError)
assert.equal(before.sections.constraints.find(row => row.kind === "t")?.name, "guard")
})
await test("orphan, mismatched, foreign and parented user constraint triggers refuse", async () => {
for (const mutate of [
(data: Data) => { data.triggers = [] },
(data: Data) => { data.triggers[0].name = "other" },
(data: Data) => { data.triggers[0].relation = "other" },
(data: Data) => { data.triggers[0].constraint_name = null },
(data: Data) => { data.triggers[0].deferrable = false },
(data: Data) => { data.triggers[0].initially_deferred = false },
(data: Data) => { data.triggers[0].internal = true },
(data: Data) => { data.triggers[0].function_namespace = "external" },
(data: Data) => { data.constraints[1].domain = "domain" },
(data: Data) => { data.constraints[1].foreign_namespace = "public"; data.constraints[1].foreign_relation = "servers" },
(data: Data) => { data.constraints[1].parent_namespace = "public"; data.constraints[1].parent_relation = "servers"; data.constraints[1].parent_constraint = "parent" },
(data: Data) => { data.triggers[0].parent_relation = "servers"; data.triggers[0].parent_trigger = "parent" },
]) { const data = constraintTriggerFixture(); mutate(data); await assert.rejects(snapshot(data), MigrationSchemaInventoryError) }
})
await test("same canonical actual content compares only as nonauthorizing evidence", async () => {
const data = fixture()
for (const rows of Object.values(data)) rows.reverse()
data.namespaces[0].acl = [...data.namespaces[0].acl as string[]].reverse()
const observed = await snapshot(data)
assert.equal(expected.sha256, observed.sha256)
assert.deepEqual(compareMigrationSchemas(expected, observed), { equal: true, evidenceOnly: true, enrollmentAuthorized: false, sha256: expected.sha256 })
})
await test("fixed queries cover all required object classes without mutable sequence values", () => {
assert.equal(Object.keys(MIGRATION_SCHEMA_QUERIES).length, 13)
for (const sql of Object.values(MIGRATION_SCHEMA_QUERIES)) { assert(sql.includes("WITH captured AS MATERIALIZED")); assert(sql.includes("LIMIT ")); assert(!sql.includes("last_value")); assert(!sql.includes("set_config(")); assert(!sql.includes("nextval(")) }
assert(MIGRATION_SCHEMA_QUERIES.constraints.includes("conenforced")); assert(MIGRATION_SCHEMA_QUERIES.constraints.includes("conperiod"))
assert(MIGRATION_SCHEMA_QUERIES.triggers.includes("CREATE CONSTRAINT TRIGGER <internal>"))
})
await test("each exact catalog query binds only its declared positional parameters", async () => {
assert.deepEqual(Object.keys(MIGRATION_SCHEMA_PARAMETERS).sort(), Object.keys(MIGRATION_SCHEMA_QUERIES).sort())
const data = fixture(), calls = new Set<SchemaSection>()
await captureMigrationSchema({ query: async (sql, values) => {
const section = (Object.keys(MIGRATION_SCHEMA_QUERIES) as SchemaSection[]).find(key => MIGRATION_SCHEMA_QUERIES[key] === sql)
assert(section, "Query identity must be the fixed catalog statement")
const expectedValues = section === "database" || section === "namespaces" ? [] : [["public"]]
const placeholders = [...new Set([...sql.matchAll(/\$(\d+)/g)].map(match => Number(match[1])))].sort((a, b) => a - b)
assert.deepEqual(placeholders, expectedValues.length ? [1] : [], section)
assert.equal(MIGRATION_SCHEMA_PARAMETERS[section], expectedValues.length ? "application-namespaces" : "none", section)
assert.deepEqual(values, expectedValues, section)
calls.add(section)
return { rows: [{ ...metadata, objects: structuredClone(data[section]) }] }
} })
assert.deepEqual([...calls].sort(), Object.keys(MIGRATION_SCHEMA_QUERIES).sort())
})
await test("catalog internal char concatenation operands have explicit text casts", () => {
assert(MIGRATION_SCHEMA_QUERIES.unsupported.includes("||a.defaclobjtype::text FROM pg_catalog.pg_default_acl"))
// Audit every concatenation against internal-char catalog fields used by this
// inventory. Names (nspname/relname/etc.) are PostgreSQL name, not char.
const internalChar = /(?:\b\w+\.(?:defaclobjtype|relkind|relpersistence|relreplident|attidentity|attgenerated|attstorage|attcompression|contype|deptype|typtype|typcategory|typalign|typstorage|typdelim|prokind|provolatile|proparallel|tgenabled|datlocprovider))(?![A-Za-z_0-9])/g
for (const sql of Object.values(MIGRATION_SCHEMA_QUERIES)) for (const match of sql.matchAll(internalChar)) {
const before = sql.slice(0, match.index).trimEnd(), after = sql.slice(match.index! + match[0].length)
if (before.endsWith("||") || /^\s*(?:::text)?\s*\|\|/.test(after)) assert(after.startsWith("::text"), match[0])
}
assert(MIGRATION_SCHEMA_QUERIES.triggers.includes("||t.tgtype::text"))
assert(MIGRATION_SCHEMA_QUERIES.triggers.includes("||pt.tgtype::text"))
})
await test("query failure identifies only its fixed section and preserves original cause", async () => {
const original = Object.assign(new Error("untrusted database detail"), { code: "42725" })
const sql = client(fixture())
await assert.rejects(captureMigrationSchema({ query: async (query, values) => {
if (query === MIGRATION_SCHEMA_QUERIES.unsupported) throw original
return sql.query(query, values)
} }), error => {
assert(error instanceof MigrationSchemaInventoryError)
assert.equal(error.message, "Catalog query failed: unsupported")
assert.equal(error.cause, original)
return true
})
})
await test("PG18 null/default, zero/disabled and positive statistics targets remain distinct", async () => {
const captured: Awaited<ReturnType<typeof snapshot>>[] = []
for (const statisticsTarget of [null, 0, 100]) {
const data = fixture(); data.columns[0].statistics_target = statisticsTarget
const result = await snapshot(data)
assert.equal(result.sections.columns.find(row => row.name === "id")?.statistics_target, statisticsTarget)
assert.equal(compareMigrationSchemas(result, await snapshot(data)).equal, true)
captured.push(result)
}
for (let a = 0; a < captured.length; a++) for (let b = a + 1; b < captured.length; b++) {
assert.notEqual(captured[a].sha256, captured[b].sha256)
assert.throws(() => compareMigrationSchemas(captured[a], captured[b]), MigrationSchemaInventoryError)
}
assert(MIGRATION_SCHEMA_QUERIES.columns.includes("a.attstattarget::int AS statistics_target"))
assert(!MIGRATION_SCHEMA_QUERIES.columns.includes("COALESCE(a.attstattarget"))
})
await test("nullable statistics target does not permit nonintegers or other null integers", async () => {
for (const value of ["100", true, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1]) {
const data = fixture(); data.columns[0].statistics_target = value
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
}
for (const field of ["position", "type_modifier", "dimensions", "inherited_count"]) {
const data = fixture(); data.columns[0][field] = null
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
}
})
await test("pinned runtime default caches retain physical evidence but compare the same logical schema", async () => {
const migration = readFileSync(new URL("../../drizzle/0018_mcp_owner_validation.sql", import.meta.url), "utf8")
assert(migration.includes("ADD COLUMN IF NOT EXISTS owner_valid_until timestamptz NOT NULL DEFAULT now()"))
const captures: Awaited<ReturnType<typeof snapshot>>[] = []
for (const cached of ['{"2026-10-25 10:00:00+00"}', '{"2026-10-25 10:01:00+00"}', null]) {
const data = fixture()
Object.assign(data.columns[0], { type: "timestamp with time zone", identity: "", default_expression: "now()", has_missing: cached !== null, missing_value: cached })
const result = await snapshot(data)
assert.equal(result.sections.columns.find(row => row.name === "id")?.missing_value, cached)
assert.equal(result.observedPhysical.columns.find(row => row.name === "id")?.missing_value, cached)
captures.push(result)
}
for (const captured of captures.slice(1)) {
assert.equal(compareMigrationSchemas(captures[0], captured).equal, true)
assert.equal(captures[0].sha256, captured.sha256)
assert.notEqual(captures[0].observedPhysical.sha256, captured.observedPhysical.sha256)
}
for (const change of [{ default_expression: "clock_timestamp()" }, { type: "timestamp without time zone" }, { not_null: false }, { identity: "a" }]) {
const data = fixture()
Object.assign(data.columns[0], { type: "timestamp with time zone", identity: "", default_expression: "now()", ...change })
const changed = await snapshot(data)
assert.throws(() => compareMigrationSchemas(captures[0], changed), MigrationSchemaInventoryError)
}
})
await test("physical cache shapes and independently tampered physical evidence refuse", async () => {
for (const change of [{ has_missing: true, missing_value: null }, { has_missing: false, missing_value: "{1}" }, { has_missing: true, missing_value: 1 }, { has_missing: true, missing_value: [1] }]) {
const data = fixture(); Object.assign(data.columns[0], change)
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
}
const tampered = structuredClone(expected); tampered.observedPhysical.columns[0].missing_value = "{forged}"
assert.throws(() => compareMigrationSchemas(expected, tampered), MigrationSchemaInventoryError)
const rawTampered = structuredClone(expected)
Object.assign(rawTampered.sections.columns[0], { has_missing: true, missing_value: "{changed}" })
assert.throws(() => compareMigrationSchemas(expected, rawTampered), MigrationSchemaInventoryError)
})
for (const [name, change] of [
["column type", (data: Data) => { data.columns[0].type = "integer" }],
["identity", (data: Data) => { data.columns[0].identity = "d" }],
["generated expression", (data: Data) => { data.columns[1].default_expression = "(id + 2)" }],
["constraint enforcement", (data: Data) => { data.constraints[0].enforced = false }],
["index predicate", (data: Data) => { data.indexes[0].predicate = "(id > 1)" }],
["partition bound", (data: Data) => { data.relations[5].partition_bound = "DEFAULT" }],
["sequence cache", (data: Data) => { data.sequences[0].cache = "10" }],
["view definition", (data: Data) => { data.views[0].definition = "SELECT id+1 FROM public.servers;" }],
["function body", (data: Data) => { data.functions[0].definition = "different stored source" }],
["trigger enabled state", (data: Data) => { data.triggers[0].enabled = "D" }],
["TOAST options", (data: Data) => { data.relations[0].toast_options = ["autovacuum_enabled=false"] }],
] as const) await test("same table names do not hide " + name + " drift", async () => {
const data = fixture(); change(data)
assert.deepEqual(data.relations.map(row => row.name), fixture().relations.map(row => row.name))
const observed = await snapshot(data)
assert.throws(() => compareMigrationSchemas(expected, observed), MigrationSchemaInventoryError)
})
await test("hash/boolean as attestation and altered captured content are refused", () => {
assert.throws(() => compareMigrationSchemas({ sha256: expected.sha256, equal: true } as never, expected), MigrationSchemaInventoryError)
const changed = structuredClone(expected); changed.sections.columns[0].type = "changed"
assert.throws(() => compareMigrationSchemas(expected, changed), MigrationSchemaInventoryError)
})
await test("unsupported object classes and external procedural semantics refuse", async () => {
for (const mutate of [
(data: Data) => { data.unsupported = [{ kind: "publication", identity: "unknown" }] },
(data: Data) => { data.relations[0].kind = "f" },
(data: Data) => { data.functions[0].language = "c" },
(data: Data) => { data.functions[0].kind = "p" },
(data: Data) => { data.types[0].kind = "b" },
(data: Data) => { data.inheritance[0].detach_pending = true },
(data: Data) => { data.triggers[0].function_namespace = "excluded_control" },
(data: Data) => { data.namespaces.push({ namespace: "unknown_schema", owner: "postgres", acl: null }) },
]) { const data = fixture(); mutate(data); await assert.rejects(snapshot(data), MigrationSchemaInventoryError) }
})
await test("unsupported preflight runs before any detailed expression/type deparser", async () => {
const data = fixture(); data.unsupported = [{ kind: "unsafe-type-io", identity: "public.external_type" }]
const sql = client(data)
await assert.rejects(captureMigrationSchema(sql), MigrationSchemaInventoryError)
assert.deepEqual(sql.calls, ["database", "namespaces", "unsupported"])
})
await test("unknown/missing row fields, null PG18 semantics and overflow refuse", async () => {
for (const transform of [
(row: Row) => { row.objects = null },
(row: Row) => { row.objects = Array.from({ length: 20001 }, () => fixture().database[0]) },
(row: Row) => { (row.objects as Row[])[0].unknown = true },
(row: Row) => { delete (row.objects as Row[])[0].encoding },
]) await assert.rejects(captureMigrationSchema(client(fixture(), (row, section) => { if (section === "database") transform(row) })), MigrationSchemaInventoryError)
const data = fixture(); data.constraints[0].enforced = null; await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
})
await test("readonly snapshot and fixed PG18 deparse context are mandatory", async () => {
for (const replacement of [{ read_only: "off" }, { isolation: "read committed" }, { search_path: "public" }, { timezone: "Europe/Moscow" }, { date_style: "ISO, MDY" }, { server_version: "170000" }, { statement_timeout: "0" }, { statement_timeout: "60s" }]) await assert.rejects(captureMigrationSchema(client(fixture(), row => Object.assign(row, replacement))), MigrationSchemaInventoryError)
})
await test("backend/transaction/session changes midcapture refuse", async () => {
for (const replacement of [{ backend_pid: "72" }, { transaction_started: "2026-10-25 10:01:00+00" }, { server_version: "180001" }]) await assert.rejects(captureMigrationSchema(client(fixture(), (row, _section, index) => { if (index > 0) Object.assign(row, replacement) })), MigrationSchemaInventoryError)
})
await test("explicit control exclusion is exact and cannot hide unknown namespaces", async () => {
const data = fixture(); data.namespaces.push({ namespace: "bootstrap_control", owner: "postgres", acl: null, comment: null })
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
const observed = await captureMigrationSchema(client(data), { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["bootstrap_control"] } })
assert.throws(() => compareMigrationSchemas(expected, observed), MigrationSchemaInventoryError)
})
await test("invalid scope and expired deadline dispatch zero SQL", async () => {
for (const options of [{ scope: { applicationNamespaces: ["public;DROP"] } }, { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["public"] } }, { deadlineAt: Date.now() - 1 }]) {
const sql = client(fixture()); await assert.rejects(captureMigrationSchema(sql, options), MigrationSchemaInventoryError); assert.equal(sql.calls.length, 0)
}
})
function withDroppedSlot(data: Data): Data {
data.columns[1].position = 3
data.columns.push(column("servers", "........pg.dropped.2........", { position: 2, dropped: true, type: null, identity: "", not_null: false }))
return data
}
await test("v3 tombstone compaction preserves visible order and exact raw/physical evidence", async () => {
const compact = await snapshot(fixture()), gapped = await snapshot(withDroppedSlot(fixture()))
assert.equal(gapped.format, "pg18-application-schema-v3")
assert.equal(compareMigrationSchemas(gapped, compact).equal, true)
assert.equal(gapped.sections.columns.find(c => c.name === "computed")?.position, 3)
assert(gapped.sections.columns.some(c => c.dropped === true))
assert.notEqual(gapped.observedPhysical.sha256, compact.observedPhysical.sha256)
const legacy = structuredClone(compact) as unknown as { format: string }
legacy.format = "pg18-application-schema-v2"
assert.throws(() => compareMigrationSchemas(legacy as never, legacy as never), /format\/version/)
})
await test("v3 normalizes every indkey key and INCLUDE slot by live column identity while expression0 remains distinct", async () => {
const compact = fixture(), gapped = withDroppedSlot(fixture())
for (const [data, keys] of [[compact, "2 0 1"], [gapped, "3 0 1"]] as const) Object.assign(data.indexes[0], {
keys, attributes: 3, key_attributes: 2, expressions: "(id + 1)", key_options: "0 0",
definition: "CREATE UNIQUE INDEX servers_pkey ON public.servers (computed,((id + 1))) INCLUDE(id)"
})
const before = await snapshot(gapped), after = await snapshot(compact)
assert.equal(compareMigrationSchemas(before, after).equal, true)
assert.equal(before.sections.indexes[0].keys, "3 0 1")
assert(JSON.parse(before.canonical).sections.indexes[0].keys.includes("expression"))
for (const keys of ["1 0 2", "2 0 2", "2 1 1"]) {
const changed = structuredClone(compact); changed.indexes[0].keys = keys
const observed = await snapshot(changed)
assert.throws(() => compareMigrationSchemas(after, observed), MigrationSchemaInventoryError)
}
})
await test("v3 rejects dangling/dropped/system attnums, expression ambiguity, duplicate identity and vector arity", async () => {
for (const keys of ["2", "99", "-1", "0", "1 1"]) {
const data = withDroppedSlot(fixture()); data.indexes[0].keys = keys
await assert.rejects(snapshot(data), MigrationSchemaInventoryError)
}
const duplicatePosition = fixture(); duplicatePosition.columns[1].position = 1
await assert.rejects(snapshot(duplicatePosition), MigrationSchemaInventoryError)
const duplicateName = fixture(); duplicateName.columns[1].name = "id"
await assert.rejects(snapshot(duplicateName), MigrationSchemaInventoryError)
})
await test("v3 visible reorder is refused even when index still names the same logical column", async () => {
const data = fixture(); data.columns[0].position = 2; data.columns[1].position = 1; data.indexes[0].keys = "2"
const observed = await snapshot(data)
assert.throws(() => compareMigrationSchemas(expected, observed), MigrationSchemaInventoryError)
})
await test("v3 drop/readd same middle name cannot hide changed visible order", async () => {
const original = fixture(); original.columns.push(column("servers", "tail", { position: 3, identity: "" }))
const changed = withDroppedSlot(fixture()); changed.columns.find(c => c.name === "computed")!.position = 4
changed.columns.push(column("servers", "tail", { position: 3, identity: "" }))
const before = await snapshot(original), after = await snapshot(changed)
assert.throws(() => compareMigrationSchemas(before, after), MigrationSchemaInventoryError)
})
await test("v3 retains inheritance order and every live type/default/collation/identity/generated/index expression/INCLUDE field", async () => {
for (const mutate of [
(data: Data) => { data.inheritance[0].position = 2 },
(data: Data) => { data.columns[1].type = "integer" },
(data: Data) => { data.columns[1].collation = "pg_catalog.C" },
(data: Data) => { data.columns[1].default_expression = "(id + 9)" },
(data: Data) => { data.columns[1].generated = "v" },
(data: Data) => { data.columns[0].identity = "d" },
(data: Data) => { data.indexes[0].expressions = "(id + 7)" },
(data: Data) => { data.indexes[0].definition += " INCLUDE(computed)" },
]) { const data = fixture(); mutate(data); const observed = await snapshot(data); assert.throws(() => compareMigrationSchemas(expected, observed), MigrationSchemaInventoryError) }
})
await test("v3 PostgreSQL CHECK restore rendering preserves logical equality and strict original raw definition fingerprint", async () => {
const make = (definition: string, restore = "CHECK (id > 0 AND id < 10)") => {
const data = fixture(); data.constraints.push({ ...data.constraints[0], name: "owned_check", kind: "c", no_inherit: false, definition, restore_definition: restore }); return data
}
const before = await snapshot(make("CHECK (((id > 0) AND (id < 10)))")), after = await snapshot(make("CHECK ((id > 0 AND id < 10))"))
assert.equal(compareMigrationSchemas(before, after).equal, true)
assert.notEqual(before.observedRaw.sha256, after.observedRaw.sha256)
assert.equal(before.observedPhysical.sha256, after.observedPhysical.sha256)
const tampered = structuredClone(before); tampered.sections.constraints.find(c => c.name === "owned_check")!.definition = "tampered raw definition"
assert.throws(() => compareMigrationSchemas(before, tampered), /raw catalog/)
for (const restore of ["CHECK (id > 0 OR id < 10)", "CHECK (id >= 0 AND id < 10)", "CHECK (id IS NULL AND id < 10)"]) {
const changed = await snapshot(make(restore, restore)); assert.throws(() => compareMigrationSchemas(before, changed), MigrationSchemaInventoryError)
}
const unvalidated = make("CHECK (id > 0 AND id < 10)"); unvalidated.constraints.find(c => c.name === "owned_check")!.validated = false
const changed = await snapshot(unvalidated); assert.throws(() => compareMigrationSchemas(before, changed), MigrationSchemaInventoryError)
})
console.log(`migration-schema-inventory v3: ${cases} inert raw-catalog capture/comparison scenarios passed; no configured SQL`)