Internal Documentation internal
TalkIDE internal documentation

⚠️ Superseded by ADR-023 (2026-05-16): DB-per-app model nahrazen schema-per-app v sdílené data-plane DB + DO PgBouncer pooling. Sekce 1–4 tohoto ADR jsou nahrazeny. Sekce 5–12 (wrapper architektura, idempotence, error handling, test strategie) zůstávají v platnosti a aplikují se na schema provisioning.

Status: Superseded by ADR-023 Datum: 2026-05-07 Oblast: Stopa B.3 / Databázový provisioning

Context

Stopa B.3 navazuje na B.2 (ADR-015)

ADR-015 zavedl NamespaceProvisioner — per-tenant K8s namespace s ResourceQuota a LimitRange. Stopa B.3 přidává druhou klíčovou infrastrukturní operaci: provisioning samostatné PostgreSQL databáze pro každou user-app. Databáze se vytváří v sdíleném DO Managed Postgres clusteru, ale každá app má vlastní DB, vlastního DB usera a vlastní K8s Secret s přihlašovacími údaji.

Tenant model a granularita DB

TalkIDE má tabulku tenants (id BIGINT, slug VARCHAR(50) UNIQUE, owner_id FK→users). Namespace v K8s patří tenantovi (ADR-015). Databáze však patří konkrétnímu projektu v konkrétním prostředí — ne tenantovi.

Důvody pro granularitu per-(projekt, prostředí):

  • Izolace dat: dvě různé user-apps nesmějí sdílet DB (riziko úniku schématu i dat).
  • Independentní life-cycle: projekt lze smazat i s DB bez dopadů na ostatní projekty téhož tenanta.
  • Budoucí multi-env model (Vercel/Netlify): každé prostředí (dev, prod, staging) bude mít vlastní DB instanci.

Project lifecycle a DB

Existující lifecycle v CreateProjectUseCase / DeleteProjectUseCase:

OperaceK8s namespaceDB
Create Projectget-or-create (ADR-015)NEW (B.3): create DB + user + Secret
Archive Projectbeze změnybeze změny (DB zůstává, projekt je obnovitelný)
Restore Projectbeze změnybeze změny (DB intact)
Delete Project(budoucí teardown)NEW (B.3): drop DB + drop user + delete Secret

Archive/Restore jsou operace čistě na úrovni Project.status — bez dopadů na infrastrukturu. Delete je skutečný teardown.

Existující Noop provisioners

