Ga naar inhoud

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.

Dit bestaat al, niet opnieuw te beslissen:

  • applyTenantMigrations({ databaseUrl, migrationsFolder }) in packages/db/scripts/migrate-tenant.mjs, export @platform/db/migrate. Eén postgres()-client met max: 1, sql.end() in finally. Zowel migrate.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 asset tenant-migrations (nuxt.config.ts). materializeTenantMigrations in server/utils/provision-tenant-migrate.ts pakt 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 in server/utils/secrets.ts, sleutel NUXT_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_migrations bij: hash (sha256 van het .sql-bestand) en created_at (bigint, gelijk aan when uit meta/_journal.json). Gelezen in pg-core/dialect.js: alleen de nieuwste created_at is de watermerk, hash wordt 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/ops heeft listAppliedMigrations (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 een scripts/migrate-all.ts dat nooit is geschreven.
  • Importregel uit 2.5 (provision-tenant-migrate.ts, kopcommentaar): nooit @platform/db (root) en @platform/db/master in dezelfde module, want de Better Auth-tabellen (users, sessions) botsen. @platform/db/migrate importeert 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_master gemigreerd 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.

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.

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.site bestaat en site.tenant_id is niet de slug. Een lege of ongemigreerde database is géén mismatch (nog geen site-rij).
  • ahead: de database heeft een created_at nieuwer 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: zelfde created_at, andere hash. Een gecommit .sql is 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.

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.

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 argument connection, defaults lock_timeout 10 s en statement_timeout 300 s. migrate.mjs ongewijzigd.
  • Bewijs: pnpm --filter @platform/db run migrate op tenant_demo blijft een no-op met Migrations applied successfully.
  • packages/contract/src/platform.ts: tenantMigrationStateSchema, platformTenantMigrationStatusSchema, tenantMigrationResultSchema, platformMigrationRunSchema, platformMigrationOverviewSchema, migrationRunRequestSchema, tenantDatabaseWriteSchema. platformTenantDetailSchema krijgt hasDatabase: boolean. Barrel-export. Geen klok en geen hash-berekening in het contract.
  • packages/db/src/master/schema.ts: migration_runs, results getypeerd met het contract. pnpm --filter @platform/db run generate:masterdrizzle-master/0004_*. migrate:master lokaal. drizzle-kit check op de tenant-config blijft groen.
  • Datamodel bijwerken.
  • server/utils/migration-bundle.ts: readBundledMigrations() leest journal + sha256 per .sql uit het gematerialiseerde asset; memoïseert per proces.
  • server/utils/tenant-schema-probe.ts: probeTenantSchema(databaseUrl, slug) met ruwe postgres (max: 1, connect_timeout: 3), leest drizzle.__drizzle_migrations (42P01/3F000 → leeg) en site.tenant_id via to_regclass('public.site'). end() in finally.
  • Pure computeMigrationState(bundle, appliedRows, siteTenantId, slug). node --test, zes gevallen: leeg → pending alle drie; gelijk → up_to_date; één achter → pending met de juiste tag; nieuwer created_atblocked/ahead; zelfde created_at, andere hash → blocked/diverged; site-rij met andere slug → blocked/identity_mismatch.
  • server/utils/migrate-tenants.ts: runTenantMigrations({ db, secretsKey, slugs?, triggeredBy }, deps) met deps.probe en deps.apply injecteerbaar, zoals provisionTenant. Volgorde created_at asc. Advisory lock op een eigen client; finally schrijft de run-rij en sluit de client.
  • Per tenant: decrypt → probe → bij pending apply → activity → resultaat. Een fout in één tenant is failed voor die tenant; de loop gaat door.
  • node --test met fakes, vijf gevallen: drie tenants waarvan de tweede faalt → applied 2, failed 1, drie activity-rijen; no_databaseskipped; blockedskipped zonder apply-aanroep; subset slugs raakt alleen die tenants; lock bezet → conflict-error zonder run-rij.
  • 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 op databaseUrl en secretsKey zoals provision.post.ts.
  • Attach-route: valideer, probe, encryptSecret, update met where database_url_encrypted is null (geen read-then-write), tenant.database_attached.
  • app/pages/migrations.vue: useAsyncData + parse met het contract-schema, $fetchrefresh() per actie, fouten als role="alert". Link in app/layouts/default.vue.
  • [slug].vue: drie nieuwe types in activitySummary; attach-formulier, alleen zichtbaar als hasDatabase false is.
  • database.mdc: additieve-migratieregel plus “de runner draait vanuit de master, migrate.mjs is het vangnet”.
  • master-dashboard.mdc: /api/migrations sessiebeveiligd; nooit @platform/db root 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.
  • pnpm turbo run typecheck en pnpm turbo run build groen. drizzle-kit check op de tenant-config groen. migrate:master past 0004 toe 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/runs en POST /api/tenants/demo/database → 401.
  • Lokaal, tenant_demo aan tenant demo gekoppeld → up_to_date 3/3. Tweede POST op dezelfde tenant → 409, kolom ongewijzigd.
  • pnpm --filter @platform/ops run provision-db -- --tenant migr8 (lege database) → aansluiten aan tenant migr8pending met drie tags → POST /api/migrations/runs { slugs: ["migr8"] }applied met drie tags, tenant.migrated in de feed, run-rij met applied_count 1. Daarna “Alles bijwerken” → beide up_to_date, applied_count 0. pnpm --filter @platform/db run migrate op tenant_migr8 is daarna een no-op (zelfde watermerk).
  • Verkeerde DSN: tenant_demo-URL aansluiten op een tenant wrong → 422 identity_mismatch, geen rij gewijzigd.
  • Op tenant_migr8 een rij met created_at = now in __drizzle_migrationsblocked/ahead, run slaat over (skipped), geen apply. Rij weg → up_to_date. Hash van de laatste rij aanpassen → blocked/diverged; herstellen.
  • Geen postgresql:// of postgres:// in enige JSON-response (grep), ook niet in results[].error na een geforceerde fout.
  • Browser: inloggen, /migrations met 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_demo serveert nog de demo-site; webhook-curl blijft 401 zonder HMAC en 200 met HMAC.
  • Opruimen: tenant_migr8 en 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/migrations toont beide up_to_date 3/3 (de sites draaien al 0002), éé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”.
  • 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.mjs per 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.

Alles hierboven is gebouwd en lokaal bewezen; details bij 2.8 in Taken. Wat anders liep dan gepland:

  • Vorm van de status per tenant. platformTenantMigrationStatusSchema is { slug, name, status, checkedAt, schema } met schema als de state-union (tenantSchemaStateSchema), niet een vijfvoudige extend. Zo kan de pure computeSchemaState alleen de union teruggeven.
  • detail op het runresultaat. Een skipped door blocked draagt 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) is blocked/diverged; Drizzle zou dat stil accepteren.
  • Testcommando. tsx is vanuit apps/master-dashboard niet 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-exit houdt de open master-pool het proces vast.
  • Lokale platform_master was weg. Opnieuw aangemaakt met de SQL uit provision-master-db.sh via de compose-container; alle vijf master-migraties toegepast, tweede run no-op.
  • Live bewijs van identity_mismatch is niet gedaan: de naamcheck (tenant_<slug>) vuurt eerder en gaf 422 database_name. De identity-check is met unit-tests bewezen (state én attach).
  • Browser. Geen browserautomatisering beschikbaar in de sessie; SSR van /migrations en 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, /migrations moet beide Bij 3/3 tonen, één run is een no-op.