feat(datastore): add RepositoryStore.Create (S17 Phase 1 Step 6)

What

S17 Phase 1 Step 6: the datastore layer for hosted-repository creation. RepositoryStore.Create inserts the shared parent repositories row, the per-format child row (container/maven/npm), and the default-collection link in one database/sql transaction; enforces the per-(namespace_id, format) cap over active rows; and returns:

  • ErrRepositoryNameConflict on a (namespace_id, name) unique-violation (handler maps 409),
  • ErrRepositoryCapReached when the per-format cap is reached (handler maps 422).

The HTTP handler (status codes, request validation) is Step 9 and out of scope here.

Plan: docs/plans/2026-06-22-s17-phase1-repository-crud.md (Step 6). Spec: docs/specs/S17-rest-management-api.md.

Notes for reviewers

  • Transaction: BeginTx (READ COMMITTED) + deferred errors.Join rollback (tolerates sql.ErrTxDone), mirroring ContainerManifestPersister.persistTx; cap-check-first minimizes rollback work.
  • Conflict match: SQLSTATE 23505 + strings.HasSuffix(ConstraintName, "_namespace_id_name_idx"). repositories is PARTITION BY HASH, so the unique index reports per-partition leaf names; the suffix is scoped so no sibling index is misclassified (verified against structure.sql).
  • Cap source: FormatCap is caller-supplied (the Step 9 handler owns instance config); <= 0 disables. The bare ErrRepositoryCapReached sentinel suffices because the handler already holds the limit for the 422 detail.
  • Index-backing: cap COUNT rides index_repositories_on_namespace_id_and_format (partial, soft_deleted_at IS NULL); the default-collection probe rides unique_repository_collections_namespace_id_default. Both are single-partition index probes (EXPLAIN can be added on request).
  • No schema change in this MR (Step 4 landed the indexes).

MR size

~960 added LOC, over the 500 guideline. The production implementation is 336 LOC (within band); the remaining ~580 is the integration test floor (table-driven across three formats plus cap/rollback/isolation/guard cases) and unit guards, not meaningfully separable from the implementation it constrains.

Tests

