13 KiB
AGENTS.md
Guidance for AI agents (and humans) working in this repository. Read this before making changes.
Project overview
kurious (module git.loyso.art/frx/kurious) is a Go service that
aggregates, stores, and serves educational/learning course data sourced from
sravni.ru. It exposes a server-rendered web UI
for browsing courses and runs a background job that periodically syncs data
from the upstream sravni.ru API.
Go version: 1.26 (toolchain go1.26.4). Builds are CGO-free
(CGO_ENABLED=0), using the pure-Go modernc.org/sqlite driver.
Entry points (cmd/)
| Binary | Path | Purpose |
|---|---|---|
kuriousweb |
cmd/kuriweb |
HTTP web server. Serves the course browser UI + REST endpoints. |
kuriousbg |
cmd/background |
Background worker. Cron-scheduled sync of courses/orgs from sravni.ru. |
sravnicli |
cmd/dev/sravnicli |
Developer/debug CLI for inspecting the sravni.ru API and redux state. |
Each binary reads a JSON config from argv[1] (default config.json for
servers, config_cli.json for the CLI). Note: *.json is gitignored.
Build metadata (version, commit, buildTime) is injected via -ldflags
into the root package kurious.go (see Version(), Commit(), BuildTime()).
Architecture
The codebase follows Hexagonal Architecture (Ports & Adapters) with CQRS (command/query separation). The domain core has no knowledge of databases, HTTP, or external services; everything is wired together in the composition root.
cmd/ # application entrypoints (thin mains)
internal/kurious/
domain/ # CORE: entities, repository interfaces, value/param types
app/ # application layer
command/ # write-side handlers (CQRS commands)
query/ # read-side handlers (CQRS queries)
app.go # Application struct aggregating Commands + Queries
ports/ # DRIVING adapters (what drives the domain)
http/ # HTTP server (gorilla/mux + templ HTML)
background/ # cron job handlers
background.go # cron scheduler wrapper
services.go # Services aggregate
adapters/ # DRIVEN adapters (what the domain drives)
sqlite_* # SQLite repositories (course, organization, learning category)
memory_mapper.go # in-memory CourseMapper implementation
ydb_course_repository.go # legacy YDB adapter (deprecated, unsupported)
mocks/ # generated mockery mocks
service/ # COMPOSITION ROOT: wires adapters into app.Application
internal/common/ # cross-cutting utilities (see below)
pkg/xdefault/ # reusable helper (WithFallback)
migrations/sqlite/ # numbered *.sql migrations + embed migrator
assets/kurious/ # embedded static assets (go:embed)
Layer responsibilities
domain/— Pure business types:Course,Organization,LearningCategory,Category. Defines repository interfaces (CourseRepository,OrganizationRepository,LearningCategoryRepository) and theCourseMapperinterface. Each repository interface carries a//go:generate mockery ...directive. Params/results are plain structs.app/— Use-case handlers.app.godefinesApplication{ Commands, Queries }. Commands (app/command/) mutate state; queries (app/query/) read and project. Each handler is a private struct exposed via aNewXxxHandlerconstructor that returns a decorateddecorator.CommandHandler[T]/decorator.QueryHandler[Q, U].adapters/— Implementations ofdomaininterfaces. SQLite is the active engine;sqlite_connection.goopens the DB and runs migrations.memory_mapper.goholds dictionary name/id maps and aggregate counts.ports/— Inbound transports.ports/http/server.gorenders with templ templates and normalizes errors to HTTP status codes.ports/background.gowrapsrobfig/cron/v3.service/—NewApplicationis the composition root: picks the DB engine, builds repositories, and constructs all commands/queries.
internal/common/ utility packages
| Package | Role |
|---|---|
config |
JSON config structs (Log, HTTP, Sqlite, YDB, Trace) + NewSLogger factory |
errors |
Sentinel errors (ErrNotFound, ErrNotImplemented, ErrUnexpectedStatus as SimpleError) and ValidationError |
decorator |
CQRS decorators adding slog logging + OpenTelemetry tracing around every handler |
xcontext |
Context-aware structured logging (LogInfo/Debug/Error/...) carrying request id + log fields |
xlog |
Log level/format enums + slog adapters (e.g. cron logger bridge) |
nullable |
Generic nullable.Value[T] for optional fields |
xslices |
Generic slice helpers (Map, ...) |
generator |
ID generation (e.g. RandomInt64ID for request ids) |
client/sravni |
HTTP client for sravni.ru (resty), parses __NEXT_DATA__ redux state, rate-limited |
xdefault (pkg) |
WithFallback(value, default) helper |
Build & test commands
The project uses Task (Taskfile.yml). Install tools
first with task install_tools (installs golangci-lint, templ, mockery
into ./bin).
| Task | What it does |
|---|---|
task install_tools |
Install dev tools into ./bin |
task generate |
Run templ generate for *.templ sources (run before build/test) |
task mocks |
go generate ./internal/... (regenerate mockery mocks) |
task check |
golangci-lint run ./... (depends on generate) |
task test |
go test ./internal/... (depends on generate) |
task build_web |
Build kuriousweb (depends on check, test) |
task build_background |
Build kuriousbg (depends on check, test) |
task build_dev_cli |
Build sravnicli (depends on check, test) |
task build |
Build all three binaries |
task run |
Build then run kuriousweb |
Plain Go equivalents (use when Task is unavailable):
go generate ./internal/... # regenerate mocks
templ generate # render .templ -> .go
golangci-lint run ./... # lint
go test ./... # run all tests
go build -o bin/kuriousweb ./cmd/kuriweb # build a binary
Note:
templ generatemust run beforego build/go testwhenever a*.templfile changes — the generated*_templ.gofiles are committed-free and required for compilation.
Code conventions
Error handling
- Wrap errors with context using
fmt.Errorf("<action>: %w", err). Preserve the chain so callers canerrors.Is/errors.As. - Use the sentinels in
internal/common/errors/error.go:errors.ErrNotFound/errors.ErrNotImplemented/errors.ErrUnexpectedStatus(these areSimpleError, a string-backed error type).errors.NewValidationError(field, reason)for invalid input.
- The HTTP layer (
ports/http/server.go::handleError) maps these to status codes:ValidationError→ 400,ErrNotFound→ 404, anything else → 500 (with a generic message, never leaking internals). - Repository
Getmethods contractually returnErrNotFoundwhen a row is missing — honor this in new adapters.
Logging
- Use
log/slog(structured), neverfmt.Printlnin library code. - Prefer the
internal/common/xcontexthelpers (LogInfo,LogDebug,LogError,LogWithError,LogWithWarnError) over rawlog.InfoContext, because they automatically attach context-scoped fields (e.g.request_id,handler). - Create loggers via
config.NewSLogger(cfg.Log)and tag components withlog.With(slog.String("component", "...")). - In tests, use a discard logger:
slog.New(slog.NewTextHandler(io.Discard, nil)).
CQRS & handlers
- Every command/query handler is wrapped at construction time by
decorator.ApplyCommandDecorators/decorator.AddQueryDecorators. The decorator adds a tracing span and start/finish log lines — do not add your own top-level logging insideHandle; rely on the decorator. - Handler signature pattern:
- command:
Handle(ctx context.Context, cmd T) error - query:
Handle(ctx context.Context, q Q) (U, error)
- command:
- Keep handlers thin: translate the command/query into domain params, call the
repository, and map results. Put business types in
domain/.
Naming & structure
- Constructors are
NewXxx. Unexported handler struct, exported aliased type (decorator.CommandHandler[T]/decorator.QueryHandler[Q, U]). - Nullable fields use
nullable.Value[T]rather than pointers. - Exported interfaces that external code might mock get a
//go:generate mockery --name <Interface> --output ../adapters/mocksline directly above the interface declaration.
Tracing & metrics
- OpenTelemetry is wired throughout (
cmd/*/trace.gosets up the SDK). - DB adapters use the
adapters.dbtracer; CQRS decorators use theapitracer; the HTTP layer uses thewebtracer. Record errors on spans withspan.RecordError(err)and setcodes.Errorstatus.
Testing
- Framework:
github.com/stretchr/testify(suite,assert,require,mock). Mocks are generated by mockery intointernal/kurious/adapters/mocksandinternal/common/client/sravni/mocks. - SQLite adapter tests (
internal/kurious/adapters/*_test.go) extendsqliteBaseSuite, which spins up an in-memory (:memory:) SQLite DB and applies the real migrations.TearDownTestclears tables between cases. - Command/query tests use mockery mocks and a discard logger.
- After changing interfaces, regenerate mocks:
go generate ./internal/...(ortask mocks).
Key dependencies
| Dependency | Role |
|---|---|
github.com/gorilla/mux |
HTTP routing & middleware |
github.com/a-h/templ |
Type-safe HTML templates (*.templ) |
github.com/jmoiron/sqlx |
Ergonomic SQL layer over database/sql |
modernc.org/sqlite |
Pure-Go (CGO-free) SQLite driver |
github.com/robfig/cron/v3 |
Cron scheduler for the background sync |
github.com/go-resty/resty/v2 |
HTTP client for the sravni.ru API |
golang.org/x/net/html |
HTML parsing of sravni.ru __NEXT_DATA__ redux state |
golang.org/x/time/rate |
Rate limiting the sravni.ru client |
golang.org/x/sync |
errgroup for goroutine lifecycle |
github.com/teris-io/cli |
CLI framework for sravnicli |
go.opentelemetry.io/otel (+ exporters) |
Tracing & metrics (OTLP gRPC/HTTP, stdout) |
github.com/stretchr/testify |
Test assertions, suites, and mocks |
github.com/ydb-platform/ydb-go-sdk/v3 |
YDB SDK — deprecated, NewApplication returns an error for the ydb engine |
Gotchas
*.jsonis gitignored (configs contain local settings). Do not commit config files; document config shape incmd/<binary>/config.goinstead.- The
backgroundbinary currently panics on start (savingOrganizationIDInternalInsteadOfExternalguard) until the organization-id saving semantics are fixed. - Templ-generated files are not committed; always run
templ generate(ortask generate) after pulling or before building/testing. - The YDB engine path is dead code kept for reference — do not extend it; add new storage work to the SQLite adapters.