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:
parent
97632b7cfa
commit
ca2366cecf
2 changed files with 23 additions and 5 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue