Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Database Schema Reference

Generated from migration files in migrations/postgres/ and migrations/sqlite/. Migrations V1–V32. Last updated: 2026-04-02.

SQLite vs PostgreSQL Differences

AspectPostgreSQLSQLite
Primary keysUUID DEFAULT gen_random_uuid()TEXT (app-generated UUIDs)
TimestampsTIMESTAMPTZ DEFAULT now()TEXT DEFAULT (datetime('now'))
BooleansBOOLEAN (TRUE/FALSE)INTEGER (1/0)
JSON columnsJSONBTEXT (JSON stored as string)
Large integersBIGINTINTEGER
Multi-tenancyRow Level Security (RLS) policiesExplicit AND org_id = ? in queries
UUID functiongen_random_uuid()App-generated; migrations use hex(randomblob(...))
Tenant contextcurrent_setting('app.tenant_id')::uuidPassed as query parameter

Tables

schema_info

Tracks the Alloy version embedded in the database. Created in V1.

ColumnPG TypeSQLite TypeConstraints
keyTEXTTEXTPK
valueTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

organizations

Top-level tenant entity. Created in V2.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
nameTEXTTEXTNOT NULL
slugTEXTTEXTNOT NULL, UNIQUE
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): id = current_tenant_id() (V9)


users

Application users. Created in V3, amended in V22.

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V3
emailTEXTTEXTNOT NULL, UNIQUEV3
display_nameTEXTTEXTNOT NULLV3
password_hashTEXTTEXTNullableV3
activeBOOLEANINTEGERNOT NULL, DEFAULT TRUE/1V22
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV3
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV3

Note: Users are not tenant-scoped — a user can belong to multiple organizations via org_memberships.


org_memberships

Links users to organizations with a role. Created in V4.

ColumnPG TypeSQLite TypeConstraints
user_idUUIDTEXTPK (composite), FK → users(id)
org_idUUIDTEXTPK (composite), FK → organizations(id)
roleTEXTTEXTNOT NULL
joined_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): org_id = current_tenant_id() (V9)


teams

Groups of users within an organization. Created in V5.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, FK → organizations(id)
nameTEXTTEXTNOT NULL
descriptionTEXTTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): org_id = current_tenant_id() (V9)


team_memberships

Links users to teams. Created in V5.

ColumnPG TypeSQLite TypeConstraints
user_idUUIDTEXTPK (composite), FK → users(id)
team_idUUIDTEXTPK (composite), FK → teams(id)
joined_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

projects

A project within an organization, optionally assigned to a team. Created in V6, amended in V14 (workflow_id), V17 (capitalization fields), V27 (budget fields).

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V6
org_idUUIDTEXTNOT NULL, FK → organizations(id)V6
team_idUUIDTEXTNullable, FK → teams(id)V6
keyTEXTTEXTNOT NULL, UNIQUE(org_id, key)V6
nameTEXTTEXTNOT NULLV6
descriptionTEXTTEXTNullableV6
ticket_counterINTEGERINTEGERNOT NULL, DEFAULT 0V6
workflow_idUUIDTEXTNullable, FK → workflows(id)V14
capitalization_typeTEXTTEXTNullableV17
development_phaseTEXTTEXTNullableV17
cost_center_idTEXTTEXTNullableV17
amortization_monthsINTEGERINTEGERNullableV17
budget_centsBIGINTINTEGERNullableV27
budget_periodTEXTTEXTNullableV27
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV6
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV6

RLS (PG): org_id = current_tenant_id() (V9)


tickets

Work items within a project. Created in V7, amended in V15 (sprint_id), V31 (indexes).

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V7
project_idUUIDTEXTNOT NULL, FK → projects(id)V7
ticket_numberINTEGERINTEGERNOT NULL, UNIQUE(project_id, ticket_number)V7
titleTEXTTEXTNOT NULLV7
descriptionTEXTTEXTNullableV7
statusTEXTTEXTNOT NULL, DEFAULT ‘Backlog’V7
priorityTEXTTEXTNOT NULL, DEFAULT ‘None’V7
assignee_idUUIDTEXTNullable, FK → users(id)V7
reporter_idUUIDTEXTNOT NULL, FK → users(id)V7
sprint_idUUIDTEXTNullable, FK → sprints(id) ON DELETE SET NULLV15
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV7
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV7

Indexes: idx_tickets_project_status(project_id, status), idx_tickets_assignee_id(assignee_id), idx_tickets_sprint_id(sprint_id) (V31)

