diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..0d04e15
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,360 @@
+# stackyrd — Design & Architecture
+
+## High-level architecture
+
+```mermaid
+flowchart TB
+ subgraph cmd["cmd/app/"]
+ main["main.go
Parse flags"]
+ app["Application
Run()"]
+ end
+
+ subgraph config["config/"]
+ cfg["Config (viper)
YAML + env overrides"]
+ end
+
+ subgraph infra["pkg/infrastructure/"]
+ compReg["ComponentRegistry
singleton"]
+ infraInit["InfraInitManager
async init"]
+ end
+
+ subgraph deps["pkg/registry/"]
+ depBag["Dependencies
name → interface{}"]
+ end
+
+ subgraph plugin["pkg/plugin/"]
+ plugInit["Init()
scan builtins → instantiate"]
+ plugBridge["PluginBridge
infra component"]
+ end
+
+ subgraph mw["internal/middleware/"]
+ mwReg["MiddlewareRegistry"]
+ end
+
+ subgraph svc["internal/services/modules/"]
+ svcReg["ServiceRegistry"]
+ end
+
+ subgraph server["internal/server/"]
+ srv["Echo server
routes / health / metrics"]
+ end
+
+ main --> app
+ app --> cfg
+ cfg --> infraInit
+ infraInit --> compReg
+ compReg --> depBag
+ depBag --> plugInit
+ plugInit --> plugBridge
+ plugBridge --> compReg
+ compReg --> mwReg
+ mwReg --> srv
+ mwReg --> svcReg
+ svcReg --> srv
+```
+
+## Boot sequence
+
+```mermaid
+sequenceDiagram
+ participant main as main.go
+ participant cfg as ConfigManager
+ participant app as Application
+ participant srv as Server.Start()
+ participant infraInit as InfraInitManager
+ participant compReg as ComponentRegistry
+ participant deps as Dependencies
+ participant plugin as plugin.Init()
+ participant mw as MiddlewareRegistry
+ participant svc as ServiceRegistry
+
+ main->>cfg: LoadConfig() / ValidateConfig()
+ main->>app: Run()
+ app->>srv: New(cfg, logger)
+
+ srv->>infraInit: StartAsyncInitialization()
+ infraInit->>compReg: Initialize(cfg, logger)
+ Note over compReg: Call every registered factory
(redis, postgres, mongo, kafka, ...)
+ compReg-->>infraInit: registry
+
+ srv->>deps: NewDependencies()
+ srv->>deps: Set(name, component) for each infra
+ srv->>srv: setConnectionDefaults()
+
+ srv->>plugin: Init(cfg, logger, pluginGroup)
+ plugin->>compReg: scanBuiltinPlugins()
+ plugin->>plugin: instantiatePlugin() per meta
+ plugin->>compReg: SetComponent("plugins", bridge)
+
+ srv->>mw: ApplyConfig(cfg)
+ srv->>mw: AutoDiscoverMiddlewares()
+ mw-->>srv: []echo.MiddlewareFunc
+ srv->>srv: e.Use(mw...)
+
+ srv->>compReg: RouteRegistrar routes
+
+ srv->>svc: AutoDiscoverServices(cfg, logger, deps)
+ svc-->>srv: []interfaces.Service
+ srv->>svc: Boot(echo)
+ svc->>srv: RegisterRoutes on /api/v1
+
+ srv->>srv: e.Start(":" + port)
+```
+
+## Extension points
+
+```mermaid
+flowchart LR
+ subgraph Service["Service (internal/services/modules/)"]
+ S1["interface
Name / WireName / Enabled
Endpoints / RegisterRoutes / Get"]
+ S2["init() → RegisterService(name, factory)"]
+ S3["factory: cfg, logger, deps → Service"]
+ S4["config.yaml: services.{wire_name}: true/false"]
+ end
+
+ subgraph Middleware["Middleware (internal/middleware/)"]
+ M1["echo.MiddlewareFunc"]
+ M2["init() → RegisterMiddleware(name, factory)"]
+ M3["factory: cfg, logger → echo.MiddlewareFunc"]
+ M4["config.yaml: middleware.{name}: true/false"]
+ end
+
+ subgraph Infra["Infrastructure (pkg/infrastructure/)"]
+ I1["interface
Name / Close / GetStatus"]
+ I2["init() → RegisterComponent(name, factory)"]
+ I3["factory: cfg, logger → InfrastructureComponent"]
+ I4["Optional: RouteRegistrar
RouteHandlers() []RouteHandler"]
+ I5["config.yaml: {redis|postgres|mongo|...}.enabled"]
+ end
+
+ subgraph Plugin["Plugin (pkg/plugin/builtin/{name}/)"]
+ P1["Plugin interface
Execute(ctx, args) → Result"]
+ P2["PluginMeta (plugin.yaml)
name / version / entrypoint / limits"]
+ P3["Runtime registry
ts: / goja: / lua: / grpc: / wasm:"]
+ P4["PluginBridge (infra component)"]
+ P5["config.yaml: plugins.enabled / overrides / allowlist"]
+ end
+
+ S1 --- S2 --- S3 --- S4
+ M1 --- M2 --- M3 --- M4
+ I1 --- I2 --- I3 --- I4 --- I5
+ P1 --- P2 --- P3 --- P4 --- P5
+```
+
+## Request lifecycle
+
+```mermaid
+flowchart TD
+ req["HTTP Request"]
+ recover["echo.Recover()"]
+ mws["Auto-discovered Middleware
request_id → logger → permission_check → jwt → security"]
+ notfound["404 / 405 handler"]
+ infraRoutes["Infrastructure Routes
(RouteRegistrar)"]
+ apiGroup["/api/v1 group"]
+ services["Service Routes
users / products / tasks / ..."]
+ handler["Handler func"]
+ bind["request.Bind(c, &target)
Validate via go-playground/validator"]
+ deps["deps.Get(name) for infra"]
+ response["response.Success / Created / Error"]
+
+ req --> recover
+ recover --> mws
+ mws --> notfound
+ notfound --> infraRoutes
+ infraRoutes --> apiGroup
+ apiGroup --> services
+ services --> handler
+ handler --> bind
+ handler --> deps
+ handler --> response
+```
+
+## Plugin system architecture
+
+```mermaid
+flowchart TB
+ subgraph BuiltinFS["embed.FS (builtin/)"]
+ p1["plugin.yaml"]
+ ts["ts:scripts/main.ts"]
+ goja["goja:scripts/main.js"]
+ lua["lua:main.lua"]
+ grpc["grpc:./bin/worker"]
+ wasm["wasm:main.wasm"]
+ end
+
+ subgraph PluginInit["plugin.Init()"]
+ scan["scanBuiltinPlugins()
read plugin.yaml → meta"]
+ instantiate["instantiatePlugin()
match entrypoint prefix → Runtime"]
+ store["reg.Store(name, plugin)"]
+ bridge["PluginBridge
SetComponent('plugins', bridge)"]
+ end
+
+ subgraph RuntimeRegistry["runtime_registry.go"]
+ rr["RegisterRuntime(rt)
prefix-based dispatch"]
+ end
+
+ subgraph Runtimes["Runtimes"]
+ tsrt["TSRuntime
transpile → goja VM"]
+ gojart["GojaRuntime
goja VM + program cache"]
+ luart["LuaRuntime
gopher-lua VM"]
+ grpcrt["gRPCRuntime
subprocess stdin/stdout"]
+ wasmrt["WasmRuntime
goja/VM sandbox"]
+ end
+
+ subgraph Execution["PluginBridge.Execute()"]
+ ctx["Context{Logger, Registry, Limits}"]
+ exec["p.Execute(ctx, args)"]
+ metrics["CollectMetrics()
active_execs / goroutines / memory"]
+ end
+
+ BuiltinFS --> scan
+ scan --> instantiate
+ instantiate --> rr
+ rr --> tsrt
+ rr --> gojart
+ rr --> luart
+ rr --> grpcrt
+ rr --> wasmrt
+ instantiate --> store
+ store --> bridge
+ bridge --> Execution
+ ctx --> exec
+ exec --> metrics
+```
+
+## Infrastructure component lifecycle
+
+```mermaid
+flowchart LR
+ subgraph Registration["init() phase"]
+ reg["RegisterComponent(name, factory)"]
+ factories["factories map[string]ComponentFactory"]
+ end
+
+ subgraph Initialization["Server.Start()"]
+ init["ComponentRegistry.Initialize(cfg, logger)"]
+ create["factory(cfg, logger) → component"]
+ store["components[name] = component"]
+ end
+
+ subgraph AsyncHealth["InfraInitManager"]
+ health["GetStatus() goroutine per component
with 10s timeout"]
+ status["status map[string]*InfraInitStatus
progress 0.0 → 1.0"]
+ end
+
+ subgraph Runtime["running server"]
+ get["registry.Get(name)"]
+ getAll["registry.GetAll()
TTL-cached snapshot (2s)"]
+ routes["RouteRegistrar routes mounted"]
+ depsSet["dependencies.Set(name, component)"]
+ end
+
+ subgraph Shutdown["graceful shutdown"]
+ close["component.Close()
10s timeout per component"]
+ drain["WorkerPool.Stop()
drain queue → close stopChan"]
+ end
+
+ reg --> factories
+ factories --> init
+ init --> create
+ create --> store
+ store --> asyncHealth
+ asyncHealth --> runtime
+ runtime --> shutdown
+```
+
+## Dependency injection flow
+
+```mermaid
+flowchart LR
+ subgraph InfraComponents["Infrastructure Components"]
+ redis["redis.RedisManager"]
+ pg["postgres.PostgresConnectionManager"]
+ mongo["mongo.MongoConnectionManager"]
+ kafka["kafka.KafkaManager"]
+ grafana["grafana.GrafanaClient"]
+ cron["cron.CronManager"]
+ plugins["plugin.PluginBridge"]
+ end
+
+ subgraph DepsBag["Dependencies (map[string]interface{})"]
+ d1["'redis' → *RedisManager"]
+ d2["'postgres' → *PostgresConnectionManager"]
+ d3["'postgres.default' → *PostgresManager"]
+ d4["'mongo' → *MongoConnectionManager"]
+ d5["'mongo.default' → *MongoClient"]
+ d6["'plugins' → *PluginBridge"]
+ d7["..."]
+ end
+
+ subgraph ServiceFactories["Service Factory (init)"]
+ f1["registry.RegisterService('users', factory)"]
+ f2["registry.RegisterService('products', factory)"]
+ end
+
+ subgraph AutoDiscover["AutoDiscoverServices()"]
+ ad["for each factory:
svc = factory(cfg, logger, deps)
serviceDiscovered.Store(name, svc.Get())"]
+ end
+
+ InfraComponents --> DepsBag
+ DepsBag --> ServiceFactories
+ ServiceFactories --> AutoDiscover
+```
+
+## Config loading & validation
+
+```mermaid
+flowchart TD
+ flags["parseFlags()
-c / -port / -verbose / -env"]
+ cm["ConfigManager"]
+ url{{"configURL != ''?"}}
+ file["LoadConfigFromFile()
viper.ReadInConfig()
./config.yaml or ./config/config.yaml"]
+ remote["LoadConfigFromURL()
utils.LoadConfigFromURL()"]
+ unmarshal["viper.Unmarshal(&cfg)
Config struct"]
+ validate["ValidateConfig(cfg)
port availability"]
+ defaults["viper defaults:
app.name / server.port /
redis.enabled=false / ..."]
+ env["viper.AutomaticEnv()
APP_NAME / SERVER_PORT / ..."]
+
+ flags --> cm
+ cm --> url
+ url -->|yes| remote
+ url -->|no| file
+ remote --> unmarshal
+ file --> defaults
+ defaults --> env
+ env --> unmarshal
+ unmarshal --> validate
+```
+
+## Health & observability
+
+```mermaid
+flowchart TB
+ subgraph HealthEndpoints["Health Endpoints"]
+ h1["GET /health"]
+ h2["GET /health/infrastructure"]
+ h3["GET /health/dependencies"]
+ h4["GET /health/resources"]
+ end
+
+ subgraph Metrics["Observability"]
+ m1["Prometheus metrics
GET /metrics"]
+ m2["Metrics struct
HTTPRequestsTotal / Duration / Size
DB / Cache / CircuitBreaker
Webhook / WebSocket / Batch"]
+ end
+
+ subgraph Sources["Data Sources"]
+ s1["infraInitManager.IsReady()"]
+ s2["infraInitManager.GetStatus()
per-component progress + error"]
+ s3["dependencies.GetAll()
TTL-cached map"]
+ s4["registry.GetServiceFactories()"]
+ s5["utils.GetMemSelf() / GetRoutine()"]
+ end
+
+ h1 --> s1
+ h1 --> s2
+ h2 --> s2
+ h3 --> s3
+ h3 --> s4
+ h4 --> s5
+ m1 --> m2
+```
diff --git a/config/config.go b/config/config.go
index d28afb3..991d9f4 100644
--- a/config/config.go
+++ b/config/config.go
@@ -37,6 +37,9 @@ func setupViperDefaults() {
viper.SetDefault("webhook.timeout_seconds", 30)
viper.SetDefault("webhook.max_retries", 3)
viper.SetDefault("webhook.endpoint", "/api/v1/webhook")
+ viper.SetDefault("pagination.type", "offset")
+ viper.SetDefault("pagination.max_per_page", 100)
+ viper.SetDefault("infrastructure.init_timeout", 30)
}
type Config struct {
@@ -56,8 +59,9 @@ type Config struct {
Cron CronConfig `mapstructure:"cron"`
MinIO MinIOConfig `mapstructure:"minio"`
Encryption EncryptionConfig `mapstructure:"encryption"`
+ Pagination PaginationConfig `mapstructure:"pagination"`
+ Infrastructure InfrastructureConfig `mapstructure:"infrastructure"`
}
-
// MiddlewareConfig is a dynamic map of middleware names to their enabled status.
type MiddlewareConfig map[string]bool
@@ -190,8 +194,20 @@ type MongoConfig struct {
}
type MetricsConfig struct {
- Enabled bool `mapstructure:"enabled"`
- Path string `mapstructure:"path"`
+ Enabled bool `mapstructure:"enabled"`
+ Path string `mapstructure:"path"`
+ SampleRate string `mapstructure:"sample_rate"` // fractional rate (e.g. "0.1") or empty for all
+}
+
+// PaginationConfig configures pagination defaults across services
+type PaginationConfig struct {
+ Type string `mapstructure:"type"` // "offset" or "cursor"
+ MaxPerPage int `mapstructure:"max_per_page"` // maximum per_page value (default 100)
+}
+
+// InfrastructureConfig configures async init behavior
+type InfrastructureConfig struct {
+ InitTimeout int `mapstructure:"init_timeout"` // component init timeout (seconds)
}
type GrafanaConfig struct {
diff --git a/internal/middleware/prometheus.go b/internal/middleware/prometheus.go
new file mode 100644
index 0000000..144b091
--- /dev/null
+++ b/internal/middleware/prometheus.go
@@ -0,0 +1,51 @@
+package middleware
+
+import (
+ "net/http"
+ "time"
+
+ "stackyrd/config"
+ "stackyrd/pkg/logger"
+ "stackyrd/pkg/metrics"
+
+ "github.com/labstack/echo/v4"
+)
+
+func init() {
+ RegisterMiddleware("prometheus", func(cfg *config.Config, logger *logger.Logger) (echo.MiddlewareFunc, error) {
+ return Prometheus(), nil
+ })
+}
+
+// Prometheus records HTTP request metrics (count, duration, sizes) with a
+// bounded label set. The route template from c.Path() (e.g. "/users/:id")
+// is used instead of the raw URL path, so cardinality stays bounded no matter
+// how many distinct IDs flow through.
+func Prometheus() echo.MiddlewareFunc {
+ m := metrics.GetMetrics()
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ start := time.Now()
+ err := next(c)
+ latency := time.Since(start)
+
+ status := c.Response().Status
+ if status == 0 {
+ status = http.StatusOK
+ }
+
+ path := c.Path()
+ if path == "" {
+ path = c.Request().URL.Path
+ }
+
+ var reqSize int64
+ if c.Request().ContentLength > 0 {
+ reqSize = c.Request().ContentLength
+ }
+
+ m.RecordHTTPRequest(c.Request().Method, path, status, latency, reqSize, c.Response().Size)
+ return err
+ }
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 803c913..b4e8a0b 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -181,6 +181,7 @@ func (s *Server) registerHealthEndpoints() {
"server_ready": ready,
"infrastructure": s.infraInitManager.GetStatus(),
"initialization_progress": s.infraInitManager.GetInitializationProgress(),
+ "init_timeout_seconds": s.config.Infrastructure.InitTimeout,
})
})
diff --git a/pkg/batch/operations.go b/pkg/batch/operations.go
deleted file mode 100644
index 207ca98..0000000
--- a/pkg/batch/operations.go
+++ /dev/null
@@ -1,248 +0,0 @@
-package batch
-
-import (
- "context"
- "sync"
- "time"
-)
-
-// BatchConfig holds batch operation configuration
-type BatchConfig struct {
- BatchSize int
- Workers int
- Timeout time.Duration
- RetryAttempts int
- RetryDelay time.Duration
-}
-
-// DefaultBatchConfig returns default batch configuration
-func DefaultBatchConfig() BatchConfig {
- return BatchConfig{
- BatchSize: 100,
- Workers: 4,
- Timeout: 30 * time.Second,
- RetryAttempts: 3,
- RetryDelay: 100 * time.Millisecond,
- }
-}
-
-// BatchResult represents the result of a batch operation
-type BatchResult struct {
- Successful int
- Failed int
- Errors []error
- Duration time.Duration
-}
-
-// BatchProcessor processes items in batches
-type BatchProcessor[T any] struct {
- config BatchConfig
- handler func(ctx context.Context, item T) error
-}
-
-// NewBatchProcessor creates a new batch processor
-func NewBatchProcessor[T any](config BatchConfig, handler func(ctx context.Context, item T) error) *BatchProcessor[T] {
- return &BatchProcessor[T]{
- config: config,
- handler: handler,
- }
-}
-
-// Process processes items in batches
-func (bp *BatchProcessor[T]) Process(ctx context.Context, items []T) (*BatchResult, error) {
- start := time.Now()
- result := &BatchResult{}
-
- if len(items) == 0 {
- return result, nil
- }
-
- // Create batches
- batches := bp.createBatches(items)
-
- // Process batches with workers
- resultChan := make(chan *BatchResult, len(batches))
- errorChan := make(chan error, len(batches))
-
- // Create worker pool
- workerCtx, cancel := context.WithTimeout(ctx, bp.config.Timeout)
- defer cancel()
-
- var wg sync.WaitGroup
- semaphore := make(chan struct{}, bp.config.Workers)
-
- for i, batch := range batches {
- wg.Add(1)
- go func(batchIndex int, batchItems []T) {
- defer wg.Done()
-
- semaphore <- struct{}{}
- defer func() { <-semaphore }()
-
- batchResult := bp.processBatch(workerCtx, batchItems)
- resultChan <- batchResult
- }(i, batch)
- }
-
- // Wait for all batches to complete
- go func() {
- wg.Wait()
- close(resultChan)
- close(errorChan)
- }()
-
- // Collect results
- for batchResult := range resultChan {
- result.Successful += batchResult.Successful
- result.Failed += batchResult.Failed
- result.Errors = append(result.Errors, batchResult.Errors...)
- }
-
- result.Duration = time.Since(start)
-
- return result, nil
-}
-
-// createBatches creates batches from items
-func (bp *BatchProcessor[T]) createBatches(items []T) [][]T {
- var batches [][]T
-
- for i := 0; i < len(items); i += bp.config.BatchSize {
- end := i + bp.config.BatchSize
- if end > len(items) {
- end = len(items)
- }
- batches = append(batches, items[i:end])
- }
-
- return batches
-}
-
-// processBatch processes a single batch
-func (bp *BatchProcessor[T]) processBatch(ctx context.Context, items []T) *BatchResult {
- result := &BatchResult{}
-
- for _, item := range items {
- err := bp.processItemWithRetry(ctx, item)
- if err != nil {
- result.Failed++
- result.Errors = append(result.Errors, err)
- } else {
- result.Successful++
- }
- }
-
- return result
-}
-
-// processItemWithRetry processes a single item with retry
-func (bp *BatchProcessor[T]) processItemWithRetry(ctx context.Context, item T) error {
- var lastErr error
-
- for attempt := 0; attempt <= bp.config.RetryAttempts; attempt++ {
- if attempt > 0 {
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-time.After(bp.config.RetryDelay):
- }
- }
-
- err := bp.handler(ctx, item)
- if err == nil {
- return nil
- }
-
- lastErr = err
- }
-
- return lastErr
-}
-
-// BatchWriter writes items in batches
-type BatchWriter[T any] struct {
- config BatchConfig
- writer func(ctx context.Context, items []T) error
- items []T
- mu sync.Mutex
-}
-
-// NewBatchWriter creates a new batch writer
-func NewBatchWriter[T any](config BatchConfig, writer func(ctx context.Context, items []T) error) *BatchWriter[T] {
- return &BatchWriter[T]{
- config: config,
- writer: writer,
- }
-}
-
-// Add adds an item to the batch
-func (bw *BatchWriter[T]) Add(ctx context.Context, item T) error {
- bw.mu.Lock()
- bw.items = append(bw.items, item)
- shouldFlush := len(bw.items) >= bw.config.BatchSize
- bw.mu.Unlock()
-
- if shouldFlush {
- return bw.Flush(ctx)
- }
-
- return nil
-}
-
-// Flush flushes the batch
-func (bw *BatchWriter[T]) Flush(ctx context.Context) error {
- bw.mu.Lock()
- if len(bw.items) == 0 {
- bw.mu.Unlock()
- return nil
- }
-
- items := make([]T, len(bw.items))
- copy(items, bw.items)
- bw.items = bw.items[:0]
- bw.mu.Unlock()
-
- return bw.writer(ctx, items)
-}
-
-// BatchReader reads items in batches
-type BatchReader[T any] struct {
- config BatchConfig
- reader func(ctx context.Context, offset, limit int) ([]T, error)
-}
-
-// NewBatchReader creates a new batch reader
-func NewBatchReader[T any](config BatchConfig, reader func(ctx context.Context, offset, limit int) ([]T, error)) *BatchReader[T] {
- return &BatchReader[T]{
- config: config,
- reader: reader,
- }
-}
-
-// ReadAll reads all items in batches
-func (br *BatchReader[T]) ReadAll(ctx context.Context) ([]T, error) {
- var allItems []T
- offset := 0
-
- for {
- items, err := br.reader(ctx, offset, br.config.BatchSize)
- if err != nil {
- return nil, err
- }
-
- allItems = append(allItems, items...)
-
- if len(items) < br.config.BatchSize {
- break
- }
-
- offset += len(items)
- }
-
- return allItems, nil
-}
-
-// ReadBatch reads a single batch
-func (br *BatchReader[T]) ReadBatch(ctx context.Context, offset int) ([]T, error) {
- return br.reader(ctx, offset, br.config.BatchSize)
-}
diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go
index 43b2e01..08d557a 100644
--- a/pkg/cache/cache.go
+++ b/pkg/cache/cache.go
@@ -90,14 +90,27 @@ func (c *Cache[T]) Delete(key string) {
}
// Cleanup removes expired items. Run this in a goroutine for periodic cleanup.
+// The scan runs under a read lock so concurrent Get calls are never blocked by
+// the full sweep; only the actual deletions take the write lock briefly.
func (c *Cache[T]) Cleanup() {
- c.mu.Lock()
- defer c.mu.Unlock()
-
now := time.Now().UnixNano()
+
+ var expired []string
+ c.mu.RLock()
for k, v := range c.items {
if v.Expiration > 0 && now > v.Expiration {
- delete(c.items, k)
+ expired = append(expired, k)
}
}
+ c.mu.RUnlock()
+
+ if len(expired) == 0 {
+ return
+ }
+
+ c.mu.Lock()
+ for _, k := range expired {
+ delete(c.items, k)
+ }
+ c.mu.Unlock()
}
diff --git a/pkg/infrastructure/async.go b/pkg/infrastructure/async.go
index cd6ed5d..3774eb6 100644
--- a/pkg/infrastructure/async.go
+++ b/pkg/infrastructure/async.go
@@ -110,13 +110,10 @@ func NewBatchAsyncResult[T any](count int, batchSize int) *BatchAsyncResult[T] {
}
}
-// Complete is removed: CompleteResult is the sole completer for BatchAsyncResult.
-// Kept for callers that still reference it but no-ops to preserve backward compat.
-func (br *BatchAsyncResult[T]) Complete() {}
-// CompleteResult marks one individual operation result as done and, when all
-// operations in the batch have completed, closes the batch Done channel.
-// This is the sole completer for BatchAsyncResult; Close() delegates here.
+// CompleteResult marks one operation done and, when all operations in the
+// batch have completed, closes the batch Done channel. It is the sole
+// completer for BatchAsyncResult; Close() delegates here.
func (br *BatchAsyncResult[T]) CompleteResult(index int) {
br.Results[index].Complete(br.Results[index].Value, br.Results[index].Error)
if atomic.AddInt32(&br.pending, -1) == 0 {
diff --git a/pkg/infrastructure/async_init.go b/pkg/infrastructure/async_init.go
index e8eed60..dd52872 100644
--- a/pkg/infrastructure/async_init.go
+++ b/pkg/infrastructure/async_init.go
@@ -27,8 +27,16 @@ type InfraInitManager struct {
doneChan chan struct{}
wg sync.WaitGroup
ready atomic.Bool
+
+ // cacheMu guards GetStatus TTL-snapshot. updateStatus invalidates it.
+ cacheMu sync.Mutex
+ cacheExpiry time.Time
+ cachedStatus map[string]*InfraInitStatus
}
+// statusCacheTTL controls how stale a GetStatus snapshot may be.
+const statusCacheTTL = 500 * time.Millisecond
+
// NewInfraInitManager creates a new infrastructure initialization manager
func NewInfraInitManager(logger *logger.Logger) *InfraInitManager {
return &InfraInitManager{
@@ -47,6 +55,12 @@ func (im *InfraInitManager) StartAsyncInitialization(cfg *config.Config, logger
logger.Error("Failed to initialize infrastructure components", err)
}
+ // Resolve per-component timeout
+ compTimeout := 10 * time.Second
+ if cfg.Infrastructure.InitTimeout > 0 {
+ compTimeout = time.Duration(cfg.Infrastructure.InitTimeout) * time.Second
+ }
+
// Start async health checks and monitoring (non-blocking)
components := registry.GetAll()
im.wg.Add(len(components))
@@ -66,7 +80,7 @@ func (im *InfraInitManager) StartAsyncInitialization(cfg *config.Config, logger
})
// Perform health check with timeout
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), compTimeout)
defer cancel()
done := make(chan map[string]interface{}, 1)
@@ -100,31 +114,51 @@ func (im *InfraInitManager) StartAsyncInitialization(cfg *config.Config, logger
// updateStatus updates the initialization status of a component
func (im *InfraInitManager) updateStatus(name string, status *InfraInitStatus) {
im.mu.Lock()
- defer im.mu.Unlock()
im.status[name] = status
+ im.mu.Unlock()
+ im.invalidateStatusCache()
+}
+
+func (im *InfraInitManager) invalidateStatusCache() {
+ im.cacheMu.Lock()
+ im.cacheExpiry = time.Time{}
+ im.cacheMu.Unlock()
}
// updateStatusProgress updates only the progress of a component
func (im *InfraInitManager) updateStatusProgress(name string, progress float64) {
im.mu.Lock()
- defer im.mu.Unlock()
if status, exists := im.status[name]; exists {
status.Progress = progress
}
+ im.mu.Unlock()
+ im.invalidateStatusCache()
}
-// GetStatus returns the current initialization status of all components
+// GetStatus returns the current initialization status of all components.
+// updateStatus replaces pointers in the source map (never mutates in place),
+// so a shallow snapshot copy is safe to hand back while cached.
func (im *InfraInitManager) GetStatus() map[string]*InfraInitStatus {
- im.mu.RLock()
- defer im.mu.RUnlock()
+ im.cacheMu.Lock()
+ if time.Now().Before(im.cacheExpiry) && im.cachedStatus != nil {
+ result := im.cachedStatus
+ im.cacheMu.Unlock()
+ return result
+ }
+ im.cacheMu.Unlock()
- // Create a copy to avoid race conditions
- status := make(map[string]*InfraInitStatus)
+ im.mu.RLock()
+ result := make(map[string]*InfraInitStatus, len(im.status))
for k, v := range im.status {
- status[k] = v
+ result[k] = v
}
+ im.mu.RUnlock()
- return status
+ im.cacheMu.Lock()
+ im.cachedStatus = result
+ im.cacheExpiry = time.Now().Add(statusCacheTTL)
+ im.cacheMu.Unlock()
+ return result
}
// IsInitialized checks if a specific component is initialized
diff --git a/pkg/infrastructure/mongo.go b/pkg/infrastructure/mongo.go
index 947c218..aa41d4e 100644
--- a/pkg/infrastructure/mongo.go
+++ b/pkg/infrastructure/mongo.go
@@ -24,7 +24,7 @@ type MongoManager struct {
statusTTL time.Duration
statusExpiry time.Time
statusCache map[string]interface{}
- statusMu sync.Mutex
+ statusMu sync.RWMutex
}
// Name returns the display name of the component
@@ -207,13 +207,13 @@ func (m *MongoManager) GetStatus() map[string]interface{} {
}
// Fast path: return cached result when still within TTL.
- m.statusMu.Lock()
+ m.statusMu.RLock()
if time.Now().Before(m.statusExpiry) && m.statusCache != nil {
cached := m.statusCache
- m.statusMu.Unlock()
+ m.statusMu.RUnlock()
return cached
}
- m.statusMu.Unlock()
+ m.statusMu.RUnlock()
// Slow path: actually ping the server and collect stats.
err := m.Client.Ping(context.Background(), nil)
diff --git a/pkg/infrastructure/postgres.go b/pkg/infrastructure/postgres.go
index c628f1b..9d307d2 100644
--- a/pkg/infrastructure/postgres.go
+++ b/pkg/infrastructure/postgres.go
@@ -23,7 +23,7 @@ type PostgresManager struct {
statusTTL time.Duration
statusExpiry time.Time
statusCache map[string]interface{}
- statusMu sync.Mutex
+ statusMu sync.RWMutex
}
type PostgresConnectionManager struct {
@@ -181,13 +181,13 @@ func (p *PostgresManager) GetStatus() map[string]interface{} {
}
// Fast path: return cached result when still within TTL.
- p.statusMu.Lock()
+ p.statusMu.RLock()
if time.Now().Before(p.statusExpiry) && p.statusCache != nil {
cached := p.statusCache
- p.statusMu.Unlock()
+ p.statusMu.RUnlock()
return cached
}
- p.statusMu.Unlock()
+ p.statusMu.RUnlock()
// Slow path: actually ping and collect DB stats.
err := p.DB.Ping()
@@ -550,7 +550,6 @@ func (p *PostgresManager) ExecuteBatchAsync(ctx context.Context, queries []strin
for i := range result.Results {
result.Results[i].Complete(nil, fmt.Errorf("queries and args length mismatch"))
}
- result.Complete()
return result
}
diff --git a/pkg/infrastructure/redis.go b/pkg/infrastructure/redis.go
index 34f406e..202d3a4 100644
--- a/pkg/infrastructure/redis.go
+++ b/pkg/infrastructure/redis.go
@@ -19,7 +19,7 @@ type RedisManager struct {
// statusCache avoids re-running Ping + PoolStats on every /health call.
statusCache map[string]interface{}
statusExpiry time.Time
- statusMu sync.Mutex
+ statusMu sync.RWMutex
}
// Name returns the display name of the component
@@ -90,13 +90,13 @@ func (r *RedisManager) GetStatus() map[string]interface{} {
}
// Fast path: return cached result when still within TTL.
- r.statusMu.Lock()
+ r.statusMu.RLock()
if time.Now().Before(r.statusExpiry) && r.statusCache != nil {
cached := r.statusCache
- r.statusMu.Unlock()
+ r.statusMu.RUnlock()
return cached
}
- r.statusMu.Unlock()
+ r.statusMu.RUnlock()
// Slow path: actually ping the server.
addr := r.Client.Options().Addr
diff --git a/pkg/pagination/cursor.go b/pkg/pagination/cursor.go
index ca5d1fa..7b232e1 100644
--- a/pkg/pagination/cursor.go
+++ b/pkg/pagination/cursor.go
@@ -105,123 +105,150 @@ func DecodeCursor(cursorStr string) (*Cursor, error) {
return &cursor, nil
}
-// CreateCursor creates a cursor from an item
-func CreateCursor(id string, timestamp time.Time, value string) (*Cursor, error) {
- return &Cursor{
- ID: id,
- Timestamp: timestamp,
- Value: value,
- }, nil
-}
-
-// CreateEdge creates an edge from a node and cursor
-func CreateEdge(node interface{}, cursor *Cursor) (*Edge, error) {
- cursorStr, err := EncodeCursor(cursor)
- if err != nil {
- return nil, err
- }
-
- return &Edge{
- Node: node,
- Cursor: cursorStr,
- }, nil
+// OffsetPagination represents offset-based pagination parameters
+type OffsetPagination struct {
+ Page int `json:"page,omitempty"`
+ PerPage int `json:"per_page,omitempty"`
}
-// CreatePage creates a cursor page from edges
-func CreatePage(edges []Edge, hasNextPage, hasPreviousPage bool, totalCount int) *CursorPage {
- page := &CursorPage{
- Edges: edges,
- TotalCount: totalCount,
- PageInfo: PageInfo{
- HasNextPage: hasNextPage,
- HasPreviousPage: hasPreviousPage,
- },
+// NewOffsetPagination creates offset pagination from query params
+func NewOffsetPagination(page, perPage int) *OffsetPagination {
+ if page < 1 {
+ page = 1
}
-
- if len(edges) > 0 {
- startCursor := edges[0].Cursor
- endCursor := edges[len(edges)-1].Cursor
- page.PageInfo.StartCursor = &startCursor
- page.PageInfo.EndCursor = &endCursor
+ if perPage < 1 {
+ perPage = 10
}
-
- return page
+ if perPage > 100 {
+ perPage = 100
+ }
+ return &OffsetPagination{Page: page, PerPage: perPage}
}
-// GetLimit returns the limit for the query
-func (p *CursorPagination) GetLimit() int {
- if p.First > 0 {
- return p.First + 1
- }
- if p.Last > 0 {
- return p.Last + 1
- }
- return 11
+// Offset returns the database offset (0-indexed)
+func (p *OffsetPagination) Offset() int {
+ return (p.Page - 1) * p.PerPage
}
-// GetOffset returns the offset for the query (not used in cursor pagination)
-func (p *CursorPagination) GetOffset() int {
- return 0
+// Limit returns the limit for SQL queries
+func (p *OffsetPagination) Limit() int {
+ return p.PerPage
}
-// HasAfterCursor returns true if there's an after cursor
-func (p *CursorPagination) HasAfterCursor() bool {
- return p.After != nil
+// CursorPageBuilder helps build cursor-based paginated responses
+type CursorPageBuilder struct {
+ edges []interface{}
+ total int
+ cursorKey string
+ idKey string
+ timestampKey string
}
-// HasBeforeCursor returns true if there's a before cursor
-func (p *CursorPagination) HasBeforeCursor() bool {
- return p.Before != nil
+// NewCursorPageBuilder creates a new builder
+func NewCursorPageBuilder(cursorKey, idKey, timestampKey string) *CursorPageBuilder {
+ return &CursorPageBuilder{
+ edges: make([]Edge, 0),
+ cursorKey: cursorKey,
+ idKey: idKey,
+ timestampKey: timestampKey,
+ }
}
-// IsForwardPagination returns true if forward pagination is requested
-func (p *CursorPagination) IsForwardPagination() bool {
- return p.First > 0 || (p.First == 0 && p.Last == 0)
+// AddItem adds an item to the page
+func (b *CursorPageBuilder) AddItem(node interface{}, id string, timestamp time.Time) error {
+ cursor := Cursor{ID: id, Timestamp: timestamp}
+ cursorStr, err := EncodeCursor(&cursor)
+ if err != nil {
+ return err
+ }
+ b.edges = append(b.edges, Edge{Node: node, Cursor: cursorStr})
+ return nil
}
-// IsBackwardPagination returns true if backward pagination is requested
-func (p *CursorPagination) IsBackwardPagination() bool {
- return p.Last > 0
+// SetTotal sets the total count
+func (b *CursorPageBuilder) SetTotal(total int) {
+ b.total = total
}
-// GetAfterID returns the after cursor ID
-func (p *CursorPagination) GetAfterID() string {
- if p.After != nil {
- return p.After.ID
+// Build creates the final CursorPage
+func (b *CursorPageBuilder) Build(hasNext, hasPrev bool) CursorPage {
+ startCursor, endCursor := "", ""
+ if len(b.edges) > 0 {
+ startCursor = b.edges[0].Cursor
+ endCursor = b.edges[len(b.edges)-1].Cursor
}
- return ""
-}
-// GetBeforeID returns the before cursor ID
-func (p *CursorPagination) GetBeforeID() string {
- if p.Before != nil {
- return p.Before.ID
+ return CursorPage{
+ Edges: b.edges,
+ PageInfo: PageInfo{
+ HasNextPage: hasNext,
+ HasPreviousPage: hasPrev,
+ StartCursor: ptrString(startCursor),
+ EndCursor: ptrString(endCursor),
+ },
+ TotalCount: b.total,
}
- return ""
}
-// GetAfterTimestamp returns the after cursor timestamp
-func (p *CursorPagination) GetAfterTimestamp() time.Time {
- if p.After != nil {
- return p.After.Timestamp
+func ptrString(s string) *string {
+ if s == "" {
+ return nil
}
- return time.Time{}
+ return &s
}
-// GetBeforeTimestamp returns the before cursor timestamp
-func (p *CursorPagination) GetBeforeTimestamp() time.Time {
- if p.Before != nil {
- return p.Before.Timestamp
+// ValidateCursor validates a cursor string format
+func ValidateCursor(cursorStr string) error {
+ if cursorStr == "" {
+ return nil
}
- return time.Time{}
+ _, err := DecodeCursor(cursorStr)
+ return err
}
-// StringToInt converts a string to int
-func StringToInt(s string) (int, error) {
- return strconv.Atoi(s)
+// CursorToOffset converts a cursor to an offset for hybrid pagination
+func CursorToOffset(cursorStr string, limit int) (int, error) {
+ if cursorStr == "" {
+ return 0, nil
+ }
+ cursor, err := DecodeCursor(cursorStr)
+ if err != nil {
+ return 0, err
+ }
+ _ = cursor // Use cursor timestamp/ID for offset calculation in real impl
+ return 0, nil
}
-// IntToString converts an int to string
-func IntToString(i int) string {
- return strconv.Itoa(i)
+// CalculateTotalPages calculates total pages for pagination meta
+func CalculateTotalPages(total, perPage int) int {
+ if perPage <= 0 {
+ return 0
+ }
+ pages := total / perPage
+ if total%perPage > 0 {
+ pages++
+ }
+ return pages
}
+
+// ParsePaginationFromQuery parses pagination from echo query params
+func ParsePaginationFromQuery(c interface {
+ QueryParam(key string) string
+}) (page, perPage int) {
+ page = 1
+ perPage = 10
+
+ if p := c.QueryParam("page"); p != "" {
+ if val, err := strconv.Atoi(p); err == nil && val > 0 {
+ page = val
+ }
+ }
+
+ if p := c.QueryParam("per_page"); p != "" {
+ if val, err := strconv.Atoi(p); err == nil && val > 0 && val <= 100 {
+ perPage = val
+ }
+ }
+
+ return page, perPage
+}
\ No newline at end of file
diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go
index 03f1095..11b10e4 100644
--- a/pkg/plugin/plugin.go
+++ b/pkg/plugin/plugin.go
@@ -62,7 +62,7 @@ type PluginStats struct {
ExecuteCount int64 `json:"execute_count"`
LastExecuteMs float64 `json:"last_execution_ms"`
TotalExecuteMs float64 `json:"total_execution_ms"`
- MemoryUsageBytes int64 `json:"memory_usage_bytes"`
+ MemoryUsageBytes int64 `json:"memory_usage_bytes"`
}
type PluginManagerMetrics struct {
diff --git a/pkg/plugin/registry.go b/pkg/plugin/registry.go
index 4db24b1..d45c6f7 100644
--- a/pkg/plugin/registry.go
+++ b/pkg/plugin/registry.go
@@ -4,10 +4,16 @@ import (
"fmt"
"sync"
"sync/atomic"
+ "time"
"github.com/spf13/afero"
)
+// cacheTTL bounds how stale a read-only snapshot of the registry may be. The
+// Prometheus/admin endpoints copy the full maps on every call; caching them
+// for a short window avoids that allocation during bursts of health polls.
+const cacheTTL = 2 * time.Second
+
type PluginFactory func(meta PluginMeta, fs afero.Fs) (Plugin, error)
type PluginRegistry struct {
@@ -18,6 +24,40 @@ type PluginRegistry struct {
stats map[string]*PluginStats
activeExecutions atomic.Int32
mu sync.RWMutex
+
+ // statsMu holds a per-plugin lock guarding the mutable counters inside
+ // PluginStats (ExecuteCount, LastExecuteMs, TotalExecuteMs). This keeps
+ // concurrent IncrementExecuteCount calls off the global registry lock and
+ // lets GetAllStats deep-copy without racing. PluginStats itself stays a
+ // plain value type so it can be safely copied/appended/serialised.
+ statsMu sync.Map // map[string]*sync.Mutex
+
+ // cacheMu guards the read-only snapshots below. cacheExpiry is set to the
+ // past by any mutating call (Store/Remove/SetMeta/SetStats/Increment*)
+ // so the next read rebuilds the snapshot.
+ cacheMu sync.Mutex
+ cacheExpiry time.Time
+ cachedPlugins map[string]Plugin
+ cachedMetas map[string]PluginMeta
+ cachedStats map[string]*PluginStats
+}
+
+// statsLock returns the per-plugin mutex for the named stats entry, creating
+// it on first use.
+func (r *PluginRegistry) statsLock(name string) *sync.Mutex {
+ if v, ok := r.statsMu.Load(name); ok {
+ return v.(*sync.Mutex)
+ }
+ m := &sync.Mutex{}
+ actual, _ := r.statsMu.LoadOrStore(name, m)
+ return actual.(*sync.Mutex)
+}
+
+// invalidateCache forces the next read of any cached snapshot to rebuild.
+func (r *PluginRegistry) invalidateCache() {
+ r.cacheMu.Lock()
+ r.cacheExpiry = time.Time{}
+ r.cacheMu.Unlock()
}
var (
@@ -52,12 +92,14 @@ func (r *PluginRegistry) SetMeta(name string, meta PluginMeta) {
r.mu.Lock()
defer r.mu.Unlock()
r.metas[name] = meta
+ r.invalidateCache()
}
func (r *PluginRegistry) SetFilesystem(name string, fs afero.Fs) {
r.mu.Lock()
defer r.mu.Unlock()
r.fsystems[name] = fs
+ r.invalidateCache()
}
func (r *PluginRegistry) Get(name string) (Plugin, bool) {
@@ -82,22 +124,48 @@ func (r *PluginRegistry) GetFilesystem(name string) (afero.Fs, bool) {
}
func (r *PluginRegistry) GetAll() map[string]Plugin {
+ r.cacheMu.Lock()
+ if time.Now().Before(r.cacheExpiry) && r.cachedPlugins != nil {
+ result := r.cachedPlugins
+ r.cacheMu.Unlock()
+ return result
+ }
+ r.cacheMu.Unlock()
+
r.mu.RLock()
- defer r.mu.RUnlock()
result := make(map[string]Plugin, len(r.plugins))
for k, v := range r.plugins {
result[k] = v
}
+ r.mu.RUnlock()
+
+ r.cacheMu.Lock()
+ r.cachedPlugins = result
+ r.cacheExpiry = time.Now().Add(cacheTTL)
+ r.cacheMu.Unlock()
return result
}
func (r *PluginRegistry) GetAllMetas() map[string]PluginMeta {
+ r.cacheMu.Lock()
+ if time.Now().Before(r.cacheExpiry) && r.cachedMetas != nil {
+ result := r.cachedMetas
+ r.cacheMu.Unlock()
+ return result
+ }
+ r.cacheMu.Unlock()
+
r.mu.RLock()
- defer r.mu.RUnlock()
result := make(map[string]PluginMeta, len(r.metas))
for k, v := range r.metas {
result[k] = v
}
+ r.mu.RUnlock()
+
+ r.cacheMu.Lock()
+ r.cachedMetas = result
+ r.cacheExpiry = time.Now().Add(cacheTTL)
+ r.cacheMu.Unlock()
return result
}
@@ -105,6 +173,7 @@ func (r *PluginRegistry) Store(name string, p Plugin) {
r.mu.Lock()
defer r.mu.Unlock()
r.plugins[name] = p
+ r.invalidateCache()
}
func (r *PluginRegistry) Remove(name string) {
@@ -115,6 +184,7 @@ func (r *PluginRegistry) Remove(name string) {
delete(r.metas, name)
delete(r.fsystems, name)
delete(r.stats, name)
+ r.invalidateCache()
}
func (r *PluginRegistry) HasFactory(name string) bool {
@@ -140,6 +210,7 @@ func (r *PluginRegistry) SetStats(name string, s *PluginStats) {
r.mu.Lock()
defer r.mu.Unlock()
r.stats[name] = s
+ r.invalidateCache()
}
func (r *PluginRegistry) GetStats(name string) (*PluginStats, bool) {
@@ -150,23 +221,46 @@ func (r *PluginRegistry) GetStats(name string) (*PluginStats, bool) {
}
func (r *PluginRegistry) GetAllStats() map[string]*PluginStats {
+ r.cacheMu.Lock()
+ if time.Now().Before(r.cacheExpiry) && r.cachedStats != nil {
+ result := r.cachedStats
+ r.cacheMu.Unlock()
+ return result
+ }
+ r.cacheMu.Unlock()
+
r.mu.RLock()
- defer r.mu.RUnlock()
result := make(map[string]*PluginStats, len(r.stats))
for k, v := range r.stats {
- result[k] = v
+ // Deep-copy the stats entry under its own lock so the cached snapshot
+ // is never mutated concurrently by IncrementExecuteCount.
+ r.statsLock(k).Lock()
+ cp := *v
+ r.statsLock(k).Unlock()
+ result[k] = &cp
}
+ r.mu.RUnlock()
+
+ r.cacheMu.Lock()
+ r.cachedStats = result
+ r.cacheExpiry = time.Now().Add(cacheTTL)
+ r.cacheMu.Unlock()
return result
}
func (r *PluginRegistry) IncrementExecuteCount(name string, durationMs float64) {
- r.mu.Lock()
- defer r.mu.Unlock()
- if s, ok := r.stats[name]; ok {
+ // Fast path: grab the stats pointer under RLock then mutate under per-entry
+ // lock. This avoids serialising all goroutines on the global write lock.
+ r.mu.RLock()
+ s, ok := r.stats[name]
+ r.mu.RUnlock()
+ if ok {
+ r.statsLock(name).Lock()
s.ExecuteCount++
s.LastExecuteMs = durationMs
s.TotalExecuteMs += durationMs
- _ = ok
+ r.statsLock(name).Unlock()
+ r.invalidateCache()
}
}
diff --git a/pkg/response/response.go b/pkg/response/response.go
index a786441..4f9c3e2 100644
--- a/pkg/response/response.go
+++ b/pkg/response/response.go
@@ -4,9 +4,7 @@ import (
"net/http"
"time"
- "fmt"
"github.com/labstack/echo/v4"
- "sync/atomic"
)
type Response struct {
@@ -18,7 +16,7 @@ type Response struct {
Meta *Meta `json:"meta,omitempty"`
Timestamp int64 `json:"timestamp"`
Datetime string `json:"datetime"`
- CorrelationID string `json:"correlation_id"`
+ CorrelationID string `json:"correlation_id,omitempty"`
}
type ErrorDetail struct {
@@ -207,24 +205,15 @@ func Error(c echo.Context, statusCode int, errorCode string, message string, det
}
func getCorrelationID(c echo.Context) string {
- id := c.Request().Header.Get("X-Request-ID")
- if id == "" {
- id = c.Request().Header.Get("X-Correlation-ID")
+ if id := c.Request().Header.Get("X-Request-ID"); id != "" {
+ return id
}
-
- if id == "" {
- id = genUUID()
+ if id := c.Request().Header.Get("X-Correlation-ID"); id != "" {
+ return id
}
- return id
-}
-
-var uuidCounter uint64
-
-func genUUID() string {
- hi := atomic.AddUint64(&uuidCounter, 1)
- lo := atomic.AddUint64(&uuidCounter, 1)
- return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
- uint32(hi>>32), uint16(hi), uint16(lo>>48), uint16(lo>>32), uint32(lo))
+ // No upstream ID: leave it empty and let the omitempty tag drop the field
+ // rather than paying 2 atomic adds + formatting for every response.
+ return ""
}
func CalculateMeta(page, perPage int, total int64, extra ...map[string]interface{}) *Meta {
diff --git a/pkg/tui/dashboard.go b/pkg/tui/dashboard.go
index d0e548c..0d1140e 100644
--- a/pkg/tui/dashboard.go
+++ b/pkg/tui/dashboard.go
@@ -64,9 +64,9 @@ var (
Padding(0, 2)
dashBoxStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#6272A4")).
- Padding(0, 1)
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("#6272A4")).
+ Padding(0, 2)
dashHeaderStyle = lipgloss.NewStyle().
Bold(true).
diff --git a/pkg/tui/live.go b/pkg/tui/live.go
index 3cad337..0b95834 100644
--- a/pkg/tui/live.go
+++ b/pkg/tui/live.go
@@ -479,7 +479,7 @@ func (m *LiveModel) View() string {
}
// Wrap entire content with minimal padding
- containerStyle := lipgloss.NewStyle().Padding(1)
+ containerStyle := lipgloss.NewStyle().Padding(7, 7, 0, 7)
return containerStyle.Render(b.String())
}
@@ -676,9 +676,9 @@ func parseLogLine(line string) (level, message string) {
// updateFilteredLogs filters the logs based on filterText
func (m *LiveModel) updateFilteredLogs() {
if m.filterText == "" {
- // No filter, show all logs
- m.filteredLogs = make([]LogEntry, len(m.allLogs))
- copy(m.filteredLogs, m.allLogs)
+ // No filter active: the full slice is already the filtered view, so
+ // alias it directly instead of copying every time AddLog is called.
+ m.filteredLogs = m.allLogs
return
}
diff --git a/pkg/tui/simple.go b/pkg/tui/simple.go
index b1b2a36..38af1cd 100644
--- a/pkg/tui/simple.go
+++ b/pkg/tui/simple.go
@@ -193,7 +193,7 @@ func (r *SimpleRenderer) PrintBox(title, content string) {
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#8daea5")).
- Padding(0, 1)
+ Padding(0, 2)
titleStyle := lipgloss.NewStyle().
Bold(true).
diff --git a/pkg/tui/startup.go b/pkg/tui/startup.go
index 5ab0c6f..12e984a 100644
--- a/pkg/tui/startup.go
+++ b/pkg/tui/startup.go
@@ -44,9 +44,9 @@ type StartupModel struct {
var (
// Title styles
titleStyle = lipgloss.NewStyle().
- Bold(true).
- Foreground(lipgloss.Color("#8daea5")).
- MarginBottom(1)
+ Bold(true).
+ Foreground(lipgloss.Color("#8daea5")).
+ MarginBottom(2)
subtitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8BE9FD")).
@@ -54,16 +54,16 @@ var (
// Banner style with gradient effect
bannerStyle = lipgloss.NewStyle().
- Foreground(lipgloss.Color("#BD93F9")).
- Bold(true).
- MarginBottom(1)
+ Foreground(lipgloss.Color("#BD93F9")).
+ Bold(true).
+ MarginBottom(2)
// Box styles
boxStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#6272A4")).
- Padding(1, 2).
- MarginTop(1)
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("#6272A4")).
+ Padding(1, 3).
+ MarginTop(1)
// Service status styles
pendingStyle = lipgloss.NewStyle().
@@ -255,7 +255,7 @@ func (m StartupModel) View() string {
b.WriteString(footerStyle.Render(footer))
// Wrap entire content with padding
- containerStyle := lipgloss.NewStyle().Padding(2)
+ containerStyle := lipgloss.NewStyle().Padding(3)
return containerStyle.Render(b.String())
}
@@ -264,7 +264,7 @@ func (m StartupModel) renderServices() string {
header := labelStyle.Render("◆ Services Initialization")
lines = append(lines, header)
- lines = append(lines, strings.Repeat("─", 40))
+ lines = append(lines, strings.Repeat("─", 50))
for i, s := range m.services {
var icon, status string
@@ -301,7 +301,7 @@ func (m StartupModel) renderServices() string {
line := fmt.Sprintf(" %s %s %s %s",
icon,
- lipgloss.NewStyle().Width(20).Render(name),
+ lipgloss.NewStyle().Width(25).Render(name),
iconArrow,
style.Render(status),
)
diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go
index bd1e070..3ecc497 100644
--- a/pkg/tui/styles.go
+++ b/pkg/tui/styles.go
@@ -35,31 +35,31 @@ func TextEffect(text string, colors []string) string {
var (
// Success box
SuccessBoxStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#50FA7B")).
- Foreground(lipgloss.Color("#50FA7B")).
- Padding(0, 1)
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("#50FA7B")).
+ Foreground(lipgloss.Color("#50FA7B")).
+ Padding(0, 2)
// Warning box
WarningBoxStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#F1FA8C")).
- Foreground(lipgloss.Color("#F1FA8C")).
- Padding(0, 1)
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("#F1FA8C")).
+ Foreground(lipgloss.Color("#F1FA8C")).
+ Padding(0, 2)
// Error box
ErrorBoxStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#FF5555")).
- Foreground(lipgloss.Color("#FF5555")).
- Padding(0, 1)
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("#FF5555")).
+ Foreground(lipgloss.Color("#FF5555")).
+ Padding(0, 2)
// Info box
InfoBoxStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#8BE9FD")).
- Foreground(lipgloss.Color("#8BE9FD")).
- Padding(0, 1)
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("#8BE9FD")).
+ Foreground(lipgloss.Color("#8BE9FD")).
+ Padding(0, 2)
// Primary box with double border
PrimaryBoxStyle = lipgloss.NewStyle().
diff --git a/pkg/utils/system.go b/pkg/utils/system.go
index a265e0d..317de23 100644
--- a/pkg/utils/system.go
+++ b/pkg/utils/system.go
@@ -214,16 +214,23 @@ func GetNetworkInfo() (map[string]string, error) {
}, nil
}
+// ResetTerminal restores terminal to main screen buffer and resets attributes
+// Useful after bubbletea alt-screen exits abnormally
+func ResetTerminal() {
+ os.Stdout.WriteString("\033[?1049l\033[0m\033[H")
+}
+
// ClearScreen clears the terminal screen (cross-platform)
+// Also resets terminal state: restores main screen buffer, cursor, and text attributes.
func ClearScreen() {
- var cmd *exec.Cmd
+ ResetTerminal()
+ os.Stdout.WriteString("\033[2J\033[3J")
+ var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
- // Windows: use cmd /c cls
cmd = exec.Command("cmd", "/c", "cls")
default:
- // Linux, macOS, and others: use clear command
cmd = exec.Command("clear")
}
diff --git a/scripts/build/build.go b/scripts/build/build.go
index 8eb808b..708a4f6 100644
--- a/scripts/build/build.go
+++ b/scripts/build/build.go
@@ -135,16 +135,16 @@ func (ctx *BuildContext) checkPath(logger *Logger) error {
return ctx.ensureProjectRoot(logger)
}
-// clear console screen
+// clear console screen and reset terminal state
func ClearScreen() {
+ fmt.Print("\033[?1049l\033[0m\033[H\033[2J\033[3J")
+
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
- // Windows: use cmd /c cls
cmd = exec.Command("cmd", "/c", "cls")
default:
- // Linux, macOS, and others: use clear command
cmd = exec.Command("clear")
}
@@ -1060,7 +1060,7 @@ func isTerminal() bool {
// runTUIBuild runs the build with the bubbletea TUI
func runTUIBuild(ctx *BuildContext, logger *Logger) {
defer func() {
- fmt.Print("\033[?25h\033[0m") // ensure cursor visible and attributes reset
+ fmt.Print("\033[?1049l\033[?25h\033[0m") // restore main buffer, show cursor, reset attrs
}()
_, err := RunBuildTUI(ctx, logger)
diff --git a/scripts/docker/docker_build.go b/scripts/docker/docker_build.go
index 226db30..245d560 100644
--- a/scripts/docker/docker_build.go
+++ b/scripts/docker/docker_build.go
@@ -500,8 +500,7 @@ func askVerbose(logger *DockerLogger) bool {
// main function
func main() {
- // Clear the terminal screen
- fmt.Print("\033[H\033[2J")
+ fmt.Print("\033[?1049l\033[0m\033[H\033[2J\033[3J")
// Initialize a temporary logger for interactive prompts
tempLogger := NewDockerLogger(false)
diff --git a/scripts/pkg/pkg.go b/scripts/pkg/pkg.go
index 68dc861..c233fac 100644
--- a/scripts/pkg/pkg.go
+++ b/scripts/pkg/pkg.go
@@ -255,7 +255,7 @@ func parseIndexLines(body string, logger *Logger) []*PackageInfo {
}
func fetchIndex(logger *Logger) ([]*PackageInfo, error) {
- logger.Info("Fetching index from %s", INDEX_URL)
+ logger.Info("Fetching package index...")
resp, err := http.Get(INDEX_URL)
if err != nil {
return nil, fmt.Errorf("failed to download index: %w", err)
@@ -368,28 +368,45 @@ func promptUserByName(packages []*PackageInfo, logger *Logger) (*PackageInfo, er
logger.Info("Selected package: %s", matches[0].Name)
return matches[0], nil
}
- fmt.Printf("\n%sMatching packages:%s\n", B_PURPLE, RESET)
- for _, pkg := range matches {
- fmt.Printf(" %s%s%s\n", B_WHITE, pkg.Name, RESET)
- }
+ const pageSize = 30
+ totalPages := (len(matches) + pageSize - 1) / pageSize
+ currentPage := 1
for {
- fmt.Printf("\n%sEnter the exact package name from the list (or 'search' to search again, 'cancel' to exit):%s ", B_YELLOW, RESET)
+ fmt.Printf("\n%sMatching packages (page %d/%d):%s\n", B_PURPLE, currentPage, totalPages, RESET)
+ start := (currentPage - 1) * pageSize
+ end := start + pageSize
+ if end > len(matches) {
+ end = len(matches)
+ }
+ for i, pkg := range matches[start:end] {
+ fmt.Printf(" %s%d.%s %s%s%s\n", P_CYAN, start+i+1, RESET, B_WHITE, pkg.Name, RESET)
+ }
+ nextHint := ""
+ if currentPage < totalPages {
+ nextHint = ", enter 'n' for next page"
+ }
+ fmt.Printf("\n%sSelect package by number (or 0 to search again%s, 'cancel' to exit):%s ", B_YELLOW, nextHint, RESET)
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
if strings.EqualFold(choice, "cancel") {
fmt.Println("Installation cancelled.")
os.Exit(0)
}
- if strings.EqualFold(choice, "search") {
- break
+ if strings.EqualFold(choice, "n") && currentPage < totalPages {
+ currentPage++
+ continue
}
- for _, pkg := range matches {
- if pkg.Name == choice {
- logger.Info("Selected package: %s", pkg.Name)
- return pkg, nil
+ idx := 0
+ if _, err := fmt.Sscanf(choice, "%d", &idx); err == nil {
+ if idx == 0 {
+ break
+ }
+ if idx >= 1 && idx <= len(matches) {
+ logger.Info("Selected package: %s", matches[idx-1].Name)
+ return matches[idx-1], nil
}
}
- fmt.Printf("%sInvalid package name. Please choose exactly from the list above.%s\n", P_RED, RESET)
+ fmt.Printf("%sInvalid choice. Enter a number between 1 and %d (or 0 to search again%s):%s ", P_RED, len(matches), nextHint, RESET)
}
}
}
@@ -1073,7 +1090,7 @@ func cmdUpdate(logger *Logger) {
if err != nil {
manifest = &Manifest{Meta: ManifestMeta{IndexURL: INDEX_URL}, Packages: make(map[string]InstalledPackage)}
}
- logger.Info("Fetching index from %s", INDEX_URL)
+ logger.Info("Fetching package index...")
resp, err := http.Get(INDEX_URL)
if err != nil {
logger.Error("Failed to fetch index: %v", err)
diff --git a/scripts/plugin/pkg.go b/scripts/plugin/pkg.go
index 8055227..4a10066 100644
--- a/scripts/plugin/pkg.go
+++ b/scripts/plugin/pkg.go
@@ -569,6 +569,8 @@ Run '%scommand -h%s' for subcommand-specific flags.
}
func main() {
+ fmt.Print("\033[?1049l\033[0m\033[H\033[2J\033[3J")
+
flag.Usage = func() {
printUsage()
}