fix(repo,service): SCALE-002 — push pagination into SQL for target/issuer/team/agent_group

Sprint 2 unified-master-audit closure. Pre-fix four service List
endpoints (target, issuer, team, agent_group) called repoFoo.List(ctx)
to fetch the full table then sliced in memory:

    rows, _ := s.repo.List(ctx)
    total := int64(len(rows))
    start := (page - 1) * perPage
    end := start + perPage
    return rows[start:end], total, nil

This page-sliced in memory pattern marshals every row per request —
fine on small fleets but unacceptable for multi-tenant or large-fleet
deploys. The agent_group case was worse — the service explicitly
ignored page/perPage and returned the entire slice.

Fix:
  - New ListPaginated(ctx, limit, offset) method on each of the four
    repositories. Postgres implementations push LIMIT + OFFSET into
    the SQL plus a SELECT COUNT(*) for the total. Mirrors the cursor
    pattern already in internal/repository/postgres/certificate.go.
  - Each ListPaginated normalises limit≤0→50 and offset<0→0,
    matching the service-layer defaults that already existed.
  - Repository interfaces grow the new method so adapters stay
    swappable.
  - Service List methods now call repoFoo.ListPaginated(ctx, perPage,
    (page-1)*perPage) directly — no more memory-slice.
  - AgentGroupService.ListAgentGroups closes the Bundle E / Audit
    L-020 'page/perPage unused' gap.

Test changes:
  - sliceWindow generic helper in testutil_test.go mirrors the SQL
    LIMIT/OFFSET semantics for in-memory mocks.
  - Six mock implementers (lifecycle_test, testutil_test x2,
    agent_group_test, team_test) gain ListPaginated methods.
  - TestTeamService_List_SCALE002_PaginationPropagatesToRepo pins
    the page=2, perPage=3 → 3 rows of 10 invariant.

