refactor(schema): migrate eleven pivot/EAV tables to ULID per addendum Q1
Retires the "integer AI PK for join performance" exception documented in earlier migrations and SCHEMA.md §3.5.11 Rule 1. Every business and pivot table now uses ULID primary keys, per /dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1. Tables migrated (WS-1 A-01 through A-11): - Pure pivots: organisation_user, event_user_roles, crowd_list_persons, event_person_activations - Model-backed: user_organisation_tags, person_section_preferences, mfa_backup_codes, mfa_email_codes, form_submission_section_statuses, form_values, form_value_options Migration pattern: one new migration per table (plus one combined for the form_values / form_value_options FK pair), timestamped today, dropping + recreating with the new ULID PK. Pre-launch — no backfill required. Original migrations remain in place; the new migrations apply in timestamp order for a clean schema history. Pivot model correction (addendum drift): The addendum's "no model required for pure pivots" reading did not account for Laravel's BelongsToMany::attach() — it cannot auto-generate a pivot ULID without a Pivot subclass. Minimal Pivot classes under app/Models/Pivots/ (OrganisationUser, EventUserRole, CrowdListPerson, EventPersonActivation) carry HasUlids so attach() works. The six belongsToMany relations (User.organisations / .events, Organisation.users, Event.users, CrowdList.persons, Person.crowdLists) now ->using(...) the appropriate Pivot class. DB::table()->insert() on event_person_activations in DevSeeder populates the ULID inline via Str::ulid(). FormValueObserver uses bulk FormValueOption::insert() which bypasses model events — ULIDs are now generated inline there too. Docs: - SCHEMA.md §3.5.11 Rule 1 rewritten to mandate ULID on pivots too, with legacy note citing the addendum. - All eleven table entries updated from "int AI PK" to "ULID PK" with addendum Q1 references. - form_values and form_submission_section_statuses prose blocks updated to drop the retired ARCH §4.4 / "high-volume pivot" rationale. - form_value_options.form_value_id column type corrected from "int FK" to "ULID FK". Tests: tests/Feature/Schema/UlidPrimaryKeyTest.php covers HasUlids trait presence, ULID shape + 26-char Crockford pattern, Route::bind resolution, distinct + sortable pivot ULIDs, attach() auto-generation on pure pivots, and the A-10/A-11 FK chain. 10 tests / 28 new assertions. Full suite: 977 passed (2662 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -140,14 +140,14 @@
|
||||
|
||||
### `organisation_user`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ----------------- | ------- | --------------------------------- |
|
||||
| `id` | int AI | PK — integer for join performance |
|
||||
| `user_id` | ULID FK | → users |
|
||||
| `organisation_id` | ULID FK | → organisations |
|
||||
| `role` | string | Spatie role via pivot |
|
||||
| Column | Type | Notes |
|
||||
| ----------------- | ------- | --------------------------------------- |
|
||||
| `id` | ULID | PK (addendum Q1). `App\Models\Pivots\OrganisationUser` generates via `HasUlids`. |
|
||||
| `user_id` | ULID FK | → users |
|
||||
| `organisation_id` | ULID FK | → organisations |
|
||||
| `role` | string | Spatie role via pivot |
|
||||
|
||||
**Type:** Pivot table — integer PK
|
||||
**Type:** Pure pivot, ULID PK
|
||||
**Unique constraint:** `UNIQUE(user_id, organisation_id)`
|
||||
|
||||
---
|
||||
@@ -275,14 +275,14 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series')
|
||||
|
||||
### `event_user_roles`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ---------- | ------- | --------------------------------- |
|
||||
| `id` | int AI | PK — integer for join performance |
|
||||
| `user_id` | ULID FK | → users |
|
||||
| `event_id` | ULID FK | → events |
|
||||
| `role` | string | |
|
||||
| Column | Type | Notes |
|
||||
| ---------- | ------- | --------------------------------------- |
|
||||
| `id` | ULID | PK (addendum Q1). `App\Models\Pivots\EventUserRole`. |
|
||||
| `user_id` | ULID FK | → users |
|
||||
| `event_id` | ULID FK | → events |
|
||||
| `role` | string | |
|
||||
|
||||
**Type:** Pivot table — integer PK
|
||||
**Type:** Pure pivot, ULID PK
|
||||
**Unique constraint:** `UNIQUE(user_id, event_id, role)`
|
||||
|
||||
---
|
||||
@@ -316,7 +316,7 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series')
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ----------- | ------------------ | ------------------------ |
|
||||
| `id` | bigint | PK, auto-increment |
|
||||
| `id` | ULID | PK (addendum Q1) |
|
||||
| `user_id` | ULID | FK → users |
|
||||
| `code_hash` | string(64) | bcrypt hash of code |
|
||||
| `used` | boolean | default: false |
|
||||
@@ -334,7 +334,7 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series')
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ----------- | ------------------ | ------------------------ |
|
||||
| `id` | bigint | PK, auto-increment |
|
||||
| `id` | ULID | PK (addendum Q1) |
|
||||
| `user_id` | ULID | FK → users |
|
||||
| `code` | string(6) | 6-digit numeric code |
|
||||
| `expires_at`| timestamp | 10 min from creation |
|
||||
@@ -866,13 +866,13 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
|
||||
### `crowd_list_persons`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------------ | ---------------- | --------------------------------- |
|
||||
| `id` | int AI | PK — integer for join performance |
|
||||
| `crowd_list_id` | ULID FK | → crowd_lists |
|
||||
| `person_id` | ULID FK | → persons |
|
||||
| `added_at` | timestamp | |
|
||||
| `added_by_user_id` | ULID FK nullable | → users |
|
||||
| Column | Type | Notes |
|
||||
| ------------------ | ---------------- | --------------------------------------- |
|
||||
| `id` | ULID | PK (addendum Q1). `App\Models\Pivots\CrowdListPerson`. |
|
||||
| `crowd_list_id` | ULID FK | → crowd_lists |
|
||||
| `person_id` | ULID FK | → persons |
|
||||
| `added_at` | timestamp | |
|
||||
| `added_by_user_id` | ULID FK nullable | → users |
|
||||
|
||||
**Unique constraint:** `UNIQUE(crowd_list_id, person_id)`
|
||||
**Indexes:** `(person_id)`
|
||||
@@ -891,11 +891,11 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
> For volunteers: activation is derived from shift assignments (no manual entry needed).
|
||||
> For fixed crew and suppliers: use this pivot for explicit activation.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ----------- | ------- | --------------------------------- |
|
||||
| `id` | int AI | PK — integer for join performance |
|
||||
| `event_id` | ULID FK | → events (the sub-event) |
|
||||
| `person_id` | ULID FK | → persons |
|
||||
| Column | Type | Notes |
|
||||
| ----------- | ------- | --------------------------------------- |
|
||||
| `id` | ULID | PK (addendum Q1). `App\Models\Pivots\EventPersonActivation`. |
|
||||
| `event_id` | ULID FK | → events (the sub-event) |
|
||||
| `person_id` | ULID FK | → persons |
|
||||
|
||||
**Unique constraint:** `UNIQUE(event_id, person_id)`
|
||||
**Indexes:** `(person_id)`, `(event_id)`
|
||||
@@ -1538,7 +1538,7 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
|
||||
| Column | Type | Notes |
|
||||
| -------------------- | --------------- | ---------------------------------------------- |
|
||||
| `id` | int AI | PK — integer for join performance (pivot table) |
|
||||
| `id` | ULID | PK (addendum Q1) |
|
||||
| `user_id` | ULID FK | → users |
|
||||
| `organisation_id` | ULID FK | → organisations |
|
||||
| `person_tag_id` | ULID FK | → person_tags |
|
||||
@@ -1582,7 +1582,7 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --------------------- | ------- | --------------------------- |
|
||||
| `id` | int AI | PK — pivot table |
|
||||
| `id` | ULID | PK (addendum Q1) |
|
||||
| `person_id` | ULID FK | → persons |
|
||||
| `festival_section_id` | ULID FK | → festival_sections |
|
||||
| `priority` | tinyint | 1 (first choice) – 5 |
|
||||
@@ -1606,9 +1606,9 @@ Design notes:
|
||||
|
||||
### Rule 1 — ULID as Primary Key
|
||||
|
||||
- Business tables: `$table->ulid('id')->primary()` + `HasUlids` trait
|
||||
- Pure pivot/link tables: `$table->id()` (auto-increment integer)
|
||||
- Never UUID v4
|
||||
Business tables AND pure pivot tables: `$table->ulid('id')->primary()` + (on modelled tables) the `HasUlids` trait. Never UUID v4.
|
||||
|
||||
**Legacy note:** before 2026-04-24 pure pivots used auto-increment integer PKs; this exception was retired by ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1. For pivots consumed via Eloquent `belongsToMany` (`organisation_user`, `event_user_roles`, `crowd_list_persons`, `event_person_activations`), a minimal `Pivot` subclass under `App\Models\Pivots\*` carries `HasUlids` so `attach()` auto-generates the key — the addendum's "no model required" phrasing refers to domain modelling, not Eloquent plumbing.
|
||||
|
||||
---
|
||||
|
||||
@@ -2072,11 +2072,12 @@ that aggregates the user's submitted, non-test `form_submissions`.
|
||||
### `form_submission_section_statuses`
|
||||
|
||||
> Per-section lifecycle state when `form_schemas.section_level_submit =
|
||||
> true`. Integer AI PK — this is a high-volume pivot. ARCH §4.9.
|
||||
> true`. ULID PK per addendum Q1 (integer-AI exception retired 2026-04-24).
|
||||
> ARCH §4.9.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| -------------------------- | ------------------ | ------------------------------------------------ |
|
||||
| `id` | int AI | PK |
|
||||
| `id` | ULID | PK |
|
||||
| `form_submission_id` | ULID FK | → form_submissions, cascade delete |
|
||||
| `form_schema_section_id` | ULID FK | → form_schema_sections, cascade delete |
|
||||
| `status` | string(30) | `draft` / `submitted` / `approved` / `rejected` / `changes_requested` |
|
||||
@@ -2114,10 +2115,12 @@ that aggregates the user's submitted, non-test `form_submissions`.
|
||||
|
||||
### `form_values`
|
||||
|
||||
> EAV row per `(submission, field)`. **Integer AI PK** — this table is
|
||||
> joined heavily and integer joins beat ULID joins for the hot read
|
||||
> paths. Canonical payload lives in the `value` JSON column; the typed
|
||||
> columns are derived. ARCH §4.4.
|
||||
> EAV row per `(submission, field)`. ULID PK per
|
||||
> ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1 — the earlier
|
||||
> "int joins beat ULID joins" rationale was retired on 2026-04-24 in
|
||||
> favour of uniform ULID consistency across every business and pivot
|
||||
> table. Canonical payload lives in the `value` JSON column; the typed
|
||||
> columns are derived.
|
||||
>
|
||||
> **Observer behaviour (`FormValueObserver`, ARCH §7.2):**
|
||||
> - When `form_fields.is_filterable = true` on a scalar field,
|
||||
@@ -2135,7 +2138,7 @@ that aggregates the user's submitted, non-test `form_submissions`.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| -------------------------- | -------------------- | ------------------------------------------- |
|
||||
| `id` | int AI | PK |
|
||||
| `id` | ULID | PK |
|
||||
| `form_submission_id` | ULID FK | → form_submissions, cascade delete |
|
||||
| `form_field_id` | ULID FK | → form_fields, cascade delete |
|
||||
| `value` | JSON | Canonical payload |
|
||||
@@ -2162,8 +2165,8 @@ that aggregates the user's submitted, non-test `form_submissions`.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| -------------------- | ------------ | ----------------------------------------------------- |
|
||||
| `id` | int AI | PK |
|
||||
| `form_value_id` | int FK | → form_values, cascade delete |
|
||||
| `id` | ULID | PK |
|
||||
| `form_value_id` | ULID FK | → form_values, cascade delete |
|
||||
| `form_field_id` | ULID FK | → form_fields, cascade delete (denormalised) |
|
||||
| `form_submission_id` | ULID FK | → form_submissions, cascade delete (denormalised) |
|
||||
| `option_value` | string(255) | Single selected option |
|
||||
|
||||
Reference in New Issue
Block a user