RLS (PG): project_id IN (SELECT id FROM projects WHERE org_id = current_tenant_id()) (V9)


api_keys

API key credentials for programmatic access. Created in V8, amended in V25 (project_ids).

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V8
org_idUUIDTEXTNOT NULL, FK → organizations(id)V8
user_idUUIDTEXTNOT NULL, FK → users(id)V8
nameTEXTTEXTNOT NULLV8
key_prefixTEXTTEXTNOT NULLV8
key_hashTEXTTEXTNOT NULL, UNIQUEV8
scopesTEXTTEXTNOT NULL, DEFAULT ‘*’V8
project_idsTEXTTEXTNOT NULL, DEFAULT ‘’V25
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV8
last_used_atTIMESTAMPTZTEXTNullableV8
expires_atTIMESTAMPTZTEXTNullableV8

Indexes: idx_api_keys_key_hash(key_hash) UNIQUE


comments

Threaded comments on tickets. Created in V10.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
ticket_idUUIDTEXTNOT NULL, FK → tickets(id) ON DELETE CASCADE
author_idUUIDTEXTNOT NULL, FK → users(id)
bodyTEXTTEXTNOT NULL
parent_comment_idUUIDTEXTNullable, FK → comments(id) ON DELETE CASCADE
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_comments_ticket_id(ticket_id), idx_comments_author_id(author_id), idx_comments_parent_id(parent_comment_id)


audit_logs

Immutable log of all mutations. Created in V11.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
entity_typeTEXTTEXTNOT NULL
entity_idTEXTTEXTNOT NULL
actionTEXTTEXTNOT NULL
actor_idUUIDTEXTNOT NULL
changesTEXTTEXTNOT NULL, DEFAULT ‘[]’
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_audit_logs_entity(entity_type, entity_id), idx_audit_logs_actor(actor_id), idx_audit_logs_created(created_at)


attachments

File attachments on tickets. Created in V12.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
ticket_idUUIDTEXTNOT NULL, FK → tickets(id) ON DELETE CASCADE
filenameTEXTTEXTNOT NULL
content_typeTEXTTEXTNOT NULL
size_bytesBIGINTINTEGERNOT NULL
s3_keyTEXTTEXTNOT NULL
uploaded_byUUIDTEXTNOT NULL, FK → users(id)
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_attachments_ticket_id(ticket_id), idx_attachments_uploaded_by(uploaded_by)

RLS (PG): Via ticket → project → org join (V12)


labels

Organization-scoped labels for categorizing tickets. Created in V13.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, FK → organizations(id) ON DELETE CASCADE
nameTEXTTEXTNOT NULL
colorTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_labels_org_id(org_id), idx_labels_org_name(org_id, name) UNIQUE

RLS (PG): org_id = current_setting('app.tenant_id')::uuid (V13)


ticket_labels

Many-to-many join between tickets and labels. Created in V13.

ColumnPG TypeSQLite TypeConstraints
ticket_idUUIDTEXTPK (composite), FK → tickets(id) ON DELETE CASCADE
label_idUUIDTEXTPK (composite), FK → labels(id) ON DELETE CASCADE

Indexes: idx_ticket_labels_label_id(label_id)

RLS (PG): Via label → org join (V13)


workflows

Configurable status workflows per organization. Created in V14, amended in V28 (seed data), V29 (enforcement).

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V14
org_idUUIDTEXTNOT NULL, FK → organizations(id)V14
nameTEXTTEXTNOT NULL, UNIQUE(org_id, name)V14
statusesJSONBTEXTNOT NULL, DEFAULT ‘[]’V14
transitionsJSONBTEXTNOT NULL, DEFAULT ‘[]’V14
enforcementTEXTTEXTNOT NULL, DEFAULT ‘none’V29
created_atTIMESTAMPTZTEXTNOT NULLV14
updated_atTIMESTAMPTZTEXTNOT NULLV14

RLS (PG): org_id = current_setting('app.tenant_id')::uuid (V14)

Seed data (V28): A “Default” workflow with 6 statuses (Backlog, Todo, InProgress, InReview, Done, Cancelled) and all-to-all transitions is created for every existing organization.


sprints

Time-boxed iterations within a project. Created in V15.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
project_idUUIDTEXTNOT NULL, FK → projects(id) ON DELETE CASCADE
nameTEXTTEXTNOT NULL
goalTEXTTEXTNullable
start_dateTEXTTEXTNOT NULL
end_dateTEXTTEXTNOT NULL
statusTEXTTEXTNOT NULL, DEFAULT ‘Planned’
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_sprints_project_id(project_id)

