Three security-review findings on migration 002: - pages.space_id and refs.space_id changed to NOT NULL + ON DELETE CASCADE (was nullable + SET NULL, which allowed orphan rows surviving space deletion). - pages.parent_id wrapped in composite FK (parent_id, space_id) -> pages(id, space_id) to prevent cross-space parent linkage (same pattern as tasks.project_id in 001). - idx_refs_external promoted to UNIQUE on (space_id, source_kind, external_id); upsertByExternal now requires space_id and dedups per-space, not globally. Added 3 regression tests covering composite FK rejection, CASCADE-on-space-delete, and per-space dedup independence.
70 lines
2.6 KiB
SQL
70 lines
2.6 KiB
SQL
CREATE TABLE pages (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
space_id uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
|
slug text NOT NULL,
|
|
title text NOT NULL,
|
|
body_md text NOT NULL DEFAULT '',
|
|
body_html text,
|
|
parent_id uuid,
|
|
-- Same-space parent enforcement (mirrors tasks.project_id pattern)
|
|
FOREIGN KEY (parent_id, space_id) REFERENCES pages(id, space_id)
|
|
ON DELETE SET NULL (parent_id),
|
|
embedding vector(1024),
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE (space_id, slug),
|
|
-- Composite key target for parent_id self-reference above + any future FK
|
|
UNIQUE (id, space_id)
|
|
);
|
|
|
|
CREATE TABLE page_revisions (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
body_md text NOT NULL,
|
|
edited_by text,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE refs (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
space_id uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
|
kind text NOT NULL
|
|
CHECK (kind IN ('url','video','pdf','image','file')),
|
|
source_url text,
|
|
title text,
|
|
description text,
|
|
summary text,
|
|
body_text text,
|
|
blob_path text,
|
|
thumbnail text,
|
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
embedding vector(1024),
|
|
status text NOT NULL DEFAULT 'ingested'
|
|
CHECK (status IN ('ingested','indexed','enriched')),
|
|
source_kind text,
|
|
external_id text,
|
|
captured_at timestamptz NOT NULL DEFAULT now(),
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX idx_pages_space ON pages(space_id);
|
|
CREATE INDEX idx_pages_parent ON pages(parent_id);
|
|
CREATE INDEX idx_pages_fts ON pages
|
|
USING GIN (to_tsvector('english', title || ' ' || body_md));
|
|
CREATE INDEX idx_pages_embed ON pages
|
|
USING hnsw (embedding vector_cosine_ops);
|
|
|
|
CREATE INDEX idx_refs_space ON refs(space_id);
|
|
CREATE INDEX idx_refs_kind ON refs(kind);
|
|
-- Per-space uniqueness on external dedup (mirrors tenancy-first pattern of 001).
|
|
-- upsertByExternal MUST pass space_id; cross-space same-external_id creates distinct rows.
|
|
CREATE UNIQUE INDEX idx_refs_external_unique
|
|
ON refs(space_id, source_kind, external_id)
|
|
WHERE source_kind IS NOT NULL AND external_id IS NOT NULL;
|
|
CREATE INDEX idx_refs_fts ON refs USING GIN (
|
|
to_tsvector('english',
|
|
coalesce(title,'') || ' ' || coalesce(summary,'') || ' ' || coalesce(body_text,''))
|
|
);
|
|
CREATE INDEX idx_refs_embed ON refs USING hnsw (embedding vector_cosine_ops);
|