.claude/skills/do-entity-store/SKILL.md
Create SQL migration and sqlc queries for a domain
npx skillsauth add viqueen/claude-go-playground .claude/skills/do-entity-storeInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Add the database schema and queries for a domain's entity store. A domain may have multiple entities — each gets its own table but they share a single migration file, a single sqlc query directory, and a single generated Go package. This PR is auditable as: "Is the data model right?"
All file paths are relative to the chosen project: connect-rpc-backend/ or grpc-backend/.
The user will specify which project. All make commands must be run from the project root.
The user will specify:
collaboration, billing) — a domain may group multiple proto packages (e.g., collaboration covers space.v1 and content.v1 protos)Cross-reference with the proto definitions in protos/ to ensure the schema
aligns with the API contract. The domain name does not need to match any single proto package.
sql/migrations/<NNNN>_create_<domain>.sqlA single migration file per domain. Creates a dedicated Postgres schema for the domain and all entity tables within it.
-- +goose Up
CREATE SCHEMA IF NOT EXISTS <domain>;
CREATE TABLE <domain>.<entity_a> (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
body TEXT NOT NULL,
status INT NOT NULL DEFAULT 1,
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE <domain>.<entity_b> (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
<entity_a>_id UUID NOT NULL REFERENCES <domain>.<entity_a>(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- +goose Down
DROP TABLE IF EXISTS <domain>.<entity_b>;
DROP TABLE IF EXISTS <domain>.<entity_a>;
DROP SCHEMA IF EXISTS <domain>;
Conventions:
CREATE SCHEMA IF NOT EXISTS <domain>)<domain>.<table> (schema-qualified)UUID DEFAULT gen_random_uuid()created_at and updated_at are TIMESTAMPTZ NOT NULL DEFAULT now()deleted_at is TIMESTAMPTZ nullable — NULL means active, non-NULL means soft-deletedINT in SQLrepeated string maps to TEXT[] in SQLgoogle.protobuf.Timestamp maps to TIMESTAMPTZREFERENCES <table>(id) with appropriate ON DELETE behavior0001, 0002)sql/queries/<domain>/One query file per entity within the domain directory:
sql/queries/<domain>/
├── <entity_a>.sql
└── <entity_b>.sql
Each file contains the full CRUD surface for that entity:
-- name: Get<EntityA> :one
SELECT * FROM <domain>.<entity_a> WHERE id = sqlc.arg('id') AND deleted_at IS NULL;
-- name: List<EntityA> :many
SELECT * FROM <domain>.<entity_a>
WHERE deleted_at IS NULL
ORDER BY created_at LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset');
-- name: Count<EntityA> :one
SELECT count(*) FROM <domain>.<entity_a> WHERE deleted_at IS NULL;
-- name: Create<EntityA> :one
INSERT INTO <domain>.<entity_a> (title, body, status, tags)
VALUES (sqlc.arg('title'), sqlc.arg('body'), sqlc.arg('status'), sqlc.arg('tags'))
RETURNING *;
-- name: Update<EntityA> :one
UPDATE <domain>.<entity_a>
SET title = COALESCE(sqlc.narg('title'), title),
body = COALESCE(sqlc.narg('body'), body),
status = COALESCE(sqlc.narg('status'), status),
tags = COALESCE(sqlc.narg('tags'), tags),
updated_at = now()
WHERE id = sqlc.arg('id') AND deleted_at IS NULL
RETURNING *;
-- name: SoftDelete<EntityA> :one
UPDATE <domain>.<entity_a>
SET deleted_at = now(), updated_at = now()
WHERE id = sqlc.arg('id') AND deleted_at IS NULL
RETURNING *;
-- name: Restore<EntityA> :one
UPDATE <domain>.<entity_a>
SET deleted_at = NULL, updated_at = now()
WHERE id = sqlc.arg('id') AND deleted_at IS NOT NULL
RETURNING *;
For child entities, include queries that filter by parent:
-- name: List<EntityB>By<EntityA> :many
SELECT * FROM <domain>.<entity_b>
WHERE <entity_a>_id = sqlc.arg('<entity_a>_id') AND deleted_at IS NULL
ORDER BY created_at LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset');
Conventions:
.sql file per entity, all under sql/queries/<domain>/sqlc.arg('name') for required paramssqlc.narg('name') with COALESCE for optional update fields (generates pgtype.Text, pgtype.Int4, etc. — NOT pointer types)updated_at = now()AND deleted_at IS NULL (exclude soft-deleted rows)AND deleted_at IS NULL (cannot update soft-deleted rows)SoftDelete<Entity> sets deleted_at = now() (returns the row for outbox events)Restore<Entity> sets deleted_at = NULL (un-deletes a soft-deleted row)ListBy<Parent> queriesAdd a single entry per domain (not per entity) to the sql: list in sqlc.yaml.
All entities in the domain share one generated Go package:
- engine: "postgresql"
queries: "sql/queries/<domain>/"
schema: "sql/migrations/"
gen:
go:
package: "<domain>"
out: "gen/db/<domain>"
sql_package: "pgx/v5"
overrides:
- db_type: "uuid"
go_type:
import: "github.com/gofrs/uuid/v5"
type: "UUID"
- db_type: "timestamptz"
go_type:
import: "time"
type: "Time"
Conventions:
gen/db/<domain> as a single Go packagegithub.com/gofrs/uuid/v5time.Timepgx/v5make codegen to generate sqlc Go codemake vet — should pass (no new Go source files reference gen/ yet)CREATE SCHEMA IF NOT EXISTS <domain><domain>.<entity>)-- +goose Up / -- +goose DownDEFAULT gen_random_uuid()created_at and updated_at TIMESTAMPTZ NOT NULL columnsdeleted_at TIMESTAMPTZ nullable column (soft delete)ON DELETE behavior.sql query file per entity under sql/queries/<domain>/ListBy<Parent> queriessqlc.narg() + COALESCE for optional fieldsupdated_at = now()AND deleted_at IS NULLdeleted_at = now() and returns the row (:one)deleted_at = NULL and returns the row (:one)out points to gen/db/<domain>make codegen succeedstesting
Review a test PR
tools
Review a search indexing PR
tools
Review a scaffold PR
tools
Review a proto PR