Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: golangci/golangci-lint-action@v7
with:
version: v2.5.0

test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go test -race -count=1 -timeout 5m ./...

build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go build -o bin/apex ./cmd/apex
28 changes: 28 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Go
bin/
*.exe
*.test
*.out

# Coverage
coverage.txt
coverage.html

# IDE
.idea/
.vscode/
*.swp
*.swo
*~

# SQLite
*.db
*.db-shm
*.db-wal

# Local config
config.local.yaml

# OS
.DS_Store
Thumbs.db
21 changes: 21 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
version: "2"

run:
go: "1.23"

linters:
enable:
- gocyclo
- misspell
- bodyclose
- prealloc
settings:
gocyclo:
min-complexity: 15
exclusions:
paths:
- vendor

formatters:
enable:
- gofumpt
52 changes: 52 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Apex — Celestia Namespace Indexer

## Build Commands

```bash
just build # compile to bin/apex
just test # go test -race ./...
just lint # golangci-lint run
just fmt # gofumpt -w .
just check # tidy + lint + test + build (CI equivalent)
just run # build and run
just clean # remove bin/
just tidy # go mod tidy
```

## Architecture

Apex is a lightweight indexer that watches Celestia namespaces, stores blobs/headers in SQLite, and exposes them via an HTTP API.

```
cmd/apex/ CLI entrypoint (cobra)
config/ YAML config loading and validation
pkg/types/ Domain types (Namespace, Blob, Header, SyncState)
pkg/store/ Storage interface (SQLite impl in Phase 1)
pkg/fetch/ Data fetcher interface (Celestia node client in Phase 1)
pkg/sync/ Sync coordinator (backfill + streaming)
pkg/api/ HTTP API server (Phase 2)
```

## Conventions

- Go 1.23 minimum (slog, range-over-func available)
- SQLite via `modernc.org/sqlite` (CGo-free)
- Config: YAML (`gopkg.in/yaml.v3`), strict unknown-field rejection
- Logging: `rs/zerolog`
- CLI: `spf13/cobra`
- Linter: golangci-lint v2 (.golangci.yml v2 format)
- Formatter: gofumpt
- Build runner: just (justfile)

## Dependencies

- Only add deps that are strictly necessary
- Prefer stdlib where reasonable
- No CGo dependencies (cross-compilation constraint)

## Testing

- All tests use `-race`
- Table-driven tests preferred
- Test files alongside source (`_test.go`)
- No test frameworks beyond stdlib `testing`
114 changes: 114 additions & 0 deletions cmd/apex/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package main

import (
"fmt"
"os"

"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"

"github.com/evstack/apex/config"
)

// Set via ldflags at build time.
var version = "dev"

func main() {
if err := rootCmd().Execute(); err != nil {
os.Exit(1)
}
}

func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "apex",
Short: "Lightweight Celestia namespace indexer",
SilenceUsage: true,
}

root.PersistentFlags().String("config", "config.yaml", "path to config file")

root.AddCommand(versionCmd())
root.AddCommand(initCmd())
root.AddCommand(startCmd())

return root
}

func configPath(cmd *cobra.Command) (string, error) {
return cmd.Flags().GetString("config")
}

func initCmd() *cobra.Command {
return &cobra.Command{
Use: "init",
Short: "Generate a default config file",
RunE: func(cmd *cobra.Command, _ []string) error {
cfgPath, err := configPath(cmd)
if err != nil {
return err
}
if err := config.Generate(cfgPath); err != nil {
return err
}
fmt.Printf("Config written to %s\n", cfgPath)
return nil
},
}
}

func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(_ *cobra.Command, _ []string) {
fmt.Println(version)
},
}
}

func startCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start the Apex indexer",
RunE: func(cmd *cobra.Command, _ []string) error {
cfgPath, err := configPath(cmd)
if err != nil {
return err
}
cfg, err := config.Load(cfgPath)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

setupLogger(cfg.Log)

log.Info().
Str("version", version).
Str("node_url", cfg.DataSource.CelestiaNodeURL).
Int("namespaces", len(cfg.DataSource.Namespaces)).
Comment on lines +88 to +90

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When an invalid log level is provided in the configuration, it's silently ignored and defaults to InfoLevel. This can be confusing for the user. It would be better to log a warning message indicating that the provided level was invalid and that a default is being used.

Suggested change
Str("version", version).
Str("node_url", cfg.DataSource.CelestiaNodeURL).
Int("namespaces", len(cfg.DataSource.Namespaces)).
if err != nil {
level = zerolog.InfoLevel
log.Warn().Err(err).Str("provided_level", cfg.Level).Msg("invalid log level specified, defaulting to 'info'")
}

Msg("starting apex indexer")

// TODO(phase1): wire store, fetcher, and sync coordinator.
log.Info().Msg("apex indexer is not yet implemented — scaffolding only")

return nil
},
}
}

func setupLogger(cfg config.LogConfig) {
level, err := zerolog.ParseLevel(cfg.Level)
if err != nil {
level = zerolog.InfoLevel
}
zerolog.SetGlobalLevel(level)

switch cfg.Format {
case "console":
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
default:
log.Logger = log.Output(os.Stdout)
}
}
82 changes: 82 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package config

import (
"fmt"

"github.com/evstack/apex/pkg/types"
)

// Config is the top-level configuration for the Apex indexer.
type Config struct {
DataSource DataSourceConfig `yaml:"data_source"`
Storage StorageConfig `yaml:"storage"`
RPC RPCConfig `yaml:"rpc"`
Sync SyncConfig `yaml:"sync"`
Log LogConfig `yaml:"log"`
}

// DataSourceConfig configures the Celestia node connection.
type DataSourceConfig struct {
CelestiaNodeURL string `yaml:"celestia_node_url"`
AuthToken string `yaml:"-"` // populated only via APEX_AUTH_TOKEN env var
Namespaces []string `yaml:"namespaces"`
}

// StorageConfig configures the SQLite database.
type StorageConfig struct {
DBPath string `yaml:"db_path"`
}

// RPCConfig configures the HTTP API server.
type RPCConfig struct {
ListenAddr string `yaml:"listen_addr"`
}

// SyncConfig configures the sync coordinator.
type SyncConfig struct {
StartHeight uint64 `yaml:"start_height"`
BatchSize int `yaml:"batch_size"`
Concurrency int `yaml:"concurrency"`
}

// LogConfig configures logging.
type LogConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}

// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() Config {
return Config{
DataSource: DataSourceConfig{
CelestiaNodeURL: "http://localhost:26658",
},
Storage: StorageConfig{
DBPath: "apex.db",
},
RPC: RPCConfig{
ListenAddr: ":8080",
},
Sync: SyncConfig{
BatchSize: 64,
Concurrency: 4,
},
Log: LogConfig{
Level: "info",
Format: "json",
},
}
}

// ParsedNamespaces converts hex namespace strings into typed Namespaces.
func (c *Config) ParsedNamespaces() ([]types.Namespace, error) {
namespaces := make([]types.Namespace, 0, len(c.DataSource.Namespaces))
for _, hex := range c.DataSource.Namespaces {
ns, err := types.NamespaceFromHex(hex)
if err != nil {
return nil, fmt.Errorf("invalid namespace %q: %w", hex, err)
}
namespaces = append(namespaces, ns)
}
return namespaces, nil
}
Loading