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.
11 KiB
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 scrutineeringconfirmed— passed scrutineering, eligible to startstarted— has begun the first stagefinished— completed all stages within MTAdnf— 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)
teamsdeferred: Phase 1 doesn't define ateamscollection. Theteam_idfield onentriesis 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:RESTRICTby default. Cascading from event → entries is appealing but risky for audit/historical purposes — leaveRESTRICTand require explicit operator action. statusenum order matters for display. Set the dropdown's option order to match the lifecycle:registered→confirmed→started→finished→dnf→dns→dq→withdrawn.race_numberis 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_idfield 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.statusdropdown 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_crewrows (pilot + co-pilot) → add threeentry_devicesrows (one withassigned_user_idset, two with null). All FKs resolve. - Try to create a second entry with the same
race_numberin the same event → error. pnpm run schema:snapshotproduces 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_idon 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 onwithdrawn.team_iddeliberately 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 NULLsince 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:
- We created 5 new collections via MCP against the running Directus.
- We then ran
docker compose build && up -dto makedb-init-post/002_*.sqlapply. - The image rebuild baked in the OLD
snapshots/schema.yaml(committed in task 1.4 — only had 7 collections). - 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. - End state: 5 collections gone, db-init-post/002 row in
migrations_appliedstill 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:
- 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).
- Re-applied the 5 ALTER TABLE statements from
db-init-post/002_*.sqldirectly via psql (sincemigrations_appliedalready had 005 recorded). - Ran
pnpm run schema:snapshotbefore 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:snapshotproduces snapshots/schema.yaml with all 5 new collections. - ⏳ End-to-end test (manually create event → class → entry → entry_crew → entry_devices) — pending user.