Implements ports.Sink over Postgres (pgx/v5 + pgxpool, DSN from env per estate convention). This is the primary sink (ADR-003) and the source of the engine's durable, cross-restart dedup — the in-engine processed map is process-lifetime only. - Migrations (golang-migrate, NNN_name.up/down.sql per estate convention, applied from an embedded FS on New): users, videos, transcripts, summaries, sink_deliveries. Every user-owned table carries user_id (Stage-0 per-user isolation promise, data-model.md). summaries has UNIQUE(user_id, video_id) — at most one summary per video; highlights / takeaways are jsonb. - Deliver upserts the summary idempotently on (user_id, video_id) (ON CONFLICT DO UPDATE) inside one tx with its sink_delivery row. Re- delivering the same summary updates in place, never duplicates or errors. - Dedup reads (store methods, not a new port): HasSummary(ctx,userID, videoID) and SeenVideoIDs(ctx,userID) — both user_id-scoped, so one user never sees another's videos. summaries.video_id is intentionally not FK-constrained to videos at Stage 0: the sink receives only a Summary, so the dedup key stands alone; video-row persistence is the engine/source's concern, deferred. Tested against a real in-process Postgres via embedded-postgres (real SQL: constraints, ON CONFLICT, jsonb, user_id scoping) — no docker, no live cluster, no creds, fully offline. Deps: golang-migrate/migrate/v4 and jackc/pgx/v5 (runtime), fergusstrange/embedded-postgres + stretchr/testify (test-only). go mod tidy raised the go directive to 1.25.0 (minimum required by the dep graph; estate elsewhere already runs 1.26.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
74 lines
3.1 KiB
SQL
74 lines
3.1 KiB
SQL
-- Tapir initial schema (Stage 0). Per-user isolation: every user-owned table
|
|
-- carries user_id even though Stage 0 has a single user (docs/data-model.md).
|
|
-- Scoped to Stage 0 dedup + delivery; Future B/C concerns are out of scope.
|
|
|
|
CREATE TABLE users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
display_name TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- One row per (user, video) — per-user isolation, not a global deduped table
|
|
-- (DECISIONS.md "Rejected alternatives"). subscription_id has no FK at Stage 0:
|
|
-- the subscriptions table is not part of the store-sink slice.
|
|
CREATE TABLE videos (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
subscription_id UUID,
|
|
provider TEXT NOT NULL,
|
|
provider_video_id TEXT NOT NULL,
|
|
title TEXT,
|
|
duration_s INTEGER,
|
|
published_at TIMESTAMPTZ,
|
|
url TEXT,
|
|
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT videos_user_provider_video_unique UNIQUE (user_id, provider, provider_video_id)
|
|
);
|
|
|
|
CREATE INDEX idx_videos_user_id ON videos(user_id);
|
|
|
|
-- At most one transcript per video. source = 'none' records "checked, no usable
|
|
-- transcript" so the watcher does not reprocess (ADR-007); content is NULL then.
|
|
CREATE TABLE transcripts (
|
|
video_id UUID PRIMARY KEY REFERENCES videos(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
source TEXT NOT NULL,
|
|
language TEXT,
|
|
content TEXT,
|
|
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_transcripts_user_id ON transcripts(user_id);
|
|
|
|
-- At most one summary per video (UNIQUE on (user_id, video_id)). video_id is not
|
|
-- FK-constrained to videos at Stage 0: the store sink receives only a Summary
|
|
-- (ports.Sink.Deliver), so the durable dedup key (user_id, video_id) stands on
|
|
-- its own; video-row persistence is the engine/source's concern, deferred.
|
|
CREATE TABLE summaries (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
video_id UUID NOT NULL,
|
|
summary TEXT NOT NULL,
|
|
highlights JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
takeaways JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
ai_provider TEXT,
|
|
ai_model TEXT,
|
|
fallback_used BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT summaries_user_video_unique UNIQUE (user_id, video_id)
|
|
);
|
|
|
|
CREATE INDEX idx_summaries_user_id ON summaries(user_id);
|
|
|
|
-- One row per (summary, sink) attempt. "Also sent to brain" lives here as a
|
|
-- delivery row with sink = 'brain'; no brain-specific tables (data-model.md).
|
|
CREATE TABLE sink_deliveries (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
summary_id UUID NOT NULL REFERENCES summaries(id) ON DELETE CASCADE,
|
|
sink TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
detail TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT sink_deliveries_summary_sink_unique UNIQUE (summary_id, sink)
|
|
);
|