chore(datastore): dispatch the repository delete on kind

Summary

The repository delete path is kind-blind, and non-hosted repositories are arriving: the Phase 6 create dispatch will insert remote and virtual rows. Step 9 of the merged Phase 6 plan makes RepositoryStore.Delete read the row's kind and fail closed on virtual with the exported ErrRepositoryDeleteVirtualKind. The virtual delete contract (association removal under the child-row lock) lands with later plan steps. The remote arm rides the existing schema: every *_remote_repositories FK into repositories is ON DELETE CASCADE, and a cache row's NO ACTION FK aborts a non-empty delete with the 23503 that mapRepositoryDeleteError already maps to ErrRepositoryNotEmpty. Merging this ahead of the create dispatch keeps every created repository deletable by construction.

Non-obvious parts:

  • The store-side kind read is deliberately redundant with the handler's row.Kind (from findRepositoryForRequest). The store is the fail-closed seam: an impossible enum value is rejected where it enters, and the downstream switch is a second line of defense.
  • No transaction around the kind read and the DELETE. Kind is immutable after insert, so the only race is a concurrent delete, which the zero-rows arm answers as ErrNotFound.
  • The kind read is a partition-pruned point SELECT: repositories is HASH-partitioned on namespace_id with PK (id, namespace_id), and both columns are in the WHERE, the same shape as FindByID.
  • The schema-guard test now derives the delete's cascade closure transitively and asserts the FK rule set (SET NULL and SET DEFAULT edges included), so a future migration widening the delete's write set fails the guard first.

888 changed lines: 109 production (repositories.go), 85 unit test, 694 integration test. Splitting would not help: the tests pin the gate and the schema facts the gate relies on, and they belong in the MR that introduces the gate.

No docs/testing/ scenario added or affected: this is a store-level gate with no reachable API surface until the create dispatch lands (no production path creates non-hosted rows yet).

Governing ADRs

ADR-007 (database schema). The remote arm rests on the FK regime it defines: child and link FKs cascade, artifact and cache FKs are NO ACTION so cleanup stays application-managed. No schema change here, and TestRepositoryStore_DeleteFKAssumption_SchemaGuard pins those facts.

Testing

go build ./..., go vet ./..., and go test ./internal/datastore/, plus the scoped integration battery against testcontainers PostgreSQL (go test -tags=integration -run 'TestRepositoryStore_Delete' ./internal/datastore/): TestRepositoryStore_Delete, TestRepositoryStore_Delete_Guards, TestRepositoryStore_Delete_RemoteKind, TestRepositoryStore_Delete_VirtualKindFailsClosed, and TestRepositoryStore_DeleteFKAssumption_SchemaGuard, all green after rebasing past the container_remote_manifest_relationships migrations.

Spec coverage

Spec: docs/specs/S17-rest-management-api.md Plan: docs/plans/2026-08-13-s17-phase6-virtual-remote-repositories.md, Step 9

Scope: the internal/datastore delete transaction. Acceptance criteria are numbered by position; the Phase 6 delete criterion is 109, the Phase 1 hosted delete criterion is 12.

Acceptance criteria

# Criterion (datastore slice) Tests
AC-109 (204 slice) Deleting an empty remote repository removes the parent, the kind-agnostic child, the remote child, and the collection link in the one cascading statement, and frees the name TestRepositoryStore_Delete_RemoteKind/removes remote child, format child, link, and parent and frees the name for reuse (docker, oci, npm, and maven arms). The route-level 204 is a handler step's
AC-109 (409 slice) A remote repository with cache rows aborts the delete on the cache tables' foreign keys and surfaces ErrRepositoryNotEmpty, leaving every row intact TestRepositoryStore_Delete_RemoteKind, the live and soft-deleted cache-row groups (four arms each, shared assertRemoteDeleteBlocked); the 23503 classification is pinned by the existing TestMapRepositoryDeleteError; the schema assumption is locked by TestRepositoryStore_DeleteFKAssumption_SchemaGuard, extended with the three remote children and the three cache -> remote-child FKs. The destructive=true 202 slice is S20-A's tombstone path, not this transaction's
AC-109 (virtual slice) The spec end state (204 under either destructive value, associations removed in the same transaction) lands with the steps that add the association tables. This step pins the interim fail-closed behavior instead: a virtual-kind row answers ErrRepositoryDeleteVirtualKind and deletes nothing TestRepositoryStore_Delete_VirtualKindFailsClosed (four arms, every seeded row survives), TestRepositoryDeleteKindGate (virtual -> ErrRepositoryDeleteVirtualKind)
AC-12 DELETE with destructive=false hard-deletes an empty repository, a repository that still has artifacts returns 409 and is left intact, a missing repository returns 404 (hosted deletes stay bit-identical) Existing TestRepositoryStore_Delete subtests and TestRepositoryStore_Delete_Guards, unchanged: the guards still precede the kind read, and the hosted suite must stay green through the dispatch
AC-110 A repository that is an upstream of a virtual repository returns 409 and deletes nothing Not covered here: the junction table, its NO ACTION FK, and the lock-ordered check land with the association steps
AC-1 to AC-11, AC-13 to AC-108, AC-111 to AC-123 Every criterion outside the repository delete transaction Not this step. The delete dispatch touches no other surface

