feat(core): initial implementation — launcher, logz, xerrors, valid

Introduces `code.nochebuena.dev/einherjar/core` — the foundational implementation
module of the Einherjar framework. Provides four sub-packages that together cover
every service's baseline needs: lifecycle management, structured logging, typed
errors, and struct validation.

- launcher: Launcher interface — three-phase managed lifecycle (OnInit → BeforeStart
  hooks → OnStart → OS signal wait → OnStop in reverse). Accepts
  lifecycle.Component and logging.Logger from contracts. Prints an ASCII art banner
  at startup (EINHERJAR_BANNER=off to suppress). Banner includes core version via
  runtime/debug.ReadBuildInfo() and a loaded-module list for every registered
  component that implements observability.Identifiable. Config struct with
  EINHERJAR_COMPONENT_STOP_TIMEOUT env tag (caarlos0/env syntax, default 15s).

- logz: Logger implementation backed by log/slog. Returns contracts/logging.Logger.
  Detects errs.CodedError and errs.ContextualError (from contracts/errs) to enrich
  log records automatically — replaces the private duck-typed bridge from micro-lib.
  Context helpers: WithRequestID, WithField, WithFields, GetRequestID. Config struct
  with EINHERJAR_LOG_LEVEL (default INFO) and EINHERJAR_LOG_JSON (default false) env
  tags (caarlos0/env syntax); programmatic-only fields StaticArgs and Writer carry no
  tags.

- xerrors: Typed error codes with context enrichment. Complete gRPC canonical set
  (16 codes) plus HTTP 410 ErrGone. Adds ErrOutOfRange, ErrAborted, ErrDataLoss
  over micro-lib. One convenience constructor per code. *Err declares compile-time
  satisfaction of errs.CodedError and errs.ContextualError.

- valid: Struct validation wrapping go-playground/validator/v10. Validator interface
  + MessageProvider interface with full built-in tag coverage (~150 tags) in both
  DefaultMessages (English) and SpanishMessages (Spanish). Backend fully hidden;
  returns *xerrors.Err with ErrInvalidInput or ErrInternal. FieldLevel interface
  abstracts the backend's field-level access for custom validators.
  WithCustomValidator registers custom validation tags at construction time;
  OverrideProvider chains a tag→handler map with a fallback MessageProvider for
  custom tag messages without re-implementing built-ins.

Compliance test enforces CT-6 (at most one exported TypeSpec per file via AST) and
verifies behavioural correctness of all four sub-packages, including custom validator
registration and OverrideProvider composition. Compile-time var _ assertions prove
interface satisfaction.

docs: ADR-001 (core module composition), ADR-002 (logz contracts/errs adoption),
ADR-003 (Config naming convention and caarlos0/env tag standard)
This commit is contained in:
2026-05-29 15:45:12 +00:00
commit 38a415c2ab
33 changed files with 3868 additions and 0 deletions

245
README.md Normal file
View File

