Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,22 @@ jobs:
- name: Verify Migration Replay
run: supabase migration up --local

# Database behaviour tests (tests/sql), including the two-user privacy test that signs in
# as two synthetic doctors and proves neither can read or change the other's rows. Each
# file seeds synthetic rows and rolls back, so they share the replayed chain. The bare-image
# equivalent is `npm run test:sql`.
- name: Run database behaviour tests against the replayed chain
run: |
set -euo pipefail
db_container="$(docker ps --format '{{.Names}}' --filter 'name=^supabase_db_' | head -n 1)"
test -n "${db_container}"
for test_file in tests/sql/*.sql; do
echo "::group::${test_file}"
docker exec -i "${db_container}" \
psql -X -U postgres -d postgres -v ON_ERROR_STOP=1 -q -1 < "${test_file}"
echo "::endgroup::"
done

# `drift:manifest` needs the same Docker-capable runner as migration
# replay. Generating it here gives a schema-changing PR an authoritative
# replacement artifact when the offline freshness check fails, rather
Expand Down
1 change: 1 addition & 0 deletions docs/organisation/systems/data-platform.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"version": 1,
"id": "data-platform",
Expand Down Expand Up @@ -51,6 +51,7 @@
"scripts/check-retrieval-owner-migration.ts",
"scripts/check-supabase-project.ts",
"scripts/generate-drift-manifest.ts",
"scripts/test-sql-behaviour.ts",
"scripts/lib/cross-tenant-*",
"scripts/lib/find-owner-id-by-email.ts",
"scripts/lib/operational-alerts.mjs",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"test:e2e:visual:update": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts --update-snapshots",
"test:e2e:style-contract": "node scripts/run-playwright.mjs tests/ui-style-contract.spec.ts --project=chromium",
"test:cross-tenant:staging": "node scripts/run-tsx.mjs scripts/test-cross-tenant-staging.ts",
"test:sql": "node scripts/run-tsx.mjs scripts/test-sql-behaviour.ts",
"worktrees:report": "node scripts/clean-worktree.mjs",
"worktrees:inventory": "node scripts/worktree-inventory.mjs",
"clean:worktree": "npm run worktrees:report",
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-change-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ const dbPatterns = [
"src/lib/supabase",
"docs/database-drift-detection.md",
"docs/supabase-migration-reconciliation.md",
"tests/sql",
/^scripts\/(check-drift|check-chain-mirror-parity|generate-drift-manifest|check-m13-migration|check-retrieval-owner-migration|check-supabase-project|audit-tables|reindex|reindex-health|cleanup-abandoned-reindex-generations)\.ts$/,
/^tests\/(supabase|drift|private-rag|private-access|retrieval-owner).*\.test\.ts$/,
];
Expand Down
226 changes: 226 additions & 0 deletions scripts/test-sql-behaviour.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { execFileSync } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

/**
* test:sql — runs the database behaviour tests in tests/sql/*.sql.
*
* Replays supabase/schema.sql into a disposable, digest-pinned Supabase Postgres container (the
* same bootstrap as `npm run drift:manifest`), then runs each SQL test in its own transaction.
* Every test seeds synthetic rows and rolls back, so they share one replay. Requires Docker and
* the cached image. Never touches the live project. CI runs the same files against the full
* migration chain in the Migration replay job.
*
* Flags:
* --files <a.sql,b.sql> run only these tests (default: every tests/sql/*.sql)
* --keep leave the container running for inspection
* --image <x> override the digest-pinned Postgres image reference
*/

const IMAGE_DEFAULT =
"supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83";
const OWNER_LABEL = "com.psychiatry-tools.sql-behaviour.worktree";
const repoUrl = (relative: string) => fileURLToPath(new URL(`../${relative}`, import.meta.url));
// Same identity scheme as drift:manifest, so concurrent worktrees never touch each other's containers.
const WORKTREE_IDENTITY = createHash("sha256").update(repoUrl("").toLowerCase()).digest("hex").slice(0, 12);

// The bare image ships auth.uid() but not auth.jwt(), which the administrator functions read.
// This is the hosted platform's definition, installed only into the scratch container.
const AUTH_JWT_SHIM = `
do $$ begin
if to_regprocedure('auth.jwt()') is null then
create function auth.jwt() returns jsonb language sql stable as $f$
select coalesce(nullif(current_setting('request.jwt.claim', true), ''),
nullif(current_setting('request.jwt.claims', true), ''))::jsonb
$f$;
grant execute on function auth.jwt() to anon, authenticated;
end if;
end $$;`;

