server/migrations/0001_initial_schema.sql
vegard a5985ef3f8 Dokumentasjon, erfaringslogg, migrasjoner og infra-oppdateringer
- Omorganiser docs/: konsepter, features, infra og proposals i egne mapper
- Ny docs/erfaringer/ med lærdommer fra chat-implementering (Svelte 5, SpacetimeDB, adapter-mønster)
- Oppdater ARCHITECTURE.md: Lag 1 status, ny §10 Erfaringslogg, SpacetimeDB i lokal dev
- Oppdater synkronisering.md med implementeringsstatus og designvalg
- Oppdater lokal.md med SpacetimeDB og AI Gateway
- Utvid PG-skjema med channels, messages, media_files, message_revisions
- Legg til seed_dev.sql, migration_safety.md, .env.example
- Nye feature-specs: chat, kanban, whiteboard, live_ai, lydmeldinger m.fl.
- Nye konsept-specs: studioet, møterommet, redaksjonen, den asynkrone gjesten m.fl.
- SpacetimeDB og AI Gateway i docker-compose.dev.yml
- collect-docs.sh inkluderer erfaringer/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:40:14 +01:00

363 lines
15 KiB
PL/PgSQL

-- Sidelinja: Initial database schema
-- Workspaces, kunnskapsgraf, jobbkø, meldinger, mediefiler
--
-- Kjøres mot en tom PostgreSQL-database.
-- Krever: PostgreSQL 15+ med pgcrypto (gen_random_uuid)
BEGIN;
-- ============================================================
-- Extensions
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- uuid_generate_v5() for deterministiske segment-UUIDs
-- ============================================================
-- 0. Felles: updated_at trigger-funksjon
-- ============================================================
-- Brukes av alle tabeller som trenger automatisk oppdatering av
-- updated_at-kolonnen. Nødvendig for inkrementell synk, cache-
-- invalidering og UI ("sist redigert").
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- 1. Workspaces
-- ============================================================
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL, -- URL-vennlig ID, brukes i filstier og media-ruting
domain TEXT UNIQUE, -- Valgfritt eget domene for RSS/media
settings JSONB NOT NULL DEFAULT '{}', -- Workspace-config: Whisper initial_prompt, AI system-prompts
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER trg_workspaces_updated_at BEFORE UPDATE ON workspaces
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ============================================================
-- 2. Brukere (tynn cache fra Authentik)
-- ============================================================
CREATE TABLE users (
authentik_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
avatar_url TEXT,
cached_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TYPE workspace_role AS ENUM ('owner', 'admin', 'member');
CREATE TABLE workspace_members (
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
role workspace_role NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, user_id)
);
-- ============================================================
-- 3. Nodes — supertabell for alle grafentiteter
-- ============================================================
CREATE TYPE node_type AS ENUM (
'tema', 'aktør', 'faktoide', 'episode', 'segment',
'channel', 'melding', 'meeting'
);
CREATE TABLE nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
node_type node_type NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_nodes_workspace ON nodes(workspace_id);
CREATE INDEX idx_nodes_type ON nodes(workspace_id, node_type);
CREATE TRIGGER trg_nodes_updated_at BEFORE UPDATE ON nodes
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ============================================================
-- 4. Relasjonstyper (globale, ikke workspace-spesifikke)
-- ============================================================
CREATE TABLE relation_types (
name TEXT PRIMARY KEY,
label TEXT NOT NULL,
description TEXT,
system BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO relation_types (name, label, description, system) VALUES
('MENTIONS', 'Nevner', 'Refererer til denne aktøren/temaet', true),
('ABOUT', 'Handler om', 'Faktoide eller klipp som omhandler', true),
('DISCUSSED_IN', 'Diskutert i', 'Tema diskutert i dette segmentet', true),
('WORKS_FOR', 'Jobber for', 'Person tilknyttet organisasjon', true),
('CONTRADICTS', 'Motsier', 'Står i motsetning til', true),
('PART_OF', 'Del av', 'Tilhører / er underordnet', true),
('AFFECTS_AXIS', 'Påvirker akse', 'Valgomat: spørsmål påvirker akse', true);
-- ============================================================
-- 5. Graph edges (workspace-scopet med RLS)
-- ============================================================
-- workspace_id er denormalisert fra nodes for å muliggjøre RLS direkte
-- på edges. Uten dette kan en direkte SELECT på graph_edges lekke
-- relasjoner på tvers av workspaces.
CREATE TABLE graph_edges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
source_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
target_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL REFERENCES relation_types(name),
context_id UUID REFERENCES nodes(id) ON DELETE SET NULL,
confidence REAL CHECK (confidence BETWEEN 0.0 AND 1.0),
created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
origin TEXT NOT NULL DEFAULT 'system',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT no_self_reference CHECK (source_id != target_id),
CONSTRAINT unique_edge UNIQUE (source_id, target_id, relation_type)
);
CREATE INDEX idx_edges_source ON graph_edges(source_id);
CREATE INDEX idx_edges_target ON graph_edges(target_id);
CREATE INDEX idx_edges_relation ON graph_edges(relation_type);
CREATE INDEX idx_edges_workspace ON graph_edges(workspace_id);
-- ============================================================
-- 6. Detailtabeller — aktører, temaer, episoder, segmenter
-- ============================================================
-- Workspace-tilhørighet arves via FK til nodes.
-- Spørringer filtrerer alltid via JOIN med nodes.
CREATE TABLE actors (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT -- 'person', 'organisasjon', etc.
);
CREATE INDEX idx_actors_name ON actors(name);
CREATE TABLE topics (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
name TEXT NOT NULL
);
CREATE INDEX idx_topics_name ON topics(name);
CREATE TABLE episodes (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
title TEXT NOT NULL,
published_at TIMESTAMPTZ,
guid TEXT UNIQUE NOT NULL -- RSS <guid>, immutabel etter opprettelse
);
-- Forhindre endring av episode-GUID etter opprettelse.
-- RSS-klienter bruker GUID for å identifisere episoder — endring bryter feeds.
CREATE OR REPLACE FUNCTION prevent_guid_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.guid IS NOT NULL AND NEW.guid != OLD.guid THEN
RAISE EXCEPTION 'episode guid er immutabel og kan ikke endres etter opprettelse';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_episodes_guid_immutable BEFORE UPDATE ON episodes
FOR EACH ROW EXECUTE FUNCTION prevent_guid_change();
CREATE TABLE segments (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
episode_id UUID NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
start_time INTERVAL NOT NULL,
end_time INTERVAL NOT NULL,
transcript TEXT,
CONSTRAINT valid_timerange CHECK (end_time > start_time)
);
CREATE INDEX idx_segments_episode ON segments(episode_id);
CREATE INDEX idx_segments_fts ON segments USING GIN (to_tsvector('norwegian', transcript));
-- ============================================================
-- 7. Channels (chat-kontekster knyttet til vilkårlig node)
-- ============================================================
-- En channel er en meldingsstrøm knyttet til en parent-node.
-- Enhver node (tema, episode, møte, aktør, ...) kan ha én eller flere channels.
-- Config styrer hvilke capabilities kanalen har.
CREATE TABLE channels (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
parent_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Diskusjon',
config JSONB NOT NULL DEFAULT '{
"threads": true,
"mentions": true,
"attachments": true,
"research_clips": false,
"ttl_days": null
}'::jsonb
-- config.threads — tillat reply-to-tråder
-- config.mentions — #/@ mention-parsing + grafkoblinger
-- config.attachments — filopplasting
-- config.research_clips — research_clip meldingstype
-- config.ttl_days — null = permanent, tall = auto-slett etter N dager
);
CREATE INDEX idx_channels_parent ON channels(parent_id);
-- ============================================================
-- 8. Faktoider
-- ============================================================
CREATE TABLE factoids (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
body TEXT NOT NULL,
source_url TEXT,
created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, -- NULL = slettet bruker
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE factoid_votes (
factoid_id UUID NOT NULL REFERENCES factoids(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
vote SMALLINT NOT NULL CHECK (vote IN (-1, 1)),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (factoid_id, user_id)
);
-- ============================================================
-- 9. Meldinger (chat)
-- ============================================================
CREATE TYPE message_type AS ENUM (
'text', -- Vanlig Markdown-melding
'research_clip', -- Innlimt artikkel (Ctrl+A)
'factoid', -- Faktoid delt i chat
'voice_memo', -- Lydmelding (lyd er master, transkripsjon som metadata)
'system' -- Automatisk systemmelding
);
CREATE TABLE messages (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
author_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, -- NULL = slettet bruker
message_type message_type NOT NULL DEFAULT 'text',
body TEXT NOT NULL,
metadata JSONB,
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_messages_channel ON messages(channel_id, created_at);
CREATE TABLE message_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
body TEXT NOT NULL,
edited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT revision_order UNIQUE (message_id, edited_at)
);
CREATE TABLE message_votes (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
vote SMALLINT NOT NULL CHECK (vote IN (-1, 1)),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id)
);
-- ============================================================
-- 10. Mediefiler (content-addressable, workspace-scopet)
-- ============================================================
CREATE TABLE media_files (
sha256 TEXT PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
mime_type TEXT NOT NULL,
file_size BIGINT NOT NULL,
uploaded_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, -- NULL = slettet bruker
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_media_workspace ON media_files(workspace_id);
CREATE TABLE message_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
sha256 TEXT NOT NULL REFERENCES media_files(sha256),
sort_order SMALLINT NOT NULL DEFAULT 0
);
-- ============================================================
-- 11. Jobbkø (workspace-scopet)
-- ============================================================
CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'retry');
CREATE TABLE job_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status job_status NOT NULL DEFAULT 'pending',
priority SMALLINT NOT NULL DEFAULT 0,
result JSONB,
error_msg TEXT,
attempts SMALLINT NOT NULL DEFAULT 0,
max_attempts SMALLINT NOT NULL DEFAULT 3,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for ASC)
WHERE status IN ('pending', 'retry');
CREATE INDEX idx_job_queue_workspace ON job_queue(workspace_id);
-- ============================================================
-- 12. Row-Level Security (Workspace-isolasjon)
-- ============================================================
-- SvelteKit setter session-variabel ved tilkobling:
-- SET app.current_workspace_id = '<uuid>';
--
-- Rust-workers kjører som superuser (bypasser RLS) for å prosessere
-- jobber på tvers av workspaces. Workspace-isolasjon for workers
-- sikres i applikasjonskode (job_queue.workspace_id).
--
-- Detailtabeller (actors, topics, etc.) har ikke egen RLS — de
-- arver workspace-tilhørighet via FK til nodes.
-- Applikasjonskode filtrerer via JOIN med nodes.
ALTER TABLE nodes ENABLE ROW LEVEL SECURITY;
ALTER TABLE graph_edges ENABLE ROW LEVEL SECURITY;
ALTER TABLE media_files ENABLE ROW LEVEL SECURITY;
ALTER TABLE job_queue ENABLE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation_nodes ON nodes
USING (workspace_id = current_setting('app.current_workspace_id')::uuid);
CREATE POLICY workspace_isolation_edges ON graph_edges
USING (workspace_id = current_setting('app.current_workspace_id')::uuid);
CREATE POLICY workspace_isolation_media ON media_files
USING (workspace_id = current_setting('app.current_workspace_id')::uuid);
CREATE POLICY workspace_isolation_jobs ON job_queue
USING (workspace_id = current_setting('app.current_workspace_id')::uuid);
COMMIT;