13 KiB
13 KiB
02 — Data Model (PostgreSQL)
Complete schema, written as golang-migrate migrations under internal/db/migrations/. Conventions from 01-architecture.md apply (identity PKs, timestamptz, soft delete, ON DELETE RESTRICT FKs). Extensions: citext.
Statuses/priorities are fixed product-wide, so native Postgres enums are safe and self-documenting. Estimation is story points, Fibonacci subset.
Deviations from the first iteration's SQLite schema are marked (deviation) with reasons; the full rationale is in 17-decisions.md.
Enum types
project_status:backlog | planned | in_progress | completed | canceledepic_status:backlog | planned | in_progress | completed | canceledissue_status:backlog | planned | in_progress | ready_for_review | done | canceled | duplicatepriority:low | medium | high | urgent | frantic(nullable column = "none")member_role:owner | memberauth_provider:github | gitearesource_kind:project | issue | epic | comment | wiki_page | user | label | attachment | link | member | webhook | authjob_status:pending | running | done | failed | deadreceipt_status:pending | processed | failed | ignoreddelivery_status:pending | success | failedemail_mode:off | instant | daily_digest
Tables
Identity & auth
- users
idbigint identity PKusernamecitext UNIQUE NOT NULL;nametext NOT NULL;emailcitext UNIQUE NOT NULLpassword_hashtext NULL (bcrypt cost 12; NULL = OAuth-only account)avatar_urltext NULL;is_adminboolean NOT NULL DEFAULT falseemail_modeemail_mode NOT NULL DEFAULT 'instant'created_at,updated_atNULL,deleted_atNULL- Seeds: id 1
system("System"), id 2deleted-user("Deleted User") — bothpassword_hashNULL, excluded from login, member pickers, and search; identity sequence restarted at 3
- oauth_accounts (deviation: replaces the first iteration's single
providercolumn on users — allows local+GitHub+Gitea linked to one account, links by verified email at callback)idPK;user_idFK→users NOT NULL;providerauth_provider NOT NULL;provider_user_idtext NOT NULL;created_at- UNIQUE(
provider,provider_user_id); UNIQUE(user_id,provider)
- sessions — exact scs pgxstore schema
tokentext PK;databytea NOT NULL;expirytimestamptz NOT NULL; index onexpiry
- api_tokens
idPK;user_idFK NOT NULL;nametext NOT NULLtoken_hashtext UNIQUE NOT NULL (SHA-256 hex of full tokensolopm_<40 random base62>)prefixtext NOT NULL (first 12 chars, display only)last_used_atNULL;expires_atNULL;revoked_atNULL;created_at
Projects
- projects
idPK;creator_idFK→users NOT NULL;owner_idFK→users NOT NULLtitletext NOT NULL;descriptiontext NOT NULL DEFAULT '' (markdown)statusproject_status NOT NULL DEFAULT 'backlog';prioritypriority NULLstart_datedate NULL;target_datedate NULL;completed_attimestamptz NULL (set when status→completed)git_repository_urltext NULL (used to route incoming webhooks)next_issue_numberint NOT NULL DEFAULT 1 (deviation: explicit counter for safe concurrent#Nallocation)created_at,updated_at,deleted_at- Indexes: (
owner_id); partial (id) WHEREdeleted_at IS NULL
- project_members
idPK;project_idFK NOT NULL;user_idFK NOT NULL;rolemember_role NOT NULL DEFAULT 'member'invited_byFK→users NULL;created_at- UNIQUE(
project_id,user_id); index (user_id) - Invariant (service-enforced): exactly one row with role
ownerper project, matchingprojects.owner_id
- project_dependencies — "this project is blocked by that project"
idPK;project_idFK NOT NULL;depends_on_project_idFK→projects NOT NULL;created_byFK NOT NULL;created_at- UNIQUE(
project_id,depends_on_project_id); CHECK(project_id <> depends_on_project_id) - Cycle detection at service layer (DFS over edges) →
409 cycle_detected
- project_favorites
- composite PK(
user_id,project_id);created_at(dashboard sorts favorites first)
- composite PK(
- labels (deviation: renamed from
project_labels)idPK;project_idFK NOT NULL;nametext NOT NULL;colortext NOT NULL (hex)created_byFK NOT NULL;created_at;deleted_at- Partial UNIQUE(
project_id,name) WHEREdeleted_at IS NULL - Seeded on project creation: enhancement, bug, chore, task, feature, documentation (colors from the design-system palette, 14-design-system.md)
Work items
- epics
idPK;project_idFK NOT NULL;creator_idFK NOT NULLtitletext NOT NULL;descriptiontext NOT NULL DEFAULT '';statusepic_status NOT NULL DEFAULT 'backlog'colortext NOT NULL (hex, from palette)created_at,updated_at,deleted_at- Partial index (
project_id) WHEREdeleted_at IS NULL
- issues
idPK;project_idFK NOT NULL;project_issue_idint NOT NULL — UNIQUE(project_id,project_issue_id)creator_idFK NOT NULL;assignee_idFK→users NULLepic_idFK→epics NULL;parent_issue_idFK→issues NULL (sub-issues; one level of UI nesting, arbitrary depth allowed in data)titletext NOT NULL;descriptiontext NOT NULL DEFAULT '' (markdown)statusissue_status NOT NULL DEFAULT 'backlog';prioritypriority NULLestimatesmallint NULL CHECK (estimateIN (1,2,3,5,8,13)) — story pointsstart_datedate NULL;target_datedate NULL;completed_attimestamptz NULL (set automatically when status→done, cleared when leaving done)board_ranktext NOT NULL — fractional lexicographic rank within status column (kanban order)epic_ranktext NULL — order within its epiccreated_at,updated_at,deleted_at- Indexes: (
project_id,status), (assignee_id), (epic_id), (parent_issue_id) - Number allocation inside the insert tx:
UPDATE projects SET next_issue_number = next_issue_number + 1 WHERE id = $1 RETURNING next_issue_number - 1
- issue_blockers — "issue X is blocked by issue Y" (same project only)
idPK;issue_idFK NOT NULL;blocked_by_issue_idFK→issues NOT NULL;created_byFK;created_at- UNIQUE(
issue_id,blocked_by_issue_id); CHECK(issue_id <> blocked_by_issue_id); service-layer cycle detection → 409
- issue_labels (deviation: hard-delete pivot, no
deleted_at— audit log preserves history)idPK;issue_idFK NOT NULL;label_idFK NOT NULL;applied_byFK NOT NULL;created_at- UNIQUE(
issue_id,label_id)
- issue_watchers
- composite PK(
issue_id,user_id);mutedboolean NOT NULL DEFAULT false;created_at muted=true= explicitly unsubscribed; auto-watch must never overwrite it
- composite PK(
Collaboration
- comments
idPK;issue_idFK NOT NULL;author_idFK NOT NULL;bodytext NOT NULL (markdown)resolved_atNULL;resolved_byFK→users NULL (resolvable threads)created_at,updated_at,deleted_at- Index (
issue_id,created_at)
- links (deviation: one polymorphic table replaces project_links/issue_links/comment_links — culling handles integrity, API surface shrinks 3×)
idPK;resource_typeresource_kind NOT NULL (allowed: project|issue|comment);resource_idbigint NOT NULLurltext NOT NULL;titletext NULL;created_byFK NOT NULL;created_at;deleted_at- Index (
resource_type,resource_id)
- attachments (same polymorphic deviation)
idPK;resource_typeresource_kind NOT NULL (project|issue|comment);resource_idbigint NOT NULLuploader_idFK NOT NULL;file_pathtext NOT NULL — relative toUPLOAD_DIR, formatproject_{pid}/{uploader_id}_{yyyymmddhhmmss}_{rand16}.{ext}original_filenametext NOT NULL;mime_typetext NOT NULL;size_bytesbigint NOT NULLcreated_at;deleted_at- Index (
resource_type,resource_id)
- wiki_pages
idPK;project_idFK NOT NULL;slugtext NOT NULL (kebab-case from title);titletext NOT NULL;bodytext NOT NULL DEFAULT '' (markdown)created_byFK NOT NULL;updated_byFK NOT NULLcreated_at,updated_at,deleted_at- Partial UNIQUE(
project_id,slug) WHEREdeleted_at IS NULL
Audit, notifications, events, jobs
- audit_log — immutable; never UPDATEd; culled only by retention policy
idPK;created_atNOT NULL DEFAULT now()initiated_byFK→users NOT NULL (system user id 1 for automation);via_webhookboolean NOT NULL DEFAULT falseproject_idbigint NULL (deviation: denormalized so project activity feed is one indexed query)resource_typeresource_kind NOT NULL;resource_idbigint NOT NULL;action_typetext NOT NULL (matches event taxonomy, 03-domain-events.md)previous_valuesjsonb NULL;new_valuesjsonb NULL (changed fields only)- Indexes: (
resource_type,resource_id), (initiated_by,created_at), (created_at), (project_id,created_atDESC) - Display text is generated app-side from action_type + values (no stored
summaryprose)
- notifications
idPK;user_idFK NOT NULL (recipient);actor_idFK NOT NULLtypetext NOT NULL:assigned | mentioned | commented | added_to_project | status_changedproject_idFK NULL;resource_typeresource_kind NOT NULL;resource_idbigint NOT NULLpayloadjsonb NOT NULL (denormalized title/snippet/refs for rendering without joins)read_atNULL;created_at- Index (
user_id,read_at,created_atDESC)
- events — transactional outbox (03-domain-events.md)
idbigserial PK (ordering matters);event_typetext NOT NULL;actor_idbigint NOT NULL;project_idbigint NULLresource_typeresource_kind NOT NULL;resource_idbigint NOT NULLpayloadjsonb NOT NULL (prev/new snapshots, mention lists, etc.);via_webhookboolean NOT NULL DEFAULT falsecreated_atNOT NULL DEFAULT now();processed_atNULL;attemptsint NOT NULL DEFAULT 0- Partial index WHERE
processed_at IS NULL
- jobs — retryable async work
idPK;kindtext NOT NULL:send_email | webhook_delivery | search_sync | daily_digest | cullpayloadjsonb NOT NULL;statusjob_status NOT NULL DEFAULT 'pending'run_attimestamptz NOT NULL DEFAULT now();attemptsint NOT NULL DEFAULT 0;max_attemptsint NOT NULL DEFAULT 8;last_errortext NULLcreated_at,updated_at- Index (
status,run_at)
Webhooks
- incoming_webhook_receipts
idPK;project_idFK NULL (NULL when repo URL didn't resolve to a project)sourceauth_provider NOT NULL;event_typetext NOT NULL;delivery_idtext NOT NULL — UNIQUE(source,delivery_id) for redelivery idempotencypayloadtext NOT NULL (raw body, replayable);signature_validboolean NOT NULLstatusreceipt_status NOT NULL DEFAULT 'pending';failure_reasontext NULLreceived_atNOT NULL DEFAULT now();processed_atNULL
- outgoing_webhooks
idPK;project_idFK NOT NULL;urltext NOT NULL;secrettext NOT NULLactiveboolean NOT NULL DEFAULT true;event_typestext[] NOT NULL DEFAULT '{}' (empty = all events)created_byFK NOT NULL;created_at;deleted_at
- outgoing_webhook_deliveries
idPK;webhook_idFK NOT NULL;event_idFK→events NOT NULL — UNIQUE(webhook_id,event_id) (idempotent fan-out)event_typetext NOT NULL;payloadjsonb NOT NULLstatusdelivery_status NOT NULL DEFAULT 'pending';response_statusint NULL;response_bodytext NULL (truncated 4 KB);attemptsint NOT NULL DEFAULT 0delivered_atNULL;created_at- Index (
webhook_id,created_atDESC)
Migration & seed checklist
- Migration 0001: extensions (
citext) + all enum types - Migrations grouped per domain in dependency order (users → oauth/sessions/tokens → projects family → epics/issues family → collaboration → audit/notifications/events/jobs → webhooks)
- Every migration has a working
.down.sql - Seed migration: system user (id 1) + zombie user (id 2), sequence restart at 3
- Label seeding on project creation implemented in the projects service (not a DB trigger)
- sqlc config covers all tables; generated code compiles;
make sqlcis idempotent