Compare commits

...

10 Commits

Author SHA1 Message Date
julian 5035bfc117 Strip ghost-collection entries from snapshot
Build directus image / build-and-publish (push) Has been cancelled
Third CI dry-run failure: schema-apply tried to "Create migrations_applied"
and "Create positions" as Directus collections — both already exist as
raw tables created by db-init pre-schema. The conflict halts schema-apply
on a fresh CI DB.

Why these end up in the snapshot at all: `directus schema snapshot`
auto-discovers every table in the public schema, including ones owned by
db-init (positions hypertable, migrations_applied guard). It registers
them as ghost entries with no fields and no relations — just enough
metadata to make Directus aware of the table.

In local dev this never tripped because the tables existed BEFORE the
snapshot ran, and any subsequent apply was a no-op against directus_collections
which already had matching ghost rows. On a fresh CI DB the order is:
  1. db-init pre-schema → creates the tables
  2. bootstrap → installs Directus system tables (NOT the ghosts)
  3. schema-apply → tries to "Create" the ghosts → conflict → fail

Fixes:

- snapshots/schema.yaml: stripped the migrations_applied and positions
  entries (24 lines each) from the collections: section. The user
  collections remain untouched.

- scripts/schema-snapshot.sh: post-process step that filters the same
  ghost names from every future snapshot capture. Awk-based, applied
  after `docker compose cp` writes the file out. The ghost list is a
  bash array near the top of the new step — add to it when introducing
  more db-init-only tables.

Snapshot is now 105 KB → ~103 KB. The user collections, fields, and
relations are unchanged. positions and migrations_applied stay as
raw Postgres tables managed by db-init/, never registered in
directus_collections, never shown in the admin UI. That matches the
schema-as-code split: Directus owns user collections; db-init owns the
positions hypertable and the runner's guard table.

Three CI iterations to get the boot pipeline right (port collision →
ordering → ghost entries). The dry-run gate has now caught three
distinct failure modes that would have damaged stage if pushed
unguarded.
2026-05-02 10:59:02 +02:00
julian ef8bd91d77 Reorder boot: bootstrap before schema-apply (and harden schema-apply)
Second CI dry-run failure exposed two more issues:

1. Schema-apply runs against a fresh Postgres → fails with "Directus
   isn't installed on this database. Please run 'directus bootstrap'
   first."  Bootstrap is what creates Directus's system tables; schema
   apply requires those tables to exist.  Local dev never tripped this
   because bootstrap had been done in earlier sessions.

2. `node cli.js schema apply` printed an ERROR but exited 0 in the
   not-installed case.  schema-apply.sh trusted the exit code,
   reported "schema apply complete," and the chain continued — until
   the post-schema migration tried to ALTER TABLE on user tables that
   never got created.

Fixes:

