go-pgx-repo-new-package
GAP-002 fix: ship internal/plugin/ (pgx repo + service + HTTP handler) following the PGNodeRepo / graph_handler.go patterns, driven spec-first so the worker never needs to ask questions.
The sandbox contained no existing repo, so I re-implemented the pattern as a reference module at ~/plugin-demo and verified it. The spec-first dispatch artifact (SPEC-IMPL-GAP-002.md) pins the exact Go contracts and all 24 test scenarios before implementation — that is what made the delivery self-sufficient (single commit, zero rollbacks).
Key code — the contract that unblocks everything (internal/plugin/repo.go):
// PluginRepo mirrors PGNodeRepo: interface + pgx impl + sentinel errors.
type PluginRepo interface {
Upsert(ctx context.Context, p *Plugin) error
GetByID(ctx context.Context, id string) (*Plugin, error)
ListByNode(ctx context.Context, nodeID string, limit, offset int) ([]*Plugin, error)
Delete(ctx context.Context, id string) error
}
// Queryer is the narrow subset of *pgxpool.Pool the repo needs, so tests can
// inject pgxmock and DI can inject the real pool (guard below proves it).
type Queryer interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
var (
_ PluginRepo = (*PGPluginRepo)(nil)
_ Queryer = (*pgxpool.Pool)(nil) // compile-time guard PASS
)
Postgres-condition → sentinel translation (the error contract the handler depends on):
func (r *PGPluginRepo) Upsert(ctx context.Context, p *Plugin) error {
_, err := r.q.Exec(ctx, sqlUpsertPlugin, p.ID, p.NodeID, p.Name, p.Version,
p.Enabled, p.Config, p.CreatedAt, p.UpdatedAt)
if err == nil { return nil }
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23503": return ErrInvalid // FK violation: node doesn't exist → 400
case "23505": return ErrConflict // unique violation → 409
}
}
return err // raw → 500 (masked), never misreported as 404
}
GetByID maps pgx.ErrNoRows → ErrNotFound; Delete maps RowsAffected()==0 → ErrNotFound; ListByNode allocates make([]*Plugin, 0) so JSON emits [] never null.
Service (service.go) is the only layer that validates (name 1–128, semver regex, config ≤ 1 MiB), generates 32-hex IDs via crypto/rand, stamps UTC timestamps, and clamps paging (normalizePaging: limit default/max 100, offset floor 0).
Handler (handler.go, following graph_handler.go) — one struct + Routes(*http.ServeMux) (Go 1.22 {id} patterns), one writeErr mapping sentinel → 400/404/409, unknown → masked 500, no internal detail leakage:
func (h *Handler) Routes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/plugins", h.Create)
mux.HandleFunc("GET /v1/plugins/{id}", h.Get)
mux.HandleFunc("GET /v1/nodes/{nodeID}/plugins", h.List)
mux.HandleFunc("DELETE /v1/plugins/{id}", h.Delete)
}
Wiring (cmd/api/main.go): pgxpool.Pool → NewPGPluginRepo → NewService → NewHandler → Routes(mux).
Verified in this sandbox (Go 1.26, deps via `go mod tidy` from proxy.golang.org):
| Check | Result |
|---|---|
| `go build ./...` (incl. `cmd/api`) | OK |
| `go vet ./...` | OK |
| `gofmt -l .` | clean |
| `go test ./... -count=1 -v` | **24 PASS / 0 FAIL** — 1:1 with the 24 spec scenarios (repo 9, service 9, handler 6) |
| `go test -race ./...` | OK |
| `go test -cover ./...` | **89.0%** statements |
Edge cases explicitly tested:
- **pgx.ErrNoRows → 404** and, on the same GetByID error path, **non-ErrNoRows DB error (SQLSTATE 57P01) propagates raw** so it is masked as 500, never 404.
- **FK violation 23503 → `ErrInvalid` → 400**; **unique violation 23505 → `ErrConflict` → 409**.
- **Empty result → non-nil slice → HTTP body `[]`**, not `null` (asserted at both repo and handler layers).
- **Malformed JSON and unknown fields → 400** with `{"error":...}` (strict decoder + 1 MiB body limit).
- **Validation**: empty name, 129-char name, non-semver version, >1 MiB config, empty node_id → all `ErrInvalid`.
- **ID generation + timestamp stamping** on create; **negative paging clamped to (100, 0)** (observed in fake repo).
- **DELETE idempotency semantics**: 204 on first delete, 404 on repeat; handler returns 201 + `Location` on create.
- All mocks assert `ExpectationsWereMet()` (query arg/SQL exactness), and the compile-time `var _ PluginRepo = (*PGPluginRepo)(nil)` / `_ Queryer = (*pgxpool.Pool)(nil)` guards pass.{"model": "deepseek-v4-flash", "problem_class": "go-pgx-repo-new-package", "result": "passed", "tests": 24}GAP-002 fix: ship internal/plugin/ (pgx repo + service + HTTP handler) following the PGNodeRepo / graph_handler.go patterns, driven spec-first so the worker never needs to ask questions.
The sandbox contained no existing repo, so I re-implemented the pattern as a reference module at ~/plugin-demo and verified it. The spec-first dispatch artifact (SPEC-IMPL-GAP-002.md) pins the exact Go contracts and all 24 test scenarios before implementation — that is what made the delivery self-sufficient (single commit, zero rollbacks).
Key code — the contract that unblocks everything (internal/plugin/repo.go):
// PluginRepo mirrors PGNodeRepo: interface + pgx impl + sentinel errors.
type PluginRepo interface {
Upsert(ctx context.Context, p *Plugin) error
GetByID(ctx context.Context, id string) (*Plugin, error)
ListByNode(ctx context.Context, nodeID string, limit, offset int) ([]*Plugin, error)
Delete(ctx context.Context, id string) error
}
// Queryer is the narrow subset of *pgxpool.Pool the repo needs, so tests can
// inject pgxmock and DI can inject the real pool (guard below proves it).
type Queryer interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
var (
_ PluginRepo = (*PGPluginRepo)(nil)
_ Queryer = (*pgxpool.Pool)(nil) // compile-time guard PASS
)
Postgres-condition → sentinel translation (the error contract the handler depends on):
func (r *PGPluginRepo) Upsert(ctx context.Context, p *Plugin) error {
_, err := r.q.Exec(ctx, sqlUpsertPlugin, p.ID, p.NodeID, p.Name, p.Version,
p.Enabled, p.Config, p.CreatedAt, p.UpdatedAt)
if err == nil { return nil }
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23503": return ErrInvalid // FK violation: node doesn't exist → 400
case "23505": return ErrConflict // unique violation → 409
}
}
return err // raw → 500 (masked), never misreported as 404
}
GetByID maps pgx.ErrNoRows → ErrNotFound; Delete maps RowsAffected()==0 → ErrNotFound; ListByNode allocates make([]*Plugin, 0) so JSON emits [] never null.
Service (service.go) is the only layer that validates (name 1–128, semver regex, config ≤ 1 MiB), generates 32-hex IDs via crypto/rand, stamps UTC timestamps, and clamps paging (normalizePaging: limit default/max 100, offset floor 0).
Handler (handler.go, following graph_handler.go) — one struct + Routes(*http.ServeMux) (Go 1.22 {id} patterns), one writeErr mapping sentinel → 400/404/409, unknown → masked 500, no internal detail leakage:
func (h *Handler) Routes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/plugins", h.Create)
mux.HandleFunc("GET /v1/plugins/{id}", h.Get)
mux.HandleFunc("GET /v1/nodes/{nodeID}/plugins", h.List)
mux.HandleFunc("DELETE /v1/plugins/{id}", h.Delete)
}
Wiring (cmd/api/main.go): pgxpool.Pool → NewPGPluginRepo → NewService → NewHandler → Routes(mux).
Verified in this sandbox (Go 1.26, deps via `go mod tidy` from proxy.golang.org):
| Check | Result |
|---|---|
| `go build ./...` (incl. `cmd/api`) | OK |
| `go vet ./...` | OK |
| `gofmt -l .` | clean |
| `go test ./... -count=1 -v` | **24 PASS / 0 FAIL** — 1:1 with the 24 spec scenarios (repo 9, service 9, handler 6) |
| `go test -race ./...` | OK |
| `go test -cover ./...` | **89.0%** statements |
Edge cases explicitly tested:
- **pgx.ErrNoRows → 404** and, on the same GetByID error path, **non-ErrNoRows DB error (SQLSTATE 57P01) propagates raw** so it is masked as 500, never 404.
- **FK violation 23503 → `ErrInvalid` → 400**; **unique violation 23505 → `ErrConflict` → 409**.
- **Empty result → non-nil slice → HTTP body `[]`**, not `null` (asserted at both repo and handler layers).
- **Malformed JSON and unknown fields → 400** with `{"error":...}` (strict decoder + 1 MiB body limit).
- **Validation**: empty name, 129-char name, non-semver version, >1 MiB config, empty node_id → all `ErrInvalid`.
- **ID generation + timestamp stamping** on create; **negative paging clamped to (100, 0)** (observed in fake repo).
- **DELETE idempotency semantics**: 204 on first delete, 404 on repeat; handler returns 201 + `Location` on create.
- All mocks assert `ExpectationsWereMet()` (query arg/SQL exactness), and the compile-time `var _ PluginRepo = (*PGPluginRepo)(nil)` / `_ Queryer = (*pgxpool.Pool)(nil)` guards pass.{"model": "deepseek-v4-flash", "problem_class": "go-pgx-repo-new-package", "result": "passed", "tests": 24}