Internal Documentation internal
TalkIDE internal documentation

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 environment a 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 environment s kind=DEFAULT, name=TalkIDE, deletable=false, namespace_ref=tenant-popelkam; a todo-list.talkide.app stá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ů

SloupecTypConstraintsPopis
idBIGINTPK, auto-incrementInterní ID
tenant_idBIGINTNOT NULL, FK → tenants.id ON DELETE CASCADEVlastník (tenant)
kindVARCHAR(32)NOT NULLDEFAULT nebo USER_CREATED
nameVARCHAR(100)NOT NULLZobrazovaný název (např. „TalkIDE”, „PROD”)
slugVARCHAR(20)NOT NULLRFC-1123 slug, unikátní v rámci tenanta; max 20 znaků (limit: {tenant}-{env-slug} ≤ 63)
resource_modeVARCHAR(32)NOT NULL, default SHAREDSHARED nebo DEDICATED (DEDICATED = F5, nedostupné v alfě)
statusVARCHAR(32)NOT NULL, default ACTIVEACTIVE, SUSPENDED, DEPROVISIONING
deletableBOOLEANNOT NULL, default truefalse pro kind=DEFAULT — hardcoded invariant
namespace_refVARCHAR(128)NULLK8s namespace name, kam se prostředí mapuje (F1: tenant-{slug}; F4: {tenant}-talkide)
configJSONBNULLBudoucí seam pro sizing/db tier/storage tier (F1: vždy NULL)
created_atTIMESTAMPTZNOT NULL, default NOW()
updated_atTIMESTAMPTZNOT NULL, default NOW()

Unikátní constrainty:

  • (tenant_id, slug) — slug je unikátní v rámci tenanta
  • (tenant_id, kind) WHERE kind = '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é id se zatím neukládá na projects tabulku — 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

FieldConstraintsSizePatternNote
backfill endpointžádný request bodyAdmin-only (ROLE_ADMIN)

Invarianty (vynucené BE — hardcoded, ne konfigurovatelné)

InvariantEnforcement
kind=DEFAULTdeletable=falseEnvironmentService.getOrCreateDefaultEnvironment vždy nastaví deletable=false; Delete Environment UC (F3) odmítne DEFAULT s 409
Max 1 DEFAULT per tenantPartial unique index uq_environment_tenant_default (DB level) + Spring Data findByTenantIdAndKind v get-or-create (application level)
slug='talkide' pro DEFAULTHardcoded v getOrCreateDefaultEnvironment; slug je unikátní v rámci tenanta (DB unique constraint)
namespace_ref='tenant-{slug}' pro F1 DEFAULTOdvozeno z tenant.slug v getOrCreateDefaultEnvironment; beze změny až do F4 (ns cut-over)
resource_mode=SHARED pro DEFAULTHardcoded v F1; DEDICATED nedostupné v alfě (OD-4)

Test Cases

GIVENWHENTHEN
Tenant (popelkam) bez jakéhokoli environment řádku v DBgetOrCreateDefaultEnvironment(tenantId) zavolánaVznikne 1 řádek: kind=DEFAULT, name=TalkIDE, slug=talkide, resource_mode=SHARED, deletable=false, namespace_ref=tenant-popelkam, status=ACTIVE
Tenant má již kind=DEFAULT environmentgetOrCreateDefaultEnvironment(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.appNasazení F1 changesetu 0033 + volání backfill endpointutodo-list.talkide.app stále vrací 200; k8s pody nedotčeny; tenant má 1 environment řádek
5 existujících tenantů, 3 bez DEFAULT environmentPOST /api/v1/admin/environments/backfill zavolánResponse: {"processed":5,"created":3,"skipped":2}; každý tenant má právě 1 DEFAULT environment
Admin zavolá backfill 2× za sebouPOST /api/v1/admin/environments/backfill (2. volání)Response: {"processed":5,"created":0,"skipped":5} — idempotentní, žádné duplicity
Neautentizovaný userGET /api/v1/environments401 UNAUTHORIZED
Autentizovaný user bez tenant kontextuGET /api/v1/environments401/403 (chybí tenant — konzistentní s ostatními endpointy)
Neadmin userPOST /api/v1/admin/environments/backfill401 AUTHENTICATION_FAILED (silent-probe — stejné jako neautentizovaný)
Autentizovaný user s 1 DEFAULT environmentGET /api/v1/environments200 OK, seznam s 1 položkou (TalkIDE)
CREATE PROJECT zavoláno na tenanta bez DEFAULT environmentCreateProjectUseCase.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-createCreateProjectUseCase.execute(...) — projekt validace selžeEnvironment řádek NENÍ persistován (celá operace v @Transactional — rollback zahrnuje environment insert)

Acceptance kritéria (pro backend developera)

  1. Nulový infra dopad: po nasazení F1 changesetu a backfill volání nevznikne žádný nový K8s namespace, žádný pod nerestartuje, todo-list.talkide.app stále vrací HTTP 200.
  2. Idempotence: getOrCreateDefaultEnvironment a backfill endpoint jsou bezpečné pro opakované volání (žádné duplicitní řádky, žádný error).
  3. Partial unique index: DB odmítne 2. DEFAULT pro stejného tenanta s unique constraint violation — aplikační kód to nesmí obejít.
  4. Všechny test cases ze sekce výše projdou jako JUnit testy.
  5. Kompilace bez K8s závislostí: F1 kód nepotřebuje žádný KubernetesClient ani NamespaceProvisioner — čistě JPA + HTTP controllers.
  6. Starý BE test suite (./gradlew test) zelený po přidání F1 kódu.

Was this page helpful?

Thanks for the feedback.