build: /design-feature data-models
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
This commit is contained in:
parent
fef18b73ca
commit
e0b6834743
569
.sdlc/features/data-models/design.md
Normal file
569
.sdlc/features/data-models/design.md
Normal file
@ -0,0 +1,569 @@
|
||||
# Design: Core Data Models & Persistence
|
||||
|
||||
## Architecture Approach
|
||||
|
||||
This feature adds four new domain entities (Project, Task, Label, Assignment) and a task-label join to the existing studio-api service. The implementation follows the established hexagonal architecture pattern demonstrated by the `Example` entity, replicating the same layering:
|
||||
|
||||
```
|
||||
domain → service → port (interface) → adapter/postgres (implementation)
|
||||
```
|
||||
|
||||
**What's new:**
|
||||
- 5 domain entities with strong-typed IDs and constructor validation
|
||||
- 5 port interfaces defining repository contracts
|
||||
- 5 PostgreSQL adapter implementations (new `adapter/postgres/` package)
|
||||
- 4 service-layer orchestrators (TaskLabel and Assignment share a service)
|
||||
- 4 handler files with CRUD + association endpoints
|
||||
- SQL migrations creating 5 tables with FK constraints and indexes
|
||||
- `main.go` wired to connect to PostgreSQL, run migrations, and inject postgres adapters
|
||||
|
||||
**What's modified:**
|
||||
- `main.go` — adds database connection, migration runner, new service/handler wiring
|
||||
- `internal/api/routes.go` — adds new route registrations
|
||||
- `internal/api/spec.go` — adds OpenAPI schemas and endpoint documentation
|
||||
- `internal/domain/errors.go` — adds domain errors for new entities
|
||||
- `internal/config/config.go` — already has `Database` config (no changes needed)
|
||||
|
||||
**What's preserved:**
|
||||
- The existing `Example` entity and its in-memory adapter remain untouched (per spec)
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Entity Relationship Diagram
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
|
||||
│ projects │ │ tasks │ │ labels │
|
||||
├──────────────┤ ├──────────────────┤ ├──────────────┤
|
||||
│ id (PK,UUID) │──1:N──│ id (PK,UUID) │ │ id (PK,UUID) │
|
||||
│ name (UNIQ) │ │ project_id (FK) │ │ name (UNIQ) │
|
||||
│ description │ │ title │ │ color │
|
||||
│ created_at │ │ description │ │ created_at │
|
||||
│ updated_at │ │ status │ └──────┬───────┘
|
||||
└──────────────┘ │ priority │ │
|
||||
│ created_at │ │
|
||||
│ updated_at │ │
|
||||
└───────┬──────────┘ │
|
||||
│ │
|
||||
┌──────────┴──────────┐ ┌────────┴────────┐
|
||||
│ assignments │ │ task_labels │
|
||||
├─────────────────────┤ ├─────────────────┤
|
||||
│ id (PK,UUID) │ │ task_id (FK,PK) │
|
||||
│ task_id (FK) │ │ label_id (FK,PK)│
|
||||
│ user_id (STRING) │ │ created_at │
|
||||
│ assigned_at │ └─────────────────┘
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Domain Types
|
||||
|
||||
Each entity follows the `Example` pattern with strong-typed IDs:
|
||||
|
||||
```go
|
||||
// internal/domain/project.go
|
||||
type ProjectID string
|
||||
type Project struct {
|
||||
ID ProjectID
|
||||
Name string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// internal/domain/task.go
|
||||
type TaskID string
|
||||
type TaskStatus string
|
||||
type TaskPriority string
|
||||
const (
|
||||
TaskStatusOpen TaskStatus = "open"
|
||||
TaskStatusInProgress TaskStatus = "in_progress"
|
||||
TaskStatusDone TaskStatus = "done"
|
||||
TaskStatusCancelled TaskStatus = "cancelled"
|
||||
)
|
||||
const (
|
||||
TaskPriorityLow TaskPriority = "low"
|
||||
TaskPriorityMedium TaskPriority = "medium"
|
||||
TaskPriorityHigh TaskPriority = "high"
|
||||
TaskPriorityCritical TaskPriority = "critical"
|
||||
)
|
||||
type Task struct {
|
||||
ID TaskID
|
||||
ProjectID ProjectID
|
||||
Title string
|
||||
Description string
|
||||
Status TaskStatus
|
||||
Priority TaskPriority
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// internal/domain/label.go
|
||||
type LabelID string
|
||||
type Label struct {
|
||||
ID LabelID
|
||||
Name string
|
||||
Color string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// internal/domain/assignment.go
|
||||
type AssignmentID string
|
||||
type Assignment struct {
|
||||
ID AssignmentID
|
||||
TaskID TaskID
|
||||
UserID string
|
||||
AssignedAt time.Time
|
||||
}
|
||||
|
||||
// internal/domain/task_label.go
|
||||
type TaskLabel struct {
|
||||
TaskID TaskID
|
||||
LabelID LabelID
|
||||
CreatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
### SQL Migrations
|
||||
|
||||
Three migration files in `services/studio-api/migrations/`:
|
||||
|
||||
**`001_create_projects.sql`**
|
||||
```sql
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description VARCHAR(500),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**`002_create_tasks.sql`**
|
||||
```sql
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY,
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description VARCHAR(2000),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'medium',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tasks_project_id ON tasks(project_id);
|
||||
CREATE INDEX idx_tasks_status ON tasks(status);
|
||||
CREATE INDEX idx_tasks_priority ON tasks(priority);
|
||||
```
|
||||
|
||||
**`003_create_labels_and_associations.sql`**
|
||||
```sql
|
||||
CREATE TABLE labels (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL UNIQUE,
|
||||
color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE task_labels (
|
||||
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (task_id, label_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_labels_label_id ON task_labels(label_id);
|
||||
|
||||
CREATE TABLE assignments (
|
||||
id UUID PRIMARY KEY,
|
||||
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(task_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_assignments_task_id ON assignments(task_id);
|
||||
CREATE INDEX idx_assignments_user_id ON assignments(user_id);
|
||||
```
|
||||
|
||||
## API Changes
|
||||
|
||||
All endpoints are under `/api/studio-api/` to match existing ingress routing.
|
||||
|
||||
### Projects
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/projects` | No | List all projects |
|
||||
| `GET` | `/projects/{id}` | No | Get project by ID |
|
||||
| `POST` | `/projects` | Yes | Create project |
|
||||
| `PUT` | `/projects/{id}` | Yes | Update project |
|
||||
| `DELETE` | `/projects/{id}` | Yes | Delete project (cascades) |
|
||||
|
||||
**Request/Response shapes:**
|
||||
|
||||
```json
|
||||
// POST /projects
|
||||
Request: { "name": "My Project", "description": "optional" }
|
||||
Response: { "data": { "id": "uuid", "name": "...", "description": "...", "created_at": "...", "updated_at": "..." } }
|
||||
|
||||
// GET /projects
|
||||
Response: { "data": [{ "id": "...", ... }] }
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/projects/{projectId}/tasks` | No | List tasks in project (filterable) |
|
||||
| `POST` | `/projects/{projectId}/tasks` | Yes | Create task in project |
|
||||
| `GET` | `/tasks/{id}` | No | Get task by ID |
|
||||
| `PUT` | `/tasks/{id}` | Yes | Update task |
|
||||
| `DELETE` | `/tasks/{id}` | Yes | Delete task |
|
||||
|
||||
**Query parameters for list:** `?status=open&priority=high`
|
||||
|
||||
**Request/Response shapes:**
|
||||
|
||||
```json
|
||||
// POST /projects/{projectId}/tasks
|
||||
Request: { "title": "Implement login", "description": "...", "status": "open", "priority": "high" }
|
||||
Response: { "data": { "id": "uuid", "project_id": "uuid", "title": "...", "description": "...", "status": "open", "priority": "high", "created_at": "...", "updated_at": "..." } }
|
||||
```
|
||||
|
||||
### Labels
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/labels` | No | List all labels |
|
||||
| `GET` | `/labels/{id}` | No | Get label by ID |
|
||||
| `POST` | `/labels` | Yes | Create label |
|
||||
| `PUT` | `/labels/{id}` | Yes | Update label |
|
||||
| `DELETE` | `/labels/{id}` | Yes | Delete label (cascades) |
|
||||
|
||||
**Request/Response shapes:**
|
||||
|
||||
```json
|
||||
// POST /labels
|
||||
Request: { "name": "Bug", "color": "#FF5733" }
|
||||
Response: { "data": { "id": "uuid", "name": "Bug", "color": "#FF5733", "created_at": "..." } }
|
||||
```
|
||||
|
||||
### Task-Label Associations
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/tasks/{taskId}/labels` | No | List labels for a task |
|
||||
| `POST` | `/tasks/{taskId}/labels/{labelId}` | Yes | Attach label to task |
|
||||
| `DELETE` | `/tasks/{taskId}/labels/{labelId}` | Yes | Remove label from task |
|
||||
|
||||
### Task-User Assignments
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/tasks/{taskId}/assignments` | No | List assignments for a task |
|
||||
| `POST` | `/tasks/{taskId}/assignments` | Yes | Assign user to task |
|
||||
| `DELETE` | `/tasks/{taskId}/assignments/{assignmentId}` | Yes | Remove assignment |
|
||||
|
||||
**Request/Response shapes:**
|
||||
|
||||
```json
|
||||
// POST /tasks/{taskId}/assignments
|
||||
Request: { "user_id": "user-123" }
|
||||
Response: { "data": { "id": "uuid", "task_id": "uuid", "user_id": "user-123", "assigned_at": "..." } }
|
||||
```
|
||||
|
||||
## Component Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HTTP Layer │
|
||||
│ routes.go → handlers/{project,task,label,association}.go │
|
||||
│ Uses: app.Wrap(), app.BindAndValidate(), chi.URLParam() │
|
||||
│ Returns: httpresponse.OK/Created/NoContent, httperror.* │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ calls service methods
|
||||
┌──────────────────────────▼──────────────────────────────────┐
|
||||
│ Service Layer │
|
||||
│ service/{project,task,label,association}.go │
|
||||
│ Business logic: duplicate checks, FK validation, │
|
||||
│ UUID generation, domain entity construction │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ calls port interfaces
|
||||
┌──────────────────────────▼──────────────────────────────────┐
|
||||
│ Port Layer │
|
||||
│ port/{project,task,label,task_label,assignment}.go │
|
||||
│ Repository interfaces for each entity │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ implemented by
|
||||
┌──────────────────────────▼──────────────────────────────────┐
|
||||
│ Adapter Layer │
|
||||
│ adapter/postgres/{project,task,label,task_label, │
|
||||
│ assignment}.go │
|
||||
│ Raw SQL via sqlx (pool.DB.SelectContext, GetContext, etc.) │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────▼──────────────────────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ projects, tasks, labels, task_labels, assignments tables │
|
||||
│ FK constraints with ON DELETE CASCADE │
|
||||
│ Unique constraints for name deduplication │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
Wiring (main.go)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. database.MustConnect(ctx, cfg.Database.URL, opts) │
|
||||
│ 2. database.MustRunMigrations(ctx, pool, migrationsFS, dir) │
|
||||
│ 3. postgres.NewXxxRepository(pool.DB) for each entity │
|
||||
│ 4. service.NewXxxService(repo, logger) for each entity │
|
||||
│ 5. api.RegisterRoutes(app, projectSvc, taskSvc, ...) │
|
||||
│ 6. pool.Close() via app.OnShutdown() │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Inventory
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/domain/project.go` | Project entity, ProjectID, constructor, validation |
|
||||
| `internal/domain/task.go` | Task entity, TaskID, status/priority enums, constructor, validation |
|
||||
| `internal/domain/label.go` | Label entity, LabelID, constructor, validation |
|
||||
| `internal/domain/assignment.go` | Assignment entity, AssignmentID, constructor |
|
||||
| `internal/domain/task_label.go` | TaskLabel join entity |
|
||||
| `internal/port/project.go` | ProjectRepository interface |
|
||||
| `internal/port/task.go` | TaskRepository interface |
|
||||
| `internal/port/label.go` | LabelRepository interface |
|
||||
| `internal/port/task_label.go` | TaskLabelRepository interface |
|
||||
| `internal/port/assignment.go` | AssignmentRepository interface |
|
||||
| `internal/adapter/postgres/project.go` | PostgreSQL ProjectRepository |
|
||||
| `internal/adapter/postgres/task.go` | PostgreSQL TaskRepository |
|
||||
| `internal/adapter/postgres/label.go` | PostgreSQL LabelRepository |
|
||||
| `internal/adapter/postgres/task_label.go` | PostgreSQL TaskLabelRepository |
|
||||
| `internal/adapter/postgres/assignment.go` | PostgreSQL AssignmentRepository |
|
||||
| `internal/service/project.go` | ProjectService with business logic |
|
||||
| `internal/service/task.go` | TaskService with business logic |
|
||||
| `internal/service/label.go` | LabelService with business logic |
|
||||
| `internal/service/association.go` | AssociationService (task-labels + assignments) |
|
||||
| `internal/api/handlers/project.go` | Project CRUD handlers |
|
||||
| `internal/api/handlers/task.go` | Task CRUD handlers |
|
||||
| `internal/api/handlers/label.go` | Label CRUD handlers |
|
||||
| `internal/api/handlers/association.go` | Task-label and assignment handlers |
|
||||
| `migrations/001_create_projects.sql` | Projects table |
|
||||
| `migrations/002_create_tasks.sql` | Tasks table + indexes |
|
||||
| `migrations/003_create_labels_and_associations.sql` | Labels, task_labels, assignments tables |
|
||||
|
||||
### New Test Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/domain/project_test.go` | Project constructor/validation tests |
|
||||
| `internal/domain/task_test.go` | Task constructor/validation/enum tests |
|
||||
| `internal/domain/label_test.go` | Label constructor/validation tests |
|
||||
| `internal/service/project_test.go` | ProjectService unit tests (mock repo) |
|
||||
| `internal/service/task_test.go` | TaskService unit tests (mock repo) |
|
||||
| `internal/service/label_test.go` | LabelService unit tests (mock repo) |
|
||||
| `internal/service/association_test.go` | AssociationService unit tests |
|
||||
| `internal/api/handlers/project_test.go` | Project handler tests (httptest) |
|
||||
| `internal/api/handlers/task_test.go` | Task handler tests (httptest) |
|
||||
| `internal/api/handlers/label_test.go` | Label handler tests (httptest) |
|
||||
| `internal/api/handlers/association_test.go` | Association handler tests (httptest) |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `internal/domain/errors.go` | Add Project/Task/Label/Assignment error sentinels |
|
||||
| `internal/api/routes.go` | Add route registrations for all new endpoints |
|
||||
| `internal/api/spec.go` | Add OpenAPI schemas and paths for all new endpoints |
|
||||
| `cmd/server/main.go` | Add DB connection, migrations, postgres adapters, new services |
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
### Domain Errors (in `errors.go`)
|
||||
|
||||
```go
|
||||
// Project errors
|
||||
var (
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrDuplicateProject = errors.New("project with this name already exists")
|
||||
ErrInvalidProjectName = errors.New("invalid project name")
|
||||
)
|
||||
|
||||
// Task errors
|
||||
var (
|
||||
ErrTaskNotFound = errors.New("task not found")
|
||||
ErrInvalidTaskTitle = errors.New("invalid task title")
|
||||
ErrInvalidTaskStatus = errors.New("invalid task status")
|
||||
ErrInvalidTaskPriority = errors.New("invalid task priority")
|
||||
)
|
||||
|
||||
// Label errors
|
||||
var (
|
||||
ErrLabelNotFound = errors.New("label not found")
|
||||
ErrDuplicateLabel = errors.New("label with this name already exists")
|
||||
ErrInvalidLabelName = errors.New("invalid label name")
|
||||
ErrInvalidLabelColor = errors.New("invalid label color")
|
||||
)
|
||||
|
||||
// Association errors
|
||||
var (
|
||||
ErrDuplicateTaskLabel = errors.New("label already attached to task")
|
||||
ErrTaskLabelNotFound = errors.New("task-label association not found")
|
||||
ErrDuplicateAssignment = errors.New("user already assigned to task")
|
||||
ErrAssignmentNotFound = errors.New("assignment not found")
|
||||
)
|
||||
```
|
||||
|
||||
### Handler Error Mapping
|
||||
|
||||
Each handler file includes a `mapDomainError` function (following the `Example` pattern) that converts domain errors to HTTP errors:
|
||||
|
||||
| Domain Error | HTTP Status | Response |
|
||||
|-------------|-------------|----------|
|
||||
| `ErrXxxNotFound` | 404 | `httperror.NotFound(msg)` |
|
||||
| `ErrDuplicateXxx` | 409 | `httperror.Conflict(msg)` |
|
||||
| `ErrInvalidXxx` | 400 | `httperror.BadRequest(msg)` |
|
||||
| Validation errors | 400 | Returned by `app.BindAndValidate()` |
|
||||
| Unknown errors | 500 | Passed through to `app.Wrap()` |
|
||||
|
||||
### PostgreSQL Error Handling in Adapters
|
||||
|
||||
Postgres adapters detect constraint violations to return appropriate domain errors:
|
||||
|
||||
```go
|
||||
func isDuplicateKeyError(err error) bool {
|
||||
var pgErr *pq.Error
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23505" // unique_violation
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isForeignKeyError(err error) bool {
|
||||
var pgErr *pq.Error
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23503" // foreign_key_violation
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication
|
||||
|
||||
- **Read endpoints** (GET) are public (no auth required) — follows existing `Example` pattern
|
||||
- **Write endpoints** (POST, PUT, DELETE) require authentication via `auth.Middleware()` — follows existing pattern
|
||||
- Auth middleware is opt-in, configured via `AUTH_ENABLED` env var
|
||||
|
||||
### Input Validation
|
||||
|
||||
All inputs are validated at two levels:
|
||||
|
||||
1. **Handler layer**: `app.BindAndValidate()` with struct tags for format validation
|
||||
- String lengths (min/max)
|
||||
- Required fields
|
||||
- Enum values via `oneof` tag
|
||||
- UUID format for path parameters
|
||||
- Hex color format via `hex_color` custom validator
|
||||
|
||||
2. **Domain layer**: Constructor validation for business rules
|
||||
- Name length constraints
|
||||
- Status/priority enum validation
|
||||
- Color format validation (regex `^#[0-9A-Fa-f]{6}$`)
|
||||
|
||||
### Data Boundaries
|
||||
|
||||
- `user_id` in assignments is an opaque string — no user entity lookup (per spec)
|
||||
- All IDs are server-generated UUIDs — clients cannot choose IDs
|
||||
- SQL queries use parameterized statements (`$1`, `$2`) — no SQL injection risk
|
||||
- JSON serialization uses struct tags — no field leakage
|
||||
|
||||
### Cascade Deletes
|
||||
|
||||
- Cascades are handled by PostgreSQL FK constraints (`ON DELETE CASCADE`)
|
||||
- Deleting a project cascades to its tasks, which cascades to task_labels and assignments
|
||||
- Deleting a label cascades to task_labels
|
||||
- No application-level cascade logic needed
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Query Complexity
|
||||
|
||||
- All list queries are simple `SELECT` with optional `WHERE` filters — O(n) scans with index support
|
||||
- Task listing filters on indexed columns (status, priority, project_id)
|
||||
- No joins required for primary CRUD operations
|
||||
- Task-label and assignment lookups use indexed FK columns
|
||||
|
||||
### Indexes
|
||||
|
||||
- `idx_tasks_project_id` — fast task listing by project
|
||||
- `idx_tasks_status` / `idx_tasks_priority` — fast filtered queries
|
||||
- `idx_task_labels_label_id` — fast "which tasks have this label" lookups
|
||||
- `idx_assignments_task_id` / `idx_assignments_user_id` — fast assignment lookups
|
||||
- Unique constraints on `projects.name` and `labels.name` double as unique indexes
|
||||
|
||||
### Connection Pool
|
||||
|
||||
- Uses existing `pkg/database` pool with sensible defaults (25 max open, 5 idle)
|
||||
- No additional caching layer needed for v1 (spec explicitly excludes pagination, so data volumes are expected to be small)
|
||||
- Connection pool is shared across all repositories
|
||||
|
||||
### No Pagination (v1)
|
||||
|
||||
- Per spec, list endpoints return all matching records
|
||||
- Acceptable for initial release; pagination can be added later as a separate feature
|
||||
- OpenAPI spec should document that results are unbounded
|
||||
|
||||
## Migration / Rollout Plan
|
||||
|
||||
### Phase 1: Database Schema (migrations)
|
||||
|
||||
1. Add migration files to `services/studio-api/migrations/`
|
||||
2. Migrations run at startup via `database.MustRunMigrations()`
|
||||
3. Migrations are idempotent (tracked in `schema_migrations` table)
|
||||
4. Tables are created in dependency order: projects → tasks → labels → task_labels → assignments
|
||||
|
||||
### Phase 2: Backward-Compatible Wiring
|
||||
|
||||
1. `main.go` adds database connection alongside existing in-memory adapter
|
||||
2. Existing `Example` entity continues using in-memory adapter (unchanged)
|
||||
3. New entities use PostgreSQL adapters
|
||||
4. `RegisterRoutes` signature expands to accept new services
|
||||
5. All new routes are additive — no existing routes change
|
||||
|
||||
### Phase 3: Testing Strategy
|
||||
|
||||
1. Domain tests: Pure unit tests with no dependencies (table-driven)
|
||||
2. Service tests: Mock repositories via interface (follows `example_test.go` pattern)
|
||||
3. Handler tests: `httptest` + chi router with mock services (follows `example_test.go` pattern)
|
||||
4. No integration tests against real PostgreSQL in v1 (adapters are thin SQL wrappers)
|
||||
5. All tests run with `cd services/studio-api && go test -v ./...`
|
||||
|
||||
### Rollout Checklist
|
||||
|
||||
- [ ] Migrations run successfully on fresh database
|
||||
- [ ] All existing tests continue to pass
|
||||
- [ ] New unit tests pass for all domain, service, and handler layers
|
||||
- [ ] OpenAPI spec renders correctly at `/docs`
|
||||
- [ ] `DATABASE_URL` environment variable documented in `.env.example`
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Separate migration files per entity group** — Keeps migrations readable and allows partial rollback reasoning. Projects first, tasks second, labels+associations third.
|
||||
|
||||
2. **One service per entity + one AssociationService** — Task-label and assignment operations are thin enough to share a service. Project, Task, and Label each get their own service for clarity.
|
||||
|
||||
3. **Domain validation in constructors** — Follows `Example` pattern. Constructors (`NewProject`, `NewTask`, etc.) validate inputs and return errors. Handlers also validate via struct tags for early feedback.
|
||||
|
||||
4. **PostgreSQL adapter per repository interface** — Each port gets its own adapter file. Adapters use `sqlx` directly (no ORM). Compile-time interface checks via `var _ port.XxxRepository = (*XxxRepository)(nil)`.
|
||||
|
||||
5. **Cascade deletes via FK constraints** — Simpler and more reliable than application-level cascade logic. PostgreSQL handles all cascading automatically.
|
||||
|
||||
6. **Global labels (not project-scoped)** — Per spec. Labels are shared across all projects. Task-label join table connects them.
|
||||
|
||||
7. **`user_id` as opaque string** — No FK to a users table. Supports any identity format (UUID, email, external ID). Per spec decision.
|
||||
|
||||
8. **Status defaults to `open`, priority to `medium`** — Sensible defaults in both SQL schema and domain constructors. Clients can omit these fields on creation.
|
||||
@ -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