Skip to content

Commit a1c1f40

Browse files
fix(migrations): keep a deleted principal's versions (#66)
The version author foreign key cascaded, so deleting a principal deleted every version they wrote and left empty documents. Migration 0010 switches it to ON DELETE SET NULL while it still cascades, so a replay is a no-op.
1 parent 0707ebb commit a1c1f40

3 files changed

Lines changed: 68 additions & 1 deletion

File tree

‎e2e/principal-delete.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2+
import { createInMemoryGrantStore } from "@intx/authz";
3+
4+
import type { Memory } from "../src/memory.js";
5+
import {
6+
allow,
7+
createTestDb,
8+
createTestMemory,
9+
seedPrincipal,
10+
testDatabaseUrl,
11+
type TestDb,
12+
} from "./helpers.js";
13+
14+
describe.skipIf(testDatabaseUrl() === undefined)("deleting a principal", () => {
15+
let db: TestDb;
16+
let memory: Memory | undefined;
17+
18+
beforeAll(async () => {
19+
db = await createTestDb();
20+
await seedPrincipal(db, "acme", "alice");
21+
memory = createTestMemory(
22+
db,
23+
createInMemoryGrantStore([allow("alice", "add")]),
24+
);
25+
});
26+
27+
afterAll(async () => {
28+
await memory?.close();
29+
await db?.close();
30+
});
31+
32+
test("keeps the documents and versions they authored, with a null author", async () => {
33+
const { documentId } = await memory!.add({
34+
tenantId: "acme",
35+
principalId: "alice",
36+
content: {
37+
title: "Outlives alice",
38+
text: "kept after the author leaves",
39+
},
40+
});
41+
42+
await db.sql`DELETE FROM public.principal WHERE id = 'alice'`;
43+
44+
const rows = await db.sql<{ author: string | null }[]>`
45+
SELECT v.created_by_principal_id AS author FROM memory.version v
46+
JOIN memory.document d ON d.id = v.document_id
47+
WHERE d.id = ${documentId}`;
48+
expect(rows.map((r) => r.author)).toEqual([null]);
49+
});
50+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- A removed principal must not delete the versions it authored: switch the
2+
-- author foreign key from ON DELETE CASCADE to ON DELETE SET NULL. Replaced
3+
-- only while it still cascades, so re-running is a no-op.
4+
5+
DO $$
6+
BEGIN
7+
IF EXISTS (
8+
SELECT 1 FROM pg_constraint
9+
WHERE conname = 'version_created_by_principal_id_fkey' AND conrelid = '"memory"."version"'::regclass
10+
AND confdeltype = 'c'
11+
) THEN
12+
ALTER TABLE "memory"."version"
13+
DROP CONSTRAINT "version_created_by_principal_id_fkey",
14+
ADD CONSTRAINT "version_created_by_principal_id_fkey"
15+
FOREIGN KEY ("created_by_principal_id") REFERENCES "public"."principal"("id") ON DELETE SET NULL;
16+
END IF;
17+
END $$;

‎src/db/schema.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const tenantRef = (column: string) =>
3131
.references(() => hostTenant.id, { onDelete: "cascade" });
3232
/** A removed principal must not vaporize the memory it created. */
3333
const principalRef = (column: string) =>
34-
text(column).references(() => hostPrincipal.id, { onDelete: "cascade" });
34+
text(column).references(() => hostPrincipal.id, { onDelete: "set null" });
3535

3636
// No built-in `bytea` helper in drizzle-orm/pg-core; raw_capture.raw_bytes
3737
// holds non-textual raw payloads (binary source formats) as a Buffer.

0 commit comments

Comments
 (0)