RLS (PG): Via project → org join (V15)


time_entries

Logged time against tickets for finance/capitalization. Created in V16, amended in V32 (indexes).

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
user_idUUIDTEXTNOT NULL, FK → users(id)
ticket_idUUIDTEXTNOT NULL, FK → tickets(id) ON DELETE CASCADE
project_idUUIDTEXTNOT NULL, FK → projects(id) ON DELETE CASCADE
dateTEXTTEXTNOT NULL
duration_minutesINTEGERINTEGERNOT NULL
descriptionTEXTTEXTNullable
activity_typeTEXTTEXTNOT NULL
statusTEXTTEXTNOT NULL, DEFAULT ‘Draft’
approved_byUUIDTEXTNullable, FK → users(id)
approved_atTIMESTAMPTZTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_time_entries_user_id(user_id), idx_time_entries_ticket_id(ticket_id), idx_time_entries_project_id(project_id), idx_time_entries_date(date), idx_time_entries_user_date(user_id, date) (V32), idx_time_entries_project_date(project_id, date) (V32)

RLS (PG): Via project → org join (V16)


user_labor_rates

Per-user billing rates for capitalization calculations. Created in V18.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
user_idUUIDTEXTNOT NULL, FK → users(id)
org_idUUIDTEXTNOT NULL, FK → organizations(id)
loaded_rate_centsINTEGERINTEGERNOT NULL
effective_dateTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_user_labor_rates_user_id(user_id), idx_user_labor_rates_org_id(org_id), idx_user_labor_rates_effective_date(effective_date), idx_user_labor_rates_user_org_date(user_id, org_id, effective_date) UNIQUE

RLS (PG): Via org join (V18)


webhooks

Outbound webhook configurations. Created in V19.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
org_idUUIDTEXTNOT NULL, FK → organizations(id)
urlTEXTTEXTNOT NULL
secretTEXTTEXTNOT NULL
event_typesTEXTTEXTNOT NULL
activeBOOLEANINTEGERNOT NULL, DEFAULT TRUE/1
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_webhooks_org_id(org_id), idx_webhooks_active(active)

RLS (PG): Via org join (V19)


webhook_deliveries

Delivery attempts for webhooks. Created in V19.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
webhook_idUUIDTEXTNOT NULL, FK → webhooks(id) ON DELETE CASCADE
event_typeTEXTTEXTNOT NULL
payloadTEXTTEXTNOT NULL
statusTEXTTEXTNOT NULL, DEFAULT ‘pending’
response_statusINTEGERINTEGERNullable
response_bodyTEXTTEXTNullable
attemptINTEGERINTEGERNOT NULL, DEFAULT 0
next_retry_atTIMESTAMPTZTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_webhook_deliveries_webhook_id(webhook_id), idx_webhook_deliveries_status(status)


slack_thread_mappings

Maps Slack threads to Alloy tickets. Created in V20.

ColumnPG TypeSQLite TypeConstraints
idTEXTTEXTPK
tenant_idTEXTNOT NULL, DEFAULT current_setting(‘app.tenant_id’) (PG only)
ticket_idTEXTTEXTNOT NULL, UNIQUE(ticket_id, channel_id)
channel_idTEXTTEXTNOT NULL
thread_tsTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): tenant_id = current_setting('app.tenant_id') (V20)

Note: The tenant_id column exists only in PostgreSQL. SQLite is single-tenant.


identity_providers

SSO/OIDC configuration per organization. Created in V21.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, UNIQUE, FK → organizations(id)
provider_typeTEXTTEXTNOT NULL, DEFAULT ‘oidc’
provider_nameTEXTTEXTNOT NULL, DEFAULT ‘okta’
issuer_urlTEXTTEXTNOT NULL
client_idTEXTTEXTNOT NULL
client_secretTEXTTEXTNOT NULL
authorization_endpointTEXTTEXTNullable
token_endpointTEXTTEXTNullable
jwks_uriTEXTTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): org_id = current_setting('app.tenant_id')::UUID (V21)


invites

Organization membership invitations. Created in V23.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, FK → organizations(id)
emailTEXTTEXTNullable
invite_codeTEXTTEXTNOT NULL, UNIQUE
roleTEXTTEXTNOT NULL, DEFAULT ‘Member’
created_byUUIDTEXTNOT NULL, FK → users(id)
expires_atTIMESTAMPTZTEXTNOT NULL
accepted_atTIMESTAMPTZTEXTNullable
revoked_atTIMESTAMPTZTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): org_id::text = current_setting('app.tenant_id', true) (V23)


