⚠️ 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:
| Operace | K8s namespace | DB |
|---|---|---|
| Create Project | get-or-create (ADR-015) | NEW (B.3): create DB + user + Secret |
| Archive Project | beze změny | beze změny (DB zůstává, projekt je obnovitelný) |
| Restore Project | beze změny | beze 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.
DeleteProjectUseCasejiž voláNoopProjectDatabaseProvisioner.dropDatabase(...)v teardown sekvenci.- B.3 nahrazuje
NoopProjectDatabaseProvisionerprodukční implementací (DatabaseProvisionerinterface +PostgresDatabaseProvisioner). NoopProjectStorageProvisionerzů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.enabled | Wired bean |
|---|---|---|
local (výchozí) | false (matchIfMissing) | NoopDatabaseProvisioner |
cloud (prod pod) | true | PostgresDatabaseProvisioner |
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ů):
| Komponenta | Délka |
|---|---|
tk_ prefix | 3 |
t{tenantId} (Long.MAX = 9223372036854775807, 19 číslic) | 20 |
_p | 2 |
| slug truncated na 30 znaků | max 30 |
_ | 1 |
env (max 5 znaků: dev, prod, stagi) | max 5 |
| Worst case celkem | 61 |
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, envdev→tk_t1_pdemo_dev - tenant 42, projekt
my-awesome-saas-app-2025, envdev→tk_t42_pmy-awesome-saas-app-2025_dev - tenant 42, projekt
a-very-long-project-slug-that-exceeds-limits, envdev→tk_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át | Dů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
prodDB 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: Longparametr. Implementace ho vypustila, protože DB naming konvence (sekce 1)projectIdnevyuží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 DATABASEv PG vyžadujeautoCommit=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):
- Validace vstupu + auth check
namespaceProvisioner.provisionTenantNamespace(tenantId)(B.2 — ADR-015)- Temp save
ProjectEntity(pro získání ID, pokud slug derivuje z ID) - Slug resolution (
uniqueSlug = ...z aktuálních dat + ID) - NEW:
databaseProvisioner.provisionAppDatabase(tenantId, uniqueSlug, "dev")(B.3) - 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
ProjectEntitypřed DB provisioning kvůli slug derivation z ID.@Transactionalrollback 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:
- Delete K8s Secret
app-{slug}-{env}-dbv tenant namespace DROP DATABASE IF EXISTS "{dbName}"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,
@Transactionalto řeší.
9. Test strategie
| Vrstva | Tool | Účel |
|---|---|---|
| Unit | mockito-kotlin (mock JDBC + K8sClient) | Naming logic, idempotence větve (DB exists / not exists, user exists / not exists), generate password, error propagation |
| Integration smoke | Testcontainers postgres:17 | Real PG: CREATE DATABASE, CREATE USER, GRANT, idempotent re-call (no error on 2nd run), DROP DATABASE IF EXISTS |
| K8s Secret test | mock K8sClient (unit) nebo Testcontainers k3s | Secret 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
talkideDB (tabulkytenants,projects,users, atd.). - Admin DataSource: připojuje se k
postgresadmin DB (CREATE DATABASEvyžaduje připojení mimo cílovou databázi). Spravuje pouzePostgresDatabaseProvisioner.
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:
DatabaseProvisionerKotlin interface (provisionAppDatabase+deprovisionAppDatabase)PostgresDatabaseProvisionerimplementace s@ConditionalOnPropertyNoopDatabaseProvisionerimplementace s@ConditionalOnProperty(matchIfMissing=true)- Volání
provisionAppDatabasezCreateProjectUseCase(po namespace provisioning + temp save, před final save) - Refactor
DeleteProjectUseCase— nahrazeníNoopProjectDatabaseProvisionerzaDatabaseProvisioner - Odstranění
NoopProjectDatabaseProvisionerzfeatures/project/infrastructure/ - Separate admin DataSource bean + konfigurace
talkide.db.provisioning.*properties vapplication.yaml- Idempotence (pg_database / pg_roles query před CREATE)
- Unit testy + Testcontainers postgres smoke test
Mimo scope (jiné B.x / future tickety):
| Feature | Issue / Stopa |
|---|---|
prod env DB | Stopa B.7 (Publish flow) |
| Password rotation | post-MVP |
| DB migrace v user-app DB | Stopa B.4 (deployment fáze) |
| DO Managed PG admin user setup runbook | talkide-infra ticket |
| Backup strategy per-app DB | post-MVP |
| Per-app DB encryption-at-rest | DO default (out of scope) |
| DO Spaces provisioning | Stopa 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 patternu —
DatabaseProvisionerwrapper 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 USERse 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í
-
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é.
-
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.
-
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).
-
Admin user secret rotation je manuální —
talkide_provisionerpassword rotation vyžaduje manuální update K8s Secrettalkide-provisioner-creds+ restart BE podu. Future mitigace: HashiCorp Vault nebo DO Secret Manager. -
Orphan namespace po failed Create Project — pokud namespace provisioning (krok 2) proběhne, DB provisioning (krok 3) selže a
@Transactionalrollback 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#16 — https://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 DATABASEs 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 naDatabaseProvisioner
Thanks for the feedback.