Behavior obligations from the plan (Step 9)

# Obligation Tests
P-1 Every defined kind gets an explicit gate decision (totality over RepositoryKinds), and an out-of-enum kind fails closed as unknown TestRepositoryDeleteKindGate (totality loop guarded against an empty enumeration; hosted and remote nil; 3, 99, and -1 -> errRepositoryDeleteUnknownKind via errors.Is)
P-2 No removal code for the remote arm: the cascade owns child removal The remote-kind groups pass against the unmodified single-statement Delete, and TestRepositoryStore_DeleteFKAssumption_SchemaGuard holds the FK facts that make that correct

Error cases

# Condition Tests
E-1 Delete: destructive=false and the repository still has artifacts -> 409 conflict (a remote repository's artifacts are its cache rows) TestRepositoryStore_Delete_RemoteKind cache-row groups; existing TestMapRepositoryDeleteError; extended TestRepositoryStore_DeleteFKAssumption_SchemaGuard
E-2 Detail, Update, Delete: repository missing -> 404 not_found Existing TestRepositoryStore_Delete miss and cross-namespace subtests, unchanged
E-3 Delete: repository is an upstream of a virtual repository -> 409 conflict Not this step: lands with the association steps (AC-110's slice)
E-4 Delete: destructive omitted or malformed -> 400 Route-level. The datastore has no request surface

Security considerations

# Concern Tests
S-1 Tenant isolation: every query is scoped by the resolved namespace_id, the partition key Existing TestRepositoryStore_Delete cross-namespace subtest, unchanged; every new fixture and assertion is namespace-scoped
S-2 Injection: all queries use the Jet builder with bound parameters Delete's statement is Jet-built and unchanged here; this step's raw SQL is fixture-only. The kind read joins the statement in the implementation step
S-3 Authentication, authorization, existence hiding, echoed input Handler-level. No request surface in the datastore
Context for LLM agents

Design rationale:

  • The store-side kind read duplicates the handler's row.Kind from findRepositoryForRequest on purpose. The store is the fail-closed seam per the project convention that impossible enum values are rejected where they enter, with any downstream switch as a second line of defense. Rejected alternative: relying on the handler's kind check alone. That leaves RepositoryStore.Delete callable with a virtual row by any future caller (jobs, CLI, another handler) with nothing stopping the cascade.
  • No transaction around the kind read and the DELETE. kind is immutable after insert (Update never lists it in the SET builder), so the read cannot go stale in the window. The only race is a concurrent delete, and the DELETE's zero-rows arm already answers it as ErrNotFound.

Non-goals, do not raise these in review:

  • No removal code for the remote arm. Every *_remote_repositories FK into repositories is ON DELETE CASCADE, so the single statement owns child removal. Explicit child deletes would duplicate the schema's work.
  • The virtual delete contract (association removal under the child-row lock) and the virtual-upstream 409 junction FK are later steps of the same plan.
  • This MR carries zero docs/plans/** changes by design. The plan's Status-table rows are filled by a dedicated table-owner MR, not per-step MRs.
  • The handler mapping of ErrRepositoryDeleteVirtualKind lands with the routes that can reach it. No production path creates non-hosted rows yet, so the gate is dormant until the create dispatch merges.

Database Review Evidence

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17.10 container (matching GL_PG_CURR_VERSION from .gitlab-ci-other-versions.yml), with synthesized seed data rolled back per query and the container torn down at the end of the run. Numbers reflect moderate cardinality and do not capture production-scale effects. See Database review evidence for seed sizing, methodology, and the anomalies the skill flags. Expand each row's details for the seed shape, rendered SQL, bound args, and raw plan.

Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
datastore.RepositoryStore.Delete.DeleteStmt Delete n/a 0 / 0 1.75 0.154ms 42 / 0 1/64
datastore.RepositoryStore.Delete.KindRead Limit repositories_p35_pkey 1 / 1 8.30 0.010ms 3 / 0 1/64
datastore.RepositoryStore.Delete.DeleteStmt

Summary: Plan-time pruning reaches only the partition holding the bound namespace_id, and the DELETE removes exactly the target row (49 siblings filtered). A Seq Scan wins inside the 50-row partition at this cardinality, and the identical predicate at 5000 rows (the Delete.KindRead entry) uses the partition primary-key index, so the WHERE shape stays index-backed at scale. The trigger footer records all seven ON DELETE CASCADE foreign keys into repositories firing once each, plus the container_images NO ACTION check against the cascade-removed format child, and the seeded container_repositories row was confirmed gone before rollback. Execution time is mostly first-call trigger setup in the fresh session (the eight trigger lines carry about 12.4 of the 12.8 ms). No anomalies.

Seed shape: namespaces=1, repositories=50, container_repositories=1

Rendered SQL:

DELETE FROM public.repositories
WHERE (repositories.namespace_id = $1::uuid) AND (repositories.id = $2::uuid);

Bound args: [bf121000-801b-41b0-bc30-a0ebad882b0f, 7c13e85e-2667-4f0f-8f1f-0b2dad799f23]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Delete on repositories  (cost=0.00..1.75 rows=0 width=0) (actual time=0.154..0.154 rows=0 loops=1)
   Delete on repositories_p22 repositories_1
   Buffers: shared hit=42
   ->  Seq Scan on repositories_p22 repositories_1  (cost=0.00..1.75 rows=1 width=10) (actual time=0.005..0.007 rows=1 loops=1)
         Filter: ((namespace_id = 'bf121000-801b-41b0-bc30-a0ebad882b0f'::uuid) AND (id = '7c13e85e-2667-4f0f-8f1f-0b2dad799f23'::uuid))
         Rows Removed by Filter: 49
         Buffers: shared hit=1
 Planning:
   Buffers: shared hit=149
 Planning Time: 0.857 ms
 Trigger for constraint container_repositories_repository_id_namespace_id_fkey22 on repositories_p22: time=0.971 calls=1
 Trigger for constraint npm_repositories_repository_id_namespace_id_fkey22 on repositories_p22: time=1.128 calls=1
 Trigger for constraint maven_repositories_repository_id_namespace_id_fkey22 on repositories_p22: time=1.299 calls=1
 Trigger for constraint repository_collection_reposit_repository_id_namespace_id_fkey22 on repositories_p22: time=1.047 calls=1
 Trigger for constraint npm_remote_repositories_repository_id_namespace_id_fkey22 on repositories_p22: time=1.486 calls=1
 Trigger for constraint maven_remote_repositories_repository_id_namespace_id_fkey22 on repositories_p22: time=1.710 calls=1
 Trigger for constraint container_remote_repositories_repository_id_namespace_id_fkey22 on repositories_p22: time=2.015 calls=1
 Trigger for constraint container_images_container_repository_id_namespace_id_fkey22 on container_repositories_p22: time=2.789 calls=1
 Execution Time: 12.781 ms

Timings: planning 0.857ms, execution 12.781ms, total 13.638ms.

datastore.RepositoryStore.Delete.KindRead

Summary: Plan matches the method's intent: a point read of kind through the partition primary-key index, with both the partition-key and id literals pruning to one of 64 partitions at plan time (no Append node survives). The estimate matches the actual rows (1 / 1) and execution completes in 0.022 ms over 5000 seeded rows, entirely from shared buffers. Same statement shape as FindByID. No anomalies.

Seed shape: namespaces=1, repositories=5000

Rendered SQL:

SELECT repositories.kind AS "repositories.kind"
FROM public.repositories
WHERE (repositories.namespace_id = $1::uuid) AND (repositories.id = $2::uuid)
LIMIT $3;

Bound args: [8a1ba4e3-8aff-42bc-ae24-311c4eb07456, ceb95315-9dc8-4825-88c4-e42753be4ca7, 1]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..8.30 rows=1 width=2) (actual time=0.010..0.010 rows=1 loops=1)
   Buffers: shared hit=3
   ->  Index Scan using repositories_p35_pkey on repositories_p35 repositories  (cost=0.28..8.30 rows=1 width=2) (actual time=0.009..0.009 rows=1 loops=1)
         Index Cond: ((id = 'ceb95315-9dc8-4825-88c4-e42753be4ca7'::uuid) AND (namespace_id = '8a1ba4e3-8aff-42bc-ae24-311c4eb07456'::uuid))
         Buffers: shared hit=3
 Planning:
   Buffers: shared hit=339
 Planning Time: 1.144 ms
 Execution Time: 0.022 ms

Timings: planning 1.144ms, execution 0.022ms, total 1.166ms.

Related to #314

Edited by Hayley Swimelar

Merge request reports

Loading
Loading