Files
directus/.planning/phase-1-slice-1-schema/05-event-participation-collections.md
julian e01abfef27 Split db-init into pre-schema and post-schema phases
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.
2026-05-02 10:48:06 +02:00

192 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: 1199 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.