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

11 KiB
Raw Permalink Blame History

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: registeredconfirmedstartedfinisheddnfdnsdqwithdrawn.
  • 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.shdirectus 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.