feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled

This commit was merged in pull request #1.
This commit is contained in:
2026-05-16 09:42:40 +00:00
parent 071809bc3f
commit 9aee4ceb6d
224 changed files with 35418 additions and 1469 deletions

View File

@@ -0,0 +1,55 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(32) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name VARCHAR(50),
bio VARCHAR(160),
avatar_url TEXT,
header_url TEXT,
custom_css TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS thoughts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS follows (
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (follower_id, following_id)
);
CREATE TABLE IF NOT EXISTS top_friends (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
friend_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
position SMALLINT NOT NULL,
PRIMARY KEY (user_id, friend_id),
UNIQUE (user_id, position)
);
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS thought_tags (
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (thought_id, tag_id)
);
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,21 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS inbox_url TEXT,
ADD COLUMN IF NOT EXISTS public_key TEXT,
ADD COLUMN IF NOT EXISTS private_key TEXT,
ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE thoughts
ADD COLUMN IF NOT EXISTS in_reply_to_id UUID REFERENCES thoughts(id),
ADD COLUMN IF NOT EXISTS in_reply_to_url TEXT,
ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'public',
ADD COLUMN IF NOT EXISTS content_warning TEXT,
ADD COLUMN IF NOT EXISTS sensitive BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;
ALTER TABLE follows
ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'accepted',
ADD COLUMN IF NOT EXISTS ap_id TEXT,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();

View File

@@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE IF NOT EXISTS boosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE IF NOT EXISTS blocks (
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE TABLE IF NOT EXISTS remote_actors (
url TEXT PRIMARY KEY,
handle TEXT NOT NULL,
display_name TEXT,
inbox_url TEXT NOT NULL,
shared_inbox_url TEXT,
public_key TEXT NOT NULL,
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
from_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
thought_id UUID REFERENCES thoughts(id) ON DELETE CASCADE,
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_thoughts_user_id ON thoughts(user_id);
CREATE INDEX IF NOT EXISTS idx_thoughts_created_at ON thoughts(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_follows_following_id ON follows(following_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id, read);

View File

@@ -0,0 +1,11 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_thoughts_content_trgm
ON thoughts USING GIN(content gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_users_username_trgm
ON users USING GIN(username gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_users_display_name_trgm
ON users USING GIN(display_name gin_trgm_ops)
WHERE display_name IS NOT NULL;

View File

@@ -0,0 +1,54 @@
-- Add avatar_url and outbox_url to remote_actors (FederationRepository::RemoteActor needs them)
ALTER TABLE remote_actors
ADD COLUMN IF NOT EXISTS avatar_url TEXT,
ADD COLUMN IF NOT EXISTS outbox_url TEXT;
-- Federation followers: remote actors following local users
CREATE TABLE IF NOT EXISTS federation_followers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_actor_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
follow_activity_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (local_user_id, remote_actor_url)
);
-- Federation following: local users following remote actors
CREATE TABLE IF NOT EXISTS federation_following (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL,
outbox_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (local_user_id, remote_actor_url)
);
-- Announces (boosts of remote objects via AP)
CREATE TABLE IF NOT EXISTS federation_announces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id TEXT NOT NULL UNIQUE,
object_url TEXT NOT NULL,
actor_url TEXT NOT NULL,
announced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Blocked domains (instance-level)
CREATE TABLE IF NOT EXISTS federation_blocked_domains (
domain TEXT PRIMARY KEY,
reason TEXT,
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Blocked actors (per local user)
CREATE TABLE IF NOT EXISTS federation_blocked_actors (
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (local_user_id, actor_url)
);
CREATE INDEX IF NOT EXISTS idx_fed_followers_user ON federation_followers(local_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_following_user ON federation_following(local_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_announces_object ON federation_announces(object_url);

View File

@@ -0,0 +1,13 @@
CREATE TABLE remote_actor_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_url TEXT NOT NULL,
connection_type TEXT NOT NULL,
page INT NOT NULL,
connected_actor_url TEXT NOT NULL,
connected_handle TEXT NOT NULL,
connected_display_name TEXT,
connected_avatar_url TEXT,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(actor_url, connection_type, page, connected_actor_url)
);
CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at);

View File

@@ -0,0 +1,3 @@
-- Remote ActivityPub posts can exceed 128 characters.
-- The 128-char limit is enforced at the application layer for local posts only.
ALTER TABLE thoughts ALTER COLUMN content TYPE TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE notifications RENAME COLUMN "type" TO notification_type;

View File

@@ -0,0 +1,15 @@
CREATE TABLE failed_events (
id UUID NOT NULL DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
failed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retry_at TIMESTAMPTZ NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
last_error TEXT NOT NULL,
CONSTRAINT failed_events_pkey PRIMARY KEY (id)
);
CREATE INDEX failed_events_due_idx
ON failed_events (retry_at)
WHERE retry_count < 3;

View File

@@ -0,0 +1,11 @@
-- Change in_reply_to_id FK from RESTRICT (default) to SET NULL.
-- Previously, deleting a thought that had replies raised a FK violation.
-- With SET NULL, deleting a thought orphans its replies (they survive but
-- lose their parent reference), which is the correct semantic for a
-- threaded social app.
ALTER TABLE thoughts
DROP CONSTRAINT IF EXISTS thoughts_in_reply_to_id_fkey;
ALTER TABLE thoughts
ADD CONSTRAINT thoughts_in_reply_to_id_fkey
FOREIGN KEY (in_reply_to_id) REFERENCES thoughts(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,10 @@
CREATE TABLE outbox_events (
seq BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivered BOOLEAN NOT NULL DEFAULT false,
delivered_at TIMESTAMPTZ
);
CREATE INDEX outbox_events_pending_idx ON outbox_events (seq) WHERE delivered = false;