Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cf46140
chore(db): scaffold FK-integrity migration with audit references (#17…
Jose-Gael-Cruz-Lopez Jun 22, 2026
db92cf3
fix(db): delete orphan graph_edges before adding user_id FK (#179)
Jose-Gael-Cruz-Lopez Jun 22, 2026
f8a1451
fix(db): add graph_edges.user_id FK to users(id), idempotently guarde…
Jose-Gael-Cruz-Lopez Jun 22, 2026
f6b761b
fix(db): delete orphan notes before adding user/course FKs (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
5ea6a39
fix(db): add notes.user_id FK to users(id), idempotently guarded (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
aef9ff0
fix(db): add notes.course_id FK to courses(id), idempotently guarded …
Jose-Gael-Cruz-Lopez Jun 22, 2026
41c3d86
fix(db): add REFERENCES users(id) to graph_edges.user_id in schema (#…
Jose-Gael-Cruz-Lopez Jun 22, 2026
3bfb2b5
fix(db): add REFERENCES to notes.user_id/course_id in schema (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
4c319d8
docs(db): clarify notes FK rationale — only the graph_node link stays…
Jose-Gael-Cruz-Lopez Jun 22, 2026
75d3729
test(db): drift-guard FK-integrity migration constraints + orphan cle…
Jose-Gael-Cruz-Lopez Jun 22, 2026
1ba451f
fix(db): add idx_graph_edges_user_id on FK referencing column (#179)
Jose-Gael-Cruz-Lopez Jun 24, 2026
cc1ec58
docs(db): document actual ON DELETE NO ACTION/RESTRICT semantics of t…
Jose-Gael-Cruz-Lopez Jun 24, 2026
471d1b5
Merge remote-tracking branch 'origin/main' into fix/fk-integrity
Jose-Gael-Cruz-Lopez Jun 24, 2026
1a20cd8
fix(db): move FK-integrity DDL into numbered migration 0020 (#179, #180)
Jose-Gael-Cruz-Lopez Jun 24, 2026
4f8655e
test(db): point FK-integrity drift guard at the canonical migration f…
Jose-Gael-Cruz-Lopez Jun 24, 2026
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
27 changes: 21 additions & 6 deletions backend/db/migrations/0001_baseline_schema.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,23 @@ CREATE TABLE IF NOT EXISTS graph_nodes (
CREATE INDEX IF NOT EXISTS idx_graph_nodes_user_course ON graph_nodes(user_id, course_id);

-- Knowledge graph edges
-- graph_edges.user_id carries a hard FK (#179) with no ON DELETE clause, so it
-- defaults to NO ACTION (RESTRICT): a users row cannot be hard-deleted while
-- edges still reference it. This prevents orphaned edges; it does not cascade.
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #179
source_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
target_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
strength DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT now(),
relationship_type TEXT DEFAULT 'related'
);

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- Learning sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
Expand DownExpand Up@@ -487,16 +494,24 @@ CREATE TABLE IF NOT EXISTS user_cosmetics (
-- filters work for tag-based search. last_summary is the cached output of
-- the most recent /summarize action; null until the user runs it.
--
-- notes.user_id / notes.course_id carry hard FKs (#180) — they are core user
-- data. The FKs have no ON DELETE clause, so they default to NO ACTION
-- (RESTRICT): a user or course row cannot be hard-deleted while notes still
-- reference it. This guarantees notes never become orphaned, but it does NOT
-- cascade-delete them. Today nothing hard-deletes users/courses
-- (delete_account is a soft delete; delete_course only removes the
-- user_courses enrollment row), so RESTRICT never fires. If cleanup-on-delete
-- is ever wanted, switch these to ON DELETE CASCADE and add a hard-delete path.
--
-- note_concepts is a junction table linking notes <-> graph_nodes.
-- ON DELETE CASCADE on note_id ensures deleting a note cleans up its
-- links. The graph_node FK is intentionally NOT a hard FK because
-- graph_nodes uses TEXT ids managed by application code (no enforced FK
-- pattern elsewhere in this codebase — see graph_edges.source_node_id).
-- links. Only the note_concepts.concept_node_id link is intentionally NOT a
-- hard FK, because graph_nodes uses TEXT ids managed by application code.
Comment on lines +508 to +509

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify the rationale for not adding FK to note_concepts.concept_node_id.

The comment states the link is "intentionally NOT a hard FK, because graph_nodes uses TEXT ids managed by application code." However, graph_edges.source_node_id and graph_edges.target_node_id (lines 98-99) both have hard FKs to graph_nodes(id), so the stated reason is inconsistent.

If the real reason is different (e.g., concepts can exist before graph nodes are created, or there's an application-level design consideration), please update the comment to explain the actual rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/supabase_schema.sql` around lines 495 - 496, The comment
explaining why note_concepts.concept_node_id is intentionally not a hard FK
states it's because graph_nodes uses TEXT ids managed by application code, but
this reasoning is inconsistent since graph_edges.source_node_id and
graph_edges.target_node_id also reference graph_nodes(id) with TEXT ids yet have
hard FKs defined. Update the comment at lines 495-496 to clarify the actual
rationale for not adding the FK constraint to note_concepts.concept_node_id,
such as whether concepts can exist before their corresponding graph nodes are
created or if there's a specific application-level design consideration that
necessitates this approach.


CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
course_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #180
course_id TEXT NOT NULL REFERENCES courses(id), -- #180
title TEXT,
body TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
Expand Down
73 changes: 73 additions & 0 deletions backend/db/migrations/0020_fk_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
-- Migration: foreign-key integrity for graph_edges + notes (#179, #180)
--
-- Backfills, on already-migrated databases, the FK constraints that fresh
-- databases now get inline from 0001_baseline_schema.sql. graph_edges.user_id
-- and notes.user_id / notes.course_id historically shipped as bare TEXT columns
-- with no REFERENCES, inconsistent with every sibling learning table:
-- #179 graph_edges.user_id -> users(id)
-- #180 notes.user_id -> users(id)
-- #180 notes.course_id -> courses(id)
--
-- migrate.py wraps each migration in a single transaction, so this is plain
-- (non-CONCURRENT) DDL. Each constraint is added behind a pg_constraint guard
-- because Postgres has no ADD CONSTRAINT IF NOT EXISTS, which also makes this
-- migration a no-op on fresh databases that already have the inline FKs from
-- the baseline. Pre-existing orphan rows are deleted first so the ALTER TABLE
-- can validate.
--
-- ON DELETE semantics: these FKs have no ON DELETE clause, so they default to
-- NO ACTION (RESTRICT). A referenced users/courses row cannot be hard-deleted
-- while a graph_edges/notes row still points at it. This guarantees no orphans
-- but does NOT cascade-delete dependents. Today nothing hard-deletes
-- users/courses (delete_account is a soft delete; delete_course only removes
-- the user_courses enrollment row), so RESTRICT never actually fires. Switch
-- to ON DELETE CASCADE (and add a hard-delete path) if cleanup is ever wanted.

-- #179 graph_edges.user_id: remove edges whose user_id has no users row, then
-- add the FK other learning tables already enforce.
DELETE FROM graph_edges
WHERE user_id NOT IN (SELECT id FROM users);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'graph_edges_user_id_fkey'
) THEN
ALTER TABLE graph_edges
ADD CONSTRAINT graph_edges_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- #180 notes.user_id / notes.course_id: notes is core user data but both
-- columns are bare TEXT. Remove rows pointing at a non-existent user or course
-- (e.g. notes left dangling after a course delete) before adding the FKs.
DELETE FROM notes
WHERE user_id NOT IN (SELECT id FROM users)
OR course_id NOT IN (SELECT id FROM courses);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_user_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_course_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_course_id_fkey
FOREIGN KEY (course_id) REFERENCES courses(id);
END IF;
END $$;
64 changes: 64 additions & 0 deletions backend/tests/test_fk_integrity_migration.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
"""
Drift guard for the FK-integrity migration (#179, #180).

No live Postgres in the unit suite, so we assert the textual invariants that
matter: the numbered migration adds each constraint behind the pg_constraint
guard (re-runnable) and cleans orphans first, and the canonical baseline schema
declares the same REFERENCES inline so a fresh database is born with the FKs.

After main restructured db/ into ordered migrations applied by migrate.py, the
FK DDL lives in migrations/0020_fk_integrity.sql (for already-migrated DBs) and
the inline REFERENCES live in migrations/0001_baseline_schema.sql (for fresh
DBs). The old flat db/supabase_schema.sql / migration_*.sql files were deleted.
"""
import os

_MIGRATIONS = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "db", "migrations"
)

FK_MIGRATION = "0020_fk_integrity.sql"
BASELINE = "0001_baseline_schema.sql"

CONSTRAINTS = (
"graph_edges_user_id_fkey",
"notes_user_id_fkey",
"notes_course_id_fkey",
)


def _read(name: str) -> str:
with open(os.path.join(_MIGRATIONS, name), encoding="utf-8") as fh:
return fh.read()


def test_migration_adds_each_constraint_behind_a_guard():
sql = _read(FK_MIGRATION)
for name in CONSTRAINTS:
assert name in sql, f"{name} missing from migration"
# Every ADD CONSTRAINT must be inside an IF NOT EXISTS pg_constraint guard.
assert sql.count("IF NOT EXISTS") >= len(CONSTRAINTS)
assert "pg_constraint" in sql


def test_migration_cleans_orphans_before_altering():
sql = _read(FK_MIGRATION)
# Orphan deletes must precede the ALTER TABLE that validates the FK.
assert "DELETE FROM graph_edges" in sql
assert "DELETE FROM notes" in sql
assert sql.index("DELETE FROM graph_edges") < sql.index("graph_edges_user_id_fkey")
assert sql.index("DELETE FROM notes") < sql.index("notes_user_id_fkey")


def test_migration_indexes_the_referencing_column():
# graph_edges.user_id needs an index on the FK referencing side (#179).
sql = _read(FK_MIGRATION)
assert "CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id" in sql


def test_baseline_declares_inline_references():
# Fresh databases must be born with the FKs, declared inline in the baseline.
sql = _read(BASELINE)
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # graph_edges
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # notes
assert "course_id TEXT NOT NULL REFERENCES courses(id)" in sql # notes
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cf46140
chore(db): scaffold FK-integrity migration with audit references (#17…
Jose-Gael-Cruz-Lopez Jun 22, 2026
db92cf3
fix(db): delete orphan graph_edges before adding user_id FK (#179)
Jose-Gael-Cruz-Lopez Jun 22, 2026
f8a1451
fix(db): add graph_edges.user_id FK to users(id), idempotently guarde…
Jose-Gael-Cruz-Lopez Jun 22, 2026
f6b761b
fix(db): delete orphan notes before adding user/course FKs (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
5ea6a39
fix(db): add notes.user_id FK to users(id), idempotently guarded (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
aef9ff0
fix(db): add notes.course_id FK to courses(id), idempotently guarded …
Jose-Gael-Cruz-Lopez Jun 22, 2026
41c3d86
fix(db): add REFERENCES users(id) to graph_edges.user_id in schema (#…
Jose-Gael-Cruz-Lopez Jun 22, 2026
3bfb2b5
fix(db): add REFERENCES to notes.user_id/course_id in schema (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
4c319d8
docs(db): clarify notes FK rationale — only the graph_node link stays…
Jose-Gael-Cruz-Lopez Jun 22, 2026
75d3729
test(db): drift-guard FK-integrity migration constraints + orphan cle…
Jose-Gael-Cruz-Lopez Jun 22, 2026
1ba451f
fix(db): add idx_graph_edges_user_id on FK referencing column (#179)
Jose-Gael-Cruz-Lopez Jun 24, 2026
cc1ec58
docs(db): document actual ON DELETE NO ACTION/RESTRICT semantics of t…
Jose-Gael-Cruz-Lopez Jun 24, 2026
471d1b5
Merge remote-tracking branch 'origin/main' into fix/fk-integrity
Jose-Gael-Cruz-Lopez Jun 24, 2026
1a20cd8
fix(db): move FK-integrity DDL into numbered migration 0020 (#179, #180)
Jose-Gael-Cruz-Lopez Jun 24, 2026
4f8655e
test(db): point FK-integrity drift guard at the canonical migration f…
Jose-Gael-Cruz-Lopez Jun 24, 2026
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
27 changes: 21 additions & 6 deletions backend/db/migrations/0001_baseline_schema.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,23 @@ CREATE TABLE IF NOT EXISTS graph_nodes (
CREATE INDEX IF NOT EXISTS idx_graph_nodes_user_course ON graph_nodes(user_id, course_id);

-- Knowledge graph edges
-- graph_edges.user_id carries a hard FK (#179) with no ON DELETE clause, so it
-- defaults to NO ACTION (RESTRICT): a users row cannot be hard-deleted while
-- edges still reference it. This prevents orphaned edges; it does not cascade.
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #179
source_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
target_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
strength DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT now(),
relationship_type TEXT DEFAULT 'related'
);

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- Learning sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
Expand DownExpand Up@@ -487,16 +494,24 @@ CREATE TABLE IF NOT EXISTS user_cosmetics (
-- filters work for tag-based search. last_summary is the cached output of
-- the most recent /summarize action; null until the user runs it.
--
-- notes.user_id / notes.course_id carry hard FKs (#180) — they are core user
-- data. The FKs have no ON DELETE clause, so they default to NO ACTION
-- (RESTRICT): a user or course row cannot be hard-deleted while notes still
-- reference it. This guarantees notes never become orphaned, but it does NOT
-- cascade-delete them. Today nothing hard-deletes users/courses
-- (delete_account is a soft delete; delete_course only removes the
-- user_courses enrollment row), so RESTRICT never fires. If cleanup-on-delete
-- is ever wanted, switch these to ON DELETE CASCADE and add a hard-delete path.
--
-- note_concepts is a junction table linking notes <-> graph_nodes.
-- ON DELETE CASCADE on note_id ensures deleting a note cleans up its
-- links. The graph_node FK is intentionally NOT a hard FK because
-- graph_nodes uses TEXT ids managed by application code (no enforced FK
-- pattern elsewhere in this codebase — see graph_edges.source_node_id).
-- links. Only the note_concepts.concept_node_id link is intentionally NOT a
-- hard FK, because graph_nodes uses TEXT ids managed by application code.
Comment on lines +508 to +509

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify the rationale for not adding FK to note_concepts.concept_node_id.

The comment states the link is "intentionally NOT a hard FK, because graph_nodes uses TEXT ids managed by application code." However, graph_edges.source_node_id and graph_edges.target_node_id (lines 98-99) both have hard FKs to graph_nodes(id), so the stated reason is inconsistent.

If the real reason is different (e.g., concepts can exist before graph nodes are created, or there's an application-level design consideration), please update the comment to explain the actual rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/supabase_schema.sql` around lines 495 - 496, The comment
explaining why note_concepts.concept_node_id is intentionally not a hard FK
states it's because graph_nodes uses TEXT ids managed by application code, but
this reasoning is inconsistent since graph_edges.source_node_id and
graph_edges.target_node_id also reference graph_nodes(id) with TEXT ids yet have
hard FKs defined. Update the comment at lines 495-496 to clarify the actual
rationale for not adding the FK constraint to note_concepts.concept_node_id,
such as whether concepts can exist before their corresponding graph nodes are
created or if there's a specific application-level design consideration that
necessitates this approach.


CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
course_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #180
course_id TEXT NOT NULL REFERENCES courses(id), -- #180
title TEXT,
body TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
Expand Down
73 changes: 73 additions & 0 deletions backend/db/migrations/0020_fk_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
-- Migration: foreign-key integrity for graph_edges + notes (#179, #180)
--
-- Backfills, on already-migrated databases, the FK constraints that fresh
-- databases now get inline from 0001_baseline_schema.sql. graph_edges.user_id
-- and notes.user_id / notes.course_id historically shipped as bare TEXT columns
-- with no REFERENCES, inconsistent with every sibling learning table:
-- #179 graph_edges.user_id -> users(id)
-- #180 notes.user_id -> users(id)
-- #180 notes.course_id -> courses(id)
--
-- migrate.py wraps each migration in a single transaction, so this is plain
-- (non-CONCURRENT) DDL. Each constraint is added behind a pg_constraint guard
-- because Postgres has no ADD CONSTRAINT IF NOT EXISTS, which also makes this
-- migration a no-op on fresh databases that already have the inline FKs from
-- the baseline. Pre-existing orphan rows are deleted first so the ALTER TABLE
-- can validate.
--
-- ON DELETE semantics: these FKs have no ON DELETE clause, so they default to
-- NO ACTION (RESTRICT). A referenced users/courses row cannot be hard-deleted
-- while a graph_edges/notes row still points at it. This guarantees no orphans
-- but does NOT cascade-delete dependents. Today nothing hard-deletes
-- users/courses (delete_account is a soft delete; delete_course only removes
-- the user_courses enrollment row), so RESTRICT never actually fires. Switch
-- to ON DELETE CASCADE (and add a hard-delete path) if cleanup is ever wanted.

-- #179 graph_edges.user_id: remove edges whose user_id has no users row, then
-- add the FK other learning tables already enforce.
DELETE FROM graph_edges
WHERE user_id NOT IN (SELECT id FROM users);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'graph_edges_user_id_fkey'
) THEN
ALTER TABLE graph_edges
ADD CONSTRAINT graph_edges_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- #180 notes.user_id / notes.course_id: notes is core user data but both
-- columns are bare TEXT. Remove rows pointing at a non-existent user or course
-- (e.g. notes left dangling after a course delete) before adding the FKs.
DELETE FROM notes
WHERE user_id NOT IN (SELECT id FROM users)
OR course_id NOT IN (SELECT id FROM courses);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_user_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_course_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_course_id_fkey
FOREIGN KEY (course_id) REFERENCES courses(id);
END IF;
END $$;
64 changes: 64 additions & 0 deletions backend/tests/test_fk_integrity_migration.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
"""
Drift guard for the FK-integrity migration (#179, #180).

No live Postgres in the unit suite, so we assert the textual invariants that
matter: the numbered migration adds each constraint behind the pg_constraint
guard (re-runnable) and cleans orphans first, and the canonical baseline schema
declares the same REFERENCES inline so a fresh database is born with the FKs.

After main restructured db/ into ordered migrations applied by migrate.py, the
FK DDL lives in migrations/0020_fk_integrity.sql (for already-migrated DBs) and
the inline REFERENCES live in migrations/0001_baseline_schema.sql (for fresh
DBs). The old flat db/supabase_schema.sql / migration_*.sql files were deleted.
"""
import os

_MIGRATIONS = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "db", "migrations"
)

FK_MIGRATION = "0020_fk_integrity.sql"
BASELINE = "0001_baseline_schema.sql"

CONSTRAINTS = (
"graph_edges_user_id_fkey",
"notes_user_id_fkey",
"notes_course_id_fkey",
)


def _read(name: str) -> str:
with open(os.path.join(_MIGRATIONS, name), encoding="utf-8") as fh:
return fh.read()


def test_migration_adds_each_constraint_behind_a_guard():
sql = _read(FK_MIGRATION)
for name in CONSTRAINTS:
assert name in sql, f"{name} missing from migration"
# Every ADD CONSTRAINT must be inside an IF NOT EXISTS pg_constraint guard.
assert sql.count("IF NOT EXISTS") >= len(CONSTRAINTS)
assert "pg_constraint" in sql


def test_migration_cleans_orphans_before_altering():
sql = _read(FK_MIGRATION)
# Orphan deletes must precede the ALTER TABLE that validates the FK.
assert "DELETE FROM graph_edges" in sql
assert "DELETE FROM notes" in sql
assert sql.index("DELETE FROM graph_edges") < sql.index("graph_edges_user_id_fkey")
assert sql.index("DELETE FROM notes") < sql.index("notes_user_id_fkey")


def test_migration_indexes_the_referencing_column():
# graph_edges.user_id needs an index on the FK referencing side (#179).
sql = _read(FK_MIGRATION)
assert "CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id" in sql


def test_baseline_declares_inline_references():
# Fresh databases must be born with the FKs, declared inline in the baseline.
sql = _read(BASELINE)
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # graph_edges
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # notes
assert "course_id TEXT NOT NULL REFERENCES courses(id)" in sql # notes
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cf46140
chore(db): scaffold FK-integrity migration with audit references (#17…
Jose-Gael-Cruz-Lopez Jun 22, 2026
db92cf3
fix(db): delete orphan graph_edges before adding user_id FK (#179)
Jose-Gael-Cruz-Lopez Jun 22, 2026
f8a1451
fix(db): add graph_edges.user_id FK to users(id), idempotently guarde…
Jose-Gael-Cruz-Lopez Jun 22, 2026
f6b761b
fix(db): delete orphan notes before adding user/course FKs (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
5ea6a39
fix(db): add notes.user_id FK to users(id), idempotently guarded (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
aef9ff0
fix(db): add notes.course_id FK to courses(id), idempotently guarded …
Jose-Gael-Cruz-Lopez Jun 22, 2026
41c3d86
fix(db): add REFERENCES users(id) to graph_edges.user_id in schema (#…
Jose-Gael-Cruz-Lopez Jun 22, 2026
3bfb2b5
fix(db): add REFERENCES to notes.user_id/course_id in schema (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
4c319d8
docs(db): clarify notes FK rationale — only the graph_node link stays…
Jose-Gael-Cruz-Lopez Jun 22, 2026
75d3729
test(db): drift-guard FK-integrity migration constraints + orphan cle…
Jose-Gael-Cruz-Lopez Jun 22, 2026
1ba451f
fix(db): add idx_graph_edges_user_id on FK referencing column (#179)
Jose-Gael-Cruz-Lopez Jun 24, 2026
cc1ec58
docs(db): document actual ON DELETE NO ACTION/RESTRICT semantics of t…
Jose-Gael-Cruz-Lopez Jun 24, 2026
471d1b5
Merge remote-tracking branch 'origin/main' into fix/fk-integrity
Jose-Gael-Cruz-Lopez Jun 24, 2026
1a20cd8
fix(db): move FK-integrity DDL into numbered migration 0020 (#179, #180)
Jose-Gael-Cruz-Lopez Jun 24, 2026
4f8655e
test(db): point FK-integrity drift guard at the canonical migration f…
Jose-Gael-Cruz-Lopez Jun 24, 2026
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
27 changes: 21 additions & 6 deletions backend/db/migrations/0001_baseline_schema.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,23 @@ CREATE TABLE IF NOT EXISTS graph_nodes (
CREATE INDEX IF NOT EXISTS idx_graph_nodes_user_course ON graph_nodes(user_id, course_id);

-- Knowledge graph edges
-- graph_edges.user_id carries a hard FK (#179) with no ON DELETE clause, so it
-- defaults to NO ACTION (RESTRICT): a users row cannot be hard-deleted while
-- edges still reference it. This prevents orphaned edges; it does not cascade.
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #179
source_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
target_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
strength DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT now(),
relationship_type TEXT DEFAULT 'related'
);

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- Learning sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
Expand DownExpand Up@@ -487,16 +494,24 @@ CREATE TABLE IF NOT EXISTS user_cosmetics (
-- filters work for tag-based search. last_summary is the cached output of
-- the most recent /summarize action; null until the user runs it.
--
-- notes.user_id / notes.course_id carry hard FKs (#180) — they are core user
-- data. The FKs have no ON DELETE clause, so they default to NO ACTION
-- (RESTRICT): a user or course row cannot be hard-deleted while notes still
-- reference it. This guarantees notes never become orphaned, but it does NOT
-- cascade-delete them. Today nothing hard-deletes users/courses
-- (delete_account is a soft delete; delete_course only removes the
-- user_courses enrollment row), so RESTRICT never fires. If cleanup-on-delete
-- is ever wanted, switch these to ON DELETE CASCADE and add a hard-delete path.
--
-- note_concepts is a junction table linking notes <-> graph_nodes.
-- ON DELETE CASCADE on note_id ensures deleting a note cleans up its
-- links. The graph_node FK is intentionally NOT a hard FK because
-- graph_nodes uses TEXT ids managed by application code (no enforced FK
-- pattern elsewhere in this codebase — see graph_edges.source_node_id).
-- links. Only the note_concepts.concept_node_id link is intentionally NOT a
-- hard FK, because graph_nodes uses TEXT ids managed by application code.
Comment on lines +508 to +509

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify the rationale for not adding FK to note_concepts.concept_node_id.

The comment states the link is "intentionally NOT a hard FK, because graph_nodes uses TEXT ids managed by application code." However, graph_edges.source_node_id and graph_edges.target_node_id (lines 98-99) both have hard FKs to graph_nodes(id), so the stated reason is inconsistent.

If the real reason is different (e.g., concepts can exist before graph nodes are created, or there's an application-level design consideration), please update the comment to explain the actual rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/supabase_schema.sql` around lines 495 - 496, The comment
explaining why note_concepts.concept_node_id is intentionally not a hard FK
states it's because graph_nodes uses TEXT ids managed by application code, but
this reasoning is inconsistent since graph_edges.source_node_id and
graph_edges.target_node_id also reference graph_nodes(id) with TEXT ids yet have
hard FKs defined. Update the comment at lines 495-496 to clarify the actual
rationale for not adding the FK constraint to note_concepts.concept_node_id,
such as whether concepts can exist before their corresponding graph nodes are
created or if there's a specific application-level design consideration that
necessitates this approach.


CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
course_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #180
course_id TEXT NOT NULL REFERENCES courses(id), -- #180
title TEXT,
body TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
Expand Down
73 changes: 73 additions & 0 deletions backend/db/migrations/0020_fk_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
-- Migration: foreign-key integrity for graph_edges + notes (#179, #180)
--
-- Backfills, on already-migrated databases, the FK constraints that fresh
-- databases now get inline from 0001_baseline_schema.sql. graph_edges.user_id
-- and notes.user_id / notes.course_id historically shipped as bare TEXT columns
-- with no REFERENCES, inconsistent with every sibling learning table:
-- #179 graph_edges.user_id -> users(id)
-- #180 notes.user_id -> users(id)
-- #180 notes.course_id -> courses(id)
--
-- migrate.py wraps each migration in a single transaction, so this is plain
-- (non-CONCURRENT) DDL. Each constraint is added behind a pg_constraint guard
-- because Postgres has no ADD CONSTRAINT IF NOT EXISTS, which also makes this
-- migration a no-op on fresh databases that already have the inline FKs from
-- the baseline. Pre-existing orphan rows are deleted first so the ALTER TABLE
-- can validate.
--
-- ON DELETE semantics: these FKs have no ON DELETE clause, so they default to
-- NO ACTION (RESTRICT). A referenced users/courses row cannot be hard-deleted
-- while a graph_edges/notes row still points at it. This guarantees no orphans
-- but does NOT cascade-delete dependents. Today nothing hard-deletes
-- users/courses (delete_account is a soft delete; delete_course only removes
-- the user_courses enrollment row), so RESTRICT never actually fires. Switch
-- to ON DELETE CASCADE (and add a hard-delete path) if cleanup is ever wanted.

-- #179 graph_edges.user_id: remove edges whose user_id has no users row, then
-- add the FK other learning tables already enforce.
DELETE FROM graph_edges
WHERE user_id NOT IN (SELECT id FROM users);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'graph_edges_user_id_fkey'
) THEN
ALTER TABLE graph_edges
ADD CONSTRAINT graph_edges_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- #180 notes.user_id / notes.course_id: notes is core user data but both
-- columns are bare TEXT. Remove rows pointing at a non-existent user or course
-- (e.g. notes left dangling after a course delete) before adding the FKs.
DELETE FROM notes
WHERE user_id NOT IN (SELECT id FROM users)
OR course_id NOT IN (SELECT id FROM courses);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_user_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_course_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_course_id_fkey
FOREIGN KEY (course_id) REFERENCES courses(id);
END IF;
END $$;
64 changes: 64 additions & 0 deletions backend/tests/test_fk_integrity_migration.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
"""
Drift guard for the FK-integrity migration (#179, #180).

No live Postgres in the unit suite, so we assert the textual invariants that
matter: the numbered migration adds each constraint behind the pg_constraint
guard (re-runnable) and cleans orphans first, and the canonical baseline schema
declares the same REFERENCES inline so a fresh database is born with the FKs.

After main restructured db/ into ordered migrations applied by migrate.py, the
FK DDL lives in migrations/0020_fk_integrity.sql (for already-migrated DBs) and
the inline REFERENCES live in migrations/0001_baseline_schema.sql (for fresh
DBs). The old flat db/supabase_schema.sql / migration_*.sql files were deleted.
"""
import os

_MIGRATIONS = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "db", "migrations"
)

FK_MIGRATION = "0020_fk_integrity.sql"
BASELINE = "0001_baseline_schema.sql"

CONSTRAINTS = (
"graph_edges_user_id_fkey",
"notes_user_id_fkey",
"notes_course_id_fkey",
)


def _read(name: str) -> str:
with open(os.path.join(_MIGRATIONS, name), encoding="utf-8") as fh:
return fh.read()


def test_migration_adds_each_constraint_behind_a_guard():
sql = _read(FK_MIGRATION)
for name in CONSTRAINTS:
assert name in sql, f"{name} missing from migration"
# Every ADD CONSTRAINT must be inside an IF NOT EXISTS pg_constraint guard.
assert sql.count("IF NOT EXISTS") >= len(CONSTRAINTS)
assert "pg_constraint" in sql


def test_migration_cleans_orphans_before_altering():
sql = _read(FK_MIGRATION)
# Orphan deletes must precede the ALTER TABLE that validates the FK.
assert "DELETE FROM graph_edges" in sql
assert "DELETE FROM notes" in sql
assert sql.index("DELETE FROM graph_edges") < sql.index("graph_edges_user_id_fkey")
assert sql.index("DELETE FROM notes") < sql.index("notes_user_id_fkey")


def test_migration_indexes_the_referencing_column():
# graph_edges.user_id needs an index on the FK referencing side (#179).
sql = _read(FK_MIGRATION)
assert "CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id" in sql


def test_baseline_declares_inline_references():
# Fresh databases must be born with the FKs, declared inline in the baseline.
sql = _read(BASELINE)
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # graph_edges
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # notes
assert "course_id TEXT NOT NULL REFERENCES courses(id)" in sql # notes
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cf46140
chore(db): scaffold FK-integrity migration with audit references (#17…
Jose-Gael-Cruz-Lopez Jun 22, 2026
db92cf3
fix(db): delete orphan graph_edges before adding user_id FK (#179)
Jose-Gael-Cruz-Lopez Jun 22, 2026
f8a1451
fix(db): add graph_edges.user_id FK to users(id), idempotently guarde…
Jose-Gael-Cruz-Lopez Jun 22, 2026
f6b761b
fix(db): delete orphan notes before adding user/course FKs (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
5ea6a39
fix(db): add notes.user_id FK to users(id), idempotently guarded (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
aef9ff0
fix(db): add notes.course_id FK to courses(id), idempotently guarded …
Jose-Gael-Cruz-Lopez Jun 22, 2026
41c3d86
fix(db): add REFERENCES users(id) to graph_edges.user_id in schema (#…
Jose-Gael-Cruz-Lopez Jun 22, 2026
3bfb2b5
fix(db): add REFERENCES to notes.user_id/course_id in schema (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
4c319d8
docs(db): clarify notes FK rationale — only the graph_node link stays…
Jose-Gael-Cruz-Lopez Jun 22, 2026
75d3729
test(db): drift-guard FK-integrity migration constraints + orphan cle…
Jose-Gael-Cruz-Lopez Jun 22, 2026
1ba451f
fix(db): add idx_graph_edges_user_id on FK referencing column (#179)
Jose-Gael-Cruz-Lopez Jun 24, 2026
cc1ec58
docs(db): document actual ON DELETE NO ACTION/RESTRICT semantics of t…
Jose-Gael-Cruz-Lopez Jun 24, 2026
471d1b5
Merge remote-tracking branch 'origin/main' into fix/fk-integrity
Jose-Gael-Cruz-Lopez Jun 24, 2026
1a20cd8
fix(db): move FK-integrity DDL into numbered migration 0020 (#179, #180)
Jose-Gael-Cruz-Lopez Jun 24, 2026
4f8655e
test(db): point FK-integrity drift guard at the canonical migration f…
Jose-Gael-Cruz-Lopez Jun 24, 2026
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
27 changes: 21 additions & 6 deletions backend/db/migrations/0001_baseline_schema.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,23 @@ CREATE TABLE IF NOT EXISTS graph_nodes (
CREATE INDEX IF NOT EXISTS idx_graph_nodes_user_course ON graph_nodes(user_id, course_id);

-- Knowledge graph edges
-- graph_edges.user_id carries a hard FK (#179) with no ON DELETE clause, so it
-- defaults to NO ACTION (RESTRICT): a users row cannot be hard-deleted while
-- edges still reference it. This prevents orphaned edges; it does not cascade.
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #179
source_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
target_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
strength DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT now(),
relationship_type TEXT DEFAULT 'related'
);

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- Learning sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
Expand DownExpand Up@@ -487,16 +494,24 @@ CREATE TABLE IF NOT EXISTS user_cosmetics (
-- filters work for tag-based search. last_summary is the cached output of
-- the most recent /summarize action; null until the user runs it.
--
-- notes.user_id / notes.course_id carry hard FKs (#180) — they are core user
-- data. The FKs have no ON DELETE clause, so they default to NO ACTION
-- (RESTRICT): a user or course row cannot be hard-deleted while notes still
-- reference it. This guarantees notes never become orphaned, but it does NOT
-- cascade-delete them. Today nothing hard-deletes users/courses
-- (delete_account is a soft delete; delete_course only removes the
-- user_courses enrollment row), so RESTRICT never fires. If cleanup-on-delete
-- is ever wanted, switch these to ON DELETE CASCADE and add a hard-delete path.
--
-- note_concepts is a junction table linking notes <-> graph_nodes.
-- ON DELETE CASCADE on note_id ensures deleting a note cleans up its
-- links. The graph_node FK is intentionally NOT a hard FK because
-- graph_nodes uses TEXT ids managed by application code (no enforced FK
-- pattern elsewhere in this codebase — see graph_edges.source_node_id).
-- links. Only the note_concepts.concept_node_id link is intentionally NOT a
-- hard FK, because graph_nodes uses TEXT ids managed by application code.
Comment on lines +508 to +509

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify the rationale for not adding FK to note_concepts.concept_node_id.

The comment states the link is "intentionally NOT a hard FK, because graph_nodes uses TEXT ids managed by application code." However, graph_edges.source_node_id and graph_edges.target_node_id (lines 98-99) both have hard FKs to graph_nodes(id), so the stated reason is inconsistent.

If the real reason is different (e.g., concepts can exist before graph nodes are created, or there's an application-level design consideration), please update the comment to explain the actual rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/supabase_schema.sql` around lines 495 - 496, The comment
explaining why note_concepts.concept_node_id is intentionally not a hard FK
states it's because graph_nodes uses TEXT ids managed by application code, but
this reasoning is inconsistent since graph_edges.source_node_id and
graph_edges.target_node_id also reference graph_nodes(id) with TEXT ids yet have
hard FKs defined. Update the comment at lines 495-496 to clarify the actual
rationale for not adding the FK constraint to note_concepts.concept_node_id,
such as whether concepts can exist before their corresponding graph nodes are
created or if there's a specific application-level design consideration that
necessitates this approach.


CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
course_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #180
course_id TEXT NOT NULL REFERENCES courses(id), -- #180
title TEXT,
body TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
Expand Down
73 changes: 73 additions & 0 deletions backend/db/migrations/0020_fk_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
-- Migration: foreign-key integrity for graph_edges + notes (#179, #180)
--
-- Backfills, on already-migrated databases, the FK constraints that fresh
-- databases now get inline from 0001_baseline_schema.sql. graph_edges.user_id
-- and notes.user_id / notes.course_id historically shipped as bare TEXT columns
-- with no REFERENCES, inconsistent with every sibling learning table:
-- #179 graph_edges.user_id -> users(id)
-- #180 notes.user_id -> users(id)
-- #180 notes.course_id -> courses(id)
--
-- migrate.py wraps each migration in a single transaction, so this is plain
-- (non-CONCURRENT) DDL. Each constraint is added behind a pg_constraint guard
-- because Postgres has no ADD CONSTRAINT IF NOT EXISTS, which also makes this
-- migration a no-op on fresh databases that already have the inline FKs from
-- the baseline. Pre-existing orphan rows are deleted first so the ALTER TABLE
-- can validate.
--
-- ON DELETE semantics: these FKs have no ON DELETE clause, so they default to
-- NO ACTION (RESTRICT). A referenced users/courses row cannot be hard-deleted
-- while a graph_edges/notes row still points at it. This guarantees no orphans
-- but does NOT cascade-delete dependents. Today nothing hard-deletes
-- users/courses (delete_account is a soft delete; delete_course only removes
-- the user_courses enrollment row), so RESTRICT never actually fires. Switch
-- to ON DELETE CASCADE (and add a hard-delete path) if cleanup is ever wanted.

-- #179 graph_edges.user_id: remove edges whose user_id has no users row, then
-- add the FK other learning tables already enforce.
DELETE FROM graph_edges
WHERE user_id NOT IN (SELECT id FROM users);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'graph_edges_user_id_fkey'
) THEN
ALTER TABLE graph_edges
ADD CONSTRAINT graph_edges_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- #180 notes.user_id / notes.course_id: notes is core user data but both
-- columns are bare TEXT. Remove rows pointing at a non-existent user or course
-- (e.g. notes left dangling after a course delete) before adding the FKs.
DELETE FROM notes
WHERE user_id NOT IN (SELECT id FROM users)
OR course_id NOT IN (SELECT id FROM courses);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_user_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_course_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_course_id_fkey
FOREIGN KEY (course_id) REFERENCES courses(id);
END IF;
END $$;
64 changes: 64 additions & 0 deletions backend/tests/test_fk_integrity_migration.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
"""
Drift guard for the FK-integrity migration (#179, #180).

No live Postgres in the unit suite, so we assert the textual invariants that
matter: the numbered migration adds each constraint behind the pg_constraint
guard (re-runnable) and cleans orphans first, and the canonical baseline schema
declares the same REFERENCES inline so a fresh database is born with the FKs.

After main restructured db/ into ordered migrations applied by migrate.py, the
FK DDL lives in migrations/0020_fk_integrity.sql (for already-migrated DBs) and
the inline REFERENCES live in migrations/0001_baseline_schema.sql (for fresh
DBs). The old flat db/supabase_schema.sql / migration_*.sql files were deleted.
"""
import os

_MIGRATIONS = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "db", "migrations"
)

FK_MIGRATION = "0020_fk_integrity.sql"
BASELINE = "0001_baseline_schema.sql"

CONSTRAINTS = (
"graph_edges_user_id_fkey",
"notes_user_id_fkey",
"notes_course_id_fkey",
)


def _read(name: str) -> str:
with open(os.path.join(_MIGRATIONS, name), encoding="utf-8") as fh:
return fh.read()


def test_migration_adds_each_constraint_behind_a_guard():
sql = _read(FK_MIGRATION)
for name in CONSTRAINTS:
assert name in sql, f"{name} missing from migration"
# Every ADD CONSTRAINT must be inside an IF NOT EXISTS pg_constraint guard.
assert sql.count("IF NOT EXISTS") >= len(CONSTRAINTS)
assert "pg_constraint" in sql


def test_migration_cleans_orphans_before_altering():
sql = _read(FK_MIGRATION)
# Orphan deletes must precede the ALTER TABLE that validates the FK.
assert "DELETE FROM graph_edges" in sql
assert "DELETE FROM notes" in sql
assert sql.index("DELETE FROM graph_edges") < sql.index("graph_edges_user_id_fkey")
assert sql.index("DELETE FROM notes") < sql.index("notes_user_id_fkey")


def test_migration_indexes_the_referencing_column():
# graph_edges.user_id needs an index on the FK referencing side (#179).
sql = _read(FK_MIGRATION)
assert "CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id" in sql


def test_baseline_declares_inline_references():
# Fresh databases must be born with the FKs, declared inline in the baseline.
sql = _read(BASELINE)
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # graph_edges
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # notes
assert "course_id TEXT NOT NULL REFERENCES courses(id)" in sql # notes
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cf46140
chore(db): scaffold FK-integrity migration with audit references (#17…
Jose-Gael-Cruz-Lopez Jun 22, 2026
db92cf3
fix(db): delete orphan graph_edges before adding user_id FK (#179)
Jose-Gael-Cruz-Lopez Jun 22, 2026
f8a1451
fix(db): add graph_edges.user_id FK to users(id), idempotently guarde…
Jose-Gael-Cruz-Lopez Jun 22, 2026
f6b761b
fix(db): delete orphan notes before adding user/course FKs (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
5ea6a39
fix(db): add notes.user_id FK to users(id), idempotently guarded (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
aef9ff0
fix(db): add notes.course_id FK to courses(id), idempotently guarded …
Jose-Gael-Cruz-Lopez Jun 22, 2026
41c3d86
fix(db): add REFERENCES users(id) to graph_edges.user_id in schema (#…
Jose-Gael-Cruz-Lopez Jun 22, 2026
3bfb2b5
fix(db): add REFERENCES to notes.user_id/course_id in schema (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
4c319d8
docs(db): clarify notes FK rationale — only the graph_node link stays…
Jose-Gael-Cruz-Lopez Jun 22, 2026
75d3729
test(db): drift-guard FK-integrity migration constraints + orphan cle…
Jose-Gael-Cruz-Lopez Jun 22, 2026
1ba451f
fix(db): add idx_graph_edges_user_id on FK referencing column (#179)
Jose-Gael-Cruz-Lopez Jun 24, 2026
cc1ec58
docs(db): document actual ON DELETE NO ACTION/RESTRICT semantics of t…
Jose-Gael-Cruz-Lopez Jun 24, 2026
471d1b5
Merge remote-tracking branch 'origin/main' into fix/fk-integrity
Jose-Gael-Cruz-Lopez Jun 24, 2026
1a20cd8
fix(db): move FK-integrity DDL into numbered migration 0020 (#179, #180)
Jose-Gael-Cruz-Lopez Jun 24, 2026
4f8655e
test(db): point FK-integrity drift guard at the canonical migration f…
Jose-Gael-Cruz-Lopez Jun 24, 2026
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
27 changes: 21 additions & 6 deletions backend/db/migrations/0001_baseline_schema.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,23 @@ CREATE TABLE IF NOT EXISTS graph_nodes (
CREATE INDEX IF NOT EXISTS idx_graph_nodes_user_course ON graph_nodes(user_id, course_id);

-- Knowledge graph edges
-- graph_edges.user_id carries a hard FK (#179) with no ON DELETE clause, so it
-- defaults to NO ACTION (RESTRICT): a users row cannot be hard-deleted while
-- edges still reference it. This prevents orphaned edges; it does not cascade.
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #179
source_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
target_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
strength DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT now(),
relationship_type TEXT DEFAULT 'related'
);

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- Learning sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
Expand DownExpand Up@@ -487,16 +494,24 @@ CREATE TABLE IF NOT EXISTS user_cosmetics (
-- filters work for tag-based search. last_summary is the cached output of
-- the most recent /summarize action; null until the user runs it.
--
-- notes.user_id / notes.course_id carry hard FKs (#180) — they are core user
-- data. The FKs have no ON DELETE clause, so they default to NO ACTION
-- (RESTRICT): a user or course row cannot be hard-deleted while notes still
-- reference it. This guarantees notes never become orphaned, but it does NOT
-- cascade-delete them. Today nothing hard-deletes users/courses
-- (delete_account is a soft delete; delete_course only removes the
-- user_courses enrollment row), so RESTRICT never fires. If cleanup-on-delete
-- is ever wanted, switch these to ON DELETE CASCADE and add a hard-delete path.
--
-- note_concepts is a junction table linking notes <-> graph_nodes.
-- ON DELETE CASCADE on note_id ensures deleting a note cleans up its
-- links. The graph_node FK is intentionally NOT a hard FK because
-- graph_nodes uses TEXT ids managed by application code (no enforced FK
-- pattern elsewhere in this codebase — see graph_edges.source_node_id).
-- links. Only the note_concepts.concept_node_id link is intentionally NOT a
-- hard FK, because graph_nodes uses TEXT ids managed by application code.
Comment on lines +508 to +509

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify the rationale for not adding FK to note_concepts.concept_node_id.

The comment states the link is "intentionally NOT a hard FK, because graph_nodes uses TEXT ids managed by application code." However, graph_edges.source_node_id and graph_edges.target_node_id (lines 98-99) both have hard FKs to graph_nodes(id), so the stated reason is inconsistent.

If the real reason is different (e.g., concepts can exist before graph nodes are created, or there's an application-level design consideration), please update the comment to explain the actual rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/supabase_schema.sql` around lines 495 - 496, The comment
explaining why note_concepts.concept_node_id is intentionally not a hard FK
states it's because graph_nodes uses TEXT ids managed by application code, but
this reasoning is inconsistent since graph_edges.source_node_id and
graph_edges.target_node_id also reference graph_nodes(id) with TEXT ids yet have
hard FKs defined. Update the comment at lines 495-496 to clarify the actual
rationale for not adding the FK constraint to note_concepts.concept_node_id,
such as whether concepts can exist before their corresponding graph nodes are
created or if there's a specific application-level design consideration that
necessitates this approach.


CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
course_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #180
course_id TEXT NOT NULL REFERENCES courses(id), -- #180
title TEXT,
body TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
Expand Down
73 changes: 73 additions & 0 deletions backend/db/migrations/0020_fk_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
-- Migration: foreign-key integrity for graph_edges + notes (#179, #180)
--
-- Backfills, on already-migrated databases, the FK constraints that fresh
-- databases now get inline from 0001_baseline_schema.sql. graph_edges.user_id
-- and notes.user_id / notes.course_id historically shipped as bare TEXT columns
-- with no REFERENCES, inconsistent with every sibling learning table:
-- #179 graph_edges.user_id -> users(id)
-- #180 notes.user_id -> users(id)
-- #180 notes.course_id -> courses(id)
--
-- migrate.py wraps each migration in a single transaction, so this is plain
-- (non-CONCURRENT) DDL. Each constraint is added behind a pg_constraint guard
-- because Postgres has no ADD CONSTRAINT IF NOT EXISTS, which also makes this
-- migration a no-op on fresh databases that already have the inline FKs from
-- the baseline. Pre-existing orphan rows are deleted first so the ALTER TABLE
-- can validate.
--
-- ON DELETE semantics: these FKs have no ON DELETE clause, so they default to
-- NO ACTION (RESTRICT). A referenced users/courses row cannot be hard-deleted
-- while a graph_edges/notes row still points at it. This guarantees no orphans
-- but does NOT cascade-delete dependents. Today nothing hard-deletes
-- users/courses (delete_account is a soft delete; delete_course only removes
-- the user_courses enrollment row), so RESTRICT never actually fires. Switch
-- to ON DELETE CASCADE (and add a hard-delete path) if cleanup is ever wanted.

-- #179 graph_edges.user_id: remove edges whose user_id has no users row, then
-- add the FK other learning tables already enforce.
DELETE FROM graph_edges
WHERE user_id NOT IN (SELECT id FROM users);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'graph_edges_user_id_fkey'
) THEN
ALTER TABLE graph_edges
ADD CONSTRAINT graph_edges_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- #180 notes.user_id / notes.course_id: notes is core user data but both
-- columns are bare TEXT. Remove rows pointing at a non-existent user or course
-- (e.g. notes left dangling after a course delete) before adding the FKs.
DELETE FROM notes
WHERE user_id NOT IN (SELECT id FROM users)
OR course_id NOT IN (SELECT id FROM courses);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_user_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_course_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_course_id_fkey
FOREIGN KEY (course_id) REFERENCES courses(id);
END IF;
END $$;
64 changes: 64 additions & 0 deletions backend/tests/test_fk_integrity_migration.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
"""
Drift guard for the FK-integrity migration (#179, #180).

No live Postgres in the unit suite, so we assert the textual invariants that
matter: the numbered migration adds each constraint behind the pg_constraint
guard (re-runnable) and cleans orphans first, and the canonical baseline schema
declares the same REFERENCES inline so a fresh database is born with the FKs.

After main restructured db/ into ordered migrations applied by migrate.py, the
FK DDL lives in migrations/0020_fk_integrity.sql (for already-migrated DBs) and
the inline REFERENCES live in migrations/0001_baseline_schema.sql (for fresh
DBs). The old flat db/supabase_schema.sql / migration_*.sql files were deleted.
"""
import os

_MIGRATIONS = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "db", "migrations"
)

FK_MIGRATION = "0020_fk_integrity.sql"
BASELINE = "0001_baseline_schema.sql"

CONSTRAINTS = (
"graph_edges_user_id_fkey",
"notes_user_id_fkey",
"notes_course_id_fkey",
)


def _read(name: str) -> str:
with open(os.path.join(_MIGRATIONS, name), encoding="utf-8") as fh:
return fh.read()


def test_migration_adds_each_constraint_behind_a_guard():
sql = _read(FK_MIGRATION)
for name in CONSTRAINTS:
assert name in sql, f"{name} missing from migration"
# Every ADD CONSTRAINT must be inside an IF NOT EXISTS pg_constraint guard.
assert sql.count("IF NOT EXISTS") >= len(CONSTRAINTS)
assert "pg_constraint" in sql


def test_migration_cleans_orphans_before_altering():
sql = _read(FK_MIGRATION)
# Orphan deletes must precede the ALTER TABLE that validates the FK.
assert "DELETE FROM graph_edges" in sql
assert "DELETE FROM notes" in sql
assert sql.index("DELETE FROM graph_edges") < sql.index("graph_edges_user_id_fkey")
assert sql.index("DELETE FROM notes") < sql.index("notes_user_id_fkey")


def test_migration_indexes_the_referencing_column():
# graph_edges.user_id needs an index on the FK referencing side (#179).
sql = _read(FK_MIGRATION)
assert "CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id" in sql


def test_baseline_declares_inline_references():
# Fresh databases must be born with the FKs, declared inline in the baseline.
sql = _read(BASELINE)
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # graph_edges
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # notes
assert "course_id TEXT NOT NULL REFERENCES courses(id)" in sql # notes
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cf46140
chore(db): scaffold FK-integrity migration with audit references (#17…
Jose-Gael-Cruz-Lopez Jun 22, 2026
db92cf3
fix(db): delete orphan graph_edges before adding user_id FK (#179)
Jose-Gael-Cruz-Lopez Jun 22, 2026
f8a1451
fix(db): add graph_edges.user_id FK to users(id), idempotently guarde…
Jose-Gael-Cruz-Lopez Jun 22, 2026
f6b761b
fix(db): delete orphan notes before adding user/course FKs (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
5ea6a39
fix(db): add notes.user_id FK to users(id), idempotently guarded (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
aef9ff0
fix(db): add notes.course_id FK to courses(id), idempotently guarded …
Jose-Gael-Cruz-Lopez Jun 22, 2026
41c3d86
fix(db): add REFERENCES users(id) to graph_edges.user_id in schema (#…
Jose-Gael-Cruz-Lopez Jun 22, 2026
3bfb2b5
fix(db): add REFERENCES to notes.user_id/course_id in schema (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
4c319d8
docs(db): clarify notes FK rationale — only the graph_node link stays…
Jose-Gael-Cruz-Lopez Jun 22, 2026
75d3729
test(db): drift-guard FK-integrity migration constraints + orphan cle…
Jose-Gael-Cruz-Lopez Jun 22, 2026
1ba451f
fix(db): add idx_graph_edges_user_id on FK referencing column (#179)
Jose-Gael-Cruz-Lopez Jun 24, 2026
cc1ec58
docs(db): document actual ON DELETE NO ACTION/RESTRICT semantics of t…
Jose-Gael-Cruz-Lopez Jun 24, 2026
471d1b5
Merge remote-tracking branch 'origin/main' into fix/fk-integrity
Jose-Gael-Cruz-Lopez Jun 24, 2026
1a20cd8
fix(db): move FK-integrity DDL into numbered migration 0020 (#179, #180)
Jose-Gael-Cruz-Lopez Jun 24, 2026
4f8655e
test(db): point FK-integrity drift guard at the canonical migration f…
Jose-Gael-Cruz-Lopez Jun 24, 2026
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
27 changes: 21 additions & 6 deletions backend/db/migrations/0001_baseline_schema.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,23 @@ CREATE TABLE IF NOT EXISTS graph_nodes (
CREATE INDEX IF NOT EXISTS idx_graph_nodes_user_course ON graph_nodes(user_id, course_id);

-- Knowledge graph edges
-- graph_edges.user_id carries a hard FK (#179) with no ON DELETE clause, so it
-- defaults to NO ACTION (RESTRICT): a users row cannot be hard-deleted while
-- edges still reference it. This prevents orphaned edges; it does not cascade.
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #179
source_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
target_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
strength DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT now(),
relationship_type TEXT DEFAULT 'related'
);

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- Learning sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
Expand DownExpand Up@@ -487,16 +494,24 @@ CREATE TABLE IF NOT EXISTS user_cosmetics (
-- filters work for tag-based search. last_summary is the cached output of
-- the most recent /summarize action; null until the user runs it.
--
-- notes.user_id / notes.course_id carry hard FKs (#180) — they are core user
-- data. The FKs have no ON DELETE clause, so they default to NO ACTION
-- (RESTRICT): a user or course row cannot be hard-deleted while notes still
-- reference it. This guarantees notes never become orphaned, but it does NOT
-- cascade-delete them. Today nothing hard-deletes users/courses
-- (delete_account is a soft delete; delete_course only removes the
-- user_courses enrollment row), so RESTRICT never fires. If cleanup-on-delete
-- is ever wanted, switch these to ON DELETE CASCADE and add a hard-delete path.
--
-- note_concepts is a junction table linking notes <-> graph_nodes.
-- ON DELETE CASCADE on note_id ensures deleting a note cleans up its
-- links. The graph_node FK is intentionally NOT a hard FK because
-- graph_nodes uses TEXT ids managed by application code (no enforced FK
-- pattern elsewhere in this codebase — see graph_edges.source_node_id).
-- links. Only the note_concepts.concept_node_id link is intentionally NOT a
-- hard FK, because graph_nodes uses TEXT ids managed by application code.
Comment on lines +508 to +509

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify the rationale for not adding FK to note_concepts.concept_node_id.

The comment states the link is "intentionally NOT a hard FK, because graph_nodes uses TEXT ids managed by application code." However, graph_edges.source_node_id and graph_edges.target_node_id (lines 98-99) both have hard FKs to graph_nodes(id), so the stated reason is inconsistent.

If the real reason is different (e.g., concepts can exist before graph nodes are created, or there's an application-level design consideration), please update the comment to explain the actual rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/supabase_schema.sql` around lines 495 - 496, The comment
explaining why note_concepts.concept_node_id is intentionally not a hard FK
states it's because graph_nodes uses TEXT ids managed by application code, but
this reasoning is inconsistent since graph_edges.source_node_id and
graph_edges.target_node_id also reference graph_nodes(id) with TEXT ids yet have
hard FKs defined. Update the comment at lines 495-496 to clarify the actual
rationale for not adding the FK constraint to note_concepts.concept_node_id,
such as whether concepts can exist before their corresponding graph nodes are
created or if there's a specific application-level design consideration that
necessitates this approach.


CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
course_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #180
course_id TEXT NOT NULL REFERENCES courses(id), -- #180
title TEXT,
body TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
Expand Down
73 changes: 73 additions & 0 deletions backend/db/migrations/0020_fk_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
-- Migration: foreign-key integrity for graph_edges + notes (#179, #180)
--
-- Backfills, on already-migrated databases, the FK constraints that fresh
-- databases now get inline from 0001_baseline_schema.sql. graph_edges.user_id
-- and notes.user_id / notes.course_id historically shipped as bare TEXT columns
-- with no REFERENCES, inconsistent with every sibling learning table:
-- #179 graph_edges.user_id -> users(id)
-- #180 notes.user_id -> users(id)
-- #180 notes.course_id -> courses(id)
--
-- migrate.py wraps each migration in a single transaction, so this is plain
-- (non-CONCURRENT) DDL. Each constraint is added behind a pg_constraint guard
-- because Postgres has no ADD CONSTRAINT IF NOT EXISTS, which also makes this
-- migration a no-op on fresh databases that already have the inline FKs from
-- the baseline. Pre-existing orphan rows are deleted first so the ALTER TABLE
-- can validate.
--
-- ON DELETE semantics: these FKs have no ON DELETE clause, so they default to
-- NO ACTION (RESTRICT). A referenced users/courses row cannot be hard-deleted
-- while a graph_edges/notes row still points at it. This guarantees no orphans
-- but does NOT cascade-delete dependents. Today nothing hard-deletes
-- users/courses (delete_account is a soft delete; delete_course only removes
-- the user_courses enrollment row), so RESTRICT never actually fires. Switch
-- to ON DELETE CASCADE (and add a hard-delete path) if cleanup is ever wanted.

-- #179 graph_edges.user_id: remove edges whose user_id has no users row, then
-- add the FK other learning tables already enforce.
DELETE FROM graph_edges
WHERE user_id NOT IN (SELECT id FROM users);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'graph_edges_user_id_fkey'
) THEN
ALTER TABLE graph_edges
ADD CONSTRAINT graph_edges_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- #180 notes.user_id / notes.course_id: notes is core user data but both
-- columns are bare TEXT. Remove rows pointing at a non-existent user or course
-- (e.g. notes left dangling after a course delete) before adding the FKs.
DELETE FROM notes
WHERE user_id NOT IN (SELECT id FROM users)
OR course_id NOT IN (SELECT id FROM courses);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_user_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_course_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_course_id_fkey
FOREIGN KEY (course_id) REFERENCES courses(id);
END IF;
END $$;
64 changes: 64 additions & 0 deletions backend/tests/test_fk_integrity_migration.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
"""
Drift guard for the FK-integrity migration (#179, #180).

No live Postgres in the unit suite, so we assert the textual invariants that
matter: the numbered migration adds each constraint behind the pg_constraint
guard (re-runnable) and cleans orphans first, and the canonical baseline schema
declares the same REFERENCES inline so a fresh database is born with the FKs.

After main restructured db/ into ordered migrations applied by migrate.py, the
FK DDL lives in migrations/0020_fk_integrity.sql (for already-migrated DBs) and
the inline REFERENCES live in migrations/0001_baseline_schema.sql (for fresh
DBs). The old flat db/supabase_schema.sql / migration_*.sql files were deleted.
"""
import os

_MIGRATIONS = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "db", "migrations"
)

FK_MIGRATION = "0020_fk_integrity.sql"
BASELINE = "0001_baseline_schema.sql"

CONSTRAINTS = (
"graph_edges_user_id_fkey",
"notes_user_id_fkey",
"notes_course_id_fkey",
)


def _read(name: str) -> str:
with open(os.path.join(_MIGRATIONS, name), encoding="utf-8") as fh:
return fh.read()


def test_migration_adds_each_constraint_behind_a_guard():
sql = _read(FK_MIGRATION)
for name in CONSTRAINTS:
assert name in sql, f"{name} missing from migration"
# Every ADD CONSTRAINT must be inside an IF NOT EXISTS pg_constraint guard.
assert sql.count("IF NOT EXISTS") >= len(CONSTRAINTS)
assert "pg_constraint" in sql


def test_migration_cleans_orphans_before_altering():
sql = _read(FK_MIGRATION)
# Orphan deletes must precede the ALTER TABLE that validates the FK.
assert "DELETE FROM graph_edges" in sql
assert "DELETE FROM notes" in sql
assert sql.index("DELETE FROM graph_edges") < sql.index("graph_edges_user_id_fkey")
assert sql.index("DELETE FROM notes") < sql.index("notes_user_id_fkey")


def test_migration_indexes_the_referencing_column():
# graph_edges.user_id needs an index on the FK referencing side (#179).
sql = _read(FK_MIGRATION)
assert "CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id" in sql


def test_baseline_declares_inline_references():
# Fresh databases must be born with the FKs, declared inline in the baseline.
sql = _read(BASELINE)
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # graph_edges
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # notes
assert "course_id TEXT NOT NULL REFERENCES courses(id)" in sql # notes
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cf46140
chore(db): scaffold FK-integrity migration with audit references (#17…
Jose-Gael-Cruz-Lopez Jun 22, 2026
db92cf3
fix(db): delete orphan graph_edges before adding user_id FK (#179)
Jose-Gael-Cruz-Lopez Jun 22, 2026
f8a1451
fix(db): add graph_edges.user_id FK to users(id), idempotently guarde…
Jose-Gael-Cruz-Lopez Jun 22, 2026
f6b761b
fix(db): delete orphan notes before adding user/course FKs (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
5ea6a39
fix(db): add notes.user_id FK to users(id), idempotently guarded (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
aef9ff0
fix(db): add notes.course_id FK to courses(id), idempotently guarded …
Jose-Gael-Cruz-Lopez Jun 22, 2026
41c3d86
fix(db): add REFERENCES users(id) to graph_edges.user_id in schema (#…
Jose-Gael-Cruz-Lopez Jun 22, 2026
3bfb2b5
fix(db): add REFERENCES to notes.user_id/course_id in schema (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
4c319d8
docs(db): clarify notes FK rationale — only the graph_node link stays…
Jose-Gael-Cruz-Lopez Jun 22, 2026
75d3729
test(db): drift-guard FK-integrity migration constraints + orphan cle…
Jose-Gael-Cruz-Lopez Jun 22, 2026
1ba451f
fix(db): add idx_graph_edges_user_id on FK referencing column (#179)
Jose-Gael-Cruz-Lopez Jun 24, 2026
cc1ec58
docs(db): document actual ON DELETE NO ACTION/RESTRICT semantics of t…
Jose-Gael-Cruz-Lopez Jun 24, 2026
471d1b5
Merge remote-tracking branch 'origin/main' into fix/fk-integrity
Jose-Gael-Cruz-Lopez Jun 24, 2026
1a20cd8
fix(db): move FK-integrity DDL into numbered migration 0020 (#179, #180)
Jose-Gael-Cruz-Lopez Jun 24, 2026
4f8655e
test(db): point FK-integrity drift guard at the canonical migration f…
Jose-Gael-Cruz-Lopez Jun 24, 2026
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
27 changes: 21 additions & 6 deletions backend/db/migrations/0001_baseline_schema.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,23 @@ CREATE TABLE IF NOT EXISTS graph_nodes (
CREATE INDEX IF NOT EXISTS idx_graph_nodes_user_course ON graph_nodes(user_id, course_id);

-- Knowledge graph edges
-- graph_edges.user_id carries a hard FK (#179) with no ON DELETE clause, so it
-- defaults to NO ACTION (RESTRICT): a users row cannot be hard-deleted while
-- edges still reference it. This prevents orphaned edges; it does not cascade.
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #179
source_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
target_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
strength DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT now(),
relationship_type TEXT DEFAULT 'related'
);

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- Learning sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
Expand DownExpand Up@@ -487,16 +494,24 @@ CREATE TABLE IF NOT EXISTS user_cosmetics (
-- filters work for tag-based search. last_summary is the cached output of
-- the most recent /summarize action; null until the user runs it.
--
-- notes.user_id / notes.course_id carry hard FKs (#180) — they are core user
-- data. The FKs have no ON DELETE clause, so they default to NO ACTION
-- (RESTRICT): a user or course row cannot be hard-deleted while notes still
-- reference it. This guarantees notes never become orphaned, but it does NOT
-- cascade-delete them. Today nothing hard-deletes users/courses
-- (delete_account is a soft delete; delete_course only removes the
-- user_courses enrollment row), so RESTRICT never fires. If cleanup-on-delete
-- is ever wanted, switch these to ON DELETE CASCADE and add a hard-delete path.
--
-- note_concepts is a junction table linking notes <-> graph_nodes.
-- ON DELETE CASCADE on note_id ensures deleting a note cleans up its
-- links. The graph_node FK is intentionally NOT a hard FK because
-- graph_nodes uses TEXT ids managed by application code (no enforced FK
-- pattern elsewhere in this codebase — see graph_edges.source_node_id).
-- links. Only the note_concepts.concept_node_id link is intentionally NOT a
-- hard FK, because graph_nodes uses TEXT ids managed by application code.
Comment on lines +508 to +509

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify the rationale for not adding FK to note_concepts.concept_node_id.

The comment states the link is "intentionally NOT a hard FK, because graph_nodes uses TEXT ids managed by application code." However, graph_edges.source_node_id and graph_edges.target_node_id (lines 98-99) both have hard FKs to graph_nodes(id), so the stated reason is inconsistent.

If the real reason is different (e.g., concepts can exist before graph nodes are created, or there's an application-level design consideration), please update the comment to explain the actual rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/supabase_schema.sql` around lines 495 - 496, The comment
explaining why note_concepts.concept_node_id is intentionally not a hard FK
states it's because graph_nodes uses TEXT ids managed by application code, but
this reasoning is inconsistent since graph_edges.source_node_id and
graph_edges.target_node_id also reference graph_nodes(id) with TEXT ids yet have
hard FKs defined. Update the comment at lines 495-496 to clarify the actual
rationale for not adding the FK constraint to note_concepts.concept_node_id,
such as whether concepts can exist before their corresponding graph nodes are
created or if there's a specific application-level design consideration that
necessitates this approach.


CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
course_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #180
course_id TEXT NOT NULL REFERENCES courses(id), -- #180
title TEXT,
body TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
Expand Down
73 changes: 73 additions & 0 deletions backend/db/migrations/0020_fk_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
-- Migration: foreign-key integrity for graph_edges + notes (#179, #180)
--
-- Backfills, on already-migrated databases, the FK constraints that fresh
-- databases now get inline from 0001_baseline_schema.sql. graph_edges.user_id
-- and notes.user_id / notes.course_id historically shipped as bare TEXT columns
-- with no REFERENCES, inconsistent with every sibling learning table:
-- #179 graph_edges.user_id -> users(id)
-- #180 notes.user_id -> users(id)
-- #180 notes.course_id -> courses(id)
--
-- migrate.py wraps each migration in a single transaction, so this is plain
-- (non-CONCURRENT) DDL. Each constraint is added behind a pg_constraint guard
-- because Postgres has no ADD CONSTRAINT IF NOT EXISTS, which also makes this
-- migration a no-op on fresh databases that already have the inline FKs from
-- the baseline. Pre-existing orphan rows are deleted first so the ALTER TABLE
-- can validate.
--
-- ON DELETE semantics: these FKs have no ON DELETE clause, so they default to
-- NO ACTION (RESTRICT). A referenced users/courses row cannot be hard-deleted
-- while a graph_edges/notes row still points at it. This guarantees no orphans
-- but does NOT cascade-delete dependents. Today nothing hard-deletes
-- users/courses (delete_account is a soft delete; delete_course only removes
-- the user_courses enrollment row), so RESTRICT never actually fires. Switch
-- to ON DELETE CASCADE (and add a hard-delete path) if cleanup is ever wanted.

-- #179 graph_edges.user_id: remove edges whose user_id has no users row, then
-- add the FK other learning tables already enforce.
DELETE FROM graph_edges
WHERE user_id NOT IN (SELECT id FROM users);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'graph_edges_user_id_fkey'
) THEN
ALTER TABLE graph_edges
ADD CONSTRAINT graph_edges_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- #180 notes.user_id / notes.course_id: notes is core user data but both
-- columns are bare TEXT. Remove rows pointing at a non-existent user or course
-- (e.g. notes left dangling after a course delete) before adding the FKs.
DELETE FROM notes
WHERE user_id NOT IN (SELECT id FROM users)
OR course_id NOT IN (SELECT id FROM courses);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_user_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_course_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_course_id_fkey
FOREIGN KEY (course_id) REFERENCES courses(id);
END IF;
END $$;
64 changes: 64 additions & 0 deletions backend/tests/test_fk_integrity_migration.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
"""
Drift guard for the FK-integrity migration (#179, #180).

No live Postgres in the unit suite, so we assert the textual invariants that
matter: the numbered migration adds each constraint behind the pg_constraint
guard (re-runnable) and cleans orphans first, and the canonical baseline schema
declares the same REFERENCES inline so a fresh database is born with the FKs.

After main restructured db/ into ordered migrations applied by migrate.py, the
FK DDL lives in migrations/0020_fk_integrity.sql (for already-migrated DBs) and
the inline REFERENCES live in migrations/0001_baseline_schema.sql (for fresh
DBs). The old flat db/supabase_schema.sql / migration_*.sql files were deleted.
"""
import os

_MIGRATIONS = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "db", "migrations"
)

FK_MIGRATION = "0020_fk_integrity.sql"
BASELINE = "0001_baseline_schema.sql"

CONSTRAINTS = (
"graph_edges_user_id_fkey",
"notes_user_id_fkey",
"notes_course_id_fkey",
)


def _read(name: str) -> str:
with open(os.path.join(_MIGRATIONS, name), encoding="utf-8") as fh:
return fh.read()


def test_migration_adds_each_constraint_behind_a_guard():
sql = _read(FK_MIGRATION)
for name in CONSTRAINTS:
assert name in sql, f"{name} missing from migration"
# Every ADD CONSTRAINT must be inside an IF NOT EXISTS pg_constraint guard.
assert sql.count("IF NOT EXISTS") >= len(CONSTRAINTS)
assert "pg_constraint" in sql


def test_migration_cleans_orphans_before_altering():
sql = _read(FK_MIGRATION)
# Orphan deletes must precede the ALTER TABLE that validates the FK.
assert "DELETE FROM graph_edges" in sql
assert "DELETE FROM notes" in sql
assert sql.index("DELETE FROM graph_edges") < sql.index("graph_edges_user_id_fkey")
assert sql.index("DELETE FROM notes") < sql.index("notes_user_id_fkey")


def test_migration_indexes_the_referencing_column():
# graph_edges.user_id needs an index on the FK referencing side (#179).
sql = _read(FK_MIGRATION)
assert "CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id" in sql


def test_baseline_declares_inline_references():
# Fresh databases must be born with the FKs, declared inline in the baseline.
sql = _read(BASELINE)
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # graph_edges
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # notes
assert "course_id TEXT NOT NULL REFERENCES courses(id)" in sql # notes
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cf46140
chore(db): scaffold FK-integrity migration with audit references (#17…
Jose-Gael-Cruz-Lopez Jun 22, 2026
db92cf3
fix(db): delete orphan graph_edges before adding user_id FK (#179)
Jose-Gael-Cruz-Lopez Jun 22, 2026
f8a1451
fix(db): add graph_edges.user_id FK to users(id), idempotently guarde…
Jose-Gael-Cruz-Lopez Jun 22, 2026
f6b761b
fix(db): delete orphan notes before adding user/course FKs (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
5ea6a39
fix(db): add notes.user_id FK to users(id), idempotently guarded (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
aef9ff0
fix(db): add notes.course_id FK to courses(id), idempotently guarded …
Jose-Gael-Cruz-Lopez Jun 22, 2026
41c3d86
fix(db): add REFERENCES users(id) to graph_edges.user_id in schema (#…
Jose-Gael-Cruz-Lopez Jun 22, 2026
3bfb2b5
fix(db): add REFERENCES to notes.user_id/course_id in schema (#180)
Jose-Gael-Cruz-Lopez Jun 22, 2026
4c319d8
docs(db): clarify notes FK rationale — only the graph_node link stays…
Jose-Gael-Cruz-Lopez Jun 22, 2026
75d3729
test(db): drift-guard FK-integrity migration constraints + orphan cle…
Jose-Gael-Cruz-Lopez Jun 22, 2026
1ba451f
fix(db): add idx_graph_edges_user_id on FK referencing column (#179)
Jose-Gael-Cruz-Lopez Jun 24, 2026
cc1ec58
docs(db): document actual ON DELETE NO ACTION/RESTRICT semantics of t…
Jose-Gael-Cruz-Lopez Jun 24, 2026
471d1b5
Merge remote-tracking branch 'origin/main' into fix/fk-integrity
Jose-Gael-Cruz-Lopez Jun 24, 2026
1a20cd8
fix(db): move FK-integrity DDL into numbered migration 0020 (#179, #180)
Jose-Gael-Cruz-Lopez Jun 24, 2026
4f8655e
test(db): point FK-integrity drift guard at the canonical migration f…
Jose-Gael-Cruz-Lopez Jun 24, 2026
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
27 changes: 21 additions & 6 deletions backend/db/migrations/0001_baseline_schema.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,23 @@ CREATE TABLE IF NOT EXISTS graph_nodes (
CREATE INDEX IF NOT EXISTS idx_graph_nodes_user_course ON graph_nodes(user_id, course_id);

-- Knowledge graph edges
-- graph_edges.user_id carries a hard FK (#179) with no ON DELETE clause, so it
-- defaults to NO ACTION (RESTRICT): a users row cannot be hard-deleted while
-- edges still reference it. This prevents orphaned edges; it does not cascade.
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #179
source_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
target_node_id TEXT NOT NULL REFERENCES graph_nodes(id),
strength DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT now(),
relationship_type TEXT DEFAULT 'related'
);

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- Learning sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
Expand DownExpand Up@@ -487,16 +494,24 @@ CREATE TABLE IF NOT EXISTS user_cosmetics (
-- filters work for tag-based search. last_summary is the cached output of
-- the most recent /summarize action; null until the user runs it.
--
-- notes.user_id / notes.course_id carry hard FKs (#180) — they are core user
-- data. The FKs have no ON DELETE clause, so they default to NO ACTION
-- (RESTRICT): a user or course row cannot be hard-deleted while notes still
-- reference it. This guarantees notes never become orphaned, but it does NOT
-- cascade-delete them. Today nothing hard-deletes users/courses
-- (delete_account is a soft delete; delete_course only removes the
-- user_courses enrollment row), so RESTRICT never fires. If cleanup-on-delete
-- is ever wanted, switch these to ON DELETE CASCADE and add a hard-delete path.
--
-- note_concepts is a junction table linking notes <-> graph_nodes.
-- ON DELETE CASCADE on note_id ensures deleting a note cleans up its
-- links. The graph_node FK is intentionally NOT a hard FK because
-- graph_nodes uses TEXT ids managed by application code (no enforced FK
-- pattern elsewhere in this codebase — see graph_edges.source_node_id).
-- links. Only the note_concepts.concept_node_id link is intentionally NOT a
-- hard FK, because graph_nodes uses TEXT ids managed by application code.
Comment on lines +508 to +509

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify the rationale for not adding FK to note_concepts.concept_node_id.

The comment states the link is "intentionally NOT a hard FK, because graph_nodes uses TEXT ids managed by application code." However, graph_edges.source_node_id and graph_edges.target_node_id (lines 98-99) both have hard FKs to graph_nodes(id), so the stated reason is inconsistent.

If the real reason is different (e.g., concepts can exist before graph nodes are created, or there's an application-level design consideration), please update the comment to explain the actual rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/supabase_schema.sql` around lines 495 - 496, The comment
explaining why note_concepts.concept_node_id is intentionally not a hard FK
states it's because graph_nodes uses TEXT ids managed by application code, but
this reasoning is inconsistent since graph_edges.source_node_id and
graph_edges.target_node_id also reference graph_nodes(id) with TEXT ids yet have
hard FKs defined. Update the comment at lines 495-496 to clarify the actual
rationale for not adding the FK constraint to note_concepts.concept_node_id,
such as whether concepts can exist before their corresponding graph nodes are
created or if there's a specific application-level design consideration that
necessitates this approach.


CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
course_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id), -- #180
course_id TEXT NOT NULL REFERENCES courses(id), -- #180
title TEXT,
body TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
Expand Down
73 changes: 73 additions & 0 deletions backend/db/migrations/0020_fk_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
-- Migration: foreign-key integrity for graph_edges + notes (#179, #180)
--
-- Backfills, on already-migrated databases, the FK constraints that fresh
-- databases now get inline from 0001_baseline_schema.sql. graph_edges.user_id
-- and notes.user_id / notes.course_id historically shipped as bare TEXT columns
-- with no REFERENCES, inconsistent with every sibling learning table:
-- #179 graph_edges.user_id -> users(id)
-- #180 notes.user_id -> users(id)
-- #180 notes.course_id -> courses(id)
--
-- migrate.py wraps each migration in a single transaction, so this is plain
-- (non-CONCURRENT) DDL. Each constraint is added behind a pg_constraint guard
-- because Postgres has no ADD CONSTRAINT IF NOT EXISTS, which also makes this
-- migration a no-op on fresh databases that already have the inline FKs from
-- the baseline. Pre-existing orphan rows are deleted first so the ALTER TABLE
-- can validate.
--
-- ON DELETE semantics: these FKs have no ON DELETE clause, so they default to
-- NO ACTION (RESTRICT). A referenced users/courses row cannot be hard-deleted
-- while a graph_edges/notes row still points at it. This guarantees no orphans
-- but does NOT cascade-delete dependents. Today nothing hard-deletes
-- users/courses (delete_account is a soft delete; delete_course only removes
-- the user_courses enrollment row), so RESTRICT never actually fires. Switch
-- to ON DELETE CASCADE (and add a hard-delete path) if cleanup is ever wanted.

-- #179 graph_edges.user_id: remove edges whose user_id has no users row, then
-- add the FK other learning tables already enforce.
DELETE FROM graph_edges
WHERE user_id NOT IN (SELECT id FROM users);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'graph_edges_user_id_fkey'
) THEN
ALTER TABLE graph_edges
ADD CONSTRAINT graph_edges_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

-- Index the FK referencing column: Postgres does not auto-index the
-- referencing side of a foreign key, and sibling tables index this path.
CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id ON graph_edges(user_id);

-- #180 notes.user_id / notes.course_id: notes is core user data but both
-- columns are bare TEXT. Remove rows pointing at a non-existent user or course
-- (e.g. notes left dangling after a course delete) before adding the FKs.
DELETE FROM notes
WHERE user_id NOT IN (SELECT id FROM users)
OR course_id NOT IN (SELECT id FROM courses);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_user_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
END IF;
END $$;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_course_id_fkey'
) THEN
ALTER TABLE notes
ADD CONSTRAINT notes_course_id_fkey
FOREIGN KEY (course_id) REFERENCES courses(id);
END IF;
END $$;
64 changes: 64 additions & 0 deletions backend/tests/test_fk_integrity_migration.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
"""
Drift guard for the FK-integrity migration (#179, #180).

No live Postgres in the unit suite, so we assert the textual invariants that
matter: the numbered migration adds each constraint behind the pg_constraint
guard (re-runnable) and cleans orphans first, and the canonical baseline schema
declares the same REFERENCES inline so a fresh database is born with the FKs.

After main restructured db/ into ordered migrations applied by migrate.py, the
FK DDL lives in migrations/0020_fk_integrity.sql (for already-migrated DBs) and
the inline REFERENCES live in migrations/0001_baseline_schema.sql (for fresh
DBs). The old flat db/supabase_schema.sql / migration_*.sql files were deleted.
"""
import os

_MIGRATIONS = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "db", "migrations"
)

FK_MIGRATION = "0020_fk_integrity.sql"
BASELINE = "0001_baseline_schema.sql"

CONSTRAINTS = (
"graph_edges_user_id_fkey",
"notes_user_id_fkey",
"notes_course_id_fkey",
)


def _read(name: str) -> str:
with open(os.path.join(_MIGRATIONS, name), encoding="utf-8") as fh:
return fh.read()


def test_migration_adds_each_constraint_behind_a_guard():
sql = _read(FK_MIGRATION)
for name in CONSTRAINTS:
assert name in sql, f"{name} missing from migration"
# Every ADD CONSTRAINT must be inside an IF NOT EXISTS pg_constraint guard.
assert sql.count("IF NOT EXISTS") >= len(CONSTRAINTS)
assert "pg_constraint" in sql


def test_migration_cleans_orphans_before_altering():
sql = _read(FK_MIGRATION)
# Orphan deletes must precede the ALTER TABLE that validates the FK.
assert "DELETE FROM graph_edges" in sql
assert "DELETE FROM notes" in sql
assert sql.index("DELETE FROM graph_edges") < sql.index("graph_edges_user_id_fkey")
assert sql.index("DELETE FROM notes") < sql.index("notes_user_id_fkey")


def test_migration_indexes_the_referencing_column():
# graph_edges.user_id needs an index on the FK referencing side (#179).
sql = _read(FK_MIGRATION)
assert "CREATE INDEX IF NOT EXISTS idx_graph_edges_user_id" in sql


def test_baseline_declares_inline_references():
# Fresh databases must be born with the FKs, declared inline in the baseline.
sql = _read(BASELINE)
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # graph_edges
assert "user_id TEXT NOT NULL REFERENCES users(id)" in sql # notes
assert "course_id TEXT NOT NULL REFERENCES courses(id)" in sql # notes
Loading