@@ -0,0 +1,245 @@
# einherjar/core
[![version](https://img.shields.io/badge/version-v1.0.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/core)
[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)
[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev)
> The chosen warriors do not choose their weapons. They forge them.
`code.nochebuena.dev/einherjar/core` is the foundational implementation module of the
Einherjar framework. It sits directly above `contracts` in the dependency graph and
provides the concrete tools every service needs before anything else can start:
a lifecycle runner, a structured logger, typed errors, and struct validation.
---
## What Is Einherjar?
In Norse mythology, the Einherjar are the chosen warriors of Valhalla — selected not
for glory, but to be ready for what comes after. They train. They prepare. They build
the capability that others will rely on.
This framework is named for that purpose. Every module is a piece of that preparation:
built carefully, documented for those who were never in the room, and designed to hold
under pressure.
---
## Sub-packages
| Package | Import path | Purpose |
|---|---|---|
| `launcher` | `.../core/launcher` | Application lifecycle — init, start, shutdown |
| `logz` | `.../core/logz` | Structured, leveled logging via `log/slog` |
| `xerrors` | `.../core/xerrors` | Typed error codes with context enrichment |
| `valid` | `.../core/valid` | Struct validation with pluggable i18n messages |
All four are in one module because they ship together in every Einherjar service.
See [ADR-001](docs/adr/ADR-001-core-module-composition.md).
---
## Usage
### Launcher
```go
import (
"code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz"
)
logger := logz.New(logz.Config{JSON: true, StaticArgs: []any{"service", "api"}})
lc := launcher.New(logger)
lc.Append(db, cache, server)
lc.BeforeStart(func() error {
return server.RegisterRoutes(db, cache)
})
if err := lc.Run(); err != nil {
logger.Error("launcher failed", err)
os.Exit(1)
}
```
Environment variables (uses `caarlos0/env` tag syntax — application supplies the loader):
| Variable | Default | Effect |
|---|---|---|
| `EINHERJAR_BANNER` | _(on)_ | Set to `off` or `false` to suppress the startup banner |
| `EINHERJAR_COMPONENT_STOP_TIMEOUT` | `15s` | Maximum time per component `OnStop` |
### Logger
```go
import "code.nochebuena.dev/einherjar/core/logz"
logger := logz.New(logz.Config{Level: slog.LevelDebug, JSON: true})
// Attach request context in middleware
ctx = logz.WithRequestID(ctx, requestID)
ctx = logz.WithField(ctx, "user_id", userID)
// Enrich logger from context in handlers
reqLogger := logger.WithContext(ctx)
reqLogger.Info("handling request", "path", r.URL.Path)
// Error enrichment is automatic — no extra code needed
reqLogger.Error("query failed", err) // appends error_code and context fields
```
Environment variables:
| Variable | Default | Effect |
|---|---|---|
| `EINHERJAR_LOG_LEVEL` | `INFO` | Minimum log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) |
| `EINHERJAR_LOG_JSON` | `false` | JSON output when `true` |
### Errors
```go
import "code.nochebuena.dev/einherjar/core/xerrors"
// Named constructors for common cases
err := xerrors.NotFound("user %s not found", userID)
err := xerrors.InvalidInput("email is required")
err := xerrors.Aborted("order modified by another session")
// Builder pattern for structured context
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
WithContext("field", "email").
WithContext("rule", "required").
WithError(cause)
// Inspecting errors
var e *xerrors.Err
if errors.As(err, &e) {
switch e.Code() {
case xerrors.ErrNotFound: // HTTP 404
case xerrors.ErrUnauthorized: // HTTP 401
case xerrors.ErrInvalidInput: // HTTP 400
}
}
```
### Validation
```go
import "code.nochebuena.dev/einherjar/core/valid"
type CreateUserReq struct {
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=18"`
}
v := valid.New() // English messages
// v := valid.New(valid.WithMessageProvider(valid.SpanishMessages))
if err := v.Struct(req); err != nil {
var xe *xerrors.Err
errors.As(err, &xe)
// xe.Code() == xerrors.ErrInvalidInput
// xe.Fields() == {"field": "email", "tag": "required"}
// xe.Message() == "field 'email' is required"
}
```
All go-playground/validator built-in tags have specific messages in both `DefaultMessages`
and `SpanishMessages`. The generic fallback only fires for unknown tags.
#### Custom validators
```go
import "strings"
v := valid.New(
valid.WithCustomValidator("nohttp", func(fl valid.FieldLevel) bool {
return !strings.HasPrefix(fl.Field().String(), "http://")
}),
valid.WithMessageProvider(valid.OverrideProvider(
map[string]func(field, param string) string{
"nohttp": func(field, _ string) string {
return fmt.Sprintf("field '%s' must not use http://", field)
},
},
valid.DefaultMessages,
)),
)
```
`OverrideProvider` chains a tag→message map with a fallback provider, so custom tag
messages are handled without re-implementing all built-ins.
---
## Error Codes
`xerrors` provides the full gRPC canonical error code set plus HTTP 410 (`ErrGone`):
| Constant | Wire value | HTTP | When to use |
|---|---|---|---|
| `ErrInvalidInput` | `INVALID_ARGUMENT` | 400 | Malformed or invalid request data |
| `ErrOutOfRange` | `OUT_OF_RANGE` | 400 | Valid value but outside accepted bounds |
| `ErrUnauthorized` | `UNAUTHENTICATED` | 401 | Missing or invalid credentials |
| `ErrPermissionDenied` | `PERMISSION_DENIED` | 403 | Authenticated but not authorised |
| `ErrNotFound` | `NOT_FOUND` | 404 | Resource does not exist |
| `ErrAlreadyExists` | `ALREADY_EXISTS` | 409 | Creation conflict (duplicate) |
| `ErrAborted` | `ABORTED` | 409 | Concurrent modification; retry may succeed |
| `ErrGone` | `GONE` | 410 | Resource permanently deleted |
| `ErrPreconditionFailed` | `FAILED_PRECONDITION` | 412 | Business rule blocks the operation |
| `ErrRateLimited` | `RESOURCE_EXHAUSTED` | 429 | Rate limit or quota exceeded |
| `ErrCancelled` | `CANCELLED` | 499 | Request cancelled by the caller |
| `ErrInternal` | `INTERNAL` | 500 | Unexpected server-side failure |
| `ErrDataLoss` | `DATA_LOSS` | 500 | Unrecoverable data corruption |
| `ErrNotImplemented` | `UNIMPLEMENTED` | 501 | Operation not implemented |
| `ErrUnavailable` | `UNAVAILABLE` | 503 | Service temporarily unavailable |
| `ErrDeadlineExceeded` | `DEADLINE_EXCEEDED` | 504 | Operation timed out |
Wire values are stable across versions and safe to persist, send over the network,
or switch on in client code.
---
## Dependency Rules
```
contracts (zero dependencies)
core (depends on contracts only)
starters (depend on core + contracts)
your app
```
`core` imports `contracts`. Nothing above `core` in this chain may import `core`
directly — they depend on starters, which compose core's sub-packages behind
framework-specific APIs.
---
## Verification
```bash
cd core/
go build ./... # must compile clean
go vet ./... # no warnings
go test ./... # structural + behavioural compliance passes
gofmt -l . # no output
```
---
## Architecture Decisions
| ADR | Title |
|---|---|
| [ADR-001](docs/adr/ADR-001-core-module-composition.md) | Four sub-packages in one Go module |
| [ADR-002](docs/adr/ADR-002-logz-contracts-errs.md) | logz adopts contracts/errs instead of private duck typing |
| [ADR-003](docs/adr/ADR-003-config-env-tags.md) | Config naming convention and caarlos0/env tag standard |
---
> *They were not chosen because they were the strongest.*
> *They were chosen because they understood what they were building toward.*