refresh_tokens

JWT refresh token tracking. Created in V24.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
user_idUUIDTEXTNOT NULL, FK → users(id)
token_hashTEXTTEXTNOT NULL, UNIQUE
expires_atTIMESTAMPTZTEXTNOT NULL
revoked_atTIMESTAMPTZTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_refresh_tokens_user_id(user_id), idx_refresh_tokens_token_hash(token_hash)


entity_tags

Key-value tags on any entity (projects, tickets, etc.). Created in V26.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, FK → organizations(id) ON DELETE CASCADE
entity_typeTEXTTEXTNOT NULL
entity_idTEXTTEXTNOT NULL
tag_keyTEXTTEXTNOT NULL
tag_valueTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

Indexes: idx_entity_tags_org_id(org_id), idx_entity_tags_entity(org_id, entity_type, entity_id), idx_entity_tags_unique(org_id, entity_type, entity_id, tag_key) UNIQUE, idx_entity_tags_search(org_id, tag_key, tag_value)

RLS (PG): org_id = current_setting('app.tenant_id')::uuid (V26)


project_memberships

Links users to projects they can access. Created in V30.

ColumnPG TypeSQLite TypeConstraints
project_idUUIDTEXTPK (composite), FK → projects(id) ON DELETE CASCADE
user_idUUIDTEXTPK (composite), FK → users(id) ON DELETE CASCADE
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): Via project → org join (V30)


RLS Summary (PostgreSQL Only)

V9 creates the current_tenant_id() helper function and enables RLS on the core tables. Subsequent migrations (V12–V30) enable RLS on each new table as it’s created.

TableRLS PolicyIsolation Path
organizationsid = current_tenant_id()Direct
projectsorg_id = current_tenant_id()Direct
ticketsVia project → orgIndirect
teamsorg_id = current_tenant_id()Direct
org_membershipsorg_id = current_tenant_id()Direct
attachmentsVia ticket → project → orgIndirect
labelsorg_id = current_setting(...)::uuidDirect
ticket_labelsVia label → orgIndirect
workflowsorg_id = current_setting(...)::uuidDirect
sprintsVia project → orgIndirect
time_entriesVia project → orgIndirect
user_labor_ratesVia orgIndirect
webhooksVia orgIndirect
webhook_deliveries(Via webhook — no direct RLS)N/A
slack_thread_mappingstenant_id = current_setting(...)Direct (own column)
identity_providersorg_id = current_setting(...)::UUIDDirect
invitesorg_id::text = current_setting(...)Direct
entity_tagsorg_id = current_setting(...)::uuidDirect
project_membershipsVia project → orgIndirect

V9 also creates the app_user role with SELECT/INSERT/UPDATE/DELETE on all tables and grants default privileges for future tables.


Entity Relationship Overview

organizations ─┬── org_memberships ── users
               ├── teams ── team_memberships ── users
               ├── projects ─┬── tickets ─┬── comments
               │             │            ├── attachments
               │             │            ├── ticket_labels ── labels
               │             │            └── time_entries
               │             ├── sprints (tickets.sprint_id)
               │             └── project_memberships ── users
               ├── workflows (projects.workflow_id)
               ├── labels
               ├── webhooks ── webhook_deliveries
               ├── identity_providers
               ├── invites
               ├── user_labor_rates
               ├── entity_tags
               └── slack_thread_mappings (via tickets)

users ─┬── api_keys
       └── refresh_tokens

audit_logs (standalone, references entities by type+id)
schema_info (standalone metadata)

Migration History

VersionDescription
V1Create schema_info
V2Create organizations
V3Create users
V4Create org_memberships
V5Create teams + team_memberships
V6Create projects
V7Create tickets
V8Create api_keys
V9Enable RLS (PG); no-op (SQLite)
V10Create comments
V11Create audit_logs
V12Create attachments
V13Create labels + ticket_labels
V14Create workflows; add projects.workflow_id
V15Create sprints; add tickets.sprint_id
V16Create time_entries
V17Add project capitalization fields
V18Create user_labor_rates
V19Create webhooks + webhook_deliveries
V20Create slack_thread_mappings
V21Create identity_providers
V22Add users.active
V23Create invites
V24Create refresh_tokens
V25Add api_keys.project_ids
V26Create entity_tags
V27Add project budget fields
V28Seed default workflows
V29Add workflows.enforcement
V30Create project_memberships
V31Add ticket indexes
V32Add time_entry indexes