Záznamová/doménová vrstva entity environment — NULOVÝ infra dopad, žádný namespace cut-over, žádný living pod nedotčen. Povinná foundation pro F2–F5 (ADR-026).
- Tato UC zavádí tabulku
environmenta zajišťuje, že každý tenant má právě jedno nesmazatelné defaultní prostředí „TalkIDE” (kind=DEFAULT,resource_mode=SHARED,deletable=false). - Prostředí se vytvoří lazy (get-or-create) při prvním volání API, které ho vyžaduje — konzistentní s ADR-015 §6 (lazy namespace). Explicitní endpoint pro backfill existujících tenantů je dostupný pouze pro admin.
- Nulový infra dopad: žádný nový K8s namespace nevzniká, žádný existující
namespace se neupravuje, žádný pod nerestartuje. Entita mapuje stávající
tenant-{slug}namespace jako záznamový referenční bod (namespace_ref). todo-list.talkide.app(popelkam) ani žádná jiná živá aplikace není touto UC nijak dotčena — guaranteed by design (čistě additivní DB changeset).- Celý kód F1 musí kompilovat s
@Profile("!test")zkarantinovanými metodami, které by vyžadovaly K8s (v F1 žádné nejsou — čistě JPA/Liquibase). - Acceptance kritérium: existující tenant (popelkam) má po nasazení právě
1 řádek
environmentskind=DEFAULT,name=TalkIDE,deletable=false,namespace_ref=tenant-popelkam; atodo-list.talkide.appstále vrací 200. - Related: ADR-026, ADR-015, UC-10011 F2–F5
Sekvence — lazy get-or-create default Environment
sequenceDiagram
actor User
participant FE
participant BE as BE (EnvironmentService)
participant DB
Note over BE,DB: Tato UC nemá vlastní FE obrazovku.<br/>Lazy get-or-create se volá interně (trigger = Create Project nebo Publish).
User->>+FE: Create Project / Publish Project
FE->>+BE: POST /api/v1/projects (nebo POST /api/v1/projects/{id}/publish)
BE->>+DB: SELECT * FROM environment WHERE tenant_id=? AND kind='DEFAULT'
alt prostředí neexistuje
DB-->>BE: 0 rows
BE->>DB: INSERT INTO environment (tenant_id, kind, name, slug, resource_mode, status, deletable, namespace_ref, ...)<br/>VALUES (?, 'DEFAULT', 'TalkIDE', 'talkide', 'SHARED', 'ACTIVE', false, 'tenant-{slug}', ...)
DB-->>BE: environment row (id)
else prostředí existuje
DB-->>BE: environment row (id)
end
BE-->>-DB: ok
BE->>DB: pokračuje normálně (Create Project / Publish logika)
BE-->>-FE: 201 Created / 200 OK
FE-->>-User: výsledek akce
GET /api/v1/environments
Vrátí seznam všech prostředí aktuálního tenanta. Volá se po vytvoření prostředí nebo pro zobrazení „Environments” UI (budoucí F3 obrazovka — v F1 slouží jen jako interní/debug endpoint).
GET /api/v1/environments
200 OK EnvironmentListResponse:
{
"environments": [
{
"id": 1,
"tenantId": 42,
"kind": "DEFAULT",
"name": "TalkIDE",
"slug": "talkide",
"resourceMode": "SHARED",
"status": "ACTIVE",
"deletable": false,
"namespaceRef": "tenant-popelkam",
"createdAt": "2026-05-19T10:00:00Z",
"updatedAt": "2026-05-19T10:00:00Z"
}
]
}
401 Unauthorized ErrorResponse:
{
"code": "UNAUTHORIZED",
"message": "Authentication required"
}
POST /api/v1/admin/environments/backfill (admin only)
Idempotentní backfill defaultního „TalkIDE” prostředí pro všechny existující tenanty, kteří ho ještě nemají. Volá se jednorázově po deployi F1. Bezpečné pro opakované volání (idempotentní — existující řádky se nepřepíší).
POST /api/v1/admin/environments/backfill
200 OK BackfillResponse:
{
"processed": 5,
"created": 3,
"skipped": 2
}
401 Unauthorized ErrorResponse (silent-probe — neadmin i neautentizovaný dostane stejné 401, endpoint svoji existenci neprozrazuje):
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
Datový model — nová entita environment
Mermaid ER (delta — pouze nové entity a vazby)
erDiagram
TENANT {
bigint id
string name
string slug
bigint owner_id
}
ENVIRONMENT {
bigint id
bigint tenant_id
string kind
string name
string slug
string resource_mode
string status
boolean deletable
string namespace_ref
jsonb config
timestamp created_at
timestamp updated_at
}
TENANT ||--o{ ENVIRONMENT : has
Popis sloupců
| Sloupec | Typ | Constraints | Popis |
|---|---|---|---|
id | BIGINT | PK, auto-increment | Interní ID |
tenant_id | BIGINT | NOT NULL, FK → tenants.id ON DELETE CASCADE | Vlastník (tenant) |
kind | VARCHAR(32) | NOT NULL | DEFAULT nebo USER_CREATED |
name | VARCHAR(100) | NOT NULL | Zobrazovaný název (např. „TalkIDE”, „PROD”) |
slug | VARCHAR(20) | NOT NULL | RFC-1123 slug, unikátní v rámci tenanta; max 20 znaků (limit: {tenant}-{env-slug} ≤ 63) |
resource_mode | VARCHAR(32) | NOT NULL, default SHARED | SHARED nebo DEDICATED (DEDICATED = F5, nedostupné v alfě) |
status | VARCHAR(32) | NOT NULL, default ACTIVE | ACTIVE, SUSPENDED, DEPROVISIONING |
deletable | BOOLEAN | NOT NULL, default true | false pro kind=DEFAULT — hardcoded invariant |
namespace_ref | VARCHAR(128) | NULL | K8s namespace name, kam se prostředí mapuje (F1: tenant-{slug}; F4: {tenant}-talkide) |
config | JSONB | NULL | Budoucí seam pro sizing/db tier/storage tier (F1: vždy NULL) |
created_at | TIMESTAMPTZ | NOT NULL, default NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, default NOW() |
Unikátní constrainty:
(tenant_id, slug)— slug je unikátní v rámci tenanta(tenant_id, kind)WHEREkind = 'DEFAULT'— každý tenant má max 1 DEFAULT prostředí (partial unique index)
Enumerace
enum class EnvironmentKind { DEFAULT, USER_CREATED }
enum class EnvironmentResourceMode { SHARED, DEDICATED }
enum class EnvironmentStatus { ACTIVE, SUSPENDED, DEPROVISIONING }
Liquibase — nový changeset (PRODUCTION fáze)
PRODUCTION fáze: immutable pravidla. Nový soubor
0033-create-environment.xml. Nikdy neupravovat existující changesety (talkide-be/src/main/resources/db/changelog/changes/).
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<changeSet id="0033-create-environment" author="talkide">
<createTable tableName="environment">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="tenant_id" type="BIGINT">
<constraints nullable="false"
foreignKeyName="fk_environment_tenant"
references="tenants(id)"
deleteCascade="true"/>
</column>
<column name="kind" type="VARCHAR(32)">
<constraints nullable="false"/>
</column>
<column name="name" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="slug" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="resource_mode" type="VARCHAR(32)" defaultValue="SHARED">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(32)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="deletable" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="namespace_ref" type="VARCHAR(128)"/>
<column name="config" type="JSONB"/>
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
</createTable>
<!-- Unikátní constraint: (tenant_id, slug) -->
<addUniqueConstraint
tableName="environment"
columnNames="tenant_id, slug"
constraintName="uq_environment_tenant_slug"/>
<!-- Partial unique index: max 1 DEFAULT per tenant -->
<sql>
CREATE UNIQUE INDEX uq_environment_tenant_default
ON environment (tenant_id)
WHERE kind = 'DEFAULT';
</sql>
</changeSet>
</databaseChangeLog>
Backfill existujících tenantů se provede voláním
POST /api/v1/admin/environments/backfill po nasazení (idempotentní, bezpečné
pro opakované volání). Liquibase changeset neobsahuje backfill SQL — je
aplikačně řízený, protože namespace_ref se odvozuje z tenant.slug
(business logika, ne čistá DB transformace).
Backend implementace — klíčové komponenty
EnvironmentEntity (JPA)
@Entity
@Table(name = "environment")
class EnvironmentEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(name = "tenant_id", nullable = false)
val tenantId: Long,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
val kind: EnvironmentKind,
@Column(nullable = false, length = 100)
val name: String,
@Column(nullable = false, length = 20)
val slug: String,
@Enumerated(EnumType.STRING)
@Column(name = "resource_mode", nullable = false, length = 32)
val resourceMode: EnvironmentResourceMode = EnvironmentResourceMode.SHARED,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
val status: EnvironmentStatus = EnvironmentStatus.ACTIVE,
@Column(nullable = false)
val deletable: Boolean = true,
@Column(name = "namespace_ref", length = 128)
val namespaceRef: String? = null,
@Column(columnDefinition = "JSONB")
val config: String? = null, // JSON string; F1 always null
@Column(name = "created_at", nullable = false)
val createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Instant = Instant.now()
)
EnvironmentRepository (Spring Data JPA)
interface EnvironmentRepository : JpaRepository<EnvironmentEntity, Long> {
fun findByTenantId(tenantId: Long): List<EnvironmentEntity>
fun findByTenantIdAndKind(tenantId: Long, kind: EnvironmentKind): EnvironmentEntity?
fun existsByTenantIdAndSlug(tenantId: Long, slug: String): Boolean
}
EnvironmentService — lazy get-or-create
@Service
@Transactional
class EnvironmentService(
private val environmentRepository: EnvironmentRepository,
private val tenantRepository: TenantRepository
) {
/**
* Lazy get-or-create default "TalkIDE" environment for tenant.
* Idempotent — safe to call multiple times.
* F1: namespace_ref = "tenant-{slug}" (current single-namespace model).
*/
fun getOrCreateDefaultEnvironment(tenantId: Long): EnvironmentEntity {
return environmentRepository.findByTenantIdAndKind(tenantId, EnvironmentKind.DEFAULT)
?: run {
val tenant = tenantRepository.findById(tenantId)
.orElseThrow { IllegalStateException("Tenant not found: $tenantId") }
val env = EnvironmentEntity(
tenantId = tenantId,
kind = EnvironmentKind.DEFAULT,
name = "TalkIDE",
slug = "talkide",
resourceMode = EnvironmentResourceMode.SHARED,
status = EnvironmentStatus.ACTIVE,
deletable = false,
namespaceRef = "tenant-${tenant.slug}"
)
environmentRepository.save(env)
}
}
/**
* Backfill all tenants that don't have a DEFAULT environment yet.
* Idempotent — skips tenants that already have one.
* Returns (processed, created, skipped) counts.
*/
fun backfillDefaultEnvironments(): BackfillResult {
val allTenants = tenantRepository.findAll()
var created = 0
var skipped = 0
for (tenant in allTenants) {
val existing = environmentRepository.findByTenantIdAndKind(tenant.id, EnvironmentKind.DEFAULT)
if (existing == null) {
getOrCreateDefaultEnvironment(tenant.id)
created++
} else {
skipped++
}
}
return BackfillResult(processed = allTenants.size, created = created, skipped = skipped)
}
}
data class BackfillResult(val processed: Int, val created: Int, val skipped: Int)
Integrace s Create Project a Publish
getOrCreateDefaultEnvironment(tenantId) se volá jako vedlejší efekt v:
CreateProjectUseCase.execute(...)— před vytvořením projektu (zajistí existenci „TalkIDE” prostředí, vrácenéidse zatím neukládá naprojectstabulku — to je F3 job: deployment-target vazba Project→Environment)PublishProjectUseCase.execute(...)— analogicky
F1 volání je fire-and-store (výsledek se v F1 nevyužívá dál — jen se zajistí, že entita existuje). Propojení Project→Environment přes deployment-target vazbu je F3 scope, ne F1.
EnvironmentController
@RestController
@RequestMapping("/api/v1/environments")
class EnvironmentController(
private val environmentService: EnvironmentService,
private val authContext: AuthContext
) {
@GetMapping
fun list(): ResponseEntity<EnvironmentListResponse> {
val tenantId = authContext.requireTenantId()
val envs = environmentService.findAllByTenantId(tenantId)
return ResponseEntity.ok(EnvironmentListResponse(envs.map { it.toDto() }))
}
}
@RestController
@RequestMapping("/api/v1/admin/environments")
class AdminEnvironmentController(
private val environmentService: EnvironmentService
) {
@PostMapping("/backfill")
fun backfill(): ResponseEntity<BackfillResult> {
val result = environmentService.backfillDefaultEnvironments()
return ResponseEntity.ok(result)
}
}
Frontend
Tato UC nemá vlastní FE obrazovku v F1. GET /api/v1/environments je
interní endpoint (debug/admin). FE obrazovka „Environments” přichází ve F3.
Validations
Žádná FE validace v F1 (žádný user-facing formulář).
Backend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| backfill endpoint | žádný request body | — | — | Admin-only (ROLE_ADMIN) |
Invarianty (vynucené BE — hardcoded, ne konfigurovatelné)
| Invariant | Enforcement |
|---|---|
kind=DEFAULT → deletable=false | EnvironmentService.getOrCreateDefaultEnvironment vždy nastaví deletable=false; Delete Environment UC (F3) odmítne DEFAULT s 409 |
| Max 1 DEFAULT per tenant | Partial unique index uq_environment_tenant_default (DB level) + Spring Data findByTenantIdAndKind v get-or-create (application level) |
slug='talkide' pro DEFAULT | Hardcoded v getOrCreateDefaultEnvironment; slug je unikátní v rámci tenanta (DB unique constraint) |
namespace_ref='tenant-{slug}' pro F1 DEFAULT | Odvozeno z tenant.slug v getOrCreateDefaultEnvironment; beze změny až do F4 (ns cut-over) |
resource_mode=SHARED pro DEFAULT | Hardcoded v F1; DEDICATED nedostupné v alfě (OD-4) |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
Tenant (popelkam) bez jakéhokoli environment řádku v DB | getOrCreateDefaultEnvironment(tenantId) zavolána | Vznikne 1 řádek: kind=DEFAULT, name=TalkIDE, slug=talkide, resource_mode=SHARED, deletable=false, namespace_ref=tenant-popelkam, status=ACTIVE |
Tenant má již kind=DEFAULT environment | getOrCreateDefaultEnvironment(tenantId) zavolána (2. volání) | Žádný nový řádek nevznikne; vrátí se existující (idempotentní) |
Existující tenant popelkam se live projektem todo-list.talkide.app | Nasazení F1 changesetu 0033 + volání backfill endpointu | todo-list.talkide.app stále vrací 200; k8s pody nedotčeny; tenant má 1 environment řádek |
| 5 existujících tenantů, 3 bez DEFAULT environment | POST /api/v1/admin/environments/backfill zavolán | Response: {"processed":5,"created":3,"skipped":2}; každý tenant má právě 1 DEFAULT environment |
| Admin zavolá backfill 2× za sebou | POST /api/v1/admin/environments/backfill (2. volání) | Response: {"processed":5,"created":0,"skipped":5} — idempotentní, žádné duplicity |
| Neautentizovaný user | GET /api/v1/environments | 401 UNAUTHORIZED |
| Autentizovaný user bez tenant kontextu | GET /api/v1/environments | 401/403 (chybí tenant — konzistentní s ostatními endpointy) |
| Neadmin user | POST /api/v1/admin/environments/backfill | 401 AUTHENTICATION_FAILED (silent-probe — stejné jako neautentizovaný) |
| Autentizovaný user s 1 DEFAULT environment | GET /api/v1/environments | 200 OK, seznam s 1 položkou (TalkIDE) |
| CREATE PROJECT zavoláno na tenanta bez DEFAULT environment | CreateProjectUseCase.execute(...) | DEFAULT environment „TalkIDE” vznikne jako vedlejší efekt; projekt se normálně vytvoří; 201 Created |
| DB rollback při selhání Create Project po get-or-create | CreateProjectUseCase.execute(...) — projekt validace selže | Environment řádek NENÍ persistován (celá operace v @Transactional — rollback zahrnuje environment insert) |
Acceptance kritéria (pro backend developera)
- Nulový infra dopad: po nasazení F1 changesetu a backfill volání nevznikne
žádný nový K8s namespace, žádný pod nerestartuje,
todo-list.talkide.appstále vrací HTTP 200. - Idempotence:
getOrCreateDefaultEnvironmenta backfill endpoint jsou bezpečné pro opakované volání (žádné duplicitní řádky, žádný error). - Partial unique index: DB odmítne 2. DEFAULT pro stejného tenanta s unique constraint violation — aplikační kód to nesmí obejít.
- Všechny test cases ze sekce výše projdou jako JUnit testy.
- Kompilace bez K8s závislostí: F1 kód nepotřebuje žádný
KubernetesClientaniNamespaceProvisioner— čistě JPA + HTTP controllers. - Starý BE test suite (
./gradlew test) zelený po přidání F1 kódu.
Thanks for the feedback.