story -- Base story datastory_tx -- Translated title/summary/contentstory_series -- Series groupingstory_series_tx -- Series translationsstory_interaction -- Bookmarks, likesstory_date_proposal -- Event date proposals
user -- User accountssession -- Authentication sessionsquestion -- Q&A questionsdiscussion_comment -- Comments on stories/profiles
Migrations use goose and live in etc/data/default/migrations/:
cd apps/services/etc/data/default/migrations# Create new migration filetouch 0011_add_profile_badges.sql
0011_add_profile_badges.sql
-- +goose UpALTER TABLE "profile" ADD COLUMN "badges" TEXT[] DEFAULT '{}';CREATE INDEX profile_badges_idx ON "profile" USING GIN ("badges");COMMENT ON COLUMN "profile"."badges" IS 'User badges: verified, early_adopter, contributor';-- +goose DownDROP INDEX IF EXISTS profile_badges_idx;ALTER TABLE "profile" DROP COLUMN IF EXISTS "badges";
cd apps/services# Apply all pending migrationsmake migrate-up# Rollback last migrationmake migrate-down# Check statusgo run ./cmd/migrate/ default status# Direct goose commandsgoose -dir etc/data/default/migrations postgres "postgres://..." upgoose -dir etc/data/default/migrations postgres "postgres://..." status
SQL queries are in etc/data/default/queries/ and use sqlc annotations:
etc/data/default/queries/profiles.sql
-- name: GetProfileBySlug :oneSELECT p.id, p.slug, p.kind, p.profile_picture_uri, p.default_locale, pt.locale_code, pt.title, pt.descriptionFROM "profile" pJOIN "profile_tx" pt ON pt.profile_id = p.idWHERE p.slug = sqlc.arg(slug) AND p.deleted_at IS NULL -- 3-tier locale fallback (CRITICAL) AND pt.locale_code = ( SELECT ptx.locale_code FROM "profile_tx" ptx WHERE ptx.profile_id = p.id ORDER BY CASE WHEN ptx.locale_code = sqlc.arg(locale_code) THEN 0 WHEN ptx.locale_code = p.default_locale THEN 1 ELSE 2 END LIMIT 1 )LIMIT 1;-- name: ListProfilesByKinds :manySELECT p.id, p.slug, p.kind, pt.locale_code, pt.title, pt.descriptionFROM "profile" pJOIN "profile_tx" pt ON pt.profile_id = p.idWHERE p.kind = ANY(sqlc.arg(kinds)::text[]) AND p.deleted_at IS NULL AND p.approved_at IS NOT NULL AND pt.locale_code = ( SELECT ptx.locale_code FROM "profile_tx" ptx WHERE ptx.profile_id = p.id ORDER BY CASE WHEN ptx.locale_code = sqlc.arg(locale_code) THEN 0 WHEN ptx.locale_code = p.default_locale THEN 1 ELSE 2 END LIMIT 1 )ORDER BY p.created_at DESCLIMIT sqlc.arg(limit)OFFSET sqlc.arg(offset);-- name: CreateProfile :oneINSERT INTO "profile" ( "id", "slug", "kind", "default_locale") VALUES ( sqlc.arg(id), sqlc.arg(slug), sqlc.arg(kind), sqlc.arg(default_locale))RETURNING *;-- name: CreateProfileTranslation :execINSERT INTO "profile_tx" ( "profile_id", "locale_code", "title", "description") VALUES ( sqlc.arg(profile_id), sqlc.arg(locale_code), sqlc.arg(title), sqlc.arg(description));
AND pt.locale_code = ( SELECT ptx.locale_code FROM "profile_tx" ptx WHERE ptx.profile_id = p.id ORDER BY CASE WHEN ptx.locale_code = sqlc.arg(locale_code) THEN 0 -- Tier 1: Requested WHEN ptx.locale_code = p.default_locale THEN 1 -- Tier 2: Default ELSE 2 -- Tier 3: Any available END LIMIT 1)
How it works:
1
Tier 1: Requested Locale
If a translation exists for pt-PT, use it. Priority = 0 (highest).
2
Tier 2: Entity's Default
If not, use the profile’s default_locale (e.g., es). Priority = 1.
3
Tier 3: Any Available
If neither exists, use ANY translation (en, tr, or fr). Priority = 2.
Result: The query always returns a translation if the entity has at least one.
For stories, Tier 2 uses the author profile’s default locale:
-- name: GetStoryBySlug :oneSELECT s.id, s.slug, st.locale_code, st.title, st.contentFROM "story" sJOIN "story_tx" st ON st.story_id = s.idWHERE s.slug = sqlc.arg(slug) AND st.locale_code = ( SELECT stx.locale_code FROM "story_tx" stx WHERE stx.story_id = s.id ORDER BY CASE WHEN stx.locale_code = sqlc.arg(locale_code) THEN 0 -- Use author's default locale WHEN stx.locale_code = ( SELECT p.default_locale FROM "profile" p WHERE p.id = s.author_profile_id ) THEN 1 ELSE 2 END LIMIT 1 )LIMIT 1;
NEVER use 2-tier fallback. Always use the 3-tier subquery pattern for ALL _tx table joins.
-- Foreign keys (always index!)CREATE INDEX profile_membership_profile_id_idx ON "profile_membership" ("profile_id");CREATE INDEX story_author_profile_id_idx ON "story" ("author_profile_id");-- Lookup fieldsCREATE UNIQUE INDEX profile_slug_unique ON "profile" ("slug") WHERE "deleted_at" IS NULL;CREATE INDEX profile_kind_idx ON "profile" ("kind");-- Composite indexes for common queriesCREATE INDEX story_kind_published_at_idx ON "story" ("kind", "published_at" DESC) WHERE "deleted_at" IS NULL;-- Full-text searchCREATE INDEX story_tx_search_idx ON "story_tx" USING gin("search_vector");-- Array columnsCREATE INDEX profile_badges_idx ON "profile" USING GIN ("badges");
-- Only index non-deleted profilesCREATE INDEX profile_approved_idx ON "profile" ("approved_at") WHERE "deleted_at" IS NULL;-- Only index published storiesCREATE INDEX story_published_at_idx ON "story" ("published_at" DESC)WHERE "published_at" IS NOT NULL AND "deleted_at" IS NULL;
Aya implements PostgreSQL full-text search for stories:
etc/data/default/migrations/0005_fts_search.sql
-- +goose UpALTER TABLE "story_tx" ADD COLUMN "search_vector" tsvector;CREATE INDEX story_tx_search_idx ON "story_tx" USING gin("search_vector");-- Auto-update search vector on insert/updateCREATE FUNCTION story_tx_search_update() RETURNS trigger AS $$BEGIN NEW.search_vector := setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') || setweight(to_tsvector('english', coalesce(NEW.summary, '')), 'B') || setweight(to_tsvector('english', coalesce(NEW.content, '')), 'C'); RETURN NEW;END;$$ LANGUAGE plpgsql;CREATE TRIGGER story_tx_search_update_triggerBEFORE INSERT OR UPDATE ON "story_tx"FOR EACH ROW EXECUTE FUNCTION story_tx_search_update();-- +goose DownDROP TRIGGER IF EXISTS story_tx_search_update_trigger ON "story_tx";DROP FUNCTION IF EXISTS story_tx_search_update();DROP INDEX IF EXISTS story_tx_search_idx;ALTER TABLE "story_tx" DROP COLUMN IF EXISTS "search_vector";
Search query:
-- name: SearchStories :manySELECT s.id, s.slug, st.title, ts_rank(st.search_vector, query) AS rankFROM "story" sJOIN "story_tx" st ON st.story_id = s.id, to_tsquery('english', sqlc.arg(search_query)) queryWHERE st.search_vector @@ query AND s.deleted_at IS NULL AND s.published_at IS NOT NULLORDER BY rank DESCLIMIT 50;
EXPLAIN ANALYZESELECT p.*, pt.*FROM "profile" pJOIN "profile_tx" pt ON pt.profile_id = p.idWHERE p.kind = 'organization' AND pt.locale_code = ( SELECT ptx.locale_code FROM "profile_tx" ptx WHERE ptx.profile_id = p.id ORDER BY CASE WHEN ptx.locale_code = 'en' THEN 0 WHEN ptx.locale_code = p.default_locale THEN 1 ELSE 2 END LIMIT 1 );
-- name: ListStoriesPaginated :manySELECT * FROM "story"WHERE deleted_at IS NULLORDER BY published_at DESCLIMIT sqlc.arg(limit)OFFSET sqlc.arg(offset);