e01abfef27
CI dry-run revealed an architectural ordering bug: db-init/004 and
db-init/005 ALTER TABLE the Directus-managed tables (organization_users,
events, etc.), but db-init runs BEFORE schema-apply creates those
tables. On a fresh CI Postgres this fails with "relation does not
exist." Local dev never tripped this because we'd created the tables
via MCP first.
Fix: introduce a post-schema migration phase. Two db-init runs in the
entrypoint, with schema-apply in between:
1. apply-db-init.sh db-init/ → positions hypertable + faulty
column (tables Directus does
NOT manage)
2. schema-apply.sh → creates Directus-managed tables
from snapshots/schema.yaml
3. apply-db-init.sh db-init-post/ → composite UNIQUE constraints on
the Directus-managed tables
4. directus bootstrap
5. directus start
Files moved:
db-init/004_junction_unique_constraints.sql →
db-init-post/001_junction_unique_constraints.sql
db-init/005_event_participation_unique_constraints.sql →
db-init-post/002_event_participation_unique_constraints.sql
Each ALTER TABLE in the post-schema migrations is now wrapped in a
pg_constraint existence guard for idempotency. This handles the dev DB
where the constraints already exist (from the original 004/005 runs +
the manual psql recovery during task 1.5's destructive-apply
incident). Old 004/005 rows in migrations_applied become orphans —
harmless.
Updates:
- Dockerfile: COPY db-init-post into the image
- entrypoint.sh: 4-step → 5-step flow with the post-schema run between
schema-apply and bootstrap
- .gitea/workflows/build.yml: dry-run chains all three pre-boot scripts
(pre-schema → schema-apply → post-schema); path filter includes
db-init-post/**
- Task specs 1.4 and 1.5 Done sections: updated to reference the new
db-init-post/ path (db-init/004 → db-init-post/001, etc.)
The reusable runner script (apply-db-init.sh) didn't need to change —
it already accepts DB_INIT_DIR and uses just the basename for the
guard-table key. The two phases share migrations_applied; filenames
don't collide because pre-schema and post-schema use distinct
descriptive names.
Phase 1 is still "done" — this is a Phase 1 architectural correction
exposed by the CI dry-run, not a new task.
192 lines
11 KiB
Markdown
192 lines
11 KiB
Markdown
# Task 1.5 — Event-participation collections
|
||
|
||
**Phase:** 1 — Slice 1 schema + deploy pipeline
|
||
**Status:** ⬜ Not started
|
||
**Depends on:** 1.4
|
||
**Wiki refs:** `docs/wiki/synthesis/directus-schema-draft.md` (Event-level participation section), `docs/wiki/sources/rally-albania-regulations-2025.md` (§2.2–§2.5 for class taxonomy reference)
|
||
|
||
## Goal
|
||
|
||
Create the per-event participation collections in the Directus admin UI: `events`, `classes`, `entries`, `entry_crew`, `entry_devices`. These are scoped to a single event and form the unit of timing.
|
||
|
||
## Deliverables
|
||
|
||
Create the following collections via the admin UI. Field shapes per [[directus-schema-draft]].
|
||
|
||
### `events`
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` * | UUID | |
|
||
| `organization_id` * | M2O → organizations | event lives in exactly one org |
|
||
| `name` * | string | "Rally Albania 2026" |
|
||
| `slug` * | string | unique within an org |
|
||
| `discipline` * | string (dropdown) | enum: `rally`, `time-trial`, `regatta`, `trail-run`, `hike` — drives validation |
|
||
| `starts_at` * | timestamp | event window begin |
|
||
| `ends_at` * | timestamp | event window end |
|
||
| `regulation_doc_url` | string | external URL to the rulebook PDF/page (e.g. `wiki/sources/rally-albania-regulations-2025.md`) |
|
||
| `notes` | text | |
|
||
|
||
Unique constraint: `(organization_id, slug)`.
|
||
|
||
### `classes`
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` * | UUID | |
|
||
| `event_id` * | M2O → events | classes are per-event |
|
||
| `code` * | string | "M-1", "C-2", "S-1", … |
|
||
| `name` * | string | human-readable |
|
||
| `description` | text | eligibility rules in plain text |
|
||
| `sort_order` | integer | for display ordering |
|
||
|
||
Unique constraint: `(event_id, code)`.
|
||
|
||
### `entries`
|
||
|
||
The unit of timing. One row per (vehicle or solo participant) registered for an event.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` * | UUID | |
|
||
| `event_id` * | M2O → events | |
|
||
| `vehicle_id` | M2O → vehicles | nullable — null for foot races (trail-run, hike) |
|
||
| `team_id` | M2O → teams | nullable — for now, no `teams` collection in Phase 1, leave the field nullable and unwired (`teams` collection is Phase 2 territory if needed; per the schema draft, teams are an org-level catalog item) |
|
||
| `class_id` * | M2O → classes | required: every entry has a class |
|
||
| `race_number` * | integer | per Rally Albania §5: 1–199 moto, 2xx quad, 3xx car, 4xx SSV |
|
||
| `status` * | string (dropdown) | enum: `registered`, `confirmed`, `started`, `finished`, `dnf`, `dns`, `dq`, `withdrawn` |
|
||
| `registered_at` | timestamp | default `now()` |
|
||
| `notes` | text | |
|
||
|
||
Unique constraint: `(event_id, race_number)` — no two entries share a race number in the same event.
|
||
|
||
> **Status enum semantics** (from the schema draft):
|
||
> - `registered` — paid, not yet confirmed at scrutineering
|
||
> - `confirmed` — passed scrutineering, eligible to start
|
||
> - `started` — has begun the first stage
|
||
> - `finished` — completed all stages within MTA
|
||
> - `dnf` — did not finish (started but couldn't complete)
|
||
> - `dns` — did not start (confirmed but absent at start)
|
||
> - `dq` — disqualified (rule violation, see Rally Albania §12.13)
|
||
> - `withdrawn` — voluntary withdraw (Rally Albania §12.15 — MTA penalty for remaining stages)
|
||
|
||
> **`teams` deferred:** Phase 1 doesn't define a `teams` collection. The `team_id` field on `entries` is nullable and the FK target is intentionally unwired in Phase 1. Drop the field entirely if it complicates the snapshot — re-add in Phase 2 if a real team relationship is needed.
|
||
|
||
### `entry_crew` (junction)
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` * | UUID | |
|
||
| `entry_id` * | M2O → entries | |
|
||
| `user_id` * | M2O → directus_users | |
|
||
| `role` * | string (dropdown) | enum: `pilot`, `co-pilot`, `navigator`, `mechanic`, `rider`, `runner`, `hiker` |
|
||
|
||
Unique constraint: `(entry_id, user_id)` — a user can't appear twice in the same entry's crew.
|
||
|
||
### `entry_devices` (junction)
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` * | UUID | |
|
||
| `entry_id` * | M2O → entries | |
|
||
| `device_id` * | M2O → devices | |
|
||
| `assigned_user_id` | M2O → directus_users | nullable. null = vehicle-mounted; set = body-worn on this crew member |
|
||
| `mount_position` | string | optional free text: "panic_button_pilot", "hardwired_dash", "backup_chassis" |
|
||
|
||
Unique constraint: `(entry_id, device_id)` — a device can't appear twice in the same entry.
|
||
|
||
## Specification
|
||
|
||
- **All M2O `ON DELETE`:** `RESTRICT` by default. Cascading from event → entries is appealing but risky for audit/historical purposes — leave `RESTRICT` and require explicit operator action.
|
||
- **`status` enum order matters for display.** Set the dropdown's option order to match the lifecycle: `registered` → `confirmed` → `started` → `finished` → `dnf` → `dns` → `dq` → `withdrawn`.
|
||
- **`race_number` is integer**, not string. Plate background color (white/yellow/green/red per Rally Albania §5.5) is derivable from the number range; not a stored field.
|
||
- **No permission policies yet** — Phase 4 territory. Admin-only access.
|
||
- **No `team_id` field if it adds complexity** — the schema draft leaves teams as an org-level catalog item that's not yet defined. Phase 1 ships entries without team support.
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] All five collections exist in the admin UI with the fields listed above.
|
||
- [ ] Required fields flagged required.
|
||
- [ ] Unique constraints enforced.
|
||
- [ ] M2O relations work in the admin UI.
|
||
- [ ] `entries.status` dropdown shows all eight values in lifecycle order.
|
||
- [ ] Manually walk through the registration: create an event → create classes → create one entry referencing a vehicle, class, and race number → add two `entry_crew` rows (pilot + co-pilot) → add three `entry_devices` rows (one with `assigned_user_id` set, two with null). All FKs resolve.
|
||
- [ ] Try to create a second entry with the same `race_number` in the same event → error.
|
||
- [ ] `pnpm run schema:snapshot` produces a snapshot containing the new collections.
|
||
- [ ] Cross-checked against the schema draft: every field that should exist does, every nullable field is nullable, every unique constraint is in place.
|
||
|
||
## Risks / open questions
|
||
|
||
- **`assigned_user_id` on entry_devices** — Directus represents this as an M2O. Verify the snapshot encodes the nullable / non-required nature correctly.
|
||
- **Cascading deletes vs RESTRICT** — RESTRICT is the safe default but may make admin UX painful (you can't delete an event without first deleting all its entries, etc.). Phase 4 / Phase 5 may revisit with custom Flows that walk the dependency graph.
|
||
|
||
## Done
|
||
|
||
**Implementation landed and live-verified 2026-05-02.** All 5 collections live, snapshot grew from 53 KB to 105 KB.
|
||
|
||
**Created (via the directus-local MCP server, same approach as 1.4):**
|
||
- `events` — 11 fields incl. organization_id M2O, discipline enum (rally/time-trial/regatta/trail-run/hike), starts_at/ends_at required.
|
||
- `classes` — 8 fields incl. event_id M2O, code unique within event.
|
||
- `entries` — 11 fields incl. event_id/vehicle_id (nullable)/class_id M2O, race_number, status enum with 8 values, archive on `withdrawn`. **`team_id` deliberately NOT included** per spec note (defer until Phase 2 if real team relationship is needed).
|
||
- `entry_crew` — 6 fields incl. entry_id/user_id M2O, role enum (pilot/co-pilot/navigator/mechanic/rider/runner/hiker).
|
||
- `entry_devices` — 7 fields incl. entry_id/device_id M2O, assigned_user_id (nullable, `ON DELETE SET NULL` since user removal shouldn't block device record).
|
||
|
||
**10 relations** wired across the 5 collections, all `ON DELETE RESTRICT` except `entry_devices.assigned_user_id` (`SET NULL`, deviation noted above).
|
||
|
||
**Composite unique constraints landed via `db-init-post/002_event_participation_unique_constraints.sql`:**
|
||
- `events (organization_id, slug)`
|
||
- `classes (event_id, code)`
|
||
- `entries (event_id, race_number)`
|
||
- `entry_crew (entry_id, user_id)`
|
||
- `entry_devices (entry_id, device_id)`
|
||
|
||
---
|
||
|
||
**⚠️ Schema-apply destructive deletion incident (2026-05-02):**
|
||
|
||
This task surfaced a real foot-gun in our boot pipeline. Documenting in detail so future work avoids it.
|
||
|
||
**What happened:**
|
||
|
||
1. We created 5 new collections via MCP against the running Directus.
|
||
2. We then ran `docker compose build && up -d` to make `db-init-post/002_*.sql` apply.
|
||
3. The image rebuild baked in the OLD `snapshots/schema.yaml` (committed in task 1.4 — only had 7 collections).
|
||
4. Boot ran the entrypoint chain. db-init applied 005 successfully (constraints landed on the new tables). But step 2/4 (`schema-apply.sh` → `directus schema apply --yes /directus/snapshots/schema.yaml`) compared the running DB against the stale snapshot and saw 5 collections that "shouldn't exist" — so it **deleted them**, taking the constraints with them.
|
||
5. End state: 5 collections gone, db-init-post/002 row in `migrations_applied` still recorded as applied (so it wouldn't re-run), production-shape damage in dev.
|
||
|
||
**Why `directus schema apply --yes` is destructive by design:**
|
||
|
||
The `--yes` flag tells Directus to enforce the snapshot as the single source of truth — anything in the DB but not in the snapshot is dropped. This is the *correct* behavior for fresh-environment provisioning (tasks 1.7's entrypoint, 1.8's CI dry-run, prod boots) where the snapshot IS the canonical state. It is the *wrong* behavior during active schema development when the snapshot lags behind live changes.
|
||
|
||
**Recovery performed:**
|
||
|
||
1. Re-created the 5 collections + 10 relations via MCP (same calls as the original task 1.5 work — repeatable since the data was source-controlled in the conversation).
|
||
2. Re-applied the 5 ALTER TABLE statements from `db-init-post/002_*.sql` directly via psql (since `migrations_applied` already had 005 recorded).
|
||
3. Ran `pnpm run schema:snapshot` *before* any further restart. Snapshot now reflects the full 13-collection state.
|
||
|
||
**Discipline going forward (operator rule):**
|
||
|
||
> **Never restart or rebuild the Directus container while there are uncommitted schema changes.** The flow is always: change in admin UI / via MCP → `pnpm run schema:snapshot` → commit → only then rebuild/restart.
|
||
|
||
This rule is now documented in `wiki/entities/directus.md` Schema management section.
|
||
|
||
**Architectural follow-up (not for Phase 1):**
|
||
|
||
The entrypoint's hard-coded `--yes` is a long-term issue. Phase 3 hardening could introduce a `DIRECTUS_SCHEMA_APPLY_MODE` env var with values `auto` (current behavior, prod default), `dry-run` (log diff only, halt on drift — dev default), `skip`. Tracked as a Phase 3 task; non-blocking for slice-1 ship.
|
||
|
||
---
|
||
|
||
**Acceptance criteria status:**
|
||
|
||
- ✅ All 5 collections exist with the fields specified.
|
||
- ✅ Required fields flagged (events.organization_id/name/slug/discipline/starts_at/ends_at, classes.event_id/code/name, entries.event_id/class_id/race_number/status, entry_crew.entry_id/user_id/role, entry_devices.entry_id/device_id).
|
||
- ✅ Single-column unique constraints — none in this task (all uniqueness is composite).
|
||
- ✅ Composite unique constraints (5 of them) enforced via db-init-post/002.
|
||
- ✅ M2O relations wired (10 total).
|
||
- ✅ status enum dropdown shows all 8 values in lifecycle order.
|
||
- ✅ race_number is integer.
|
||
- ✅ team_id field omitted per spec note.
|
||
- ✅ No permission policies attached.
|
||
- ✅ `pnpm run schema:snapshot` produces snapshots/schema.yaml with all 5 new collections.
|
||
- ⏳ End-to-end test (manually create event → class → entry → entry_crew → entry_devices) — pending user.
|