Taak 2.8 — Migratie-runner over alle tenant-databases
Status: uitgevoerd, 8 september 2026. Productie-run wacht op de master-deploy (los punt). Scope: één runner in het master dashboard die de gebundelde tenant-migraties tegen elke bekende tenant-database zet, met per database een schemastand vóór en een resultaat ná de run, een bewaarde run-historie, en het aansluiten van bestaande tenant-databases (pilot, rb-media) op hun master-rij. Geen uitrol van site-images of rollback (2.9), geen parallelle migraties, geen CLI, geen master-migraties via de UI. Wijkt de uitvoering af, werk dit document bij en noteer de afwijking bij 2.8 in Taken.
Uitgangspunt op main
Section titled “Uitgangspunt op main”Dit bestaat al, niet opnieuw te beslissen:
applyTenantMigrations({ databaseUrl, migrationsFolder })inpackages/db/scripts/migrate-tenant.mjs, export@platform/db/migrate. Eénpostgres()-client metmax: 1,sql.end()infinally. Zowelmigrate.mjs(Coolify pre-deployment) als provisioning (2.5) lopen door dit pad. Het is de enige plek die migraties toepast; dat blijft zo.- De master bundelt
packages/db/drizzle/als Nitro server assettenant-migrations(nuxt.config.ts).materializeTenantMigrationsinserver/utils/provision-tenant-migrate.tspakt dat uit naar een tmp-map en memoïseert;migrateTenantDatabase(databaseUrl)zit erbovenop. - De tenant-DSN staat in
tenants.database_url_encrypted(AES-256-GCM inserver/utils/secrets.ts, sleutelNUXT_SECRETS_KEY). Provisioning (2.5) schrijft die kolom. De rijen van pilot en rb-media zijn met de hand aangemaakt (2.4) en hebben geen DSN. - Drizzle 0.45.2 houdt per database
drizzle.__drizzle_migrationsbij:hash(sha256 van het.sql-bestand) encreated_at(bigint, gelijk aanwhenuitmeta/_journal.json). Gelezen inpg-core/dialect.js: alleen de nieuwstecreated_atis de watermerk,hashwordt geschreven maar nooit teruggelezen, en alle openstaande migraties van één database gaan in één transactie. Een database is dus na een run óf helemaal bij, óf onaangeraakt. - De master heeft géén kolom of tabel die de schemastand per tenant kent.
@platform/opsheeftlistAppliedMigrations(lib/inventory.ts) en een journal-teller (bootstrap-tenant.ts), maar itereert nergens over tenants. Het enige spoor van een runner is een dode verwijzing in Plan van aanpak naar eenscripts/migrate-all.tsdat nooit is geschreven. - Importregel uit 2.5 (
provision-tenant-migrate.ts, kopcommentaar): nooit@platform/db(root) en@platform/db/masterin dezelfde module, want de Better Auth-tabellen (users,sessions) botsen.@platform/db/migrateimporteert geen schema en is veilig. database.mdc: “Bij database-per-tenant itereert de migratie-runner over alle tenant-databases. Die runner rapporteert per database succes of falen en stopt niet stilzwijgend halverwege.” Dat is de norm.- De master-deploy is voorbereid (Master-deploy: image gebouwd,
platform_mastergemigreerd op de VPS), maar de Coolify-app bestaat nog niet. Het bewijs van 2.8 is lokaal; de eerste productie-run volgt zodra het losse punt “Master dashboard deployen” af is.
Vastgestelde versies
Section titled “Vastgestelde versies”Geen nieuwe packages. Pins afgelezen uit de package.json’s op main.
| Pakket | Pin | Relevant omdat |
|---|---|---|
drizzle-orm |
0.45.2 | het migrator-gedrag hierboven is op deze versie gelezen; bij een bump pg-core/dialect.js opnieuw lezen |
postgres |
3.4.9 | ruwe client voor de probe en de lock (connect_timeout, connection.lock_timeout) |
drizzle-kit |
0.31.10 | generate:master voor 0004_* |
nuxt |
4.5.2 | serverAssets + useStorage("assets:tenant-migrations") |
Testen zoals 2.2–2.5: node --test naast de helpers, geen testrunner erbij.
Data shapes
Section titled “Data shapes”1. Doel (bundel). Wat de master ín het image heeft: journal-entries
{ idx, tag, when } plus per bestand sha256(inhoud), dezelfde hash die
drizzle in __drizzle_migrations.hash schrijft. Niet persistent; één keer
per proces uit het asset gelezen.
2. Schemastand per tenant (platformTenantMigrationStatusSchema,
contract). Discriminated union op state, zoals platformDomainSchema:
state |
Betekenis | Extra velden |
|---|---|---|
no_database |
database_url_encrypted is null |
— |
unreachable |
verbinden of lezen mislukt | error (geredigeerd) |
blocked |
runner weigert deze database aan te raken | reason: identity_mismatch | ahead | diverged; detail |
pending |
achter op de bundel | appliedCount, pendingTags[] |
up_to_date |
gelijk aan de bundel | appliedCount, lastTag |
Plus slug, name, status (tenant-status) en checkedAt.
identity_mismatch:public.sitebestaat ensite.tenant_idis niet de slug. Een lege of ongemigreerde database is géén mismatch (nog geen site-rij).ahead: de database heeft eencreated_atnieuwer dan de laatste journal-entry in de bundel. Het master-image is dan ouder dan het site-image. Niets te doen, wél luid melden.diverged: zelfdecreated_at, anderehash. Een gecommit.sqlis achteraf aangepast. Drizzle zelf merkt dit nooit (zie hierboven).
3. Runresultaat per tenant (tenantMigrationResultSchema):
{ slug, outcome: applied | up_to_date | skipped | failed, appliedTags[], reason?, error?, durationMs }. skipped draagt de blocked-reason of
no_database; failed de geredigeerde foutmelding, gecapt op 500 tekens.
4. Run (platformMigrationRunSchema + tabel migration_runs, master):
| Kolom | Type | Toelichting |
|---|---|---|
id |
uuid pk | |
started_at / finished_at |
timestamptz not null | |
target_tag |
text | laatste journal-tag in de bundel |
target_count |
int | aantal journal-entries in de bundel |
triggered_by_email |
text null | uit de sessie; geen FK, accounts kunnen weg |
applied_count / up_to_date_count / skipped_count / failed_count |
int | samenvatting voor de lijst |
results |
jsonb, $type<TenantMigrationResult[]> |
gevalideerd bij schrijven én lezen |
Plus created_at / updated_at. Eén rij per run, geschreven in finally,
dus nooit een eeuwige running-rij. Eén master-migratie 0004_*.
5. Activity per tenant. tenant.migrated (appliedTags, runId) en
tenant.migration_failed (error, runId), met tenant_id gezet en
direct na elke tenant geschreven. Dat is de incrementele log als het proces
midden in een run sterft. Derde nieuwe type tenant.database_attached
(host, database; nooit de DSN).
6. Write: database aansluiten (tenantDatabaseWriteSchema).
{ databaseUrl }: scheme postgresql: of postgres:, databasenaam moet
tenant_<slug> zijn. Alleen schrijven; komt nooit terug in JSON.
7. Run-verzoek (migrationRunRequestSchema). { slugs?: string[] }
via tenantSlugSchema. Leeg of afwezig betekent alle tenants met een DSN.
Keuzes, met reden
Section titled “Keuzes, met reden”De runner leeft in het master dashboard, niet in @platform/ops en niet
in @platform/db. Alleen de master heeft de DSN’s én de sleutel. Een CLI
zou een tweede kopie van NUXT_SECRETS_KEY op de laptop of in de
ops-runner vragen, precies wat Master-deploy afraadt. database.mdc
verbiedt businesslogica in @platform/db; applyTenantMigrations blijft
daar, de iteratie en rapportage niet.
Verworpen: ops-CLI via scripts/ops-runner.Dockerfile (sleutelkopie).
Verworpen: runner in @platform/db (regel van het package).
Live probe, geen cache-kolom op tenants. De waarheid staat in
drizzle.__drizzle_migrations van de tenant zelf. Een schema_tag-kolom
in de master loopt achter zodra iemand docker exec … migrate.mjs draait
of de pre-deployment hook vuurt. Een probe is één SELECT per tenant met
connect_timeout: 3; de probes lopen in groepjes van vijf parallel, het
migreren niet.
Verworpen: tenants.schema_version (nieuwe drift-bron).
Run-historie wél persistent. 4.4 (statuspagina) en 2.9 (uitrol) willen
weten wanneer tenant X voor het laatst is gemigreerd en naar welke tag.
Zonder tabel is die kennis weg bij een browser-refresh. Eén tabel met
jsonb-resultaten; de per-tenant-log is activity.
Verworpen: alleen activity (geen run-samenvatting, geen target_tag).
Verworpen: aparte migration_run_tenants-tabel (twee tabellen voor een
run per paar weken).
Sequentieel, synchroon, één run tegelijk. Volgorde tenants.created_at asc, dus pilot eerst. Eén gedeelde Postgres-resource; parallel wint niets
bij drie tenants en maakt de rapportage onleesbaar. Synchroon zoals
provision: de handler loopt door als de browser afhaakt, de resultaten
staan per tenant in activity en de run-rij komt in finally.
Gelijktijdige runs: pg_try_advisory_lock(hashtext('tenant-migrations'))
op een eigen postgres()-client (max: 1) naar de master-database; niet
vrij is 409. end() in finally geeft de sessie-lock altijd vrij, ook
als de handler crasht.
Verworpen: achtergrondjob met polling (stale running-rijen na een
Nitro-restart, geen winst op deze schaal).
Verworpen: drizzle-multitenant (nieuwe dependency, parallel).
Doorgaan bij een fout, niets stilzwijgend. Elke tenant zit in een eigen
try/catch, krijgt altijd een resultaat, en de run loopt door tot de
laatste. failed_count > 0 kleurt de UI rood. Dit is de regel uit
database.mdc. Een canary is geen vlag maar een subset: eerst
{ slugs: ["pilot"] }, dan alles.
Verworpen: --stop-on-first-failure (de subset dekt het).
blocked slaat over, migreert niet. ahead en diverged betekenen dat
master en site-image niet uit dezelfde commit komen; dan is “toch
migreren” het gevaarlijke antwoord. De operator herbouwt eerst de master.
identity_mismatch is de les van SA-10: een verkeerde DSN op een rij mag
nooit een migratie op een andere klant worden.
Tenant-status is geen filter. suspended en failed tenants met een
DSN worden gewoon meegenomen. Een geschorste site die later terugkomt moet
niet drie migraties achterlopen. De status staat wel in het rapport.
Lock- en statement-timeout op de migratieverbinding. DDL op een live
site kan wachten op een lock van een lopende query; zonder timeout hangt
de hele run op één tenant. applyTenantMigrations krijgt defaults
lock_timeout 10 s en statement_timeout 5 min via postgres-js
connection, overschrijfbaar. De Coolify-hook profiteert mee.
Bestaande databases aansluiten hoort bij deze taak. Zonder DSN op de
rijen van pilot en rb-media rapporteert de runner op productie alleen
no_database, en dat is niet “over alle tenant-databases”.
POST /api/tenants/:slug/database probet eerst (SELECT 1 plus
identity-check), versleutelt, schrijft. 409 als er al een DSN staat:
rotatie is een los punt, overschrijven is geen 2.8.
Verworpen: eenmalig script met de sleutel (handwerk, het patroon dat de
freeze-check al pijn deed).
De pre-deployment migrate.mjs blijft staan. Idempotent vangnet voor
het geval de runner niet is gedraaid. Wél een regel erbij: het
master-image is minstens zo nieuw als het site-image dat je uitrolt;
ahead betrapt de overtreding. Of de hook weg kan, beslist 2.9.
Migraties zijn additief. 2.8 migreert vóór 2.9 uitrolt, dus elke
tenant-migratie moet veilig zijn onder de site-versie die nú draait: nieuwe
tabellen en nullable kolommen wel, drops en renames in dezelfde release
niet (expand/contract). Komt als regel in database.mdc.
Twee bestanden, vanwege de importbotsing. migrate-tenants.ts
(orchestrator: @platform/db/master, decryptSecret, activity,
migration_runs) en tenant-schema-probe.ts (ruwe postgres, geen
drizzle-schema). Toepassen via het bestaande provision-tenant-migrate.ts.
listAppliedMigrations uit @platform/ops niet hergebruiken: die is
getypeerd op de tenant-Database en trekt @platform/db root binnen.
API onder /api/migrations, allemaal requireAdminSession.
| Methode | Pad | Doel |
|---|---|---|
| GET | /api/migrations |
bundel-doel, schemastand per tenant (live), laatste 10 runs |
| POST | /api/migrations/runs |
body { slugs? }; 200 + run, 409 bij lopende run, 404 bij onbekende slug |
| POST | /api/tenants/:slug/database |
body { databaseUrl }; 200 + detail, 409 al gezet, 422 probe faalt |
Cache-Control: no-store. Nooit een DSN in een response of in error:
foutmeldingen gaan door één redactie-helper die postgres://… en
postgresql://… wegstreept voordat ze in results of activity landen.
UI: pagina /migrations, link “Migraties” in de layout. Kop met het
doel (“3 migraties, laatste 0002_special_junta”), banner bij blocked of
failed, tabel per tenant (naam, status, schemastand-badge,
applied/target, knop “Bijwerken” bij pending), knop “Alles bijwerken
(N)” uit als N nul is. Daaronder de laatste runs, uitklapbaar naar
per-tenant-resultaten. De tenant-detail toont alleen de activity-regels;
geen live probe daar, dat maakt elke detailpagina traag.
Stappen
Section titled “Stappen”Volgorde is vijf verifieerbare eenheden: timeouts (no-op-bewijs), contract plus schema, probe (unit), runner (unit), API en UI (curl, browser). De pagina leest alleen de API.
1. @platform/db — timeouts op de migratieverbinding
Section titled “1. @platform/db — timeouts op de migratieverbinding”-
scripts/migrate-tenant.mjs+.d.mts: optioneel argumentconnection, defaultslock_timeout10 s enstatement_timeout300 s.migrate.mjsongewijzigd. - Bewijs:
pnpm --filter @platform/db run migrateoptenant_demoblijft een no-op metMigrations applied successfully.
2. Contract en master-schema
Section titled “2. Contract en master-schema”-
packages/contract/src/platform.ts:tenantMigrationStateSchema,platformTenantMigrationStatusSchema,tenantMigrationResultSchema,platformMigrationRunSchema,platformMigrationOverviewSchema,migrationRunRequestSchema,tenantDatabaseWriteSchema.platformTenantDetailSchemakrijgthasDatabase: boolean. Barrel-export. Geen klok en geen hash-berekening in het contract. -
packages/db/src/master/schema.ts:migration_runs,resultsgetypeerd met het contract.pnpm --filter @platform/db run generate:master→drizzle-master/0004_*.migrate:masterlokaal.drizzle-kit checkop de tenant-config blijft groen. - Datamodel bijwerken.
3. Bundel en probe
Section titled “3. Bundel en probe”-
server/utils/migration-bundle.ts:readBundledMigrations()leest journal + sha256 per.sqluit het gematerialiseerde asset; memoïseert per proces. -
server/utils/tenant-schema-probe.ts:probeTenantSchema(databaseUrl, slug)met ruwepostgres(max: 1,connect_timeout: 3), leestdrizzle.__drizzle_migrations(42P01/3F000→ leeg) ensite.tenant_idviato_regclass('public.site').end()infinally. - Pure
computeMigrationState(bundle, appliedRows, siteTenantId, slug).node --test, zes gevallen: leeg →pendingalle drie; gelijk →up_to_date; één achter →pendingmet de juiste tag; nieuwercreated_at→blocked/ahead; zelfdecreated_at, andere hash →blocked/diverged; site-rij met andere slug →blocked/identity_mismatch.
4. Runner
Section titled “4. Runner”-
server/utils/migrate-tenants.ts:runTenantMigrations({ db, secretsKey, slugs?, triggeredBy }, deps)metdeps.probeendeps.applyinjecteerbaar, zoalsprovisionTenant. Volgordecreated_at asc. Advisory lock op een eigen client;finallyschrijft de run-rij en sluit de client. - Per tenant: decrypt → probe → bij
pendingapply →activity→ resultaat. Een fout in één tenant isfailedvoor die tenant; de loop gaat door. -
node --testmet fakes, vijf gevallen: drie tenants waarvan de tweede faalt →applied 2, failed 1, drie activity-rijen;no_database→skipped;blocked→skippedzonder apply-aanroep; subsetslugsraakt alleen die tenants; lock bezet → conflict-error zonder run-rij.
5. API en UI
Section titled “5. API en UI”-
server/api/migrations/index.get.ts,server/api/migrations/runs.post.ts,server/api/tenants/[slug]/database.post.ts. Prologue zoals de tenant-routes:no-store+requireAdminSession; guards opdatabaseUrlensecretsKeyzoalsprovision.post.ts. - Attach-route: valideer, probe,
encryptSecret, update metwhere database_url_encrypted is null(geen read-then-write),tenant.database_attached. -
app/pages/migrations.vue:useAsyncData+ parse met het contract-schema,$fetch→refresh()per actie, fouten alsrole="alert". Link inapp/layouts/default.vue. -
[slug].vue: drie nieuwe types inactivitySummary; attach-formulier, alleen zichtbaar alshasDatabasefalse is.
6. Documentatie
Section titled “6. Documentatie”-
database.mdc: additieve-migratieregel plus “de runner draait vanuit de master,migrate.mjsis het vangnet”. -
master-dashboard.mdc:/api/migrationssessiebeveiligd; nooit@platform/dbroot importeren. - Deploy § Migraties: releasevolgorde bij een schemawijziging.
1) master bouwen en deployen, 2)
/migrations→ alles bijwerken, groen, 3) sites uitrollen (2.9). Hook blijft als vangnet. - Operations: bestaande tenant aansluiten (DSN uit de wachtwoordmanager → formulier op de detailpagina).
- Architectuur, Contract, Taken 2.8 afvinken met wat er gebouwd is en wat bewust is blijven liggen.
Klaar wanneer
Section titled “Klaar wanneer”-
pnpm turbo run typecheckenpnpm turbo run buildgroen.drizzle-kit checkop de tenant-config groen.migrate:masterpast0004toe en is daarna een no-op. - Unit: de zes state-gevallen en de vijf runner-gevallen groen.
- Zonder sessie:
GET /api/migrations,POST /api/migrations/runsenPOST /api/tenants/demo/database→ 401. - Lokaal,
tenant_demoaan tenantdemogekoppeld →up_to_date3/3. Tweede POST op dezelfde tenant → 409, kolom ongewijzigd. -
pnpm --filter @platform/ops run provision-db -- --tenant migr8(lege database) → aansluiten aan tenantmigr8→pendingmet drie tags →POST /api/migrations/runs { slugs: ["migr8"] }→appliedmet drie tags,tenant.migratedin de feed, run-rij metapplied_count 1. Daarna “Alles bijwerken” → beideup_to_date,applied_count 0.pnpm --filter @platform/db run migrateoptenant_migr8is daarna een no-op (zelfde watermerk). - Verkeerde DSN:
tenant_demo-URL aansluiten op een tenantwrong→ 422identity_mismatch, geen rij gewijzigd. - Op
tenant_migr8een rij metcreated_at = nowin__drizzle_migrations→blocked/ahead, run slaat over (skipped), geen apply. Rij weg →up_to_date. Hash van de laatste rij aanpassen →blocked/diverged; herstellen. - Geen
postgresql://ofpostgres://in enige JSON-response (grep), ook niet inresults[].errorna een geforceerde fout. - Browser: inloggen,
/migrationsmet badges en doel, “Bijwerken” op één tenant, “Alles bijwerken”, run-historie uitklappen, tenant-detail toont de activity-regel en het attach-formulier verdwijnt na aansluiten. - Regressie: provision op een verse tenant zonder Coolify werkt nog
(zelfde
applyTenantMigrations);tenant_demoserveert nog de demo-site; webhook-curl blijft 401 zonder HMAC en 200 met HMAC. - Opruimen:
tenant_migr8en rol droppen via de compose-container. - Na de master-deploy (los punt): pilot en rb-media aansluiten met
hun DSN uit de wachtwoordmanager,
GET /api/migrationstoont beideup_to_date3/3 (de sites draaien al0002), één run is een no-op. Dat is de eerste productie-run; vastleggen bij 2.8 in Taken. Niet blokkerend voor de code, wel voor “af”.
Buiten scope, wel vastgelegd
Section titled “Buiten scope, wel vastgelegd”- Uitrol van site-images, volgorde en rollback — 2.9. 2.8 levert de schemastand (“wie loopt achter”) en de subset-run waar 2.9 op bouwt.
- Master-migraties via de UI — blijven
migrate-master.mjsper release (Master-deploy). - Down-migraties. Drizzle genereert ze niet. Rollback van een schema is restore (Backup) of een nieuwe forward-migratie; daarom de additieve regel.
- Parallelle runs,
drizzle-multitenant, achtergrondjob met polling. Pas als een run langer duurt dan een proxy-timeout. - DSN-rotatie of overschrijven — los punt “Rotatie van per-site secrets”.
- Live probe op de tenant-detail of in het overzicht — bewust alleen
op
/migrations. - Alarmering bij
failed— 4.4. - Ops-CLI voor migraties — vraagt de sleutel buiten de master.
- Master dashboard deployen — los punt, voorwaarde voor de productie-run, niet voor de code.
Uitvoering (8 september 2026)
Section titled “Uitvoering (8 september 2026)”Alles hierboven is gebouwd en lokaal bewezen; details bij 2.8 in Taken. Wat anders liep dan gepland:
- Vorm van de status per tenant.
platformTenantMigrationStatusSchemais{ slug, name, status, checkedAt, schema }metschemaals destate-union (tenantSchemaStateSchema), niet een vijfvoudigeextend. Zo kan de purecomputeSchemaStatealleen de union teruggeven. detailop het runresultaat. Eenskippeddoorblockeddraagt de reden én de uitleg, zodat de run-historie op zichzelf leesbaar is.- Extra
diverged-geval. Ook een gat achter het watermerk (0000 en 0002 toegepast, 0001 niet) isblocked/diverged; Drizzle zou dat stil accepteren. - Testcommando.
tsxis vanuitapps/master-dashboardniet als package resolvbaar en Node’s type stripping struikelt over extensieloze imports in@platform/db/master. Werkend:cd packages/db && MASTER_DATABASE_URL=… node --import tsx --test --test-force-exit ../../apps/master-dashboard/server/utils/*.test.ts. Zonder--test-force-exithoudt de open master-pool het proces vast. - Lokale
platform_masterwas weg. Opnieuw aangemaakt met de SQL uitprovision-master-db.shvia de compose-container; alle vijf master-migraties toegepast, tweede run no-op. - Live bewijs van
identity_mismatchis niet gedaan: de naamcheck (tenant_<slug>) vuurt eerder en gaf 422database_name. De identity-check is met unit-tests bewezen (state én attach). - Browser. Geen browserautomatisering beschikbaar in de sessie; SSR van
/migrationsen de klantpagina met sessiecookie via curl toont badges, koppelformulier en “Gekoppeld”. Klik-door in een echte browser staat open. - Productie-run staat open tot de master op Coolify draait (los punt).
Volgorde daarna: pilot en rb-media koppelen via de klantpagina,
/migrationsmoet beideBij3/3 tonen, één run is een no-op.