Integration (//go:build integration): each-format create + three-row invariant, visibility persistence, rollback atomicity, duplicate-name conflict, cap boundary + per-format independence, cap-counts-active-only, namespace isolation. Unit: insertFormatChild unknown-format guard, Create argument guards (nil ctx / zero namespace).

Spec coverage

# Criterion Tests
AC-2 parent + per-format child + link in one tx; full rollback ..._EachFormat, ..._RollsBackOnFailure
AC-3 existing name conflicts (ErrRepositoryNameConflict; 409) ..._DuplicateNameConflict
AC-4 visibility persisted verbatim (private default is Step 9) ..._PersistsVisibility
AC-5 per-format cap rejects (ErrRepositoryCapReached; 422) ..._CapBoundary, ..._CapCountsActiveOnly
AC-6 Maven creates alongside container and npm ..._EachFormat/{maven,container,npm}
AC-12 namespace isolation: name + cap scoped per namespace ..._NamespaceIsolation

Error cases E-C (409 name conflict) and E-K (422 cap) covered; input-validation 400s and read/update/delete ACs are owned by Steps 5/7/8/9/10.

Related to #172 (closed)

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.checkRepositoryCap Aggregate / Bitmap Index Scan repositories_pNN_namespace_id_format_idx (partial) 1 / 1 108.04 0.20ms 20 / 0 1
datastore.defaultCollectionID Limit / Index Scan repository_collections_pNN_namespace_id_idx (partial WHERE is_default) 1 / 1 8.14 0.02ms 2 / 0 1
datastore.insertRepository Insert n/a 1 / 1 0.01 2.51ms 27 / 14 1
datastore.insertFormatChild.Container Insert n/a 1 / 0 0.01 2.82ms 31 / 2 1
datastore.insertFormatChild.Maven Insert n/a 1 / 0 0.01 2.16ms 30 / 2 1
datastore.insertFormatChild.Npm Insert n/a 1 / 0 0.01 2.80ms 31 / 2 1
datastore.linkDefaultCollection Insert n/a 1 / 0 0.01 2.74ms 18 / 2 1
datastore.checkRepositoryCap

Summary: Plan matches intent. With a selective format mix (1000 of the counted format among 5000 rows in the namespace), the planner uses a Bitmap Index Scan over the partial (namespace_id, format) WHERE soft_deleted_at IS NULL index, pruning to one of 64 hash partitions; estimate matches actual (1000 / 1000) and execution is 0.2ms. Note: when a single format fills the whole namespace (predicate matches every row in the partition) the planner instead picks a Seq Scan of that one partition, which is the cheaper plan in that case, not a missing index. No anomalies.

Seed shape: namespaces=1, repositories=5000 (1000 format=0 + 4000 format=1, all active)

Rendered SQL:

SELECT COUNT(repositories.id) AS "count"
FROM public.repositories
WHERE ((repositories.namespace_id = $1::uuid) AND (repositories.format = $2)) AND (repositories.soft_deleted_at IS NULL);

Bound args: [<namespace uuid>, 0]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Aggregate  (cost=108.03..108.04 rows=1 width=8) (actual time=0.158..0.159 rows=1 loops=1)
   Buffers: shared hit=20
   ->  Bitmap Heap Scan on repositories_p37 repositories  (cost=18.53..105.53 rows=1000 width=8) (actual time=0.044..0.119 rows=1000 loops=1)
         Recheck Cond: ((namespace_id = '...'::uuid) AND (format = 0) AND (soft_deleted_at IS NULL))
         Heap Blocks: exact=15
         Buffers: shared hit=20
         ->  Bitmap Index Scan on repositories_p37_namespace_id_format_idx  (cost=0.00..18.28 rows=1000 width=0) (actual time=0.035..0.036 rows=1000 loops=1)
               Index Cond: ((namespace_id = '...'::uuid) AND (format = 0))
               Buffers: shared hit=5
 Planning:
   Buffers: shared hit=398
 Planning Time: 1.380 ms
 Execution Time: 0.200 ms

Timings: planning 1.380ms, execution 0.200ms, total 1.580ms.

datastore.defaultCollectionID

Summary: Plan matches intent: Index Scan over the partial unique unique_repository_collections_namespace_id_default index (leaf name ..._namespace_id_idx WHERE is_default IS TRUE), so no is_default recheck is needed and the index holds exactly one entry per namespace. The namespace_id literal prunes to one of 64 partitions; 1 / 1 rows, 0.02ms, 2 buffers. No anomalies.

Seed shape: namespaces=1, repository_collections=5000 (1 default + 4999 non-default)

Rendered SQL:

SELECT repository_collections.id AS "repository_collections.id"
FROM public.repository_collections
WHERE (repository_collections.namespace_id = $1::uuid) AND (repository_collections.is_default IS TRUE)
LIMIT $2;

Bound args: [<namespace uuid>, 1]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.12..8.14 rows=1 width=8) (actual time=0.008..0.008 rows=1 loops=1)
   Buffers: shared hit=2
   ->  Index Scan using repository_collections_p10_namespace_id_idx on repository_collections_p10 repository_collections  (cost=0.12..8.14 rows=1 width=8) (actual time=0.007..0.007 rows=1 loops=1)
         Index Cond: (namespace_id = '...'::uuid)
         Buffers: shared hit=2
 Planning:
   Buffers: shared hit=297
 Planning Time: 0.856 ms
 Execution Time: 0.017 ms

Timings: planning 0.856ms, execution 0.017ms, total 0.873ms.

datastore.insertRepository

Summary: Single-row Insert on the repositories parent, with the fk_repositories_namespace_id_namespaces FK check firing as an index lookup (0.43ms). No anomalies; cost is dominated by the FK trigger and WAL, as expected for a one-row write.

Seed shape: namespaces=1 (insert target seeded empty)

Rendered SQL:

INSERT INTO public.repositories (namespace_id, name, format, kind, visibility, description)
VALUES ($1::uuid, $2::text, $3, $4, $5, $6::text)
RETURNING repositories.namespace_id, repositories.id, repositories.artifacts_count,
          repositories.downloads_count, repositories.size_bytes, repositories.created_at,
          repositories.last_updated_at, repositories.soft_deleted_at, repositories.format,
          repositories.kind, repositories.visibility, repositories.name,
          repositories.description, repositories.gitlab_created_by_user_id,
          repositories.gitlab_last_updated_by_user_id;

Bound args: [<namespace uuid>, "review-prep-name", 0, 1, 0, "review-prep-description"]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on repositories  (cost=0.00..0.01 rows=1 width=206) (actual time=1.889..1.890 rows=1 loops=1)
   Buffers: shared hit=27 read=14 dirtied=29 written=15
   ->  Result  (cost=0.00..0.01 rows=1 width=206) (actual time=0.115..0.115 rows=1 loops=1)
         Buffers: shared hit=13
 Planning:
   Buffers: shared hit=6
 Planning Time: 0.300 ms
 Trigger for constraint fk_repositories_namespace_id_namespaces on repositories_p15: time=0.425 calls=1
 Execution Time: 2.510 ms

Timings: planning 0.300ms, execution 2.510ms, total 2.810ms.

datastore.insertFormatChild.Container

Summary: Single-row Insert on container_repositories; both FK checks (namespace_id -> namespaces, (namespace_id, repository_id) -> repositories) fire as index lookups. No anomalies. Maven and Npm branches are structurally identical against their respective child tables.