V features/project/infrastructure/ existují z prereq fáze (talkide-be#39) dva stub implementátory: NoopProjectDatabaseProvisioner a NoopProjectStorageProvisioner.

  • DeleteProjectUseCase již volá NoopProjectDatabaseProvisioner.dropDatabase(...) v teardown sekvenci.
  • B.3 nahrazuje NoopProjectDatabaseProvisioner produkční implementací (DatabaseProvisioner interface + PostgresDatabaseProvisioner).
  • NoopProjectStorageProvisioner zůstává — DO Spaces provisioning přijde v separátní Stopě C.

Deployment topology (zděděno z ADR-014/015)

Prostředítalkide.db.provisioning.enabledWired bean
local (výchozí)false (matchIfMissing)NoopDatabaseProvisioner
cloud (prod pod)truePostgresDatabaseProvisioner

Decision

1. Naming convention: tk_t{tenantId}_p{slugTruncated30}_{env}

DB name = tk_t{tenantId}_p{slugTruncated30}_{env} (vše lowercase).

Analýza délky PG identifier limitu (63 znaků):

KomponentaDélka
tk_ prefix3
t{tenantId} (Long.MAX = 9223372036854775807, 19 číslic)20
_p2
slug truncated na 30 znakůmax 30
_1
env (max 5 znaků: dev, prod, stagi)max 5
Worst case celkem61

Výsledek: worst-case 61 znaků — v limitu 63, s rezervou 2 znaky.

Slug se truncate na 30 znaků při vytváření DB jména. Pokud project.slug.length > 30, vezme se prvních 30 znaků. Čitelnost se ztrácí pro velmi dlouhé slugy, ale DB jméno zůstává jednoznačné díky tenantId.

Příklady:

  • tenant 1, projekt demo, env devtk_t1_pdemo_dev
  • tenant 42, projekt my-awesome-saas-app-2025, env devtk_t42_pmy-awesome-saas-app-2025_dev
  • tenant 42, projekt a-very-long-project-slug-that-exceeds-limits, env devtk_t42_pa-very-long-project-slug-that-_dev

DB user: stejné jméno jako DB (např. tk_t1_pdemo_dev). Auditability: SELECT usename, ... v PG logu přímo identifikuje projekt i prostředí. PG user = DB = 1:1 izolace.

Odmítnuté alternativy:

KandidátDůvod odmítnutí
talkide_t{id}_p{slug}_{env}Prefix talkide_ = 8 znaků; worst-case 78 znaků — překračuje 63 char limit
tk_t{id}_p{hash8}_{env}Hash-based: předvídatelná délka, ale nečitelné. Vhodné jako fallback pokud se prokáže, že truncate způsobuje kolize
app_{projectId}_{env}Číselné project ID: čitelné, ale neobsahuje tenant kontext; BIGINT ID je neprůhledné

2. Env strategie pro MVP: pouze dev

CreateProjectUseCase vytvoří při vzniku projektu právě jednu DB pro prostředí dev.

prod DB bude provisionována až v Stopě B.7 (Publish flow). Důvody pro odložení:

  • Úspora storage a connection slots v DO Basic clusteru.
  • Zjednodušení MVP scope — uživatel může vyvíjet a testovat bez publish.
  • Pub/unPublish lifecycle bude řadit create/drop prod DB jako součást deployment flow.

Architektura je připravena na N prostředí — env parametr je String, ne enum. Přidání dalšího prostředí nevyžaduje změnu provisioner interface.

3. Admin user: dedikovaný talkide_provisioner

V DO Managed Postgres clusteru existuje vedle doadmin superusera dedikovaný provisioner user s minimálními privilegii:

CREATE USER talkide_provisioner WITH CREATEDB CREATEROLE PASSWORD '<random-32-char>';

Oprávnění CREATEDB + CREATEROLE postačují pro CREATE DATABASE, CREATE USER, GRANT ALL PRIVILEGES. BE superuser přístup (doadmin) nemá — least privilege princip.

Setup je jednorázový manuální krok (DO Console nebo psql s doadmin). Credentials se uloží do K8s Secret talkide-provisioner-creds v namespace talkide. BE injectuje přes env vars TALKIDE_DB_ADMIN_USERNAME / TALKIDE_DB_ADMIN_PASSWORD.

Podrobný runbook patří do talkide-infra ticketu (mimo scope #16).

Lokální dev: postgres17 container s existujícím root userem — plně postačuje pro lokální NoopDatabaseProvisioner (provisioning je no-op), ani admin datasource se nevytváří.

4. K8s Secret formát: Spring-style separate fields

Po úspěšném CREATE DATABASE + CREATE USER + GRANT PostgresDatabaseProvisioner vytvoří K8s Secret v tenant namespace:

  • Secret name: app-{projectSlug}-{env}-db
  • Namespace: tenant-{tenantSlug} (provisioned v ADR-015)
apiVersion: v1
kind: Secret
metadata:
  name: app-demo-dev-db
  namespace: tenant-acme
type: Opaque
stringData:
  SPRING_DATASOURCE_URL: "jdbc:postgresql://<host>:25060/tk_t1_pdemo_dev?sslmode=require"
  SPRING_DATASOURCE_USERNAME: "tk_t1_pdemo_dev"
  SPRING_DATASOURCE_PASSWORD: "<random-32-char>"

User-app pody (Spring Boot BE) mountují Secret přes envFrom: secretRef. Spring Boot automaticky binduje SPRING_DATASOURCE_* env vars na spring.datasource.* properties — nulová konfigurace na straně user-app.

Password je generován při provisioning (SecureRandom, 32 znaků). Uložen pouze v K8s Secret — TalkIDE platforma ho po provisioning neuchovává.

5. Wrapper architektura

Konzistentní s ADR-014 (K8sClient) a ADR-015 (NamespaceProvisioner) pattern:

DatabaseProvisioner  (Kotlin interface — nahrazuje NoopProjectDatabaseProvisioner)
  fun provisionAppDatabase(tenantId: Long, projectSlug: String, env: String): AppDatabaseInfo
  fun deprovisionAppDatabase(tenantId: Long, projectSlug: String, env: String)

PostgresDatabaseProvisioner  (@ConditionalOnProperty talkide.db.provisioning.enabled=true)
  - inject: adminDataSource (separate Spring DataSource pro provisioner user)
  - inject: K8sClient (pro create/delete Secret v tenant namespace)
  - inject: TenantRepository (pro lookup tenantSlug → namespace name)
  - CREATE DATABASE + CREATE USER + GRANT + create K8s Secret
  - idempotence: pg_database / pg_roles query před CREATE

NoopDatabaseProvisioner  (@ConditionalOnProperty name=["talkide.db.provisioning.enabled"], matchIfMissing=true)
  - log volání, vrátí bez akce
  - deprovisionAppDatabase také no-op

Pozn. (errata 2026-05-07): Původní návrh signatury obsahoval projectId: Long parametr. Implementace ho vypustila, protože DB naming konvence (sekce 1) projectId nevyužívá. Čistší API s úzkou signaturou.

Package: features/database/ — databázový provisioning je logicky oddělený concern od K8s namespace managementu (features/k8s/). Oba features mohou být testovány nezávisle.

NoopProjectDatabaseProvisioner z features/project/infrastructure/ je odstraněn. DeleteProjectUseCase je refactorován — místo NoopProjectDatabaseProvisioner.dropDatabase(...) volá DatabaseProvisioner.deprovisionAppDatabase(...).

6. Idempotence: pg_database / pg_roles query před CREATE

PostgreSQL nemá CREATE DATABASE IF NOT EXISTS. Idempotence je implementována explicitním dotazem na systémové katalogy:

// Kontrola existence DB
val dbExists = adminConnection.prepareStatement(
    "SELECT 1 FROM pg_database WHERE datname = ?"
).use { ps -> ps.setString(1, dbName); ps.executeQuery().next() }

if (!dbExists) {
    // CREATE DATABASE nelze spustit uvnitř transakce — autoCommit=true
    adminConnection.createStatement().execute("""CREATE DATABASE "$dbName"""")
}

// Kontrola existence usera
val userExists = adminConnection.prepareStatement(
    "SELECT 1 FROM pg_roles WHERE rolname = ?"
).use { ps -> ps.setString(1, dbName); ps.executeQuery().next() }

if (!userExists) {
    adminConnection.createStatement().execute(
        """CREATE USER "$dbName" WITH PASSWORD '$generatedPassword'"""
    )
    adminConnection.createStatement().execute(
        """GRANT ALL PRIVILEGES ON DATABASE "$dbName" TO "$dbName""""
    )
}

Idempotence K8s Secret: get-or-create přes K8sClient (stejný pattern jako ADR-015 namespace resources). Pro MVP: pokud Secret existuje, volání je no-op (password rotation je future feature).

Pozor (implementační detail): CREATE DATABASE v PG vyžaduje autoCommit=true — nelze spustit uvnitř transakce. Admin DataSource connection musí mít autoCommit zapnuto pro tuto operaci.

7. Trigger: volání z CreateProjectUseCase a DeleteProjectUseCase

CreateProjectUseCase — pořadí operací (modifikace existujícího use case):

  1. Validace vstupu + auth check
  2. namespaceProvisioner.provisionTenantNamespace(tenantId) (B.2 — ADR-015)
  3. Temp save ProjectEntity (pro získání ID, pokud slug derivuje z ID)
  4. Slug resolution (uniqueSlug = ... z aktuálních dat + ID)
  5. NEW: databaseProvisioner.provisionAppDatabase(tenantId, uniqueSlug, "dev") (B.3)
  6. Final save (s plnými údaji)

@Transactional na use case zajistí rollback celého flow — i temp save by se rollbackoval, pokud DB provisioning selže.

Namespace provisioning (krok 2) je idempotentní — při případném rollback + retry se vytvoří znovu pouze pokud neexistuje. DB provisioning (krok 5) je stejně idempotentní.

Pozn. (errata 2026-05-07): Původní popis “po validaci, před persist” byl zjednodušený. Reálná implementace vyžaduje temp save ProjectEntity před DB provisioning kvůli slug derivation z ID. @Transactional rollback platí pro celý flow.

DeleteProjectUseCase — modifikace existující teardown sekvence:

Stávající volání NoopProjectDatabaseProvisioner.dropDatabase(...) je nahrazeno voláním databaseProvisioner.deprovisionAppDatabase(tenantId, projectId, slug, "dev"). PostgresDatabaseProvisioner.deprovisionAppDatabase provede:

  1. Delete K8s Secret app-{slug}-{env}-db v tenant namespace
  2. DROP DATABASE IF EXISTS "{dbName}"
  3. DROP USER IF EXISTS "{dbName}"

8. Error handling: throw + @Transactional rollback

Konzistentní s ADR-015 (NamespaceProvisioner) error handling strategie.

PostgresDatabaseProvisioner nezachytává SQLException ani KubernetesClientException — propaguje je ven. CreateProjectUseCase je @Transactional: selhání kroku 3 způsobí rollback DB transakce a Project entity se neuloží.

Edge case — namespace vytvořen, DB selže:

  • Po rollback: namespace zůstává (orphan v alpha), DB nebyla vytvořena.
  • Příští retry CreateProjectUseCase: namespace get-or-create → no-op, DB create → pokusí se znovu.
  • Akceptovatelné pro alpha (1 tenant, manuální teardown orphan namespace je triviální).

Alternativy odmítnuty (stejné zdůvodnění jako ADR-015):

  • Retry s exponential backoff — overkill pro alpha; user retry je dostatečný.
  • Best effort (projekt se vytvoří i bez DB) — anti-pattern; projekt bez DB je nefunkční.
  • Compensating transaction — komplexita bez benefitu, @Transactional to řeší.

9. Test strategie

VrstvaToolÚčel
Unitmockito-kotlin (mock JDBC + K8sClient)Naming logic, idempotence větve (DB exists / not exists, user exists / not exists), generate password, error propagation
Integration smokeTestcontainers postgres:17Real PG: CREATE DATABASE, CREATE USER, GRANT, idempotent re-call (no error on 2nd run), DROP DATABASE IF EXISTS
K8s Secret testmock K8sClient (unit) nebo Testcontainers k3sSecret creation + delete v tenant namespace

Smoke testy jsou záměrně minimalistické (1 Testcontainers test pokrývající happy path + idempotenci). Analogie s ADR-014/015 pattern.

10. Connection pooling: separate admin DataSource

BE používá dvě Spring DataSources:

  • Main DataSource (Spring Boot default): připojuje se k talkide DB (tabulky tenants, projects, users, atd.).
  • Admin DataSource: připojuje se k postgres admin DB (CREATE DATABASE vyžaduje připojení mimo cílovou databázi). Spravuje pouze PostgresDatabaseProvisioner.

Konfigurace přes application.yaml:

talkide:
  db:
    provisioning:
      enabled: ${TALKIDE_DB_PROVISIONING_ENABLED:false}
      admin-url: ${TALKIDE_DB_ADMIN_URL:}
      admin-username: ${TALKIDE_DB_ADMIN_USERNAME:}
      admin-password: ${TALKIDE_DB_ADMIN_PASSWORD:}
      host: ${TALKIDE_DB_PROVISIONING_HOST:}
      port: ${TALKIDE_DB_PROVISIONING_PORT:25060}

Admin DataSource bean je @ConditionalOnProperty(talkide.db.provisioning.enabled=true) — nevytváří se pro lokální dev (kde je provisioning disabled a NoopDatabaseProvisioner je aktivní). Žádné null pointer riziko, žádná zbytečná DB connection.

11. K8s Secret update vs. create (idempotence policy)

Při idempotent re-call provisionAppDatabase (např. po restartu podu při probíhající transakci):

  • DB existuje → no-op (pg_database check)
  • User existuje → no-op (pg_roles check, password se NEobnovuje)
  • Secret existuje → no-op pro MVP (obsah se nekontroluje, neaktualizuje)

Password rotation je explicitně mimo scope — budoucí feature. Manuální rotace je možná přímou editací K8s Secret + restart user-app podu.

12. Scope issue #16 (úzce vymezený)

Zahrnuto v #16:

  • DatabaseProvisioner Kotlin interface (provisionAppDatabase + deprovisionAppDatabase)
  • PostgresDatabaseProvisioner implementace s @ConditionalOnProperty
  • NoopDatabaseProvisioner implementace s @ConditionalOnProperty(matchIfMissing=true)
  • Volání provisionAppDatabase z CreateProjectUseCase (po namespace provisioning + temp save, před final save)
  • Refactor DeleteProjectUseCase — nahrazení NoopProjectDatabaseProvisioner za DatabaseProvisioner
  • Odstranění NoopProjectDatabaseProvisioner z features/project/infrastructure/
  • Separate admin DataSource bean + konfigurace
  • talkide.db.provisioning.* properties v application.yaml
  • Idempotence (pg_database / pg_roles query před CREATE)
  • Unit testy + Testcontainers postgres smoke test

Mimo scope (jiné B.x / future tickety):

FeatureIssue / Stopa
prod env DBStopa B.7 (Publish flow)
Password rotationpost-MVP
DB migrace v user-app DBStopa B.4 (deployment fáze)
DO Managed PG admin user setup runbooktalkide-infra ticket
Backup strategy per-app DBpost-MVP
Per-app DB encryption-at-restDO default (out of scope)
DO Spaces provisioningStopa C (NoopProjectStorageProvisioner zůstává)

Consequences

Pozitiva

  • Izolace dat — každá user-app má vlastní DB + vlastního DB usera. Žádný cross-app přístup na úrovni databázové autorizace.
  • Konzistence patternuDatabaseProvisioner wrapper zapadá do ADR-014/015 bez výjimek; nový developer vidí identický vzor napříč všemi provisionery.
  • Idempotence — safe pro restart/retry; CREATE DATABASE + CREATE USER se neopakují pokud již existují.
  • Automatický Spring Boot binding — K8s Secret s SPRING_DATASOURCE_* klíči nevyžaduje žádnou konfiguraci na straně user-app; funguje out-of-the-box.
  • Lazy provisioning — DB vzniká pouze při skutečném vytvoření projektu; žádný overhead pro neaktivní účty.

Rizika a omezení

  1. Slug truncate na 30 znaků snižuje readability pro dlouhé slugy. Slugy delší než 30 znaků budou ve jméně DB neúplné. Future mitigation: hash-based shortening (prvních 8 znaků SHA-256 ze slug) — předvídatelná délka, kolize mizivé.

  2. Connection pool bottleneck v alpha+ — každý user-app Spring Boot pod má HikariCP s defaultem 10 connections. 4 user-apps × 10 connections = 40 connections per tenant. DO Managed Postgres Basic 1 GB cluster má limit ~25 connections → bottleneck překvapí dříve, než se čeká. Mitigace: PgBouncer sidecar v Stopě B.4, nebo upgrade DO PG plánu před public alpha.

  3. Noisy neighbor risk v sdíleném clusteru — jeden tenant s výpočetně náročnou query může degradovat performance ostatních tenantů. Akceptovatelné pro alpha (1–10 tenantů). Mitigace post-alpha: per-tenant DO Managed Postgres instance (výrazně vyšší cena).

  4. Admin user secret rotation je manuálnítalkide_provisioner password rotation vyžaduje manuální update K8s Secret talkide-provisioner-creds + restart BE podu. Future mitigace: HashiCorp Vault nebo DO Secret Manager.

  5. Orphan namespace po failed Create Project — pokud namespace provisioning (krok 2) proběhne, DB provisioning (krok 3) selže a @Transactional rollback smaže Project entity, namespace zůstane orphaned v K8s. V alpha: manuální kubectl delete ns tenant-{slug}. Post-alpha: compensating cleanup job nebo saga pattern.


Alternatives Considered

DB per tenant (ne per projekt)

Odmítnuto. Sdílená DB pro všechny projekty téhož tenanta by znemožnila nezávislý lifecycle (delete projekt A nesmí ovlivnit projekt B). User-app schémata se mohou navíc střetávat — dva projekty generované AI agentem mohou mít tabulku users s jiným schématem.

Hash-based DB naming (předvídatelná délka, nečitelné)

Zvažováno jako alternativa k truncate. tk_t{id}_p{hash8(slug)}_{env} — vždy přesně 3+20+2+8+1+5 = 39 znaků. Odmítnuto pro B.3 MVP: truncate je čitelnější pro debugging a monitoring. Hash-based přístup je evidován jako future option pokud truncate způsobí problémy (kolize jsou teoreticky možné, i když nepravděpodobné).

Shared DB user pro všechny projekty téhož tenanta

Odmítnuto. Jeden tenant DB user s přístupem do všech tenant DB by snížil granularitu auditability a znemožnil budoucí row-level security per projekt. Per-app user = per-app izolace na PG úrovni.

Async provisioning (event / background job)

Zvažováno, odmítnuto — stejné zdůvodnění jako ADR-015. Synchronní volání v CreateProjectUseCase je jednoduché, error je okamžitě propagován uživateli, @Transactional rollback funguje out-of-the-box. Async přístup zvážit post-alpha pokud se DB create time stane UX problémem (DO Managed PG CREATE DATABASE typicky trvá < 100 ms).


Implementation Notes

  • Issue: talkide-be#16https://gitlab.com/talkide/talkide-be/-/work_items/16
  • talkide-be#15 (B.2): namespace provisioning — parent dependency (namespace musí existovat před K8s Secret creation)
  • talkide-be#44 (slug RFC-1123 validator): slug je zdrojem DB jména; nevalidní znaky způsobí PG error (CREATE DATABASE s mezerami/uppercase) — blocking::public-alpha
  • ADR-014 (B.1): K8s client foundation — K8sClient inject pro Secret management
  • ADR-015 (B.2): NamespaceProvisioner pattern — vzor pro wrapper architekturu, error handling, @ConditionalOnProperty
  • talkide-be#39 (Delete UC): existující teardown sekvence s NoopProjectDatabaseProvisioner — refactorovat na DatabaseProvisioner

Was this page helpful?

Thanks for the feedback.