function arg(flag: string): string | undefined {
const index = process.argv.indexOf(flag);
if (index < 0) return undefined;
const value = process.argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
return value;
}

function docker(args: string[], input?: string, environment?: Record<string, string>): string {
return execFileSync("docker", args, {
encoding: "utf8",
env: environment ? { ...process.env, ...environment } : process.env,
input,
maxBuffer: 64 * 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
});
}

function removeOwnedContainer(container: string) {
let labels: Record<string, string> | undefined;
try {
labels = (
JSON.parse(docker(["container", "inspect", container])) as Array<{ Config?: { Labels?: Record<string, string> } }>
)[0]?.Config?.Labels;
} catch {
return;
}
if (labels?.[OWNER_LABEL] !== WORKTREE_IDENTITY) {
throw new Error(`Refusing to remove Docker container ${container}: it is not owned by this worktree.`);
}
docker(["rm", "-f", container]);
}

function selectedTests(): string[] {
const requested = arg("--files");
if (requested)
return requested
.split(",")
.map((file) => file.trim())
.filter(Boolean);
return readdirSync(repoUrl("tests/sql"))
.filter((name) => name.endsWith(".sql"))
.sort()
.map((name) => `tests/sql/${name}`);
}

async function main() {
const image = arg("--image") ?? IMAGE_DEFAULT;
if (!/@sha256:[a-f0-9]{64}$/i.test(image)) throw new Error("--image must use an immutable @sha256 digest reference");
const keep = process.argv.includes("--keep");
const container = `clinical-kb-sqltest-${WORKTREE_IDENTITY}-${process.pid}`;
const tests = selectedTests();
if (tests.length === 0) throw new Error("No SQL behaviour tests found in tests/sql.");

try {
docker(["info", "--format", "{{.ServerVersion}}"]);
} catch {
throw new Error(
"Docker is required for test:sql (it replays schema.sql into a scratch container). Start Docker and retry.",
);
}
try {
docker(["image", "inspect", image]);
} catch {
throw new Error(
`Required image is not cached: ${image}. Pull it explicitly after approving registry access, then retry.`,
);
}

const query = (user: string, sql: string) =>
docker([
"exec",
"-i",
container,
"psql",
"-U",
user,
"-d",
"postgres",
"-tA",
"-v",
"ON_ERROR_STOP=1",
"-c",
sql,
]).trim();
const psql = (user: string, sql: string, singleTransaction = false) =>
docker(
[
"exec",
"-i",
container,
"psql",
"-X",
"-U",
user,
"-d",
"postgres",
"-v",
"ON_ERROR_STOP=1",
"-q",
...(singleTransaction ? ["-1"] : []),
"-f",
"-",
],
sql,
);

const failures: string[] = [];
try {
console.log(`Starting scratch container ${container} (${image})…`);
docker(
[
"run",
"-d",
"--pull=never",
"--name",
container,
"--label",
`${OWNER_LABEL}=${WORKTREE_IDENTITY}`,
"-e",
"POSTGRES_PASSWORD",
image,
],
undefined,
{ POSTGRES_PASSWORD: randomBytes(16).toString("hex") },
);
// The image restarts Postgres once during init; require sustained readiness.
let ready = 0;
for (let attempt = 0; attempt < 60 && ready < 5; attempt += 1) {
try {
docker(["exec", container, "pg_isready", "-U", "postgres", "-q"]);
ready += 1;
} catch {
ready = 0;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (ready < 5) throw new Error("scratch Postgres did not remain ready after initialization");

psql("postgres", readFileSync(repoUrl("supabase/roles.sql"), "utf8"));
const storageOwner = query(
"postgres",
"select pg_catalog.pg_get_userbyid(nspowner) from pg_catalog.pg_namespace where nspname = 'storage';",
);
const authOwner = query(
"postgres",
"select pg_catalog.pg_get_userbyid(nspowner) from pg_catalog.pg_namespace where nspname = 'auth';",
);
if (!storageOwner || !authOwner)
throw new Error("bare Supabase image does not expose owners for the storage and auth schemas");
psql(storageOwner, readFileSync(repoUrl("scripts/sql/drift-replay-scaffold.sql"), "utf8"));
console.log("Replaying supabase/schema.sql…");
psql("postgres", readFileSync(repoUrl("supabase/schema.sql"), "utf8"));
psql(authOwner, AUTH_JWT_SHIM);

for (const test of tests) {
try {
const output = psql("postgres", readFileSync(repoUrl(test), "utf8"), true);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
const passLine =
output
.split("\n")
.map((line) => line.trim())
// Skip psql's table furniture so the test's own result line is what gets reported.
.filter((line) => line && !/^\(\d+ rows?\)$/.test(line) && !/^[-+]+$/.test(line))
.at(-1) ?? "";
console.log(`PASS ${test} ${passLine}`);
} catch (error) {
const stderr = (error as { stderr?: string }).stderr ?? String(error);
const reason = stderr.split("\n").find((line) => line.includes("ERROR")) ?? stderr.trim();
console.error(`FAIL ${test} ${reason}`);
failures.push(test);
}
}
} finally {
if (keep) console.log(`Container ${container} kept running (--keep).`);
else removeOwnedContainer(container);
}

if (failures.length > 0) throw new Error(`${failures.length} of ${tests.length} SQL behaviour test(s) failed.`);
console.log(`All ${tests.length} SQL behaviour tests passed.`);
}

main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
18 changes: 14 additions & 4 deletions tests/sql/enrichment-repair.sql
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,28 @@ insert into public.document_index_quality(document_id, quality_score, extraction
select id, 1, 'good' from cases where n in (4, 6, 7, 8);

insert into public.indexing_v3_agent_jobs(document_id, status, enrichment_status, attempt_count, max_attempts,
locked_by, locked_at, created_at, updated_at)
locked_by, locked_at, created_at, updated_at, lease_token, lease_expected_generation_id,
lease_write_generation_id, lease_authority_digest, lease_expires_at, lease_duration_seconds)
select id, case n when 1 then 'needs_enrichment_artifacts' when 2 then 'failed' when 7 then 'processing' else 'pending' end,
case n when 1 then 'needs_enrichment_artifacts' when 2 then 'failed' when 7 then 'processing' else 'pending' end,
case when n in (1, 2, 3) then 3 else 1 end, 3,
case when n = 7 then 'synthetic-worker' end, case when n = 7 then now() end,
now() - interval '2 hours', now() - interval '2 hours'
now() - interval '2 hours', now() - interval '2 hours',
-- A processing row must carry the full lease tuple (indexing_v3_agent_jobs_lease_tuple).
case when n = 7 then gen_random_uuid() end, case when n = 7 then gen_random_uuid() end,
case when n = 7 then gen_random_uuid() end, case when n = 7 then 'sha256:' || repeat('0', 64) end,
case when n = 7 then now() + interval '1 hour' end, case when n = 7 then 3600 end
from cases where n in (1, 2, 3, 6, 7);
insert into public.ingestion_jobs(document_id, status, stage, next_run_at, created_at, locked_at, locked_by)
insert into public.ingestion_jobs(document_id, status, stage, next_run_at, created_at, locked_at, locked_by,
lease_token, lease_write_generation_id, lease_authority_digest, lease_expires_at, lease_duration_seconds)
select id, case when n = 8 then 'processing' else 'pending' end, 'queued',
case when n = 5 then now() + interval '1 day' else now() end,
now() - interval '2 hours', case when n = 8 then now() - interval '2 hours' end,
case when n = 8 then 'expired-worker' end
case when n = 8 then 'expired-worker' end,
-- Full lease tuple (ingestion_jobs_lease_tuple), expired an hour ago.
case when n = 8 then gen_random_uuid() end, case when n = 8 then gen_random_uuid() end,
case when n = 8 then 'sha256:' || repeat('0', 64) end,
case when n = 8 then now() - interval '1 hour' end, case when n = 8 then 3600 end
from cases where n in (4, 5, 8);
create temp table before_documents as select d.* from public.documents d join cases c on c.id = d.id where n in (4, 5, 6, 7);
create temp table before_jobs as select j.* from public.ingestion_jobs j join cases c on c.id = j.document_id where n in (4, 5);
Expand Down
5 changes: 5 additions & 0 deletions tests/sql/roster-behaviour.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
-- Behaviour checks for the combined Roster DB PR, run against a disposable replay of files 1-5.
-- Every block raises on a wrong answer, so a clean run prints only the final line.
-- Run: psql -v ON_ERROR_STOP=1 -1 -f tests/sql/roster-behaviour.sql (never against a live database)
begin;
create temp table ids (k text primary key, v uuid) on commit drop;
grant all on ids to service_role;

Expand Down Expand Up @@ -543,3 +544,7 @@ $delete_account$;
set local role service_role;

select 'roster behaviour: all checks passed' as result;

-- The local runner and CI use psql -1, but the explicit begin and rollback mean a direct psql -f run
-- (without -1) also leaves no synthetic fixtures behind.
rollback;
Loading
Loading