This commit is contained in:
parent
54d83c03d4
commit
7c195fe034
527
.sdlc/features/data-models/design.md
Normal file
527
.sdlc/features/data-models/design.md
Normal 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.
|
||||
@ -13,7 +13,7 @@ artifacts:
|
||||
status: pending
|
||||
path: audit.md
|
||||
design:
|
||||
status: pending
|
||||
status: draft
|
||||
path: design.md
|
||||
qa_plan:
|
||||
status: pending
|
||||
|
||||
Loading…
Reference in New Issue
Block a user