A completely empty MariaDB database could not start: the reconciler assumed
migration-owned tables already existed, and migrations assumed reconciler-owned
tables already existed. Neither could go first. Existing databases worked, so
only fresh installs were affected.
Startup is now an explicit sequence: connect, reconcile, migrate, reconcile,
start. The reconciler runs twice because neither position alone works — pass 1
repairs legacy schemas and creates the reconciler-owned tables that migrations
reference, pass 2 picks up everything that could not exist yet on a fresh
database. Every statement is existence-guarded, so the second pass is a no-op
scan on a correct database.
Untangled the overlapping ownership:
- RuleSettings is migration-owned. The reconciler also created it, which made a
clean install fail with "Table 'RuleSettings' already exists". It now only
seeds the default row, and only once the table exists.
- The six CareerProfile child tables are reconciler-owned. Their migration was
scaffolded against SQLite and indexed an unbounded longtext OwnerUserId, which
exceeds MariaDB's 3072-byte key limit; it is now a no-op and the reconciler
carries correct per-provider DDL. OwnerUserId and ItemKey are bounded to
varchar(255) in the model so the index fits.
- Reconciler tables that reference another table are guarded on their parent, so
pass 1 skips them on an empty database instead of failing on the foreign key.
- All index creation goes through one EnsureMySqlIndex helper, guarded on table
existence as well as index existence. This removes ten copies of the raw block
that crashed on a missing table.
- The DbContext-owned connection is no longer disposed by the reconciler, and
Open() is guarded on connection state, so the second pass can reuse it.
Verified against MariaDB 11 and SQLite: empty MariaDB (40 tables, starts),
restart on the populated database (idempotent, rows preserved), empty MariaDB
via the Docker image, fresh SQLite (42 tables), and an existing partially
migrated SQLite dev database (34 tables upgraded to 44 with all 13 applications
and 8 companies intact). 329 backend tests pass in Release.
Ownership rules, startup order, fresh install and production upgrade are
documented in docs/infrastructure/database-ownership.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, schema layer. Relational children of CareerProfile — the editable master
career profile. See docs/architecture/career-profile-model.md.
- New entities (Models/CareerEntities.cs): CareerExperience, CareerEducation,
CareerSkill, CareerProject, CareerCertification, CareerLanguage. Each carries
OwnerUserId (tenant filter), a stable ItemKey (carried from the blob so future
CV variants can reference items), and SortOrder. List fields persist as JSON
string columns via [NotMapped] accessors — plain TEXT, reconciler-friendly.
- CareerProfile gains typed child collections + a LongTailJson column (contact,
summary, interests, achievements, orgs, pubs, courses, custom sections,
metadata). ProfileJson becomes a derived projection for legacy read paths.
- DbContext: DbSets + tenant query filters + ordered indexes; FK/cascade by
convention via the typed collections.
- Migration hand-edited to add only the 6 new tables + LongTailJson; the
scaffolder re-emitted four reconciler-owned tables (AiWorkspaceNotes,
CareerProfiles, InterviewPrepNotes, CareerProfileVersions) which were stripped.
The regenerated snapshot now includes them, closing the drift. Verified against
a copy of the real dev DB: applies cleanly, no data loss.
Long tail (achievements/orgs/pubs/courses) starts as JSON; promotable to
relational later without a source-of-truth change. Source-of-truth flip stays
deferred; the blob is kept as a derived projection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>