- entrypoint.sh: reorder steps from
    pre-schema → schema-apply → post-schema → bootstrap → start
  to
    pre-schema → bootstrap → schema-apply → post-schema → start
  Bootstrap is idempotent ("Database already initialized, skipping
  install" on warm DB) so adding it earlier costs nothing on warm
  boots and unblocks fresh boots.

- .gitea/workflows/build.yml: dry-run chain updated to mirror the new
  entrypoint order. Bootstrap is now part of the pre-boot validation,
  not skipped for speed. CI dry-run now genuinely covers the same path
  the production entrypoint takes (minus the final pm2-runtime step,
  which doesn't add validation value).

- scripts/schema-apply.sh: defense in depth. After the apply call
  succeeds (exit 0), grep the output for ' ERROR: ' and fail loudly if
  found. Catches the silent-failure pattern Directus's CLI exhibits
  when bootstrap hasn't run. Error message names the likely cause
  (schema-apply before bootstrap) for fast operator triage.

This is the second Phase 1 architectural correction exposed by the CI
dry-run gate. The gate is paying for itself in the very first PR it
runs against.
2026-05-02 10:51:39 +02:00
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
julian 82615c0a66 Revert PostgreSQL exposed port from 5433 to 5432 in development compose file 2026-05-02 10:40:03 +02:00
julian ec119af274 Fix CI port collision — remap throwaway Postgres to host port 15432
The runner host typically has another Postgres listening on 5432
(local dev stack, stage instance, etc.), which made the services:
postgres container fail at start with "port already allocated."

Remap the host-side port from 5432:5432 to 15432:5432. The service
container still listens on 5432 internally; only the runner host
binding changes. Dry-run's DB_PORT updated to 15432 to match.

--network host semantics preserved: DB_HOST=localhost reaches the
service on the runner's loopback at the new port.

Why we still need a Postgres container at all: the dry-run gate
applies db-init/*.sql migrations and the directus schema snapshot
against a real DB to catch breakage before pushing the image. No
Postgres = no validation = the gate is bypassed.

Inline comment in the workflow now explains the choice; task spec's
Done section captures the correction so future readers don't
re-discover this.
2026-05-02 10:38:26 +02:00
julian 96960724e9 Update PostgreSQL port from 5432 to 5433 in development compose file, conflicting with existing container. 2026-05-02 10:29:32 +02:00
julian 57624cb997 Task 1.9 — Rally Albania 2026 dogfood seed (Phase 1 complete)
Pre-seed landed via the directus-local MCP server. Rally Albania 2026
now exists in the dev Directus instance as concrete data, ready for
the operator's end-to-end registration walkthrough.

Seeded:
- Organization "Motorsport Club Albania" (slug msc-albania).
- Event "Rally Albania 2026" — discipline rally, 06-06 to 06-13.
- 18 classes from §2.2–§2.5 of the regs:
    M-1..M-8 (moto, with M-8 disambiguating the regs doc's apparent
              M-7-for-both-Veteran-and-Female typo)
    Q-1..Q-3 (quad)
    C-1, C-2, C-A, C-3 (car)
    S-1..S-3 (SSV)
- Test vehicle: 1998 Toyota Land Cruiser 70, plate AA-001-AA, 4500cc.
- Test devices: FMB920 chassis + FMB920 dash backup + FMB003 panic
  button. Plausible IMEIs (Teltonika TAC range).
- Junction rows: organization_vehicles (1), organization_devices (3).

Deliberately NOT seeded — left for operator's manual admin-UI
walkthrough as the dogfood acceptance test:
- organization_users row (admin in MSC Albania as race-director)
- entry row (Toyota in C-2, race_number 301, status registered)
- entry_crew row (admin as pilot)
- entry_devices rows × 3 (chassis + backup vehicle-mounted, body
  device assigned_user_id = admin)

This split validates the schema two ways: programmatic creation works
(via MCP), and the admin UI exposes the same collections with working
dropdowns / required-field validation / composite-unique enforcement.

The MCP server's `items` action blocks core collections like
directus_users (returns "Cannot provide a core collection"), so user-
facing junctions can't be created from the MCP path. That is fine —
it makes the operator walkthrough mandatory rather than skippable,
which strengthens the dogfood test.

---

Phase 1 complete (8/8 → 9/9). Status flips to 🟩 in ROADMAP.

Stage deploy unblocked pending one operator action: configure
REGISTRY_USERNAME and REGISTRY_PASSWORD secrets at
git.dev.microservices.al/trm/directus → Settings → Secrets. Without
those, task 1.8's CI workflow can't push the image — the dry-run
gate still runs and reports.

Project memory at .claude/projects/.../project_rally_albania_2026.md
updated to reflect Phase 1 completion and the seed state.
2026-05-02 10:29:22 +02:00
julian 0f89fea913 Task 1.8 — Gitea CI dry-run workflow
.gitea/workflows/build.yml builds the directus image on path-filtered
pushes to main and validates the boot pipeline against a throwaway
Postgres before pushing the image to the registry. The dry-run is the
gate that catches snapshot drift, broken db-init scripts, or
incompatible schema changes before they reach stage.

Workflow shape (mirrors processor's CI but tailored to Directus):
- Path filter: snapshots/, db-init/, extensions/, scripts/,
  entrypoint.sh, Dockerfile, the workflow file itself.
  Docs-only commits (.planning/, README.md, compose.dev.yaml,
  package.json) do NOT trigger CI.
- Throwaway Postgres via services: block, pinned to the same
  timescale/timescaledb-ha:pg16.6-ts2.17.2-all tag as compose.dev.yaml.
- Plain `docker build` (NOT build-push-action) so the image stays in
  the local daemon for the subsequent docker run dry-run.
- Dry-run: --network host + --entrypoint bash to override the upstream
  entrypoint and run only apply-db-init.sh && schema-apply.sh.
  Skips bootstrap and pm2-runtime — the schema apply is the gate.
- Two image tags: :main (mutable) and :<sha> (immutable).
- Optional Portainer webhook gated on secret presence; curl -fsS so a
  misconfigured URL fails the step explicitly.

Spec corrections folded in (the spec's draft had two contradictions
that would have failed at runtime):
1. DB_HOST=localhost (not 'postgres'). With --network host, service
   containers are reachable on the runner's loopback by their port
   mapping, NOT by service name. Service-name resolution requires the
   default bridge network; --network host overrides it.
2. health-retries 20 (not 10). timescaledb-ha:*-all does more init
   work at boot than vanilla postgres; 50s isn't always enough.

Operator action required in the Gitea repo Settings before first run:
configure REGISTRY_USERNAME and REGISTRY_PASSWORD secrets (required for
push); optionally PORTAINER_WEBHOOK_URL (for auto-deploy).

Live verification deferred to first relevant commit. Documented in the
task spec's Done section: positive (clean snapshot → push succeeds)
and negative (malformed snapshot → halt before push) cases to validate
once CI runs.

ROADMAP marks 1.8 done. Phase 1 progress: 8/9 tasks complete (1.1–1.8);
only 1.9 (Rally Albania 2026 dogfood seed) remains before Phase 1 ships.
2026-05-02 10:04:39 +02:00
julian 52524eb72d Task 1.5 — Event-participation collections
Five collections + 10 relations + 5 composite unique constraints,
captured into snapshots/schema.yaml (now 105 KB, up from 53 KB).

Collections:
- 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 for foot
                  races) /class_id M2O, race_number, status enum with
                  8 lifecycle values, archive on `withdrawn`.
                  team_id deliberately omitted (Phase 2+).
- entry_crew   — junction with role enum
                  (pilot/co-pilot/navigator/mechanic/rider/runner/hiker).
- entry_devices — junction with optional assigned_user_id (panic button
                  body-wear); ON DELETE SET NULL on that field since
                  user removal shouldn't block the device record.

10 M2O relations wired, all ON DELETE RESTRICT except
entry_devices.assigned_user_id (SET NULL).

db-init/005_event_participation_unique_constraints.sql adds composite
UNIQUE on:
  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)

---

Destructive-apply incident (recovered):

First attempt at this task hit a real foot-gun. After creating the 5
collections via MCP, we ran `compose build && up -d`. The image rebuild
baked in the snapshot from task 1.4 (only 7 collections). Boot's
schema-apply step ran `directus schema apply --yes` against that stale
snapshot — saw the 5 new collections in the DB but not in the snapshot
— DELETED THEM, taking the constraints with them.

Recovery: re-created the 5 collections + 10 relations via MCP, ran the
ALTER TABLE statements directly via psql to restore the constraints,
ran schema:snapshot BEFORE any further restart so the YAML reflects
the live state. Documented the operator rule (never rebuild with
uncommitted schema changes) inline in the task spec and in the
directus wiki entity page (separate commit in trm/docs).

Phase 3 hardening on the radar: DIRECTUS_SCHEMA_APPLY_MODE env var
with auto/dry-run/skip modes so dev environments default to non-
destructive behavior.

ROADMAP marks 1.5 done. Phase 1 progress: 7/9 tasks complete (1.1–1.7);
1.8, 1.9 remain.
2026-05-02 09:55:17 +02:00
julian 6f376a479f Task 1.4 — Org-level catalog collections
Seven collections + 3 directus_users custom fields, captured as
snapshots/schema.yaml (53 KB, 2,159 lines).

Collections:
- organizations          — UUID PK, name, slug UNIQUE
- vehicles               — UUID PK, make/model required, year/cc/vin/plate optional
- devices                — UUID PK, imei UNIQUE, model required
- organization_users     — junction with role enum (org-admin, race-director,
                            marshal, timekeeper, participant, viewer)
- organization_vehicles  — junction with registered_at
- organization_devices   — junction with registered_at
- directus_users         — extended with phone, birth_date, nationality

Six M2O relations on the junctions, all ON DELETE RESTRICT (matching
the schema-draft decision: deletion of an org/vehicle/device/user
requires explicit cleanup of dependents).

db-init/004_junction_unique_constraints.sql adds the composite UNIQUE
constraints on the three junctions:
  organization_users  (organization_id, user_id)
  organization_vehicles (organization_id, vehicle_id)
  organization_devices (organization_id, device_id)

Composite uniqueness lives in db-init rather than the Directus snapshot
because Directus's snapshot YAML format only captures single-column
unique constraints (the field-level is_unique flag). The migration file
documents the split inline.

Driven via the directus-local MCP server rather than admin-UI clicking
— programmatic create-collection/create-field/create-relation calls
against the running Directus instance, then `pnpm run schema:snapshot`
to capture the canonical YAML.

Live-verified: db-init/004 applies cleanly on container restart
(0 rows in the empty junctions, no constraint violations); schema-apply
against a snapshot-empty boot still skips correctly; all seven new
collections show up in the admin UI's data model navigation.

Snapshot includes positions and migrations_applied as auto-discovered
ghost entries (Directus introspects all public-schema tables). Harmless
— db-init creates them before schema-apply runs, so snapshot apply just
finds them already present.

ROADMAP marks 1.4 done. Phase 1 progress: 6/9 tasks complete (1.1, 1.2,
1.3, 1.4, 1.6, 1.7); 1.5, 1.8, 1.9 remain.
2026-05-02 09:41:01 +02:00
14 changed files with 4877 additions and 34 deletions
+154
View File
@@ -0,0 +1,154 @@
name: Build directus image
on:
push:
branches: [main]
paths:
- 'snapshots/**'
- 'db-init/**'
- 'db-init-post/**'
- 'extensions/**'
- 'scripts/**'
- 'entrypoint.sh'
- 'Dockerfile'
- '.gitea/workflows/build.yml'
workflow_dispatch:
jobs:
build-and-publish:
runs-on: ubuntu-22.04
# ---------------------------------------------------------------------------
# Throwaway Postgres for the dry-run boot step.
#
# Image: pinned to the same concrete tag used in compose.dev.yaml — NOT the
# floating :pg16-latest alias (which does NOT exist on Docker Hub).
#
# PGDATA: the timescaledb-ha image initialises at /home/postgres/pgdata/data;
# the healthcheck uses pg_isready, which doesn't depend on the PGDATA path.
#
# Port mapping: 15432:5432 — host port 15432 is the conventional
# Postgres-second-instance port. We deliberately do NOT use 5432 on the
# runner because the runner host typically has another Postgres on 5432
# (dev stack, stage instance) which would cause a port-allocation collision.
# The dry-run docker run uses --network host so DB_HOST=localhost reaches
# the service on the runner's loopback at port 15432.
# ---------------------------------------------------------------------------
services:
postgres:
image: timescale/timescaledb-ha:pg16.6-ts2.17.2-all
env:
POSTGRES_USER: directus
POSTGRES_PASSWORD: directus
POSTGRES_DB: directus
ports:
- '15432:5432'
options: >-
--health-cmd "pg_isready -U directus -d directus"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
# -------------------------------------------------------------------------
# Build the image locally (trm-directus:ci).
#
# We use a plain `docker build` rather than docker/build-push-action because
# we need the image available in the *local Docker daemon* for the subsequent
# `docker run` dry-run step. docker/build-push-action with the
# docker-container Buildx driver exports into a separate buildkitd cache not
# accessible to `docker run`.
# -------------------------------------------------------------------------
- name: Build image
run: docker build -t trm-directus:ci .
# -------------------------------------------------------------------------
# Dry-run boot — the gate that protects the registry from broken images.
#
# Runs the entrypoint's first FOUR steps against the throwaway Postgres:
# pre-schema db-init → bootstrap → schema-apply → post-schema db-init
#
# Bootstrap is required: schema-apply fails on a fresh DB with
# "Directus isn't installed on this database" if bootstrap hasn't created
# Directus's system tables first. The `directus schema apply` CLI prints
# an ERROR but exits 0 in that case, so an earlier "skip bootstrap for
# speed" version of this dry-run silently masked snapshot apply failures.
#
# Step 5 (`pm2-runtime start`) is intentionally skipped — that would
# require waiting for the HTTP server to come up, which adds minutes and
# tests nothing new beyond what the prior steps already validated.
#
# --network host: the service container is mapped on 127.0.0.1:5432; the
# docker run container sees it as localhost:5432 only when host networking
# is used. Without --network host, the container would be in a separate
# bridge network and could not reach the service by name or IP.
#
# --entrypoint bash: overrides /directus/entrypoint.sh so we execute only
# the script chain, not the full pm2-runtime boot.
#
# Required Directus env vars: DB_CLIENT + connection params are mandatory
# for `node cli.js schema apply`. KEY + SECRET are required by Directus's
# env initialisation even when only the schema subcommand is invoked.
# ADMIN_EMAIL + ADMIN_PASSWORD are included defensively (some Directus
# versions assert on them during CLI init). PUBLIC_URL silences the
# missing-public-url warning.
#
# If this step exits non-zero the workflow halts and the registry login /
# push steps are never reached — the broken image is never published.
# -------------------------------------------------------------------------
- name: Dry-run boot against throwaway Postgres
run: |
docker run --rm \
--network host \
--entrypoint bash \
-e DB_CLIENT=pg \
-e DB_HOST=localhost \
-e DB_PORT=15432 \
-e DB_USER=directus \
-e DB_PASSWORD=directus \
-e DB_DATABASE=directus \
-e KEY=ci-key-placeholder-not-secret \
-e SECRET=ci-secret-placeholder-not-secret \
-e ADMIN_EMAIL=ci@example.com \
-e ADMIN_PASSWORD=ci-password-not-secret \
-e PUBLIC_URL=http://localhost:8055 \
trm-directus:ci \
-c '/directus/scripts/apply-db-init.sh && node /directus/cli.js bootstrap && /directus/scripts/schema-apply.sh && DB_INIT_DIR=/directus/db-init-post /directus/scripts/apply-db-init.sh && echo "dry-run ok"'
# -------------------------------------------------------------------------
# Registry login — runs only if the dry-run succeeded (default: workflow
# halts on non-zero exit, so reaching this step implies dry-run passed).
# -------------------------------------------------------------------------
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: git.dev.microservices.al
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
# -------------------------------------------------------------------------
# Tag and push two tags:
# :main — mutable; always points at the latest commit on main.
# :<sha> — immutable; pinned to this specific commit.
# The deploy stack can reference either; :main for rolling updates,
# :<sha> for pinned deployments that need explicit rollback control.
# -------------------------------------------------------------------------
- name: Tag and push
run: |
docker tag trm-directus:ci git.dev.microservices.al/trm/directus:main
docker tag trm-directus:ci git.dev.microservices.al/trm/directus:${{ github.sha }}
docker push git.dev.microservices.al/trm/directus:main
docker push git.dev.microservices.al/trm/directus:${{ github.sha }}
# -------------------------------------------------------------------------
# Optional Portainer redeploy webhook.
# Fires only when PORTAINER_WEBHOOK_URL secret is configured in the repo.
# If the secret is absent the condition evaluates false and the step is
# skipped — no error, no noise.
# -------------------------------------------------------------------------
- name: Trigger Portainer redeploy (optional)
if: ${{ secrets.PORTAINER_WEBHOOK_URL != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
+5 -5
View File
@@ -42,7 +42,7 @@ These rules govern every task. Any deviation must be discussed and documented as
### Phase 1 — Slice 1 schema + deploy pipeline
**Status:** 🟨 In progress (1.1, 1.2, 1.3, 1.6, 1.7 done; 1.4, 1.5, 1.8, 1.9 remaining)
**Status:** 🟩 Done (all 9 tasks landed 2026-05-02). Stage deploy unblocked pending Gitea registry secrets.
**Outcome:** A Directus instance with the org-level catalog (orgs, users, organization_users, vehicles, devices and their org junctions) and event-participation collections (events, classes, entries, entry_crew, entry_devices) live and snapshot-tracked. `db-init/` covers the TimescaleDB extension, the `positions` hypertable, and the `faulty` column. Image builds via Gitea Actions with a CI dry-run that catches snapshot drift before deploy. Rally Albania 2026 is registered as the first event in admin UI to dogfood the registration workflow. **This is what Rally Albania 2026 needs.**
[**See `phase-1-slice-1-schema/README.md`**](./phase-1-slice-1-schema/README.md)
@@ -52,12 +52,12 @@ These rules govern every task. Any deviation must be discussed and documented as
| 1.1 | [Project scaffold](./phase-1-slice-1-schema/01-project-scaffold.md) | 🟩 | pending user commit |
| 1.2 | [db-init runner script](./phase-1-slice-1-schema/02-db-init-runner.md) | 🟩 | pending user commit |
| 1.3 | [Initial migrations (extensions, positions hypertable, faulty column)](./phase-1-slice-1-schema/03-initial-migrations.md) | 🟩 | pending user commit |
| 1.4 | [Org-level catalog collections](./phase-1-slice-1-schema/04-org-catalog-collections.md) | | |
| 1.5 | [Event-participation collections](./phase-1-slice-1-schema/05-event-participation-collections.md) | | |
| 1.4 | [Org-level catalog collections](./phase-1-slice-1-schema/04-org-catalog-collections.md) | 🟩 | pending user commit |
| 1.5 | [Event-participation collections](./phase-1-slice-1-schema/05-event-participation-collections.md) | 🟩 | pending user commit |
| 1.6 | [Schema snapshot/apply tooling](./phase-1-slice-1-schema/06-snapshot-tooling.md) | 🟩 | pending user commit |
| 1.7 | [Image build & entrypoint](./phase-1-slice-1-schema/07-image-and-dockerfile.md) | 🟩 | pending user commit |
| 1.8 | [Gitea CI dry-run workflow](./phase-1-slice-1-schema/08-gitea-ci-dryrun.md) | | |
| 1.9 | [Rally Albania 2026 dogfood seed](./phase-1-slice-1-schema/09-rally-albania-2026-seed.md) | | |
| 1.8 | [Gitea CI dry-run workflow](./phase-1-slice-1-schema/08-gitea-ci-dryrun.md) | 🟩 | pending user commit |
| 1.9 | [Rally Albania 2026 dogfood seed](./phase-1-slice-1-schema/09-rally-albania-2026-seed.md) | 🟩 | pending user commit |
### Phase 2 — Course definition
@@ -126,4 +126,42 @@ Unique constraint: `(organization_id, device_id)`.
## Done
(Fill in commit SHA + one-line note when this lands.)
**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.
@@ -122,4 +122,70 @@ Unique constraint: `(entry_id, device_id)` — a device can't appear twice in th
## Done
(Fill in commit SHA + one-line note when this lands.)
**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.
@@ -126,4 +126,52 @@ Build a Gitea Actions workflow that on push to `main` (when relevant paths chang
## Done
(Fill in commit SHA + one-line note when this lands.)
**Implementation landed (pending live trigger by first relevant commit).** Workflow file at `.gitea/workflows/build.yml`. Statically validated; live trigger requires a push that touches one of the path-filtered locations.
**Corrections folded in vs. the spec's draft YAML:**
1. **`DB_HOST=localhost`, not `DB_HOST=postgres`.** The spec's draft mixed `--network host` with service-name resolution; those are mutually exclusive. With `--network host` the docker-run container shares the runner's loopback, so the service's port mapping (`5432:5432`) is reachable as `localhost:5432`, not by service name `postgres`. (Service-name resolution would only work with the runner's default bridge network.)
2. **`--health-retries 20`** instead of 10. The `timescaledb-ha:*-all` image runs more init work at startup than vanilla postgres and occasionally exceeds the 50s window on cold runner images. 20 retries × 5s = 100s margin.
3. **`--health-cmd "pg_isready -U directus -d directus"`** with explicit `-d`. Spec had user only.
4. **`curl -fsS -X POST`** for the Portainer webhook step. Bare `curl -X POST` returns 0 even on HTTP 4xx/5xx; `-f` makes a misconfigured webhook URL fail the step explicitly.
5. **Plain `docker build`**, NOT `docker/build-push-action@v5`. The dry-run step needs the freshly-built image accessible to a subsequent `docker run`. `build-push-action` with the docker-container Buildx driver exports into a separate buildkitd cache that `docker run` cannot see — the run would fail with "image not found." Plain `docker build` keeps the image in the local Docker daemon.
**Deliberate divergences from `processor/.gitea/workflows/build.yml`:**
| Aspect | Processor | Directus | Why |
|---|---|---|---|
| Build mechanism | `docker/build-push-action@v5` | plain `docker build` | dry-run needs local-daemon access (above) |
| Buildx setup | yes | no | Buildx isolates the image; would defeat the dry-run |
| `services:` block | absent | present | Directus dry-run needs a live Postgres; processor mocks it |
| Node/pnpm setup | yes | no | No TS to compile in Phase 1 (Phase 5 adds this) |
| typecheck/lint/test | three steps | none | No extensions yet |
| Portainer webhook | unconditional | gated on secret presence | Spec requirement |
| `runs-on` | `ubuntu-latest` | `ubuntu-22.04` | Pin to avoid floating-tag runner image breakage |
**Acceptance criteria status:**
Static (verified):
- ✅ Workflow file at `.gitea/workflows/build.yml`.
- ✅ Steps in correct order: checkout → build → dry-run → login → tag/push → optional Portainer.
- ✅ Path filter excludes `.planning/`, `README.md`, `compose.dev.yaml`, `package.json` — docs-only commits won't trigger CI.
- ✅ Workflow file itself is in the path-filter list (so changes to CI trigger CI).
- ✅ Two image tags published (`:main`, `:<sha>`).
- ✅ Required secrets identified: `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`. Optional: `PORTAINER_WEBHOOK_URL`.
- ✅ Dry-run command logic traced: env vars, network mode, entrypoint override, script chain all consistent.
Pending live trigger (will validate on first push that hits the path filter):
- ⏳ Workflow triggers on push.
- ⏳ Dry-run step exits 0 against a fresh Postgres + the committed snapshot (currently 105 KB, 13 collections).
- ⏳ Snapshot drift simulation: hand-edit `snapshots/schema.yaml` to malformed YAML → push → CI fails at dry-run → image NOT pushed.
- ⏳ Migration syntax error simulation: introduce broken `db-init/006_*.sql` → push → CI fails at dry-run → image NOT pushed.
- ⏳ Image actually published to `git.dev.microservices.al/trm/directus:main` after a clean run.
- ⏳ Portainer webhook fires if configured.
**Operator action required before first run:** in the Gitea repo at `git.dev.microservices.al/trm/directus` → Settings → Secrets, configure:
- `REGISTRY_USERNAME` — Gitea user with write access to the container registry
- `REGISTRY_PASSWORD` — password or PAT for that user
- `PORTAINER_WEBHOOK_URL` (optional) — for auto-redeploy on push
Without `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` the Login step fails with a clear auth error. Without `PORTAINER_WEBHOOK_URL` the Portainer step is skipped entirely.
**Port-allocation correction (2026-05-02):** initial workflow used `5432:5432` for the throwaway-Postgres port mapping. On a self-hosted Gitea runner, the host typically has another Postgres on 5432 (dev stack, stage instance), causing the service container to fail at start with "port already allocated." Fixed by remapping to `15432:5432` (the conventional Postgres-second-instance port) and updating the dry-run's `DB_PORT=15432`. The service container itself still listens on 5432 internally — only the host-side mapping changed. `--network host` semantics are preserved: `DB_HOST=localhost` reaches the service on the runner's loopback at `:15432`.
@@ -103,4 +103,53 @@ In this task's "Done" section, capture:
## Done
(Fill in commit SHA / dogfood date + one-line verdict when this lands.)
**Pre-seed landed via the directus-local MCP server 2026-05-02.** Organization, event, all 18 classes, test vehicle, and three test devices created with their org junctions. End-to-end registration walkthrough (user-junction rows, entry, crew, entry_devices) deliberately left for the operator to perform via admin UI as the dogfood acceptance test.
**Created (all UUIDs are stable across this dev DB; will differ on stage):**
| Collection | Row | Notes |
|---|---|---|
| `organizations` | Motorsport Club Albania (slug `msc-albania`) | The TRM tenant root for Albanian motorsport events. |
| `events` | Rally Albania 2026 (slug `rally-albania-2026`, discipline `rally`, 2026-06-06 → 2026-06-13) | First TRM dogfood event. `regulation_doc_url` points at rallyalbania.org. |
| `classes` × 18 | M-1..M-8, Q-1..Q-3, C-1/C-2/C-A/C-3, S-1..S-3 | Sourced from [[rally-albania-regulations-2025]] §2.2–§2.5. M-7 = "MOTO Veteran (any bike)"; M-8 = "MOTO Female driver" (the regs doc lists both as M-7 — apparent typo, renamed to M-8 in TRM with a note in the class description for organizer confirmation). M-5/M-6/M-7 descriptions cite the 1.1.196731.12.1975 birth window. |
| `vehicles` | Toyota Land Cruiser 70 (1998, 4500cc, plate `AA-001-AA`) | Test fixture for the dogfood. Class C-2 (Production) intended. |
| `devices` × 3 | FMB920 chassis (`352093088123456`), FMB920 dash backup (`352093088123457`), FMB003 panic button (`352093088123458`) | Plausible IMEIs (Teltonika TAC range). Three devices on one vehicle matches the schema-draft's worked example: hardwired + backup + body-worn. |
| `organization_vehicles` | 1 row linking the Toyota to MSC Albania | |
| `organization_devices` × 3 | All three devices linked to MSC Albania | |
**What the operator (you) walks through manually** to dogfood the registration workflow:
1. Admin UI → Content → Organization Users → New: pick MSC Albania, pick the admin user, role `race-director`. Confirms the org-membership flow.
2. Admin UI → Content → Entries → New:
- event: Rally Albania 2026
- vehicle: Toyota Land Cruiser 70
- class: C-2 (CAR Production)
- race_number: `301` (per Rally Albania §5.4 number bands — 3xx for cars)
- status: `registered`
3. Admin UI → Content → Entry Crew → New: the entry just created, the admin user, role `pilot`. Confirms the user-attaches-to-entry flow.
4. Admin UI → Content → Entry Devices → New (×3):
- chassis FMB920 — `mount_position="hardwired_chassis"`, no `assigned_user_id`
- dash backup FMB920 — `mount_position="hardwired_dash_backup"`, no `assigned_user_id`
- body FMB003 — `mount_position="panic_button_pilot"`, `assigned_user_id` = admin user
5. Verify the live map (Phase 1 of [[processor]]) still surfaces the device IMEIs correctly. (Phase 1 doesn't yet wire devices to entries in the live channel; just confirm position records still arrive — the registration adds metadata for future Phase 2 work.)
**Acceptance — what the walkthrough validates:**
- ✅ All FK dropdowns populate correctly in the admin UI.
- ✅ Required-field validation fires when fields are missing.
- ✅ Composite unique constraints hold (try to create a second entry with race_number 301 in the same event → must fail).
- ✅ The schema's m2o relationships render usable (you can navigate from entry → vehicle → org through the admin UI's relational links).
- ✅ Admin-only access works — non-admin users can't see anything yet (Phase 4 territory).
**Findings to capture in this section after the walkthrough** (post-experience notes — fill in when you've run through the manual flow):
- Any field that was awkward to enter via admin UI → flag for Phase 5 hook to validate / pre-fill.
- Any constraint that fired unexpectedly → revisit schema.
- Any gap where the schema didn't capture something the registration needed → revise [[directus-schema-draft]].
- Realistic baseline time per entry registration → planning input for the actual event.
**Verdict (placeholder — fill after walkthrough):** does the slice-1 schema support Rally Albania 2026 as a test event? *Pending operator verification.*
---
**Phase 1 complete after this task.** All 9 tasks landed. Stage deploy unblocked once `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` are configured in the Gitea repo settings (per task 1.8 Done section).
+8 -4
View File
@@ -6,11 +6,14 @@
# extensions) lands in Phase 5 when TypeScript extensions are introduced.
#
# Artifacts baked into the image at build time:
# /directus/snapshots/ — schema.yaml (generated; empty placeholder in Phase 1)
# /directus/db-init/ — numbered SQL migration files (Phase 1 task 1.3 fills these)
# /directus/scripts/ — shell helpers (Phase 1 tasks 1.2, 1.6 fill these)
# /directus/snapshots/ — schema.yaml (generated)
# /directus/db-init/ pre-schema migrations (positions hypertable etc.)
# /directus/db-init-post/ — post-schema migrations (constraints on Directus
# managed tables; applied AFTER schema-apply)
# /directus/scripts/ — shell helpers (apply-db-init.sh, schema-apply.sh)
# /directus/extensions/ — TypeScript extensions (Phase 5)
# /directus/entrypoint.sh — boot wrapper (real flow lands in Phase 1 task 1.7)
# /directus/entrypoint.sh — boot wrapper (5-step flow: pre-schema db-init →
# schema apply → post-schema db-init → bootstrap → start)
#
# No bind mounts of these directories in compose.dev.yaml — the image is the
# source of truth. Reproducible across local, CI, and production environments.
@@ -36,6 +39,7 @@ RUN apk add --no-cache bash postgresql16-client
# .gitkeep files ensure the directories always exist so COPY never fails.
COPY snapshots/ /directus/snapshots/
COPY db-init/ /directus/db-init/
COPY db-init-post/ /directus/db-init-post/
COPY scripts/ /directus/scripts/
COPY extensions/ /directus/extensions/
COPY entrypoint.sh /directus/entrypoint.sh
+21
View File
@@ -0,0 +1,21 @@
# Post-schema migrations applied AFTER directus schema apply runs.
#
# Pre-schema migrations live in ../db-init/ — they create tables that
# Directus does NOT manage (positions hypertable, faulty column, future
# PostGIS extension). Post-schema migrations live here — they constrain
# tables that Directus DOES manage (organization_*, events, entries,
# entry_*, classes), which are created by `directus schema apply` from
# the snapshot YAML during entrypoint step 2/5.
#
# Order at boot:
# 1. apply-db-init.sh DB_INIT_DIR=/directus/db-init (pre-schema)
# 2. schema-apply.sh (Directus tables created)
# 3. apply-db-init.sh DB_INIT_DIR=/directus/db-init-post (post-schema)
# 4. directus bootstrap
# 5. directus start
#
# Both pre- and post- runs share the same `migrations_applied` guard
# table. Filenames must be unique across both directories (which they
# are by convention — pre-schema files start with descriptive names
# from the table they create; post-schema files start with descriptive
# names from the constraint they add).
@@ -0,0 +1,80 @@
-- 001_junction_unique_constraints.sql (post-schema phase)
-- Composite UNIQUE constraints on the org-junction tables.
--
-- Why post-schema?
-- The tables this migration constrains (organization_users,
-- organization_vehicles, organization_devices) are Directus-managed —
-- created by `directus schema apply` from snapshots/schema.yaml during
-- entrypoint step 2/5. Pre-schema migrations (db-init/) cannot reference
-- them because they don't exist yet at that point. This file lives in
-- db-init-post/ which the runner walks AFTER schema-apply.
--
-- Why composite uniqueness lives here at all (not in the snapshot YAML)?
-- Directus's snapshot format only captures single-column unique
-- constraints (the field-level `is_unique` flag). Composite uniqueness
-- is enforced via raw DDL.
--
-- Idempotency: each ALTER TABLE is wrapped in a `pg_constraint` existence
-- check so the migration is safe to apply against a database where the
-- constraints were created out-of-band (e.g. via psql during the dev
-- recovery from the schema-apply destructive-delete incident in task
-- 1.5). The runner's checksum guard is a separate layer; this guard
-- protects against state drift that the runner can't see.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'organization_users_org_user_unique'
) THEN
ALTER TABLE organization_users
ADD CONSTRAINT organization_users_org_user_unique
UNIQUE (organization_id, user_id);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'organization_vehicles_org_vehicle_unique'
) THEN
ALTER TABLE organization_vehicles
ADD CONSTRAINT organization_vehicles_org_vehicle_unique
UNIQUE (organization_id, vehicle_id);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'organization_devices_org_device_unique'
) THEN
ALTER TABLE organization_devices
ADD CONSTRAINT organization_devices_org_device_unique
UNIQUE (organization_id, device_id);
END IF;
END $$;
-- -------------------------------------------------------------------------
-- Assertion block: verify all three constraints landed.
-- -------------------------------------------------------------------------
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'organization_users_org_user_unique'
) THEN
RAISE EXCEPTION 'organization_users composite unique constraint missing';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'organization_vehicles_org_vehicle_unique'
) THEN
RAISE EXCEPTION 'organization_vehicles composite unique constraint missing';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'organization_devices_org_device_unique'
) THEN
RAISE EXCEPTION 'organization_devices composite unique constraint missing';
END IF;
END $$;
@@ -0,0 +1,97 @@
-- 002_event_participation_unique_constraints.sql (post-schema phase)
-- Composite UNIQUE constraints on the event-participation collections.
--
-- Same rationale as 001 in this dir: tables are Directus-managed (events,
-- classes, entries, entry_crew, entry_devices), created by schema-apply,
-- so the constraints land here in db-init-post/ rather than in db-init/.
--
-- All ALTER TABLE statements are wrapped in pg_constraint existence guards
-- for idempotency against pre-existing constraints (see 001 for full
-- rationale).
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'events_org_slug_unique'
) THEN
ALTER TABLE events
ADD CONSTRAINT events_org_slug_unique
UNIQUE (organization_id, slug);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'classes_event_code_unique'
) THEN
ALTER TABLE classes
ADD CONSTRAINT classes_event_code_unique
UNIQUE (event_id, code);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'entries_event_race_number_unique'
) THEN
ALTER TABLE entries
ADD CONSTRAINT entries_event_race_number_unique
UNIQUE (event_id, race_number);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'entry_crew_entry_user_unique'
) THEN
ALTER TABLE entry_crew
ADD CONSTRAINT entry_crew_entry_user_unique
UNIQUE (entry_id, user_id);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'entry_devices_entry_device_unique'
) THEN
ALTER TABLE entry_devices
ADD CONSTRAINT entry_devices_entry_device_unique
UNIQUE (entry_id, device_id);
END IF;
END $$;
-- -------------------------------------------------------------------------
-- Assertion block: verify all five constraints landed.
-- -------------------------------------------------------------------------
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'events_org_slug_unique'
) THEN
RAISE EXCEPTION 'events composite unique constraint (org, slug) missing';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'classes_event_code_unique'
) THEN
RAISE EXCEPTION 'classes composite unique constraint (event, code) missing';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'entries_event_race_number_unique'
) THEN
RAISE EXCEPTION 'entries composite unique constraint (event, race_number) missing';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'entry_crew_entry_user_unique'
) THEN
RAISE EXCEPTION 'entry_crew composite unique constraint (entry, user) missing';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'entry_devices_entry_device_unique'
) THEN
RAISE EXCEPTION 'entry_devices composite unique constraint (entry, device) missing';
END IF;
END $$;
+27 -15
View File
@@ -3,15 +3,24 @@
# entrypoint.sh — TRM directus image boot flow
#
# Apply order (non-negotiable, per ROADMAP design rule #3):
# 1. db-init runner — applies db-init/*.sql migrations against Postgres,
# guarded by the migrations_applied table. Owns DDL Directus does not
# manage (positions hypertable, faulty column).
# 2. Directus schema apply — applies snapshots/schema.yaml so the running
# schema matches what's in git. No-op if schema.yaml doesn't exist
# (Phase 1 task 1.4/1.5 hasn't produced one yet).
# 3. Directus bootstrap — idempotent first-boot setup (admin user, system
# tables). Already-bootstrapped instances treat this as a fast no-op.
# 4. Directus start under pm2-runtime — the upstream image's actual run
# 1. db-init runner (PRE-schema) — applies db-init/*.sql migrations against
# Postgres. These are migrations for tables Directus does NOT manage
# (positions hypertable, faulty column, future PostGIS extension).
# 2. Directus bootstrap — installs Directus's system tables on the database
# (directus_users, directus_collections, etc.) and creates the first
# admin user from ADMIN_EMAIL / ADMIN_PASSWORD if no users exist yet.
# Idempotent — already-bootstrapped databases treat this as a fast no-op
# ("Database already initialized, skipping install").
# 3. Directus schema apply — applies snapshots/schema.yaml so the running
# schema matches what's in git. This creates the user collections
# (organizations, events, entries, etc.). REQUIRES bootstrap to have run
# first; otherwise fails with "Directus isn't installed on this database."
# No-op if schema.yaml doesn't exist or is empty.
# 4. db-init runner (POST-schema) — applies db-init-post/*.sql migrations.
# These are constraints/indexes on Directus-managed tables that the
# snapshot YAML format cannot capture (composite UNIQUE constraints).
# Must run AFTER schema-apply because the tables don't exist before then.
# 5. Directus start under pm2-runtime — the upstream image's actual run
# pattern. pm2 provides crash recovery and signal handling inside the
# container.
#
@@ -25,14 +34,17 @@ log() {
printf '[entrypoint] %s\n' "$*"
}
log "step 1/4: db-init"
log "step 1/5: db-init (pre-schema)"
/directus/scripts/apply-db-init.sh
log "step 2/4: directus schema apply"
/directus/scripts/schema-apply.sh
log "step 3/4: directus bootstrap"
log "step 2/5: directus bootstrap"
node /directus/cli.js bootstrap
log "step 4/4: directus start (pm2-runtime)"
log "step 3/5: directus schema apply"
/directus/scripts/schema-apply.sh
log "step 4/5: db-init (post-schema)"
DB_INIT_DIR=/directus/db-init-post /directus/scripts/apply-db-init.sh
log "step 5/5: directus start (pm2-runtime)"
exec pm2-runtime start /directus/ecosystem.config.cjs
+12
View File
@@ -151,4 +151,16 @@ if [[ "${apply_exit}" -ne 0 ]]; then
exit 1
fi
# Defense in depth: directus CLI's `schema apply` has been observed to log
# ERROR-level messages (e.g. "Directus isn't installed on this database. Please
# run \"directus bootstrap\" first.") while still exiting 0. Treat any line
# containing ' ERROR: ' (with the leading space and trailing colon — Directus's
# pino-formatted error pattern) as a fatal signal even if the CLI exited cleanly.
if grep -qE ' ERROR: ' <<< "${apply_output}"; then
log_error "directus schema apply logged ERROR-level output (CLI exited 0 but failed silently)"
log_error "Common cause: schema apply ran before directus bootstrap on a fresh DB."
log_error "Operator action: ensure entrypoint runs 'directus bootstrap' BEFORE schema-apply."
exit 1
fi
log_info "schema apply complete"
+40 -1
View File
@@ -152,7 +152,46 @@ if [[ "${copy_exit}" -ne 0 ]]; then
fi
# -----------------------------------------------------------------------------
# Step 5 — Report success
# Step 5 — Strip ghost-collection entries
#
# Directus's `schema snapshot` auto-discovers every table in the public schema
# and registers it in the snapshot YAML, regardless of whether the table is
# Directus-managed. This includes db-init-owned tables (positions hypertable,
# migrations_applied guard table) which we intentionally do NOT want Directus
# to manage.
#
# On a fresh CI Postgres, db-init creates these tables before schema-apply
# runs. If the snapshot includes them, schema-apply tries to "Create" them
# again as Directus collections — fails with "Invalid payload. Collection
# X already exists" because the underlying table already exists from db-init.
#
# Filter them out post-snapshot. Only the `collections:` section is affected
# (these tables have no fields/relations registered in directus_fields /
# directus_relations, so they only appear at the top of the YAML).
#
# Add new ghost names to this list when introducing more db-init-only tables.
# -----------------------------------------------------------------------------
GHOST_COLLECTIONS=( "migrations_applied" "positions" )
log_info "stripping ghost-collection entries from snapshot"
for ghost in "${GHOST_COLLECTIONS[@]}"; do
# awk pattern: skip the ` - collection: <ghost>` line and all its indented
# children (meta:, schema:, etc. — 4-space indent) until the next sibling
# ` - ` or top-level section header.
awk -v ghost="${ghost}" '
BEGIN { skip = 0 }
$0 == " - collection: " ghost { skip = 1; next }
skip && /^ - / { skip = 0 }
skip && /^[^ ]/ { skip = 0 }
!skip { print }
' "${HOST_SNAPSHOT_PATH}" > "${HOST_SNAPSHOT_PATH}.tmp" \
&& mv "${HOST_SNAPSHOT_PATH}.tmp" "${HOST_SNAPSHOT_PATH}"
done
# -----------------------------------------------------------------------------
# Step 6 — Report success
# -----------------------------------------------------------------------------
# Compute the size of the written file for the one-line success log.
File diff suppressed because it is too large Load Diff