diff --git a/e2e/principal-delete.test.ts b/e2e/principal-delete.test.ts new file mode 100644 index 0000000..48af83c --- /dev/null +++ b/e2e/principal-delete.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; + +import type { Memory } from "../src/memory.js"; +import { + allow, + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + type TestDb, +} from "./helpers.js"; + +describe.skipIf(testDatabaseUrl() === undefined)("deleting a principal", () => { + let db: TestDb; + let memory: Memory | undefined; + + beforeAll(async () => { + db = await createTestDb(); + await seedPrincipal(db, "acme", "alice"); + memory = createTestMemory( + db, + createInMemoryGrantStore([allow("alice", "add")]), + ); + }); + + afterAll(async () => { + await memory?.close(); + await db?.close(); + }); + + test("keeps the documents and versions they authored, with a null author", async () => { + const { documentId } = await memory!.add({ + tenantId: "acme", + principalId: "alice", + content: { + title: "Outlives alice", + text: "kept after the author leaves", + }, + }); + + await db.sql`DELETE FROM public.principal WHERE id = 'alice'`; + + const rows = await db.sql<{ author: string | null }[]>` + SELECT v.created_by_principal_id AS author FROM memory.version v + JOIN memory.document d ON d.id = v.document_id + WHERE d.id = ${documentId}`; + expect(rows.map((r) => r.author)).toEqual([null]); + }); +}); diff --git a/migrations/0010_version_author_set_null.sql b/migrations/0010_version_author_set_null.sql new file mode 100644 index 0000000..3321d69 --- /dev/null +++ b/migrations/0010_version_author_set_null.sql @@ -0,0 +1,17 @@ +-- A removed principal must not delete the versions it authored: switch the +-- author foreign key from ON DELETE CASCADE to ON DELETE SET NULL. Replaced +-- only while it still cascades, so re-running is a no-op. + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'version_created_by_principal_id_fkey' AND conrelid = '"memory"."version"'::regclass + AND confdeltype = 'c' + ) THEN + ALTER TABLE "memory"."version" + DROP CONSTRAINT "version_created_by_principal_id_fkey", + ADD CONSTRAINT "version_created_by_principal_id_fkey" + FOREIGN KEY ("created_by_principal_id") REFERENCES "public"."principal"("id") ON DELETE SET NULL; + END IF; +END $$; diff --git a/src/db/schema.ts b/src/db/schema.ts index dcb5637..71a0e36 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -31,7 +31,7 @@ const tenantRef = (column: string) => .references(() => hostTenant.id, { onDelete: "cascade" }); /** A removed principal must not vaporize the memory it created. */ const principalRef = (column: string) => - text(column).references(() => hostPrincipal.id, { onDelete: "cascade" }); + text(column).references(() => hostPrincipal.id, { onDelete: "set null" }); // No built-in `bytea` helper in drizzle-orm/pg-core; raw_capture.raw_bytes // holds non-textual raw payloads (binary source formats) as a Buffer.