Closes SCALE-002.
This commit is contained in:
shankar0123
2026-05-16 04:01:45 +00:00
parent 8f2e5771db
commit a485e31f63
13 changed files with 335 additions and 56 deletions
+15
View File
@@ -210,6 +210,11 @@ type OCSPResponderRepository interface {
type IssuerRepository interface {
// List returns all issuers, optionally filtered.
List(ctx context.Context) ([]*domain.Issuer, error)
// ListPaginated returns a window of issuers (sorted by created_at DESC)
// plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16) —
// pushes pagination into the SQL layer so admin pages don't marshal
// the full table per request.
ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error)
// Get retrieves an issuer by ID.
Get(ctx context.Context, id string) (*domain.Issuer, error)
// Create stores a new issuer.
@@ -227,6 +232,10 @@ type IssuerRepository interface {
type TargetRepository interface {
// List returns all targets, optionally filtered.
List(ctx context.Context) ([]*domain.DeploymentTarget, error)
// ListPaginated returns a window of deployment targets (sorted by
// created_at DESC) plus the total row count. SCALE-002 closure
// (Sprint 2, 2026-05-16).
ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error)
// Get retrieves a target by ID.
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
// Create stores a new target.
@@ -550,6 +559,9 @@ type NotificationRepository interface {
type TeamRepository interface {
// List returns all teams.
List(ctx context.Context) ([]*domain.Team, error)
// ListPaginated returns a window of teams (sorted by created_at DESC)
// plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16).
ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Team, int64, error)
// Get retrieves a team by ID.
Get(ctx context.Context, id string) (*domain.Team, error)
// Create stores a new team.
@@ -578,6 +590,9 @@ type CertificateProfileRepository interface {
type AgentGroupRepository interface {
// List returns all agent groups.
List(ctx context.Context) ([]*domain.AgentGroup, error)
// ListPaginated returns a window of agent groups (sorted by name)
// plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16).
ListPaginated(ctx context.Context, limit, offset int) ([]*domain.AgentGroup, int64, error)
// Get retrieves an agent group by ID.
Get(ctx context.Context, id string) (*domain.AgentGroup, error)
// Create stores a new agent group.
@@ -44,6 +44,40 @@ func (r *AgentGroupRepository) List(ctx context.Context) ([]*domain.AgentGroup,
return groups, rows.Err()
}
// ListPaginated returns a slice of agent groups bounded by limit/offset
// plus the total count. SCALE-002 closure (Sprint 2, 2026-05-16).
func (r *AgentGroupRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.AgentGroup, int64, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agent_groups`).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count agent groups: %w", err)
}
rows, err := r.db.QueryContext(ctx,
`SELECT id, name, description, match_os, match_architecture, match_ip_cidr, match_version, enabled, created_at, updated_at
FROM agent_groups ORDER BY name LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to query agent groups: %w", err)
}
defer rows.Close()
var groups []*domain.AgentGroup
for rows.Next() {
g, err := scanAgentGroup(rows)
if err != nil {
return nil, 0, err
}
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return groups, total, nil
}
// Get retrieves an agent group by ID.
func (r *AgentGroupRepository) Get(ctx context.Context, id string) (*domain.AgentGroup, error) {
row := r.db.QueryRowContext(ctx,
+42
View File
@@ -57,6 +57,48 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
return issuers, nil
}
// ListPaginated returns a slice of issuers bounded by limit/offset plus the
// total count. SCALE-002 closure (Sprint 2, 2026-05-16).
func (r *IssuerRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issuers`).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count issuers: %w", err)
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, type, config, COALESCE(encrypted_config, NULL), enabled,
last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'),
created_at, updated_at
FROM issuers
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to query issuers: %w", err)
}
defer rows.Close()
var issuers []*domain.Issuer
for rows.Next() {
var iss domain.Issuer
if err := rows.Scan(&iss.ID, &iss.Name, &iss.Type, &iss.Config,
&iss.EncryptedConfig, &iss.Enabled,
&iss.LastTestedAt, &iss.TestStatus, &iss.Source,
&iss.CreatedAt, &iss.UpdatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan issuer: %w", err)
}
issuers = append(issuers, &iss)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("error iterating issuer rows: %w", err)
}
return issuers, total, nil
}
// Get retrieves an issuer by ID
func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
var issuer domain.Issuer
+42
View File
@@ -82,6 +82,48 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
return targets, nil
}
// ListPaginated returns a slice of deployment targets bounded by limit/offset
// plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16) — pushes
// pagination into SQL so the admin UI doesn't marshal the entire targets
// table per request. limit≤0 is normalised to 50; offset<0 to 0.
func (r *TargetRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deployment_targets`).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count targets: %w", err)
}
rows, err := r.db.QueryContext(ctx, `
SELECT `+targetSelectColumns+`
FROM deployment_targets
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to query targets: %w", err)
}
defer rows.Close()
var targets []*domain.DeploymentTarget
for rows.Next() {
var t domain.DeploymentTarget
if err := scanTarget(rows, &t); err != nil {
return nil, 0, fmt.Errorf("failed to scan target: %w", err)
}
targets = append(targets, &t)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("error iterating target rows: %w", err)
}
return targets, total, nil
}
// Get retrieves a target by ID
func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
var target domain.DeploymentTarget
+38
View File
@@ -53,6 +53,44 @@ func (r *TeamRepository) List(ctx context.Context) ([]*domain.Team, error) {
return teams, nil
}
// ListPaginated returns a slice of teams bounded by limit/offset plus the
// total count. SCALE-002 closure (Sprint 2, 2026-05-16).
func (r *TeamRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Team, int64, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM teams`).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count teams: %w", err)
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, description, created_at, updated_at
FROM teams
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to query teams: %w", err)
}
defer rows.Close()
var teams []*domain.Team
for rows.Next() {
var team domain.Team
if err := rows.Scan(&team.ID, &team.Name, &team.Description,
&team.CreatedAt, &team.UpdatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan team: %w", err)
}
teams = append(teams, &team)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("error iterating team rows: %w", err)
}
return teams, total, nil
}
// Get retrieves a team by ID
func (r *TeamRepository) Get(ctx context.Context, id string) (*domain.Team, error) {
var team domain.Team