build: /design-feature data-models
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
rdev-worker 2026-02-11 01:59:08 +00:00
parent 54d83c03d4
commit 7c195fe034
2 changed files with 528 additions and 1 deletions

View File

@ -0,0 +1,527 @@
# Design: Core Data Models & Persistence
## Architecture Approach
This feature replaces the in-memory example adapter with PostgreSQL-backed persistence for four new domain entities: **Project**, **Task**, **Label**, and **Assignment**. Every layer of the hexagonal architecture is affected:
| Layer | What Changes |
|-------|-------------|
| **Domain** (`internal/domain/`) | New entity files: `project.go`, `task.go`, `label.go`, `assignment.go`; extended `errors.go` |
| **Ports** (`internal/port/`) | Four new repository interfaces |
| **Adapters** (`internal/adapter/postgres/`) | New package with four Postgres repository implementations |
| **Services** (`internal/service/`) | Four new service files with business logic |
| **Handlers** (`internal/api/handlers/`) | Four new handler files with request/response DTOs |
| **Routes** (`internal/api/routes.go`) | Extended to register all new endpoints |
| **OpenAPI** (`internal/api/spec.go`) | Extended with schemas and paths for all entities |
| **Migrations** (`migrations/`) | Four SQL migration files |
| **Wiring** (`cmd/server/main.go`) | Database connection, migration execution, adapter creation, shutdown hook |
The existing `Example` entity and its layers remain untouched; new entities follow the identical established patterns.
## Data Model Changes
### New Database Tables
All tables use UUID primary keys, UTC timestamps, and snake_case naming.
#### `projects`
```sql
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
description VARCHAR(1000) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_projects_name UNIQUE (name),
CONSTRAINT chk_projects_status CHECK (status IN ('active', 'archived'))
);
```
#### `tasks`
```sql
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title VARCHAR(300) NOT NULL,
description VARCHAR(5000) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'todo',
priority VARCHAR(10) NOT NULL DEFAULT 'medium',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_tasks_status CHECK (status IN ('todo', 'in_progress', 'done')),
CONSTRAINT chk_tasks_priority CHECK (priority IN ('low', 'medium', 'high'))
);
CREATE INDEX idx_tasks_project_id ON tasks(project_id);
CREATE INDEX idx_tasks_status ON tasks(status);
```
#### `labels`
```sql
CREATE TABLE labels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
color VARCHAR(7) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_labels_project_name UNIQUE (project_id, name)
);
CREATE INDEX idx_labels_project_id ON labels(project_id);
```
#### `assignments`
```sql
CREATE TABLE assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
assignee VARCHAR(200) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_assignments_task_assignee UNIQUE (task_id, assignee)
);
CREATE INDEX idx_assignments_task_id ON assignments(task_id);
```
### Migration Files
| File | Purpose |
|------|---------|
| `001_create_projects.sql` | `projects` table with unique name constraint |
| `002_create_tasks.sql` | `tasks` table with FK to projects, status/priority checks, indexes |
| `003_create_labels.sql` | `labels` table with FK to projects, unique (project_id, name) |
| `004_create_assignments.sql` | `assignments` table with FK to tasks, unique (task_id, assignee) |
Migrations are embedded via `//go:embed migrations/*.sql` and executed with `database.MustRunMigrations()`. Each runs in a transaction.
### Domain Types
Each entity gets a strongly-typed ID and pure struct following the `Example` pattern:
```go
// domain/project.go
type ProjectID string
type ProjectStatus string
const (
ProjectStatusActive ProjectStatus = "active"
ProjectStatusArchived ProjectStatus = "archived"
)
type Project struct {
ID ProjectID
Name string
Description string
Status ProjectStatus
CreatedAt time.Time
UpdatedAt time.Time
}
```
Similar patterns for `Task` (with `TaskStatus`, `TaskPriority`), `Label`, and `Assignment`. Constructors validate required fields and set timestamps. Update methods validate mutable fields and bump `UpdatedAt`.
## API Changes
### New Endpoints
All endpoints are mounted under `/api/studio-api` and follow existing patterns.
#### Projects
| Method | Path | Handler | Success | Description |
|--------|------|---------|---------|-------------|
| `POST` | `/projects` | `Create` | 201 | Create project |
| `GET` | `/projects` | `List` | 200 | List all projects |
| `GET` | `/projects/{id}` | `Get` | 200 | Get project by ID |
| `PUT` | `/projects/{id}` | `Update` | 200 | Update project |
| `DELETE` | `/projects/{id}` | `Delete` | 204 | Delete project (cascades) |
#### Tasks (nested under projects for create/list, flat for get/update/delete)
| Method | Path | Handler | Success | Description |
|--------|------|---------|---------|-------------|
| `POST` | `/projects/{projectId}/tasks` | `Create` | 201 | Create task in project |
| `GET` | `/projects/{projectId}/tasks` | `ListByProject` | 200 | List tasks in project |
| `GET` | `/tasks/{id}` | `Get` | 200 | Get task by ID |
| `PUT` | `/tasks/{id}` | `Update` | 200 | Update task |
| `DELETE` | `/tasks/{id}` | `Delete` | 204 | Delete task (cascades assignments) |
#### Labels (nested under projects for create/list, flat for get/update/delete)
| Method | Path | Handler | Success | Description |
|--------|------|---------|---------|-------------|
| `POST` | `/projects/{projectId}/labels` | `Create` | 201 | Create label in project |
| `GET` | `/projects/{projectId}/labels` | `ListByProject` | 200 | List labels in project |
| `GET` | `/labels/{id}` | `Get` | 200 | Get label by ID |
| `PUT` | `/labels/{id}` | `Update` | 200 | Update label |
| `DELETE` | `/labels/{id}` | `Delete` | 204 | Delete label |
#### Assignments (nested under tasks)
| Method | Path | Handler | Success | Description |
|--------|------|---------|---------|-------------|
| `POST` | `/tasks/{taskId}/assignments` | `Create` | 201 | Assign user to task |
| `GET` | `/tasks/{taskId}/assignments` | `ListByTask` | 200 | List task assignments |
| `DELETE` | `/assignments/{id}` | `Delete` | 204 | Remove assignment |
### Request/Response Shapes
All responses use the `{data, meta}` envelope via `httpresponse.OK/Created/NoContent`.
**CreateProjectRequest:**
```json
{
"name": "My Project", // required, 1-200 chars
"description": "Description" // optional, max 1000 chars
}
```
**ProjectResponse:**
```json
{
"data": {
"id": "uuid",
"name": "My Project",
"description": "Description",
"status": "active",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
}
```
**CreateTaskRequest:**
```json
{
"title": "Task title", // required, 1-300 chars
"description": "Details", // optional, max 5000 chars
"priority": "high", // optional, default "medium"
"position": 1 // optional, default 0
}
```
**TaskResponse:**
```json
{
"data": {
"id": "uuid",
"project_id": "uuid",
"title": "Task title",
"description": "Details",
"status": "todo",
"priority": "high",
"position": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
}
```
**CreateLabelRequest:**
```json
{
"name": "Bug", // required, 1-50 chars
"color": "#FF5733" // required, hex color
}
```
**CreateAssignmentRequest:**
```json
{
"assignee": "user@example.com" // required, 1-200 chars
}
```
**UpdateProjectRequest:** same shape as create, plus optional `status` field (`"active"` or `"archived"`).
**UpdateTaskRequest:** title, description, status, priority, position -- all mutable fields.
**UpdateLabelRequest:** name, color -- all mutable fields.
### Error Responses
| HTTP Status | When |
|-------------|------|
| 400 | Invalid UUID format, validation failure, invalid status/priority |
| 404 | Entity not found |
| 409 | Duplicate project name, duplicate label name per project, duplicate assignment |
| 422 | Struct validation failure (from `app.BindAndValidate`) |
| 500 | Unexpected infrastructure error |
## Component Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ HTTP Client │
└────────────────────────────┬────────────────────────────────┘
┌────────▼────────┐
│ chi Router │
│ /api/studio-api│
└────────┬────────┘
┌──────────────────┼──────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Project │ │ Task │ │ Label │ ...
│ Handler │ │ Handler │ │ Handler │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Project │ │ Task │ │ Label │ ...
│ Service │ │ Service │ │ Service │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Project │ │ Task │ │ Label │ ...
│ Repository │ │ Repository │ │ Repository │
│ (port) │ │ (port) │ │ (port) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
┌───────▼──────────────────▼──────────────────▼───────┐
│ Postgres Adapter Layer │
│ adapter/postgres/project.go │
│ adapter/postgres/task.go │
│ adapter/postgres/label.go │
│ adapter/postgres/assignment.go │
└──────────────────────┬──────────────────────────────┘
┌────────▼────────┐
│ database.Pool │
│ (*sqlx.DB) │
└────────┬────────┘
┌────────▼────────┐
│ PostgreSQL │
│ studio-db │
└─────────────────┘
```
### Dependency Injection (main.go)
```
config.Load()
→ database.MustConnect(ctx, cfg.Database.URL, opts)
→ database.MustRunMigrations(ctx, pool, migrationsFS, "migrations")
→ postgres.NewProjectRepository(pool.DB)
→ postgres.NewTaskRepository(pool.DB)
→ postgres.NewLabelRepository(pool.DB)
→ postgres.NewAssignmentRepository(pool.DB)
→ service.NewProjectService(projectRepo, logger)
→ service.NewTaskService(taskRepo, projectRepo, logger)
→ service.NewLabelService(labelRepo, projectRepo, logger)
→ service.NewAssignmentService(assignmentRepo, taskRepo, logger)
→ api.RegisterRoutes(application, services...)
→ application.OnShutdown(pool.Close)
→ application.Run()
```
## File Structure
```
services/studio-api/
├── cmd/server/main.go # Updated: DB connect, migrations, adapter wiring
├── migrations/
│ ├── 001_create_projects.sql
│ ├── 002_create_tasks.sql
│ ├── 003_create_labels.sql
│ └── 004_create_assignments.sql
├── internal/
│ ├── domain/
│ │ ├── errors.go # Extended: new sentinel errors
│ │ ├── project.go # New: Project entity
│ │ ├── task.go # New: Task entity
│ │ ├── label.go # New: Label entity
│ │ └── assignment.go # New: Assignment entity
│ ├── port/
│ │ ├── project.go # New: ProjectRepository interface
│ │ ├── task.go # New: TaskRepository interface
│ │ ├── label.go # New: LabelRepository interface
│ │ └── assignment.go # New: AssignmentRepository interface
│ ├── adapter/
│ │ └── postgres/
│ │ ├── project.go # New: Postgres ProjectRepository
│ │ ├── task.go # New: Postgres TaskRepository
│ │ ├── label.go # New: Postgres LabelRepository
│ │ └── assignment.go # New: Postgres AssignmentRepository
│ ├── service/
│ │ ├── project.go # New: ProjectService
│ │ ├── project_test.go # New: ProjectService tests
│ │ ├── task.go # New: TaskService
│ │ ├── task_test.go # New: TaskService tests
│ │ ├── label.go # New: LabelService
│ │ ├── label_test.go # New: LabelService tests
│ │ ├── assignment.go # New: AssignmentService
│ │ └── assignment_test.go # New: AssignmentService tests
│ └── api/
│ ├── routes.go # Updated: register new handlers
│ ├── spec.go # Updated: new schemas and paths
│ └── handlers/
│ ├── project.go # New: Project handler
│ ├── project_test.go # New: Project handler tests
│ ├── task.go # New: Task handler
│ ├── task_test.go # New: Task handler tests
│ ├── label.go # New: Label handler
│ ├── label_test.go # New: Label handler tests
│ ├── assignment.go # New: Assignment handler
│ └── assignment_test.go # New: Assignment handler tests
```
## Error Handling Strategy
### Domain Layer
Sentinel errors defined in `domain/errors.go`:
- `ErrProjectNotFound`, `ErrTaskNotFound`, `ErrLabelNotFound`, `ErrAssignmentNotFound`
- `ErrDuplicateProjectName`, `ErrDuplicateLabelName`, `ErrDuplicateAssignment`
- `ErrInvalidStatus`, `ErrInvalidPriority`
- `ErrProjectHasTasks` (reserved for future restrict-delete if needed)
### Adapter Layer (Postgres)
Each Postgres adapter maps database errors to domain errors:
- `sql.ErrNoRows``domain.ErrXxxNotFound`
- Unique constraint violation (Postgres error code `23505`) → `domain.ErrDuplicateXxx`
- Detected via `*pq.Error` type assertion with `Code == "23505"`
- The specific constraint name determines which duplicate error to return
- FK violation (code `23503`) → `domain.ErrXxxNotFound` (referenced entity missing)
- All other errors propagate unwrapped (become 500s)
### Service Layer
Business logic validation before persistence:
- **Create project:** Check `ExistsByName` before insert (avoids race to friendly error)
- **Create task:** Verify project exists via `ProjectRepository.Get`
- **Create label:** Verify project exists, check `ExistsByName` scoped to project
- **Create assignment:** Verify task exists, check `ExistsByTaskAndAssignee`
- Update operations: same duplicate checks when names change
### Handler Layer
Each handler file contains a `mapDomainError()` function:
```go
func mapDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrProjectNotFound):
return httperror.NotFound("project not found")
case errors.Is(err, domain.ErrDuplicateProjectName):
return httperror.Conflict("project with this name already exists")
// ...
default:
return err // becomes 500 via app.Wrap()
}
}
```
### Infrastructure Failures
- Database connection failures: `database.MustConnect` panics on startup (fail-fast)
- Migration failures: `database.MustRunMigrations` panics on startup (fail-fast)
- Runtime query failures: propagate through service → handler → `app.Wrap()` → 500
## Security Considerations
### Input Validation
- **Struct tags:** All request DTOs use `validate:"required,min=X,max=Y"` enforced by `app.BindAndValidate()`
- **UUID format:** All ID parameters validated with `uuid.Parse()` before use
- **Enum validation:** Status and priority values validated in domain constructors and via CHECK constraints
- **Color format:** Label color validated as hex color format (regex `^#[0-9A-Fa-f]{6}$`) in domain constructor
- **SQL injection:** All queries use parameterized placeholders (`$1`, `$2`) via sqlx; no string concatenation
### Authentication
- Auth is opt-in per the existing pattern in `routes.go`
- Write endpoints (POST, PUT, DELETE) are placed inside the auth-protected group
- Read endpoints (GET) remain public (matching existing Example pattern)
- Auth middleware integration is not part of this feature per spec's out-of-scope
### Data Boundaries
- Each entity is scoped: tasks/labels belong to a project, assignments belong to a task
- Service layer verifies parent entity exists before creating children
- No cross-service data access; all queries stay within `studio-api`'s database
- `assignee` is an opaque string — no user lookup or validation against external systems
### Cascading Deletes
- `ON DELETE CASCADE` on FKs means deleting a project removes all its tasks, labels, and transitively all assignments
- This is the spec's chosen behavior; the handler should document this in OpenAPI descriptions
## Performance Considerations
### Query Complexity
- All list operations are full table scans scoped by a single FK (e.g., `WHERE project_id = $1`)
- Indexes on `project_id` and `task_id` FKs ensure efficient lookups
- Index on `tasks.status` supports future filtering
### Connection Pool
- Uses `pkg/database` pool defaults: 25 max open, 5 max idle, 5min lifetime
- Configurable via `DATABASE_MAX_OPEN_CONNS`, `DATABASE_MAX_IDLE_CONNS` env vars
- Pool is shared across all repository adapters
### Expected Load
- This is a developer-facing studio tool, not a high-traffic public API
- No pagination in v1 (per spec out-of-scope) — acceptable for expected data volumes
- No caching layer needed; direct DB queries are sufficient
### Future Optimization Points
- Add pagination when list endpoints exceed ~100 records
- Add filtering (by status, priority) as query parameters
- Consider read replicas if read-heavy patterns emerge
## Migration / Rollout Plan
### Step 1: Database Setup
- Ensure `DATABASE_URL` is configured in the service environment
- The PostgreSQL database must exist and be accessible
### Step 2: Migration Execution
- Migrations run automatically on service startup via `database.MustRunMigrations()`
- They are idempotent (tracked in `schema_migrations` table)
- Each migration runs in a transaction — partial failure rolls back
### Step 3: Code Deployment
- The service binary includes embedded migrations and Postgres adapters
- On startup: connect → migrate → serve
- If migrations fail, the service panics and does not start (fail-fast)
### Backward Compatibility
- The existing `Example` entity and endpoints remain unchanged
- New endpoints are additive — no existing API contracts are modified
- The in-memory `Example` adapter remains for now (can be migrated to Postgres separately)
### Rollback Strategy
- If issues are found, revert the deployment to the previous binary
- Migrations are forward-only (no down migrations in this system)
- To revert schema changes, deploy a new migration that drops/alters the tables
### Health Checks
- Add database health check to the existing health endpoint:
```go
healthHandler := handlers.NewHealth(logger, app.HealthConfig{
Service: "studio-api",
Checks: map[string]app.HealthChecker{
"database": app.PingChecker(pool.Ping),
},
})
```
- Service reports unhealthy (503) if database is unreachable
## Design Decisions
1. **Cascade deletes (not restrict):** The spec calls for `ON DELETE CASCADE`. Deleting a project removes everything underneath. This is simpler and matches the user mental model of "delete project = delete everything in it."
2. **Separate Assignment entity (not a field on Task):** Supports multiple assignees per task. The `(task_id, assignee)` unique constraint prevents duplicates while allowing flexibility.
3. **Position field included:** Included per spec. Default 0, managed as a simple integer. No automatic reordering — the client is responsible for setting position values.
4. **No status transition rules:** Free status changes as specified. Any status can transition to any other valid status. Business rules can be layered on later.
5. **`DATABASE_URL` configuration:** Uses the existing `config.ReadDatabaseConfig()` + env var pattern. No new database name concept — the database is whatever `DATABASE_URL` points to.
6. **Postgres adapters take `*sqlx.DB`:** Following the established pattern from `pkg/queue/postgres.go`. The adapter receives the raw `*sqlx.DB` (from `pool.DB`), not the `*database.Pool` wrapper.
7. **Internal row structs in adapters:** Each Postgres adapter defines a private `xxxRow` struct with `db:"column_name"` tags, and a `toDomain()` converter. This decouples the DB schema from the domain model.
8. **One handler struct per entity:** Each entity gets its own handler file with request/response DTOs and `mapDomainError()`. This keeps files focused and avoids a monolithic handler.
9. **TaskService depends on ProjectRepository:** To verify the parent project exists when creating a task. Same pattern for LabelService. AssignmentService depends on TaskRepository.
10. **No soft deletes:** Hard deletes only, per spec. `deleted_at` columns are not included.

View File

@ -13,7 +13,7 @@ artifacts:
status: pending
path: audit.md
design:
status: pending
status: draft
path: design.md
qa_plan:
status: pending