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>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
-- 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)
|
||||
);
|
||||
Reference in New Issue
Block a user