Files
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

9.5 KiB

Task 1.4 — Org-level catalog collections

Phase: 1 — Slice 1 schema + deploy pipeline Status: Not started Depends on: 1.3 (db-init applied so Directus can boot) Wiki refs: docs/wiki/synthesis/directus-schema-draft.md (Org-level catalog section), docs/wiki/sources/rally-albania-regulations-2025.md

Goal

Create the durable, org-level collections in the Directus admin UI: organizations, users (using Directus's built-in users with custom fields), organization_users, vehicles, organization_vehicles, devices, organization_devices. These are the resources that exist independently of any single event.

This task happens against a locally running Directus instance (from pnpm dev). The output is a snapshot YAML that captures the collection definitions; that snapshot lands in git in task 1.6.

Deliverables

Create the following collections via the admin UI (Settings → Data Model). Field shapes per directus-schema-draft. Required-field columns marked *.

organizations

Field Type Notes
id * UUID primary key, auto-generated
name * string display name
slug * string URL-friendly identifier, unique
created_at timestamp Directus standard
updated_at timestamp Directus standard

Singleton: false. Sort: name asc.

users (extending Directus built-in directus_users)

Use the built-in user collection. Add custom fields (Settings → Data Model → directus_users):

Field Type Notes
phone string optional
birth_date date optional, used for age-derived class eligibility (M-5/M-6/M-7)
nationality string ISO 3166-1 alpha-2 country code

Do NOT add an organization_id here — multi-tenancy goes through organization_users.

organization_users (junction)

Field Type Notes
id * UUID
organization_id * M2O → organizations
user_id * M2O → directus_users
role * string (dropdown) enum: org-admin, race-director, marshal, timekeeper, participant, viewer
joined_at timestamp default now()

Unique constraint: (organization_id, user_id) — a user can only have one row per org. Multiple roles per user in same org → not yet (single role per tenant; revisit if needed).

vehicles

Field Type Notes
id * UUID
make * string "Toyota"
model * string "Land Cruiser 70"
year integer
engine_cc integer engine displacement, used for class assignment
vin string optional
plate_number string optional
notes text

No owner_user_id / owner_team_id — vehicles are org-scoped only, ownership is not modeled (per directus-schema-draft decision).

organization_vehicles (junction)

Field Type Notes
id * UUID
organization_id * M2O → organizations
vehicle_id * M2O → vehicles
registered_at timestamp default now()

Unique constraint: (organization_id, vehicle_id).

devices

Field Type Notes
id * UUID
imei * string unique, the canonical device identifier
model * string "FMB920", "FMB003", etc. — drives IO mapping in processor
serial_number string optional
notes text

imei UNIQUE — same IMEI can't be registered twice anywhere in the system.

organization_devices (junction)

Field Type Notes
id * UUID
organization_id * M2O → organizations
device_id * M2O → devices
registered_at timestamp default now()

Unique constraint: (organization_id, device_id).

Specification

  • Use UUIDs for all primary keys (Directus offers UUID v4 generation natively). Avoids leaking row counts and simplifies cross-env data sync.
  • All M2O relations have ON DELETE set to RESTRICT by default — accidentally deleting an org or vehicle should require the operator to clean up dependents first. Override per-relation only with explicit reason.
  • No permission policies — Phase 4 territory. Set every collection to "All Access" → none (admin only) for now.
  • No interface customization beyond defaults — the SPA isn't using these collections directly yet, and admin UI usability for operators happens after Phase 4 (when policies define what they see).
  • Do not commit .env or any secrets. This task only modifies Directus schema, which is captured in the snapshot.

Acceptance criteria

  • All seven collections exist in the admin UI with the fields listed above.
  • Required fields are flagged required.
  • All unique constraints are enforced (test by trying to create a duplicate row — should error).
  • M2O relations are visible and clickable in the admin UI's relational fields.
  • No permission policies attached (admin-only).
  • Manually create one organization, one user, one organization_user row → the relationships work end-to-end.
  • pnpm run schema:snapshot produces a snapshots/schema.yaml with all seven collections present (verified by grep).
  • Booting a brand-new Directus instance (fresh DB, fresh containers) and running directus schema apply --yes snapshots/schema.yaml recreates the seven collections identically.

Risks / open questions

  • directus_users field additions — Directus does allow adding fields to its built-in user collection, but the snapshot/apply behavior for those additions has historically been finicky across versions. Verify on the pinned Directus version that custom user fields round-trip cleanly via schema snapshot + schema apply. If they don't, fall back to a separate user_profiles collection M2O'd to directus_users.
  • Slug uniqueness on organizations — Directus enforces this at the field level. Confirm it generates a unique-index DDL in the snapshot.

Done

Implementation landed and live-verified 2026-05-02. All 7 collections live in Directus, snapshot captured at 53,450 bytes / 2,159 lines.

Driven via the directus-local MCP server rather than the admin UI — same canonical result (directus_collections / directus_fields / directus_relations rows + actual Postgres tables), captured cleanly by directus schema snapshot. This was the API-driven path the spec hinted at; sub-agents can't inherit MCP from the parent conversation, so this work was driven directly without delegation.

Created:

  • organizations — 5 fields (id UUID PK, name, slug unique, date_created, date_updated).
  • vehicles — 10 fields (id UUID PK, make, model, year, engine_cc, vin, plate_number, notes, date_created, date_updated). No ownership fields.
  • devices — 7 fields (id UUID PK, imei UNIQUE, model, serial_number, notes, date_created, date_updated).
  • directus_users — 3 custom fields added (phone, birth_date, nationality).
  • organization_users — 7 fields (id UUID PK, organization_id M2O, user_id M2O, role enum dropdown with 6 values, joined_at, date_created, date_updated).
  • organization_vehicles — 6 fields (id UUID PK, organization_id M2O, vehicle_id M2O, registered_at, date_created, date_updated).
  • organization_devices — 6 fields (id UUID PK, organization_id M2O, device_id M2O, registered_at, date_created, date_updated).
  • 6 M2O relations on the junctions, all with ON DELETE RESTRICT.

Composite unique constraints landed via db-init-post/001_junction_unique_constraints.sql because Directus's snapshot YAML format does not capture composite unique constraints (only single-column ones via is_unique). The migration adds:

  • organization_users (organization_id, user_id)
  • organization_vehicles (organization_id, vehicle_id)
  • organization_devices (organization_id, device_id)

Boot logs confirm: [db-init] apply 004_junction_unique_constraints.sql[db-init] done 004_junction_unique_constraints.sql → assertion block passes.

Snapshot review (snapshots/schema.yaml):

  • 8 collections registered (the 7 above + positions and migrations_applied as ghost entries — Directus auto-discovers tables in the public schema and registers minimal metadata for them, even though they're owned by db-init/processor not Directus). The ghost entries are harmless: schema apply against a fresh DB sees them already created by db-init and skips DDL.
  • directus_users custom fields round-trip correctly (no need for the spec's fallback user_profiles workaround).
  • All 6 M2O relations present in the relations section.
  • File size 53,450 bytes — well under the 200KB sanity threshold.

Acceptance criteria status:

  • All seven collections exist with the fields specified.
  • Required fields flagged (organizations.name/slug, devices.imei/model, vehicles.make/model, junction org/target/role).
  • Single-column unique constraints (organizations.slug, devices.imei) enforced.
  • Composite unique constraints on junctions enforced via db-init-post/001 (assertion block confirms).
  • M2O relations clickable in admin UI (Directus auto-resolves the dropdowns from the relation metadata).
  • No permission policies attached — admin-only by default.
  • pnpm run schema:snapshot produces snapshots/schema.yaml with all 7 collections present.
  • End-to-end test (manually create org → user → org_user via admin UI) — pending user.
  • Apply-to-fresh-DB roundtrip — pending CI dry-run in task 1.8.

Phase 5 follow-up note (not blocking): boot logs still WARN about positions lacking a PK. Already documented in task 1.7's Done section.