Files
tapir/internal/adapters/store/migrations/001_initial.up.sql
T
mathiasandClaude Opus 4.8 2695b5d91e
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
feat(adapters): add Postgres store sink with durable dedup
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>
2026-06-02 20:04:04 +02:00

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)
);