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:
ErrRepositoryNameConflicton a(namespace_id, name)unique-violation (handler maps 409),ErrRepositoryCapReachedwhen 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) + deferrederrors.Joinrollback (toleratessql.ErrTxDone), mirroringContainerManifestPersister.persistTx; cap-check-first minimizes rollback work. - Conflict match: SQLSTATE 23505 +
strings.HasSuffix(ConstraintName, "_namespace_id_name_idx").repositoriesisPARTITION BY HASH, so the unique index reports per-partition leaf names; the suffix is scoped so no sibling index is misclassified (verified againststructure.sql). - Cap source:
FormatCapis caller-supplied (the Step 9 handler owns instance config);<= 0disables. The bareErrRepositoryCapReachedsentinel 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 ridesunique_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 msTimings: 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 msTimings: 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 msTimings: 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 msTimings: 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 msTimings: 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 msTimings: 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 msTimings: planning 0.045ms, execution 2.737ms, total 2.782ms.