Schema check: validity is the rule; pin the printed predicates

The mutations dropping indisvalid or indisready each survived alone,
because a failed CREATE INDEX CONCURRENTLY clears both. Postgres never
marks an index valid before it is ready (DROP INDEX CONCURRENTLY clears
validity first), so indisready adds nothing and is deleted. A fixture now
holds an index that is ready but not valid, the state an interrupted DROP
INDEX CONCURRENTLY leaves, set directly in pg_index by the admin role.

Comparing the raw declared predicate instead of Postgres's printing of it
survived too: the schema's one predicate splits into the same conditions
either way. A live test now pins printedDeclaredPredicates to the printed
predicate of the applied attempt index, so the printing itself is
checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 21:28:50 +02:00
commit ca2366cecf
2 changed files with 23 additions and 5 deletions

View file

@ -370,7 +370,7 @@ export function samePredicate(declared: string, actual: string, keyColumns: stri
* it with the declared predicate, reads the printed form and rolls back.
* The names and SQL are the schema's own constants, never input.
*/
async function printedDeclaredPredicates(pool: Pick<Pool, "query">): Promise<string[]> {
export async function printedDeclaredPredicates(pool: Pick<Pool, "query">): Promise<string[]> {
const partial = REQUIRED_KEYS.flatMap((required, index) => (required.predicate ? [{ ...required, index }] : []));
if (partial.length === 0) return REQUIRED_KEYS.map(() => "");
const statements = partial.flatMap(({ table, columns, predicate, index }) => {
@ -426,9 +426,10 @@ export class PostgresStore implements HeimdallStore {
* columns. A missing or altered piece fails with code SCHEMA_MISSING and a
* missing privilege with SCHEMA_PRIVILEGES; neither error names the piece.
*
* A key is met by a unique index on its table that is valid and ready (a
* failed CREATE INDEX CONCURRENTLY leaves one that is neither), immediate
* (ON CONFLICT refuses a deferrable arbiter), has exactly the key's columns
* A key is met by a unique index on its table that is valid (a failed
* CREATE INDEX CONCURRENTLY or an interrupted DROP INDEX CONCURRENTLY leaves
* one that is not; Postgres never marks an index valid before it is ready),
* immediate (ON CONFLICT refuses a deferrable arbiter), has exactly the key's columns
* as its key columns (INCLUDE columns aside), compares them under
* deterministic collations (a case-insensitive one would merge two
* providers' user ids into one row), and has the key's predicate. A
@ -473,7 +474,6 @@ export class PostgresStore implements HeimdallStore {
JOIN pg_index i ON i.indrelid = to_regclass(required.table_name)
WHERE i.indisunique
AND i.indisvalid
AND i.indisready
AND i.indimmediate
AND i.indnkeyatts = cardinality(string_to_array(required.columns, ','))
AND (

View file

@ -8,6 +8,7 @@ import {
createPostgresStore,
PostgresStore,
predicateConditions,
printedDeclaredPredicates,
samePredicate,
STORE_TABLE_PRIVILEGES,
} from "../src/store/postgres.js";
@ -516,6 +517,17 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
expect(await start(urlFor(name, role, rolePassword), true)).toBe(refusal("42501"));
});
it("prints each declared predicate exactly as Postgres prints the applied index's", async () => {
const { owner } = await appliedDatabase();
const printed = await printedDeclaredPredicates(owner);
const applied = await owner.query<{ predicate: string }>(
"SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'auth_completions_attempt_unconsumed_unique_idx'::regclass"
);
const attemptKey = REQUIRED_KEYS.findIndex((key) => key.predicate !== "");
expect(printed[attemptKey]).toBe(applied.rows[0]!.predicate);
expect(printed.filter((predicate) => predicate !== "")).toHaveLength(1);
});
it("refuses a table whose primary key was dropped, even after applying the schema", async () => {
const { name, owner } = await appliedDatabase();
await owner.query("ALTER TABLE auth_completions DROP CONSTRAINT auth_completions_pkey");
@ -580,6 +592,12 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
"DROP INDEX auth_completions_attempt_unconsumed_unique_idx",
"INSERT INTO auth_completions (code, attempt_id, app_slug, provider, mode, account_id, session_id, return_to, payload_json, created_at, expires_at) VALUES ('c1', 'att', 'ghostlight', 'discord', 'sign_in', 'acc', 's', 'https://x/', '{}', now(), now() + interval '1 hour'), ('c2', 'att', 'ghostlight', 'discord', 'sign_in', 'acc', 's', 'https://x/', '{}', now(), now() + interval '1 hour')",
"!CREATE UNIQUE INDEX CONCURRENTLY auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL"]],
// An interrupted DROP INDEX CONCURRENTLY leaves an index ready but not
// valid; the fixture sets that state directly (the admin role may).
["a key left only as an index that is ready but not valid",
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
"CREATE UNIQUE INDEX li_half_dropped ON linked_identities(provider, provider_user_id)",
"UPDATE pg_index SET indisvalid = false WHERE indexrelid = 'li_half_dropped'::regclass"]],
["a key compared under a case-insensitive collation (two users' ids become one row)",
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",