Seed shape: namespaces=1, repositories=1 (insert target seeded empty)

Rendered SQL:

INSERT INTO public.container_repositories (namespace_id, repository_id)
VALUES ($1::uuid, $2);

Bound args: [<namespace uuid>, <repository id>]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on container_repositories  (cost=0.00..0.01 rows=0 width=0) (actual time=0.465..0.465 rows=0 loops=1)
   Buffers: shared hit=31 read=2 dirtied=5 written=3
   ->  Result  (cost=0.00..0.01 rows=1 width=32) (actual time=0.077..0.078 rows=1 loops=1)
         Buffers: shared hit=12
 Planning Time: 0.037 ms
 Trigger for constraint fk_container_repositories_namespace_id_namespaces on container_repositories_p10: time=0.191 calls=1
 Trigger for constraint fk_container_repositories_repository_id_repositories on container_repositories_p10: time=1.495 calls=1
 Execution Time: 2.817 ms

Timings: planning 0.037ms, execution 2.817ms, total 2.854ms.

datastore.insertFormatChild.Maven

Summary: Single-row Insert on maven_repositories; identical shape to the Container branch, both FK checks index-backed. No anomalies.

Seed shape: namespaces=1, repositories=1 (insert target seeded empty)

Rendered SQL:

INSERT INTO public.maven_repositories (namespace_id, repository_id)
VALUES ($1::uuid, $2);

Bound args: [<namespace uuid>, <repository id>]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on maven_repositories  (cost=0.00..0.01 rows=0 width=0) (actual time=0.319..0.319 rows=0 loops=1)
   Buffers: shared hit=30 read=2 dirtied=5 written=3
   ->  Result  (cost=0.00..0.01 rows=1 width=32) (actual time=0.065..0.066 rows=1 loops=1)
         Buffers: shared hit=12
 Planning Time: 0.036 ms
 Trigger for constraint fk_maven_repositories_namespace_id_namespaces on maven_repositories_p42: time=0.186 calls=1
 Trigger for constraint fk_maven_repositories_repository_id_repositories on maven_repositories_p42: time=1.172 calls=1
 Execution Time: 2.157 ms

Timings: planning 0.036ms, execution 2.157ms, total 2.193ms.

datastore.insertFormatChild.Npm

Summary: Single-row Insert on npm_repositories; identical shape to the Container and Maven branches, both FK checks index-backed. No anomalies.

Seed shape: namespaces=1, repositories=1 (insert target seeded empty)

Rendered SQL:

INSERT INTO public.npm_repositories (namespace_id, repository_id)
VALUES ($1::uuid, $2);

Bound args: [<namespace uuid>, <repository id>]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on npm_repositories  (cost=0.00..0.01 rows=0 width=0) (actual time=0.393..0.393 rows=0 loops=1)
   Buffers: shared hit=31 read=2 dirtied=5 written=3
   ->  Result  (cost=0.00..0.01 rows=1 width=32) (actual time=0.085..0.085 rows=1 loops=1)
         Buffers: shared hit=12
 Planning Time: 0.041 ms
 Trigger for constraint fk_npm_repositories_namespace_id_namespaces on npm_repositories_p10: time=0.204 calls=1
 Trigger for constraint fk_npm_repositories_repository_id_repositories on npm_repositories_p10: time=1.349 calls=1
 Execution Time: 2.802 ms

Timings: planning 0.041ms, execution 2.802ms, total 2.843ms.

datastore.linkDefaultCollection

Summary: Single-row Insert on repository_collection_repositories; both composite FK checks (to repository_collections and repositories) fire as index lookups. No anomalies.

Seed shape: namespaces=1, repositories=1, repository_collections=1 (insert target seeded empty)

Rendered SQL:

INSERT INTO public.repository_collection_repositories (namespace_id, repository_collection_id, repository_id)
VALUES ($1::uuid, $2, $3);

Bound args: [<namespace uuid>, <collection id>, <repository id>]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on repository_collection_repositories  (cost=0.00..0.01 rows=0 width=0) (actual time=0.324..0.324 rows=0 loops=1)
   Buffers: shared hit=18 read=2 dirtied=5 written=3
   ->  Result  (cost=0.00..0.01 rows=1 width=40) (actual time=0.001..0.001 rows=1 loops=1)
 Planning Time: 0.045 ms
 Trigger for constraint fk_rcr_collection_id_and_namespace_id_repository_collections on repository_collection_repositories_p12: time=0.638 calls=1
 Trigger for constraint fk_rcr_repository_id_and_namespace_id_repositories on repository_collection_repositories_p12: time=1.111 calls=1
 Execution Time: 2.737 ms

Timings: planning 0.045ms, execution 2.737ms, total 2.782ms.

Edited by João Pereira

Merge request reports

Loading
Loading