Schema check: deterministic column collations, a named TEMP refusal, and kills for the printer

Soul pass 7, S7-3 and S7-4, P7-3 and P7-5.

Columns (S7-3): every declared column must be under a deterministic
collation. A case-insensitive provider_user_id whose key index compares
under "C" passed, and the store's own lookups then matched other users'
ids.

The check becomes checkSchemaRequirements(pool, requirements), with
Heimdall's store calling it with HEIMDALL_SCHEMA_REQUIREMENTS (its tables,
columns, keys and privileges). That seam lets a live test check a
predicate Postgres prints differently from how it is written (a literal
gains its cast): the check must still pass, so comparing the raw text
instead of the printed form (P7-3's Y1) now fails.

A role that may not create temporary tables was refused with 42501 and
told to check GC_ACCESS_DATABASE_URL_FILE (P7-5). The predicate printer now
refuses it as SCHEMA_TEMP_PRIVILEGE, and startup says to grant the role
TEMPORARY on its database, in fixed text.

New fixtures (S7-4): a case-insensitive collation on a key's first column
(Y3), NOT NULL added to a nullable column of linked_identities (Y5), and
every privilege on auth_completions except on attempt_id (Y8) are refused;
a key index that INCLUDEs an undeclared case-insensitive column starts
(Y4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 22:52:41 +02:00
commit 996f21f58c
3 changed files with 240 additions and 138 deletions

View file

@ -32,9 +32,13 @@ export async function createStore(config: HeimdallConfig): Promise<HeimdallStore
// A refused store is never handed out, so it is closed here: its pool
// would otherwise hold the connection open.
await store.close().catch(() => undefined);
throw new Error(
`Postgres storage could not be prepared (${typeof code === "string" ? code : "error"}); check GC_ACCESS_DATABASE_URL_FILE.`
);
// A role that may not create temporary tables is not a bad URL, so that
// refusal names the privilege instead.
const remedy =
code === "SCHEMA_TEMP_PRIVILEGE"
? "grant the database role TEMPORARY on its database"
: "check GC_ACCESS_DATABASE_URL_FILE";
throw new Error(`Postgres storage could not be prepared (${typeof code === "string" ? code : "error"}); ${remedy}.`);
}
return store;

View file

@ -363,36 +363,6 @@ export function samePredicate(declared: string, actual: string, keyColumns: stri
return left.length === right.length && left.every((condition, index) => condition === right[index]);
}
/**
* Each REQUIRED_KEYS predicate as Postgres prints it back (empty for none).
* One simple-protocol round trip on one connection: a transaction that
* builds a temporary copy of each keyed table's declared columns, indexes
* it with the declared predicate, reads the printed form and rolls back.
* The names and SQL are the schema's own constants, never input.
*/
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 }) => {
const probe = `heimdall_key_probe_${index}`;
const declared = REQUIRED_COLUMNS.filter((column) => column.table === table)
.map((column) => `${column.column} ${column.type}`)
.join(", ");
return [
`CREATE TEMP TABLE ${probe} (${declared}) ON COMMIT DROP`,
`CREATE UNIQUE INDEX ${probe}_idx ON ${probe} (${columns}) WHERE ${predicate}`,
];
});
const select = partial
.map(({ index }) => `SELECT ${index} AS key, pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'pg_temp.heimdall_key_probe_${index}_idx'::regclass`)
.join(" UNION ALL ");
const results = (await pool.query(["BEGIN", ...statements, select, "ROLLBACK"].join(";\n"))) as unknown as Array<{
rows: Array<{ key: number; predicate: string }>;
}>;
const printed = results[results.length - 2]!.rows;
return REQUIRED_KEYS.map((_, index) => printed.find((row) => row.key === index)?.predicate ?? "");
}
/**
* What the store's statements do to each table, and so the privileges its
* database role needs: it reads what it returns or looks up, inserts what it
@ -411,6 +381,170 @@ export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT"
audit_events: ["INSERT"],
};
/** What a database must hold for a store to serve it; see checkSchemaRequirements. */
export interface SchemaRequirements {
tables: readonly string[];
columns: readonly { table: string; column: string; type: string; nullable: boolean }[];
keys: readonly { table: string; columns: string; predicate: string }[];
privileges: Readonly<Record<string, readonly string[]>>;
}
/** What Heimdall's store needs, read from its schema and its statements. */
export const HEIMDALL_SCHEMA_REQUIREMENTS: SchemaRequirements = {
tables: REQUIRED_TABLES,
columns: REQUIRED_COLUMNS,
keys: REQUIRED_KEYS,
privileges: STORE_TABLE_PRIVILEGES,
};
/**
* Each required key's predicate as Postgres prints it back (empty for none).
* One simple-protocol round trip on one connection: a transaction that builds
* a temporary copy of each keyed table's declared columns, indexes it with the
* declared predicate, reads the printed form and rolls back. The names and SQL
* come from the requirements, which are constants, never input. A role that
* may not create temporary tables fails with code SCHEMA_TEMP_PRIVILEGE.
*/
export async function printedDeclaredPredicates(pool: Pick<Pool, "query">, requirements: SchemaRequirements): Promise<string[]> {
const partial = requirements.keys.flatMap((required, index) => (required.predicate ? [{ ...required, index }] : []));
if (partial.length === 0) return requirements.keys.map(() => "");
const statements = partial.flatMap(({ table, columns, predicate, index }) => {
const probe = `heimdall_key_probe_${index}`;
const declared = requirements.columns
.filter((column) => column.table === table)
.map((column) => `${column.column} ${column.type}`)
.join(", ");
return [
`CREATE TEMP TABLE ${probe} (${declared}) ON COMMIT DROP`,
`CREATE UNIQUE INDEX ${probe}_idx ON ${probe} (${columns}) WHERE ${predicate}`,
];
});
const select = partial
.map(({ index }) => `SELECT ${index} AS key, pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'pg_temp.heimdall_key_probe_${index}_idx'::regclass`)
.join(" UNION ALL ");
let results: Array<{ rows: Array<{ key: number; predicate: string }> }>;
try {
results = (await pool.query(["BEGIN", ...statements, select, "ROLLBACK"].join(";\n"))) as unknown as typeof results;
} catch (error) {
if ((error as { code?: unknown }).code === "42501") {
throw Object.assign(new Error("The Heimdall database role may not create temporary tables."), { code: "SCHEMA_TEMP_PRIVILEGE" });
}
throw error;
}
const printed = results[results.length - 2]!.rows;
return requirements.keys.map((_, index) => printed.find((row) => row.key === index)?.predicate ?? "");
}
/**
* Proves that the database can take a store's requests: every required
* table; every column with its declared type, under a deterministic
* collation, and nullable where declared nullable; every unique key; and
* every privilege, held on the table or on each of its 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 (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
* predicate matches when both, as Postgres prints them, are the same set of
* AND-ed conditions; a `key_column IS NOT NULL` condition may be absent on
* either side, because a unique index never makes NULL keys collide unless
* it is NULLS NOT DISTINCT. Postgres prints the declared predicates itself
* (printedDeclaredPredicates).
*
* What this does not prove is listed where the schema is: see schema.ts.
*/
export async function checkSchemaRequirements(pool: Pick<Pool, "query">, requirements: SchemaRequirements): Promise<void> {
const declaredPredicates = await printedDeclaredPredicates(pool, requirements);
const privileges = Object.entries(requirements.privileges).flatMap(([table, wanted]) =>
wanted.map((privilege) => ({ table, privilege }))
);
const result = await pool.query<{
kind: "missing" | "privilege" | "candidate";
key: number | null;
predicate: string | null;
nulls_not_distinct: boolean | null;
}>(
`
SELECT 'missing' AS kind, NULL::int AS key, NULL::text AS predicate, NULL::boolean AS nulls_not_distinct
FROM unnest($1::text[]) AS name WHERE to_regclass(name) IS NULL
UNION ALL
SELECT 'missing', NULL, NULL, NULL
FROM unnest($2::text[], $3::text[], $4::text[], $5::boolean[]) AS required(table_name, column_name, type_name, nullable)
WHERE NOT EXISTS (
SELECT 1 FROM pg_attribute a
WHERE a.attrelid = to_regclass(required.table_name)
AND a.attname = required.column_name
AND a.attnum > 0
AND NOT a.attisdropped
AND a.atttypid = to_regtype(required.type_name)
AND (NOT required.nullable OR NOT a.attnotnull)
AND NOT EXISTS (SELECT 1 FROM pg_collation c WHERE c.oid = a.attcollation AND NOT c.collisdeterministic)
)
UNION ALL
SELECT 'candidate', required.key::int, pg_get_expr(i.indpred, i.indrelid),
coalesce((to_jsonb(i) ->> 'indnullsnotdistinct')::boolean, false)
FROM unnest($6::text[], $7::text[]) WITH ORDINALITY AS required(table_name, columns, key)
JOIN pg_index i ON i.indrelid = to_regclass(required.table_name)
WHERE i.indisunique
AND i.indisvalid
AND i.indimmediate
AND i.indnkeyatts = cardinality(string_to_array(required.columns, ','))
AND (
SELECT string_agg(a.attname, ',' ORDER BY a.attname)
FROM unnest(i.indkey::int2[]) WITH ORDINALITY AS key(attnum, position)
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = key.attnum
WHERE key.position <= i.indnkeyatts
) = required.columns
AND NOT EXISTS (
SELECT 1 FROM unnest(i.indcollation::oid[]) WITH ORDINALITY AS key_collation(oid, position)
JOIN pg_collation c ON c.oid = key_collation.oid
WHERE key_collation.position <= i.indnkeyatts AND NOT c.collisdeterministic
)
UNION ALL
SELECT 'privilege', NULL, NULL, NULL
FROM unnest($8::text[], $9::text[]) AS required(table_name, privilege)
WHERE EXISTS (
SELECT 1 FROM unnest($2::text[], $3::text[]) AS used(table_name, column_name)
JOIN pg_attribute a ON a.attrelid = to_regclass(used.table_name) AND a.attname = used.column_name AND NOT a.attisdropped
WHERE used.table_name = required.table_name
AND NOT has_column_privilege(a.attrelid, a.attnum, required.privilege)
)
`,
[
requirements.tables,
requirements.columns.map((required) => required.table),
requirements.columns.map((required) => required.column),
requirements.columns.map((required) => required.type),
requirements.columns.map((required) => required.nullable),
requirements.keys.map((required) => required.table),
requirements.keys.map((required) => required.columns),
privileges.map((required) => required.table),
privileges.map((required) => required.privilege),
]
);
const keyMet = requirements.keys.map((required, index) =>
result.rows.some(
(row) =>
row.kind === "candidate" &&
row.key === index + 1 &&
samePredicate(declaredPredicates[index]!, row.predicate ?? "", required.columns.split(","), row.nulls_not_distinct === true)
)
);
if (result.rows.some((row) => row.kind === "missing") || keyMet.includes(false)) {
throw Object.assign(new Error("The Heimdall schema is not applied."), { code: "SCHEMA_MISSING" });
}
if (result.rows.some((row) => row.kind === "privilege")) {
throw Object.assign(new Error("The Heimdall database role lacks a privilege the store needs."), {
code: "SCHEMA_PRIVILEGES",
});
}
}
export class PostgresStore implements HeimdallStore {
constructor(private readonly pool: Pick<Pool, "query" | "end">) {}
@ -418,113 +552,9 @@ export class PostgresStore implements HeimdallStore {
await this.pool.query(CREATE_SCHEMA_SQL);
}
/**
* Proves, before Heimdall serves, that the database can take its requests:
* every table the schema makes; every column with its declared type, and
* nullable where the schema leaves it nullable; every unique key; and every
* privilege in STORE_TABLE_PRIVILEGES, held on the table or on each of its
* 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 (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
* predicate matches when both, as Postgres prints them, are the same set of
* AND-ed conditions; a `key_column IS NOT NULL` condition may be absent on
* either side, because a unique index never makes NULL keys collide unless
* it is NULLS NOT DISTINCT.
*
* Postgres prints the declared predicates itself: they are indexed on a
* temporary copy of the table in a transaction that is rolled back.
*/
/** Proves, before Heimdall serves, that the database can take its requests (checkSchemaRequirements). */
async checkSchema(): Promise<void> {
const declaredPredicates = await printedDeclaredPredicates(this.pool);
const privileges = Object.entries(STORE_TABLE_PRIVILEGES).flatMap(([table, wanted]) =>
wanted.map((privilege) => ({ table, privilege }))
);
const result = await this.pool.query<{
kind: "missing" | "privilege" | "candidate";
key: number | null;
predicate: string | null;
nulls_not_distinct: boolean | null;
}>(
`
SELECT 'missing' AS kind, NULL::int AS key, NULL::text AS predicate, NULL::boolean AS nulls_not_distinct
FROM unnest($1::text[]) AS name WHERE to_regclass(name) IS NULL
UNION ALL
SELECT 'missing', NULL, NULL, NULL
FROM unnest($2::text[], $3::text[], $4::text[], $5::boolean[]) AS required(table_name, column_name, type_name, nullable)
WHERE NOT EXISTS (
SELECT 1 FROM pg_attribute a
WHERE a.attrelid = to_regclass(required.table_name)
AND a.attname = required.column_name
AND a.attnum > 0
AND NOT a.attisdropped
AND a.atttypid = to_regtype(required.type_name)
AND (NOT required.nullable OR NOT a.attnotnull)
)
UNION ALL
SELECT 'candidate', required.key::int, pg_get_expr(i.indpred, i.indrelid),
coalesce((to_jsonb(i) ->> 'indnullsnotdistinct')::boolean, false)
FROM unnest($6::text[], $7::text[]) WITH ORDINALITY AS required(table_name, columns, key)
JOIN pg_index i ON i.indrelid = to_regclass(required.table_name)
WHERE i.indisunique
AND i.indisvalid
AND i.indimmediate
AND i.indnkeyatts = cardinality(string_to_array(required.columns, ','))
AND (
SELECT string_agg(a.attname, ',' ORDER BY a.attname)
FROM unnest(i.indkey::int2[]) WITH ORDINALITY AS key(attnum, position)
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = key.attnum
WHERE key.position <= i.indnkeyatts
) = required.columns
AND NOT EXISTS (
SELECT 1 FROM unnest(i.indcollation::oid[]) WITH ORDINALITY AS key_collation(oid, position)
JOIN pg_collation c ON c.oid = key_collation.oid
WHERE key_collation.position <= i.indnkeyatts AND NOT c.collisdeterministic
)
UNION ALL
SELECT 'privilege', NULL, NULL, NULL
FROM unnest($8::text[], $9::text[]) AS required(table_name, privilege)
WHERE EXISTS (
SELECT 1 FROM unnest($2::text[], $3::text[]) AS used(table_name, column_name)
JOIN pg_attribute a ON a.attrelid = to_regclass(used.table_name) AND a.attname = used.column_name AND NOT a.attisdropped
WHERE used.table_name = required.table_name
AND NOT has_column_privilege(a.attrelid, a.attnum, required.privilege)
)
`,
[
REQUIRED_TABLES,
REQUIRED_COLUMNS.map((required) => required.table),
REQUIRED_COLUMNS.map((required) => required.column),
REQUIRED_COLUMNS.map((required) => required.type),
REQUIRED_COLUMNS.map((required) => required.nullable),
REQUIRED_KEYS.map((required) => required.table),
REQUIRED_KEYS.map((required) => required.columns),
privileges.map((required) => required.table),
privileges.map((required) => required.privilege),
]
);
const keyMet = REQUIRED_KEYS.map((required, index) =>
result.rows.some(
(row) =>
row.kind === "candidate" &&
row.key === index + 1 &&
samePredicate(declaredPredicates[index]!, row.predicate ?? "", required.columns.split(","), row.nulls_not_distinct === true)
)
);
if (result.rows.some((row) => row.kind === "missing") || keyMet.includes(false)) {
throw Object.assign(new Error("The Heimdall schema is not applied."), { code: "SCHEMA_MISSING" });
}
if (result.rows.some((row) => row.kind === "privilege")) {
throw Object.assign(new Error("The Heimdall database role lacks a privilege the store needs."), {
code: "SCHEMA_PRIVILEGES",
});
}
await checkSchemaRequirements(this.pool, HEIMDALL_SCHEMA_REQUIREMENTS);
}
async close(): Promise<void> {

View file

@ -7,6 +7,8 @@ import { createStore } from "../src/store/index.js";
import {
createPostgresStore,
PostgresStore,
checkSchemaRequirements,
HEIMDALL_SCHEMA_REQUIREMENTS,
predicateConditions,
printedDeclaredPredicates,
samePredicate,
@ -519,7 +521,7 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
it("prints each declared predicate exactly as Postgres prints the applied index's", async () => {
const { owner } = await appliedDatabase();
const printed = await printedDeclaredPredicates(owner);
const printed = await printedDeclaredPredicates(owner, HEIMDALL_SCHEMA_REQUIREMENTS);
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"
);
@ -552,6 +554,51 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
await exerciseStore(new PostgresStore(pool));
});
it("refuses a privilege missing on one column of another table", async () => {
const { name, owner } = await appliedDatabase();
await grant(owner, { ...STORE_TABLE_PRIVILEGES, auth_completions: [] });
const columns = REQUIRED_COLUMNS.filter((column) => column.table === "auth_completions" && column.column !== "attempt_id")
.map((column) => column.column)
.join(", ");
for (const privilege of STORE_TABLE_PRIVILEGES.auth_completions!) {
await owner.query(`GRANT ${privilege} (${columns}) ON auth_completions TO ${role}`);
}
expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES"));
});
it("names the TEMPORARY privilege when the role may not create temporary tables", async () => {
const { name, owner } = await appliedDatabase();
await grant(owner, STORE_TABLE_PRIVILEGES);
await owner.query(`REVOKE TEMPORARY ON DATABASE ${name} FROM PUBLIC`);
expect(await start(urlFor(name, role, rolePassword), false)).toBe(
"Postgres storage could not be prepared (SCHEMA_TEMP_PRIVILEGE); grant the database role TEMPORARY on its database."
);
});
// The schema's own predicate splits into the same conditions raw or
// printed, so only a predicate whose printed form differs (a literal gains
// its cast) shows that the check compares Postgres's printing.
it("matches a declared predicate that Postgres prints differently from how it is written", async () => {
const { owner } = await appliedDatabase();
await owner.query("CREATE TABLE predicate_probe (id INTEGER NOT NULL, state TEXT, attempts INTEGER)");
await owner.query("CREATE UNIQUE INDEX predicate_probe_live ON predicate_probe (id) WHERE state = 'live' AND attempts > 0");
const requirements = {
tables: ["predicate_probe"],
columns: [
{ table: "predicate_probe", column: "id", type: "INTEGER", nullable: false },
{ table: "predicate_probe", column: "state", type: "TEXT", nullable: true },
{ table: "predicate_probe", column: "attempts", type: "INTEGER", nullable: true },
],
keys: [{ table: "predicate_probe", columns: "id", predicate: "state = 'live' AND attempts > 0" }],
privileges: {},
};
expect((await printedDeclaredPredicates(owner, requirements))[0]).toBe("((state = 'live'::text) AND (attempts > 0))");
await expect(checkSchemaRequirements(owner, requirements)).resolves.toBeUndefined();
});
it("refuses a privilege held on only some of a table's columns", async () => {
const { name, owner } = await appliedDatabase();
await grant(owner, { ...STORE_TABLE_PRIVILEGES, audit_events: [] });
@ -598,6 +645,18 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
["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's first column compared under a case-insensitive collation",
["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",
"CREATE UNIQUE INDEX li_ci_first ON linked_identities(provider COLLATE heimdall_ci, provider_user_id)"]],
// Soul pass 7: the key index compares under "C", but the column itself is
// case-insensitive, so the store's own lookups by provider_user_id match
// other users' ids.
["a column declared under a case-insensitive collation, its key index under C",
["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",
"ALTER TABLE linked_identities ALTER COLUMN provider_user_id TYPE text COLLATE heimdall_ci",
'CREATE UNIQUE INDEX li_c ON linked_identities(provider, provider_user_id COLLATE "C")']],
["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",
@ -617,6 +676,8 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
["a column retyped", ["ALTER TABLE sessions ALTER COLUMN access_revision TYPE TEXT"]],
["a created table's column dropped", ["ALTER TABLE auth_completions DROP COLUMN mode"]],
["NOT NULL added to a column the store leaves null", ["ALTER TABLE auth_completions ALTER COLUMN attempt_id SET NOT NULL"]],
["NOT NULL added to a nullable column of another table",
["UPDATE linked_identities SET username = ''", "ALTER TABLE linked_identities ALTER COLUMN username SET NOT NULL"]],
])("refuses %s", async (_damage, steps) => {
const { name, owner } = await appliedDatabase();
for (const step of steps) {
@ -639,6 +700,13 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
["the attempt index without its redundant attempt_id IS NOT NULL",
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX attempt_unconsumed ON auth_completions(app_slug, attempt_id) WHERE consumed_at IS NULL"]],
["a lookup index dropped", ["DROP INDEX audit_events_lookup_idx", "DROP INDEX auth_completions_lookup_idx"]],
// Only a key's own columns decide which rows collide; an INCLUDEd column's
// collation does not, whatever it is.
["a key index that INCLUDEs an undeclared case-insensitive column",
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
"ALTER TABLE linked_identities ADD COLUMN operator_note text COLLATE heimdall_ci",
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
"CREATE UNIQUE INDEX li_note ON linked_identities(provider, provider_user_id) INCLUDE (operator_note)"]],
])("accepts %s", async (_change, steps) => {
const { name, owner } = await appliedDatabase();
for (const step of steps) await owner.query(step);