This file provides guidance for agentic coding agents operating in the pgcqrs repository.
PGCQRS is a Go project that provides a JSON event store with multi-tenancy and observability support, backed by PostgreSQL. The project requires Go 1.26+.
# Run all unit tests (pkg and internal)
go test -count=1 ./pkg/... ./internal/...
# Run a single test by name
go test -run TestName ./path/to/package
# Run tests with verbose output
go test -v ./pkg/...
# Run linter (golangci-lint must be installed)
golangci-lint run ./...
# Run system/integration tests (requires Docker and running service)
./integration-tests.shDiagnostics from integration runs land in
run/test-diagnostics/<run-id>/; seedocs/operations.mdfor the file inventory and CI artifact behavior.
Note: To verify fixes work systemically (as CI does), run ./dev.sh up. This runs the full test suite including unit tests, integration tests with a real PostgreSQL database, and system tests across all transports (memory, HTTP, gRPC).
# Build all binaries (runs tests + builds migrator, service, pgcqrs CLI)
./build.sh
# Build individual binaries
go build ./cmd/migrator
go build ./cmd/service
go build ./cmd/pgcqrsThe dev.sh script provides fine-grained control over the development workflow:
# Full pipeline: build, test, deploy, run examples + integration tests
./dev.sh up
# Rebuild and restart containers only (after code changes)
./dev.sh services
# Run example drift detection only
./dev.sh examples
# Run transport verification tests only (memory, HTTP, gRPC)
./dev.sh integration
# Run both examples + integration tests
./dev.sh system_tests
# Run storage integration tests (testcontainers with PostgreSQL)
./dev.sh storage-integTypical workflow: Make code changes → ./dev.sh services → ./dev.sh examples or ./dev.sh integration as needed.
Alternatively, use docker-up.sh for quicker setup on ports 26000 (HTTP) and 26001 (gRPC).
Service health endpoints (
/ops/liveness,/ops/readiness) and integration-test diagnostics (run/test-diagnostics/) are documented indocs/operations.md.
This project provides comprehensive local testing capabilities that mirror CI/CD pipelines. All tests can be run locally without external dependencies beyond Docker.
1. Unit Tests (no external dependencies)
go test -count=1 ./pkg/... ./internal/...- Tests pure logic, data structures, and algorithms
- Uses in-memory transports and mocks
- Fast execution (< 5 seconds)
2. Storage Integration Tests (testcontainers)
./dev.sh storage-integ
# or directly:
go test -tags testcontainers_pg -timeout 30s -count 1 -v ./internal/service/storage/...- Tests PostgreSQL-specific storage logic
- Uses testcontainers-go to spin up ephemeral PostgreSQL instances
- Tests consumer locks, positions, queries, and migrations
- Requires Docker but no manual setup
3. System Tests (all transports)
./dev.sh integration
# or directly:
./integration-tests.sh- Tests the full system across all three transports:
- Memory transport: In-process, no network
- HTTP transport: REST API over HTTP
- gRPC transport: gRPC with bidirectional streaming
- Requires running service (via
./dev.sh servicesordocker-up.sh) - Validates transport-agnostic behavior
4. Example Drift Detection
./dev.sh examples
# or directly:
./run-examples.sh- Runs all example applications against all transports
- Ensures examples stay in sync with API changes
- Catches breaking changes early
5. Full Pipeline
./dev.sh up- Runs all tests in sequence:
- Unit tests
- Storage integration tests (testcontainers)
- Integration tests (requires running service)
- Example drift detection
- Mirrors CI/CD pipeline exactly
- Use this before committing to ensure nothing breaks
Some tests require specific build tags:
testcontainers_pg: Tests that need PostgreSQL via testcontainers- Tests are automatically skipped if tag is not provided
Example:
# Run testcontainers tests
go test -tags testcontainers_pg ./internal/service/storage/...
# Run without testcontainers (tests will be skipped)
go test ./internal/service/storage/...System tests use environment variables to configure transport:
PGCQRS_TEST_TRANSPORT:memory,http, orgrpcPGCQRS_TEST_URL: Service URL (e.g.,http://localhost:9000orlocalhost:9001)PGCQRS_TEST_APP_BASE: App name prefix for test isolation
The integration-tests.sh script automatically sets these for each transport.
The local development environment includes:
- PostgreSQL 18: Primary database (port 26113)
- pgcqrs service: HTTP (port 26000) + gRPC (port 26001)
- OpenTelemetry Collector: For tracing (port 16001)
Services are defined in docker-compose.yaml and can be managed via:
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose downgRPC-specific features (consumer locks, bidirectional streaming, WaitForLock) can be tested locally:
-
Unit tests: Test gRPC handlers directly with testcontainers
go test -tags testcontainers_pg -v ./internal/service/... -
System tests: Test gRPC client against running service
export PGCQRS_TEST_TRANSPORT=grpc export PGCQRS_TEST_URL=localhost:26001 go test -v ./systest/...
-
Examples: Run gRPC examples
./dev.sh examples # Runs all examples including gRPC
Testing with Memory Transport (fastest, no setup):
go test -v ./pkg/v1/...Testing with Real Database (testcontainers):
go test -tags testcontainers_pg -v ./internal/service/storage/...Testing Full System (requires running service):
./dev.sh up # Full pipeline
# or
./dev.sh integration # Just system testsTesting Specific Transport:
# Memory
PGCQRS_TEST_TRANSPORT=memory go test ./systest/...
# HTTP
PGCQRS_TEST_TRANSPORT=http PGCQRS_TEST_URL=http://localhost:26000 go test ./systest/...
# gRPC
PGCQRS_TEST_TRANSPORT=grpc PGCQRS_TEST_URL=localhost:26001 go test ./systest/...Tests fail with "connection refused":
- Ensure services are running:
docker-compose ps - Start services:
./dev.sh servicesordocker-compose up -d
Testcontainers tests fail:
- Ensure Docker is running
- Check Docker socket permissions
- Increase Docker memory limit if needed
Port conflicts:
- Check if ports 26000, 26001, 26113 are in use
- Stop conflicting services or change ports in
docker-compose.yaml
gRPC tests skip unexpectedly:
- Ensure
PGCQRS_TEST_TRANSPORT=grpcis set - Ensure gRPC service is running on port 26001
This project has quality gates that mirror the pre-commit pipeline. Run these for quick changes before committing, then finalize with pre-commit run --all-files:
Quick local checks (run before commit):
# Run golangci-lint funlen check only (fast)
golangci-lint run --enable funlen ./...
# Or run the full linter (matches pre-commit)
golangci-lint run ./...Finalize with pre-commit (run before pushing):
pre-commit run --all-filesThis runs all hooks including golangci-lint (full repo scan), golangci-lint-config-verify, and other pre-commit checks.
Both are automatically run via ./dev.sh up when the system tests stage executes.
# Run migrations (requires CFG_PRIMARY env var pointing to config file)
./migrator primary- Documentation: Limit lines to 120 characters for readability (matching book formatting)
- Testing: Always test code changes before presenting them
- Git: Do NOT use the
gitcommand (per GEMINI.md) - Protocol Design: Use relative times (e.g.,
ttl_seconds,timeout_ms) instead of absolute timestamps in gRPC messages to avoid clock skew issues between client and server. Absolute timestamps are acceptable for logging and metrics.
- Use
gofmtandgoimportsfor code formatting - Run
gofmt -w -s .orgoimports -w .before committing - No line length limit enforced by gofmt, but keep lines reasonable
- Generated files: Do NOT manually edit or run formatters/linters on protobuf-generated files (
*.pb.go,*_grpc.pb.go). These are auto-generated from.protofiles and excluded via.golangci.yml. To regenerate, use./gen-grpc.sh(preferred) orprotocwith the appropriate plugins.
This project uses golangci-lint for code quality. Run with:
golangci-lint run ./...The linter is configured in .golangci.yml. Key rules:
- Complexity: Keep cyclomatic complexity under 8
- Duplication: No more than 80 lines of duplicate code (dupl)
- Print statements: Avoid
fmt.Print*in non-CLI code - use structured logging instead - Parallel tests: All tests must call
t.Parallel() - Testify: Use testify correctly (testifylint)
Note: The cmd/ directory is excluded from most linters (CLI tools have different standards).
Standard Go import grouping:
import (
"context"
"fmt"
"github.com/example/package"
"github.com/meschbach/pgcqrs/pkg/v1"
"go.opentelemetry.io/otel/trace"
"golang.org/x/exp/slices"
)Order: stdlib, external dependencies, internal packages.
- Packages: Short, lowercase, e.g.,
v1,query2,memory - Types: PascalCase, e.g.,
Stream,QueryBuilder,TransportError - Functions/Methods: PascalCase, e.g.,
MustSubmit,Perform - Variables: CamelCase, e.g.,
ctx,err,harness - Constants: PascalCase or SCREAMING_SNAKE_CASE
- Interfaces: Often end with
-er, e.g.,Transport,QueryResults
- Complexity: Keep cyclomatic complexity under 8 per function (gocyclo)
- Duplication: Avoid more than 80 lines of duplicate code (dupl)
- Logging: Use structured logging instead of
fmt.Print*(forbidigo) - Exhaustiveness: Use exhaustive enums where applicable
Two patterns are used in this codebase:
-
Panic-style (Must functions): For fatal errors that should crash
func (s *Stream) MustSubmit(ctx context.Context, kind string, event interface{}) *Submitted { out, err := s.Submit(ctx, kind, event) junk.Must(err) // panics on error return out }
-
Regular error returns: For recoverable errors
func (s *Stream) Submit(ctx context.Context, kind string, event interface{}) (*Submitted, error) { return s.system.Transport.Submit(ctx, s.domain, s.stream, kind, event) }
Use junk.Must(err) from internal/junk for panic-style error handling. Use standard error returns for public APIs.
Implement the error interface with wrapped errors:
type TransportError struct {
Underlying error
}
func (t *TransportError) Error() string {
return fmt.Sprintf("transport error: %s", t.Underlying.Error())
}
func (t *TransportError) Unwrap() error {
return t.Underlying
}When using defer to close resources (like resp.Body.Close()), don't ignore the error. Use errors.Join:
// Pattern 1: Using defer with closure
resp, err := c.wire.Do(req)
if err != nil {
return err
}
defer func() { err = errors.Join(err, resp.Body.Close()) }()
// Pattern 2: Direct close with multiple return paths
resp, err := c.wire.Do(req)
if err != nil {
return err
}
closeErr := resp.Body.Close()
if resp.StatusCode != 200 {
return errors.Join(&BadResponseCode{URL: url, Code: resp.StatusCode}, closeErr)
}
return result, closeErrUse errors.Join to combine the close error with any operation errors.
This project uses:
- testify:
github.com/stretchr/testify/assertandrequire - faker:
github.com/go-faker/faker/v4for generating test data - paralleltest: All tests must call
t.Parallel()(enforced by linter) - testifylint: Use testify correctly (see linter rules)
package v1
import (
"context"
"testing"
"time"
"github.com/go-faker/faker/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExample(t *testing.T) {
// Use MemoryHarness for unit tests
MemoryHarness(t, func(ctx context.Context, h Harness) {
// test code here
require.NoError(t, err)
assert.Equal(t, expected, actual)
})
}Use the MemoryHarness helper for unit tests (found in pkg/v1/query_test.go):
func MemoryHarness(t *testing.T, perform func(ctx context.Context, h Harness)) {
t.Parallel()
ctx, done := context.WithTimeout(context.Background(), 2*time.Second)
defer done()
harness := Harness{
appName: faker.Name(),
streamName: faker.Name(),
}
mem := NewMemoryTransport()
harness.system = NewSystem(mem)
harness.stream = harness.system.MustStream(ctx, harness.appName, harness.streamName)
perform(ctx, harness)
}For tests requiring a running service:
export PGCQRS_TEST_TRANSPORT="memory" # or "grpc"
export PGCQRS_TEST_URL="http://localhost:9000"
export PGCQRS_TEST_APP_BASE="systest-"
go test -count=1 --timeout 5s ./systest/...- Always accept
context.Contextas the first parameter - Use
context.WithTimeoutfor operations with deadlines - Pass context to all transport and query operations
The project uses OpenTelemetry for tracing. Use the package-level tracer:
import "github.com/meschbach/pgcqrs/pkg/v1"
func ExampleFunction(ctx context.Context) {
ctx, span := tracer.Start(ctx, "pgcqrs.ExampleFunction")
defer span.End()
// ... operation
}pkg/- Public API packages (v1, query2, service)internal/- Internal implementation packagescmd/- CLI entrypoints (migrator, service, pgcqrs)systest/- System/integration testsexamples/- Example applicationsmigrations/- Database migrationsdeploy/- Deployment configurations
When deprecating functions, add a doc comment:
// Deprecated: This method is superseded by the `pgcqrs/pkg/v1/query2` package...
func (s *Stream) Query() *QueryBuilder { ... }Configuration is typically JSON-based. See deploy/integration-tests/primary.json for examples.
CFG_PRIMARY- Path to primary configuration filePGCQRS_SERVICE_TRANSPORT- Transport type for examples (memory, http, grpc)PGCQRS_SERVICE_URL- URL for examples (e.g., http://localhost:9000 or localhost:9001)PGCQRS_TEST_TRANSPORT- Transport type for tests (memory, http, grpc)PGCQRS_TEST_URL- URL for integration testsPGCQRS_TEST_APP_BASE- App base name for tests
These instructions are for AI assistants handling code review in this project.
Always open .ocr/skills/SKILL.md when the request:
- Asks for code review, PR review, or feedback on changes
- Mentions "review my code" or similar phrases
- Wants multi-perspective analysis of code quality
- Asks to map, organize, or navigate a large changeset
Use .ocr/skills/SKILL.md to learn:
- How to run the 8-phase review workflow
- How to generate a Code Review Map for large changesets
- Available reviewer personas and their focus areas
- Session management and output format
Keep this managed block so ocr init can refresh the instructions.