refactor(layout): consolidate backend codebase into backend/ package and clean root directory

- Moved cmd/, core/, plugins/, pkg/, downstream/, and main.go into backend/ directory
- Batch updated all Go source files to import github.com/Rain-kl/Wavelet/backend/...
- Updated Makefile, scripts/swagger.sh, architecture guards, and platform skills
- Passed all quality gates (100% tests, 0 lint issues, clean build)
This commit is contained in:
ryan
2026-08-28 12:56:02 +08:00
parent 33b38f8687
commit 43dc97e48c
319 changed files with 912 additions and 1031 deletions
+492
View File
@@ -0,0 +1,492 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
)
const (
defaultShutdownTimeout = 10 * time.Second
)
// AppOption configures an App instance during construction.
type AppOption func(*App)
// WithContext sets a custom root Context for the App.
func WithContext(ctx *Context) AppOption {
return func(a *App) {
if ctx != nil {
a.ctx = ctx
}
}
}
// WithProfile sets the runtime profile for the App.
func WithProfile(profile Profile) AppOption {
return func(a *App) {
a.profile = normalizeProfile(profile)
}
}
// WithPlugins registers initial plugins for the App.
func WithPlugins(plugins ...Plugin) AppOption {
return func(a *App) {
a.Use(plugins...)
}
}
// WithMigrationEngine sets the database migration engine for the App.
func WithMigrationEngine(engine MigrationEngine) AppOption {
return func(a *App) {
a.migrationEngine = engine
}
}
// WithMigrationRunner sets the migration runner function for the App.
func WithMigrationRunner(runner MigrationRunner) AppOption {
return func(a *App) {
a.migrationEngine = runner
}
}
// WithShutdownTimeout sets the fallback timeout for graceful application shutdown.
func WithShutdownTimeout(timeout time.Duration) AppOption {
return func(a *App) {
if timeout > 0 {
a.shutdownTimeout = timeout
}
}
}
// App is the unified assembly entrypoint and runtime aspect dispatcher of the Cordis micro-kernel.
// It manages plugin collection, dependency mounting, migration execution, profile-based driver startup,
// and graceful signal-driven LIFO shutdown.
type App struct {
mu sync.RWMutex
ctx *Context
profile Profile
plugins []Plugin
pluginMap map[string]Plugin
applied bool
running bool
startedDrivers []Driver
migrationEngine MigrationEngine
shutdownTimeout time.Duration
}
// NewApp creates a new Cordis application instance with default options.
func NewApp(opts ...AppOption) *App {
app := &App{
ctx: NewContext(context.Background()),
profile: ProfileAll,
pluginMap: make(map[string]Plugin),
shutdownTimeout: defaultShutdownTimeout,
}
for _, opt := range opts {
if opt != nil {
opt(app)
}
}
return app
}
// Context returns the root micro-kernel Context of the application.
func (a *App) Context() *Context {
return a.ctx
}
// Profile returns the current runtime profile of the application.
func (a *App) Profile() Profile {
a.mu.RLock()
defer a.mu.RUnlock()
return a.profile
}
// WithProfile sets the application runtime profile and returns the App for fluent chaining.
func (a *App) WithProfile(profile Profile) *App {
a.mu.Lock()
defer a.mu.Unlock()
a.profile = normalizeProfile(profile)
return a
}
// SetProfile sets the application runtime profile.
func (a *App) SetProfile(profile Profile) *App {
return a.WithProfile(profile)
}
// Use registers one or more plugins into the application in registration order.
// Duplicate plugins (by Name) update existing registrations in-place to preserve order.
func (a *App) Use(plugins ...Plugin) *App {
a.mu.Lock()
defer a.mu.Unlock()
for _, p := range plugins {
if p == nil {
continue
}
name := p.Name()
if name == "" {
continue
}
if _, exists := a.pluginMap[name]; exists {
for i, existing := range a.plugins {
if existing.Name() == name {
a.plugins[i] = p
break
}
}
} else {
a.plugins = append(a.plugins, p)
}
a.pluginMap[name] = p
}
return a
}
// Plugins returns a copy of all registered plugins in registration order.
func (a *App) Plugins() []Plugin {
a.mu.RLock()
defer a.mu.RUnlock()
res := make([]Plugin, len(a.plugins))
copy(res, a.plugins)
return res
}
// Plugin retrieves a registered plugin by its unique name.
func (a *App) Plugin(name string) (Plugin, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
p, ok := a.pluginMap[name]
return p, ok
}
// SetMigrationEngine sets the migration engine for the application.
func (a *App) SetMigrationEngine(engine MigrationEngine) *App {
a.mu.Lock()
defer a.mu.Unlock()
a.migrationEngine = engine
return a
}
// SetMigrationRunner sets the migration runner function for the application.
func (a *App) SetMigrationRunner(runner MigrationRunner) *App {
return a.SetMigrationEngine(runner)
}
// ApplyPlugins applies all registered plugins on the application Context.
// It is idempotent and only applies plugins once per App instance.
func (a *App) ApplyPlugins() error {
a.mu.Lock()
if a.applied {
a.mu.Unlock()
return nil
}
a.applied = true
plugins := make([]Plugin, len(a.plugins))
copy(plugins, a.plugins)
a.mu.Unlock()
for _, p := range plugins {
if err := p.Apply(a.ctx); err != nil {
return fmt.Errorf("core: apply plugin %q failed: %w", p.Name(), err)
}
}
return nil
}
// RunMigrations dispatches migration execution across all registered plugin migration entries.
func (a *App) RunMigrations() error {
entries := a.ctx.Migrations().Entries()
if len(entries) == 0 {
return nil
}
a.mu.RLock()
engine := a.migrationEngine
a.mu.RUnlock()
if engine == nil {
// Attempt to resolve from IoC container
if resolved, err := Inject[MigrationEngine](a.ctx); err == nil && resolved != nil {
engine = resolved
}
}
if engine == nil {
return nil
}
if err := engine.Migrate(a.ctx, entries); err != nil {
return fmt.Errorf("core: migration failed: %w", err)
}
return nil
}
// Start executes the application boot pipeline:
// 1. Applies all registered plugins to populate services, routes, tasks, and drivers.
// 2. Dispatches database migrations via MigrationEngine.
// 3. Filters and starts drivers matching the active Profile.
// 4. Emits "app:ready" on the EventBus.
//
//nolint:contextcheck
func (a *App) Start(ctx ...context.Context) error {
a.mu.Lock()
if a.running {
a.mu.Unlock()
return ErrAppRunning
}
a.running = true
a.mu.Unlock()
var baseCtx context.Context
switch {
case len(ctx) > 0 && ctx[0] != nil:
baseCtx = ctx[0]
case a.ctx != nil:
baseCtx = a.ctx.GoContext()
default:
baseCtx = context.Background()
}
// 1. Apply plugins
if err := a.ApplyPlugins(); err != nil {
a.mu.Lock()
a.running = false
a.mu.Unlock()
return err
}
// 2. Run migrations
if err := a.RunMigrations(); err != nil {
a.mu.Lock()
a.running = false
a.mu.Unlock()
return err
}
// 3. Filter drivers matching active profile
a.mu.RLock()
prof := a.profile
a.mu.RUnlock()
allDrivers := a.ctx.Drivers()
var driversToStart []Driver
for _, d := range allDrivers {
if matchesProfile(prof, d.Type()) {
driversToStart = append(driversToStart, d)
}
}
// 4. Start matching drivers
for _, d := range driversToStart {
if err := d.Start(baseCtx); err != nil {
// Rollback already started drivers in reverse order
a.mu.Lock()
started := a.startedDrivers
a.startedDrivers = nil
a.running = false
a.mu.Unlock()
for i := len(started) - 1; i >= 0; i-- {
_ = started[i].Stop(context.Background())
}
return fmt.Errorf("core: start driver %s failed: %w", d.Type(), err)
}
a.mu.Lock()
a.startedDrivers = append(a.startedDrivers, d)
a.mu.Unlock()
}
// 5. Emit app:ready event
_ = a.ctx.Events().Emit(baseCtx, "app:ready", a)
return nil
}
// Stop gracefully shuts down the application:
// 1. Emits "app:stopping" on the EventBus.
// 2. Stops all started drivers in LIFO (reverse) order.
// 3. Disposes the Context (running registered OnDispose callbacks in LIFO order).
// 4. Emits "app:stopped" on the EventBus.
//
//nolint:contextcheck
func (a *App) Stop(ctx ...context.Context) error {
a.mu.Lock()
if !a.running {
a.mu.Unlock()
return nil
}
a.running = false
started := a.startedDrivers
a.startedDrivers = nil
timeout := a.shutdownTimeout
a.mu.Unlock()
var shutdownCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
shutdownCtx = ctx[0]
} else {
var cancel context.CancelFunc
shutdownCtx, cancel = context.WithTimeout(context.Background(), timeout)
defer cancel()
}
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopping", a)
var errs []error
// 1. Stop drivers in reverse order
for i := len(started) - 1; i >= 0; i-- {
d := started[i]
if err := d.Stop(shutdownCtx); err != nil {
errs = append(errs, fmt.Errorf("core: stop driver %s failed: %w", d.Type(), err))
}
}
// 2. Dispose context
if a.ctx != nil && !a.ctx.IsDisposed() {
if err := a.ctx.Dispose(); err != nil {
errs = append(errs, fmt.Errorf("core: dispose context failed: %w", err))
}
}
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopped", a)
return errors.Join(errs...)
}
// Run starts the application and blocks until an OS signal (SIGINT, SIGTERM) or context cancellation is received,
// then executes graceful shutdown.
//
//nolint:contextcheck
func (a *App) Run(ctx ...context.Context) error {
var parent context.Context
switch {
case len(ctx) > 0 && ctx[0] != nil:
parent = ctx[0]
case a.ctx != nil:
parent = a.ctx.GoContext()
default:
parent = context.Background()
}
sigCtx, stopSignals := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
defer stopSignals()
if err := a.Start(sigCtx); err != nil {
return err
}
// Wait for OS signal or context cancellation
<-sigCtx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)
defer cancel()
return a.Stop(shutdownCtx)
}
// IsRunning returns whether the application is currently running.
func (a *App) IsRunning() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.running
}
// StartedDrivers returns a copy of currently running drivers.
func (a *App) StartedDrivers() []Driver {
a.mu.RLock()
defer a.mu.RUnlock()
res := make([]Driver, len(a.startedDrivers))
copy(res, a.startedDrivers)
return res
}
// ExecuteCLI parses CLI arguments to configure the profile and runs the application.
func (a *App) ExecuteCLI(args ...string) error {
var ctx context.Context
if a.ctx != nil {
ctx = a.ctx.GoContext()
} else {
ctx = context.Background()
}
return a.ExecuteCLIWithContext(ctx, args...)
}
// ExecuteCLIWithContext parses CLI arguments, configures the profile, and runs the application with the given context.
//
//nolint:contextcheck
func (a *App) ExecuteCLIWithContext(ctx context.Context, args ...string) error {
cliArgs := args
if len(cliArgs) == 0 {
cliArgs = os.Args[1:]
}
profile := ProfileAll
if len(cliArgs) > 0 {
first := strings.TrimSpace(cliArgs[0])
switch {
case strings.HasPrefix(first, "--profile="):
profile = Profile(strings.TrimPrefix(first, "--profile="))
case strings.HasPrefix(first, "-p="):
profile = Profile(strings.TrimPrefix(first, "-p="))
case !strings.HasPrefix(first, "-"):
profile = Profile(first)
}
}
a.WithProfile(profile)
return a.Run(ctx)
}
func matchesProfile(profile Profile, dt DriverType) bool {
norm := normalizeProfile(profile)
switch norm {
case ProfileAll, "":
return true
case ProfileAPI:
return dt == DriverTypeHTTP
case ProfileWorker:
return dt == DriverTypeWorker
case ProfileSchedule:
return dt == DriverTypeScheduler
default:
return string(norm) == string(dt)
}
}
func normalizeProfile(p Profile) Profile {
switch strings.ToLower(strings.TrimSpace(string(p))) {
case "api", "http":
return ProfileAPI
case "worker":
return ProfileWorker
case "schedule", "scheduler", "cron":
return ProfileSchedule
case "all", "fused", "full", "":
return ProfileAll
default:
return p
}
}
+507
View File
@@ -0,0 +1,507 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"context"
"errors"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Rain-kl/Wavelet/backend/core"
"github.com/Rain-kl/Wavelet/backend/core/extpoints"
)
// appMockDriver is a test driver tracking its start/stop lifecycle.
type appMockDriver struct {
mu sync.Mutex
driverType core.DriverType
startCalled bool
stopCalled bool
startErr error
stopErr error
}
func newAppMockDriver(dt core.DriverType) *appMockDriver {
return &appMockDriver{driverType: dt}
}
func (m *appMockDriver) Type() core.DriverType {
return m.driverType
}
func (m *appMockDriver) Start(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.startErr != nil {
return m.startErr
}
m.startCalled = true
return nil
}
func (m *appMockDriver) Stop(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.stopErr != nil {
return m.stopErr
}
m.stopCalled = true
return nil
}
func (m *appMockDriver) isStarted() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.startCalled
}
func (m *appMockDriver) isStopped() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.stopCalled
}
// appMockPlugin is a test plugin.
type appMockPlugin struct {
name string
applyFn func(ctx *core.Context) error
}
func (p *appMockPlugin) Name() string {
return p.name
}
func (p *appMockPlugin) Apply(ctx *core.Context) error {
if p.applyFn != nil {
return p.applyFn(ctx)
}
return nil
}
func TestAppNewAndConfiguration(t *testing.T) {
customCtx := core.NewContext(context.Background())
p1 := &appMockPlugin{name: "plugin1"}
p2 := &appMockPlugin{name: "plugin2"}
app := core.NewApp(
core.WithContext(customCtx),
core.WithProfile(core.ProfileAPI),
core.WithPlugins(p1, p2),
core.WithShutdownTimeout(5*time.Second),
)
assert.Equal(t, customCtx, app.Context())
assert.Equal(t, core.ProfileAPI, app.Profile())
assert.Len(t, app.Plugins(), 2)
retrieved, ok := app.Plugin("plugin1")
assert.True(t, ok)
assert.Equal(t, p1, retrieved)
_, ok = app.Plugin("non_existent")
assert.False(t, ok)
// Update existing plugin in-place
p1Updated := &appMockPlugin{name: "plugin1"}
app.Use(p1Updated, nil)
assert.Len(t, app.Plugins(), 2)
retrieved, ok = app.Plugin("plugin1")
assert.True(t, ok)
assert.Equal(t, p1Updated, retrieved)
// Test SetProfile
app.SetProfile(core.ProfileWorker)
assert.Equal(t, core.ProfileWorker, app.Profile())
}
func TestAppProfileDispatch(t *testing.T) {
tests := []struct {
name string
profile core.Profile
expectedHTTP bool
expectedWorker bool
expectedCron bool
expectedCustom bool
}{
{
name: "ProfileAPI only starts HTTP driver",
profile: core.ProfileAPI,
expectedHTTP: true,
expectedWorker: false,
expectedCron: false,
expectedCustom: false,
},
{
name: "ProfileWorker only starts Worker driver",
profile: core.ProfileWorker,
expectedHTTP: false,
expectedWorker: true,
expectedCron: false,
expectedCustom: false,
},
{
name: "ProfileSchedule only starts Schedule driver",
profile: core.ProfileSchedule,
expectedHTTP: false,
expectedWorker: false,
expectedCron: true,
expectedCustom: false,
},
{
name: "Profile 'scheduler' alias starts Schedule driver",
profile: core.Profile("scheduler"),
expectedHTTP: false,
expectedWorker: false,
expectedCron: true,
expectedCustom: false,
},
{
name: "ProfileAll starts all drivers",
profile: core.ProfileAll,
expectedHTTP: true,
expectedWorker: true,
expectedCron: true,
expectedCustom: true,
},
{
name: "Custom profile starts custom driver",
profile: core.Profile("custom_rpc"),
expectedHTTP: false,
expectedWorker: false,
expectedCron: false,
expectedCustom: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpD := newAppMockDriver(core.DriverTypeHTTP)
workerD := newAppMockDriver(core.DriverTypeWorker)
cronD := newAppMockDriver(core.DriverTypeScheduler)
customD := newAppMockDriver(core.DriverType("custom_rpc"))
p := &appMockPlugin{
name: "drivers_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(httpD)
_ = ctx.RegisterDriver(workerD)
_ = ctx.RegisterDriver(cronD)
_ = ctx.RegisterDriver(customD)
return nil
},
}
app := core.NewApp(
core.WithProfile(tt.profile),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.NoError(t, err)
assert.Equal(t, tt.expectedHTTP, httpD.isStarted(), "HTTP driver start mismatch")
assert.Equal(t, tt.expectedWorker, workerD.isStarted(), "Worker driver start mismatch")
assert.Equal(t, tt.expectedCron, cronD.isStarted(), "Cron driver start mismatch")
assert.Equal(t, tt.expectedCustom, customD.isStarted(), "Custom driver start mismatch")
err = app.Stop(context.Background())
require.NoError(t, err)
})
}
}
func TestAppLifecycleStartStop(t *testing.T) {
var stopOrder []string
var stopOrderMu sync.Mutex
httpD := newAppMockDriver(core.DriverTypeHTTP)
workerD := newAppMockDriver(core.DriverTypeWorker)
httpD.stopErr = nil
workerD.stopErr = nil
// Wrap stop to record order
origHttpStop := httpD.Stop
_ = origHttpStop
p := &appMockPlugin{
name: "test_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(httpD)
_ = ctx.RegisterDriver(workerD)
ctx.OnDispose(func() error {
stopOrderMu.Lock()
stopOrder = append(stopOrder, "ctx_disposer")
stopOrderMu.Unlock()
return nil
})
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
var readyReceived, stoppingReceived, stoppedReceived bool
app.Context().Events().On("app:ready", func() {
readyReceived = true
})
app.Context().Events().On("app:stopping", func() {
stoppingReceived = true
})
app.Context().Events().On("app:stopped", func() {
stoppedReceived = true
})
err := app.Start(context.Background())
require.NoError(t, err)
assert.True(t, app.IsRunning())
assert.Len(t, app.StartedDrivers(), 2)
assert.True(t, readyReceived)
err = app.Stop(context.Background())
require.NoError(t, err)
assert.False(t, app.IsRunning())
assert.Empty(t, app.StartedDrivers())
assert.True(t, stoppingReceived)
assert.True(t, stoppedReceived)
assert.True(t, httpD.isStopped())
assert.True(t, workerD.isStopped())
assert.True(t, app.Context().IsDisposed())
stopOrderMu.Lock()
assert.Contains(t, stopOrder, "ctx_disposer")
stopOrderMu.Unlock()
}
func TestAppStartDriverFailureRollback(t *testing.T) {
driver1 := newAppMockDriver(core.DriverTypeHTTP)
driver2 := newAppMockDriver(core.DriverTypeWorker)
driver2.startErr = errors.New("worker listen port conflict")
driver3 := newAppMockDriver(core.DriverTypeScheduler)
p := &appMockPlugin{
name: "fail_driver_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(driver1)
_ = ctx.RegisterDriver(driver2)
_ = ctx.RegisterDriver(driver3)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "worker listen port conflict")
assert.False(t, app.IsRunning())
// Driver 1 was started then rolled back (stopped)
assert.True(t, driver1.isStarted())
assert.True(t, driver1.isStopped())
// Driver 3 was never started
assert.False(t, driver3.isStarted())
}
func TestAppMigrationEngineExecution(t *testing.T) {
var migratedEntries []extpoints.MigrationEntry
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
migratedEntries = entries
return nil
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE users(id int);")},
}
p := &appMockPlugin{
name: "auth",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("auth", sqlFS)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
core.WithMigrationRunner(runner),
)
err := app.Start(context.Background())
require.NoError(t, err)
defer func() { _ = app.Stop(context.Background()) }()
require.Len(t, migratedEntries, 1)
assert.Equal(t, "auth", migratedEntries[0].PluginID)
}
func TestAppMigrationEngineFromIoCContainer(t *testing.T) {
var executed bool
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
executed = true
return nil
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE logs(id int);")},
}
p := &appMockPlugin{
name: "logstore",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("logstore", sqlFS)
core.Provide[core.MigrationEngine](ctx, runner)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.NoError(t, err)
defer func() { _ = app.Stop(context.Background()) }()
assert.True(t, executed)
}
func TestAppRunContextCancellation(t *testing.T) {
d := newAppMockDriver(core.DriverTypeHTTP)
p := &appMockPlugin{
name: "http_plugin",
applyFn: func(ctx *core.Context) error {
return ctx.RegisterDriver(d)
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAPI),
core.WithPlugins(p),
core.WithShutdownTimeout(1*time.Second),
)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- app.Run(ctx)
}()
// Wait briefly then cancel
time.Sleep(50 * time.Millisecond)
assert.True(t, app.IsRunning())
assert.True(t, d.isStarted())
cancel()
select {
case err := <-errCh:
assert.NoError(t, err)
assert.False(t, app.IsRunning())
assert.True(t, d.isStopped())
case <-time.After(3 * time.Second):
t.Fatal("app.Run did not terminate upon context cancellation")
}
}
func TestAppExecuteCLI(t *testing.T) {
// Test CLI argument parsing logic
tests := []struct {
args []string
expectedProfile core.Profile
}{
{args: []string{"api"}, expectedProfile: core.ProfileAPI},
{args: []string{"worker"}, expectedProfile: core.ProfileWorker},
{args: []string{"scheduler"}, expectedProfile: core.ProfileSchedule},
{args: []string{"schedule"}, expectedProfile: core.ProfileSchedule},
{args: []string{"all"}, expectedProfile: core.ProfileAll},
{args: []string{"--profile=worker"}, expectedProfile: core.ProfileWorker},
{args: []string{"-p=api"}, expectedProfile: core.ProfileAPI},
}
for _, tt := range tests {
t.Run(tt.args[0], func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
// Use custom root context to control cancellation
customApp := core.NewApp(core.WithContext(core.NewContext(ctx)))
_ = customApp.ExecuteCLI(tt.args...)
assert.Equal(t, tt.expectedProfile, customApp.Profile())
})
}
}
func TestAppIdempotencyAndErrorStates(t *testing.T) {
app := core.NewApp()
// Double start returns error
err := app.Start(context.Background())
require.NoError(t, err)
err = app.Start(context.Background())
assert.ErrorIs(t, err, core.ErrAppRunning)
// Stop clears running state
err = app.Stop(context.Background())
require.NoError(t, err)
// Double stop succeeds
err = app.Stop(context.Background())
require.NoError(t, err)
// Plugin apply failure
failPlugin := &appMockPlugin{
name: "failing_plugin",
applyFn: func(ctx *core.Context) error {
return errors.New("plugin init boom")
},
}
app2 := core.NewApp(core.WithPlugins(failPlugin))
err = app2.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "plugin init boom")
assert.False(t, app2.IsRunning())
// Migration failure
migFailRunner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
return errors.New("sql migrate error")
})
sqlFS := fstest.MapFS{
"migrations/001.sql": &fstest.MapFile{Data: []byte("...")},
}
migPlugin := &appMockPlugin{
name: "db_plugin",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("db_plugin", sqlFS)
return nil
},
}
app3 := core.NewApp(
core.WithPlugins(migPlugin),
core.WithMigrationRunner(migFailRunner),
)
err = app3.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "sql migrate error")
assert.False(t, app3.IsRunning())
}
+193
View File
@@ -0,0 +1,193 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package core provides the micro-kernel service bus, generic IoC container, and runtime extensions.
package core
import (
"fmt"
"reflect"
"sync"
)
// Container manages service registration and resolution using Go reflection and generics.
type Container struct {
mu sync.RWMutex
parent *Container
services map[reflect.Type]any
listeners map[reflect.Type][]func(any)
}
// NewContainer creates a new IoC container instance with an optional parent container.
func NewContainer(parent *Container) *Container {
return &Container{
parent: parent,
services: make(map[reflect.Type]any),
listeners: make(map[reflect.Type][]func(any)),
}
}
func isNil(i any) bool {
if i == nil {
return true
}
v := reflect.ValueOf(i)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, reflect.Interface, reflect.Slice:
return v.IsNil()
default:
return false
}
}
// Provide registers a typed service implementation into the Context's IoC container.
func Provide[T any](ctx *Context, service T) {
if ctx == nil {
panic("core: nil context provided to Provide")
}
if isNil(service) {
panic("core: cannot provide nil service")
}
targetType := reflect.TypeFor[T]()
ctx.Container().provide(targetType, service)
}
func (c *Container) provide(targetType reflect.Type, service any) {
c.mu.Lock()
c.services[targetType] = service
// Collect any matching listeners to invoke outside the lock
var callbacks []func(any)
svcType := reflect.TypeOf(service)
for lType, cbs := range c.listeners {
if lType == targetType || (lType.Kind() == reflect.Interface && svcType.Implements(lType)) {
callbacks = append(callbacks, cbs...)
}
}
c.mu.Unlock()
for _, cb := range callbacks {
cb(service)
}
}
// Inject resolves a registered service of type T from the Context.
func Inject[T any](ctx *Context) (T, error) {
var zero T
if ctx == nil {
return zero, ErrNilContext
}
targetType := reflect.TypeFor[T]()
val, err := ctx.Container().resolve(targetType)
if err != nil {
return zero, err
}
typedVal, ok := val.(T)
if !ok {
return zero, fmt.Errorf("%w: cannot cast %T to %v", ErrServiceNotFound, val, targetType)
}
return typedVal, nil
}
func (c *Container) resolve(targetType reflect.Type) (any, error) {
c.mu.RLock()
// 1. Direct type match
if val, ok := c.services[targetType]; ok {
c.mu.RUnlock()
return val, nil
}
// 2. Interface assignment scan
if targetType.Kind() == reflect.Interface {
for _, val := range c.services {
if reflect.TypeOf(val).Implements(targetType) {
c.mu.RUnlock()
return val, nil
}
}
}
c.mu.RUnlock()
// 3. Fallback to parent container
if c.parent != nil {
return c.parent.resolve(targetType)
}
return nil, fmt.Errorf("%w: %v", ErrServiceNotFound, targetType)
}
// MustInject resolves a service of type T or panics if the service is not found.
func MustInject[T any](ctx *Context) T {
s, err := Inject[T](ctx)
if err != nil {
panic(fmt.Sprintf("core: failed to inject service %v: %v", reflect.TypeFor[T](), err))
}
return s
}
// Has returns true if a service of type T is registered and resolvable in the Context.
func Has[T any](ctx *Context) bool {
_, err := Inject[T](ctx)
return err == nil
}
// Using executes the given function synchronously if the required dependency is ready.
func Using[T1 any](ctx *Context, fn func(s1 T1)) error {
s1, err := Inject[T1](ctx)
if err != nil {
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
}
fn(s1)
return nil
}
// Using2 executes the given function synchronously if both required dependencies are ready.
func Using2[T1, T2 any](ctx *Context, fn func(s1 T1, s2 T2)) error {
s1, err1 := Inject[T1](ctx)
s2, err2 := Inject[T2](ctx)
if err1 != nil || err2 != nil {
return fmt.Errorf("%w: (dep1: %v, dep2: %v)", ErrServiceNotReady, err1, err2)
}
fn(s1, s2)
return nil
}
// Using3 executes the given function synchronously if all 3 required dependencies are ready.
func Using3[T1, T2, T3 any](ctx *Context, fn func(s1 T1, s2 T2, s3 T3)) error {
s1, err1 := Inject[T1](ctx)
s2, err2 := Inject[T2](ctx)
s3, err3 := Inject[T3](ctx)
if err1 != nil || err2 != nil || err3 != nil {
return fmt.Errorf("%w: (dep1: %v, dep2: %v, dep3: %v)", ErrServiceNotReady, err1, err2, err3)
}
fn(s1, s2, s3)
return nil
}
// When registers a reactive hook that is called immediately if T is already provided,
// or called as soon as T is provided in the future.
func When[T any](ctx *Context, fn func(s T)) {
if ctx == nil {
panic("core: nil context provided to When")
}
targetType := reflect.TypeFor[T]()
c := ctx.Container()
// If already ready, execute immediately
if s, err := Inject[T](ctx); err == nil {
fn(s)
}
// Also register listener for future calls / updates
c.mu.Lock()
defer c.mu.Unlock()
c.listeners[targetType] = append(c.listeners[targetType], func(val any) {
if typed, ok := val.(T); ok {
fn(typed)
}
})
}
+341
View File
@@ -0,0 +1,341 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/Rain-kl/Wavelet/backend/core/extpoints"
)
// Context is the central micro-kernel service bus and runtime lifecycle container.
// It embeds Go standard context.Context compatibility, hierarchical scoping,
// service resolution, and LIFO disposer teardown.
type Context struct {
goCtx context.Context
cancel context.CancelFunc
parent *Context
container *Container
events *EventBus
router extpoints.RouterExtension
migrations extpoints.MigrationExtension
tasks extpoints.TaskExtension
schedules extpoints.ScheduleExtension
settings extpoints.SettingExtension
mu sync.RWMutex
children []*Context
disposers []Disposer
drivers []Driver
values map[any]any
disposed bool
}
// NewContext creates a new root Context wrapping a standard Go context.
// If base is nil, context.Background() is used by default.
//
//nolint:contextcheck
func NewContext(base context.Context) *Context {
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithCancel(base)
return &Context{
goCtx: ctx,
cancel: cancel,
container: NewContainer(nil),
events: NewEventBus(),
router: extpoints.NewRouterRegistry(),
migrations: extpoints.NewMigrationRegistry(),
tasks: extpoints.NewTaskRegistry(),
schedules: extpoints.NewScheduleRegistry(),
settings: extpoints.NewSettingRegistry(),
values: make(map[any]any),
}
}
// Deadline returns the time when work done on behalf of this context should be canceled.
func (c *Context) Deadline() (deadline time.Time, ok bool) {
return c.goCtx.Deadline()
}
// Done returns a channel that's closed when work done on behalf of this context should be canceled.
func (c *Context) Done() <-chan struct{} {
return c.goCtx.Done()
}
// Err returns a non-nil error value after Done is closed.
func (c *Context) Err() error {
return c.goCtx.Err()
}
// Value returns the value associated with key, searching the local values map,
// the underlying Go context, and fallback parent Contexts.
func (c *Context) Value(key any) any {
c.mu.RLock()
if v, ok := c.values[key]; ok {
c.mu.RUnlock()
return v
}
c.mu.RUnlock()
if v := c.goCtx.Value(key); v != nil {
return v
}
if c.parent != nil {
return c.parent.Value(key)
}
return nil
}
// GoContext returns the underlying standard Go context.Context.
func (c *Context) GoContext() context.Context {
return c.goCtx
}
// Set stores an arbitrary key-value pair in this Context's local storage.
func (c *Context) Set(key any, val any) {
c.mu.Lock()
defer c.mu.Unlock()
if c.values == nil {
c.values = make(map[any]any)
}
c.values[key] = val
}
// Get retrieves a key-value pair from this Context's local storage.
func (c *Context) Get(key any) (any, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.values == nil {
return nil, false
}
v, ok := c.values[key]
return v, ok
}
// Container returns the underlying IoC container for this Context.
func (c *Context) Container() *Container {
return c.container
}
// Parent returns the parent Context, or nil if this is a root Context.
func (c *Context) Parent() *Context {
return c.parent
}
// Fork creates a child Context with its own scoped IoC container and values,
// linked to this Context for hierarchical fallback resolution and cascading teardown.
func (c *Context) Fork() *Context {
return c.ForkWithContext(c.goCtx)
}
// ForkWithContext creates a child Context using a specific standard Go context.
//
//nolint:contextcheck
func (c *Context) ForkWithContext(base context.Context) *Context {
if base == nil {
base = c.goCtx
}
ctx, cancel := context.WithCancel(base)
child := &Context{
goCtx: ctx,
cancel: cancel,
parent: c,
container: NewContainer(c.container),
events: c.Events(),
router: c.Router(),
migrations: c.Migrations(),
tasks: c.Tasks(),
schedules: c.Schedules(),
settings: c.Settings(),
values: make(map[any]any),
}
c.mu.Lock()
c.children = append(c.children, child)
c.mu.Unlock()
return child
}
// Events returns the domain EventBus associated with this Context hierarchy.
func (c *Context) Events() *EventBus {
return c.events
}
// Router returns the RouterExtension registry.
func (c *Context) Router() extpoints.RouterExtension {
return c.router
}
// Migrations returns the MigrationExtension registry.
func (c *Context) Migrations() extpoints.MigrationExtension {
return c.migrations
}
// Tasks returns the TaskExtension registry.
func (c *Context) Tasks() extpoints.TaskExtension {
return c.tasks
}
// Task is an alias for Tasks().
func (c *Context) Task() extpoints.TaskExtension {
return c.tasks
}
// Schedules returns the ScheduleExtension registry.
func (c *Context) Schedules() extpoints.ScheduleExtension {
return c.schedules
}
// Schedule is an alias for Schedules().
func (c *Context) Schedule() extpoints.ScheduleExtension {
return c.schedules
}
// Settings returns the SettingExtension registry.
func (c *Context) Settings() extpoints.SettingExtension {
return c.settings
}
// Setting is an alias for Settings().
func (c *Context) Setting() extpoints.SettingExtension {
return c.settings
}
// OnDispose registers a cleanup callback function to be executed when this Context is disposed.
// It accepts func() error, func(), or Disposer.
func (c *Context) OnDispose(fn any) {
if fn == nil {
return
}
var d Disposer
switch f := fn.(type) {
case Disposer:
d = f
case func() error:
d = f
case func():
d = func() error {
f()
return nil
}
default:
panic(fmt.Sprintf("core: OnDispose expects func() error or func(), got %T", fn))
}
c.mu.Lock()
defer c.mu.Unlock()
c.disposers = append(c.disposers, d)
}
// Dispose shuts down this Context and all child Contexts, running registered disposers in LIFO order.
func (c *Context) Dispose() error {
c.mu.Lock()
if c.disposed {
c.mu.Unlock()
return nil
}
c.disposed = true
// Copy children and disposers under lock
children := make([]*Context, len(c.children))
copy(children, c.children)
disposers := make([]Disposer, len(c.disposers))
copy(disposers, c.disposers)
c.mu.Unlock()
var errs []error
// 1. Dispose all child contexts in reverse order
for i := len(children) - 1; i >= 0; i-- {
if err := children[i].Dispose(); err != nil {
errs = append(errs, err)
}
}
// 2. Run local disposers in LIFO order
for i := len(disposers) - 1; i >= 0; i-- {
if err := disposers[i](); err != nil {
errs = append(errs, err)
}
}
// 3. Cancel the Go context
if c.cancel != nil {
c.cancel()
}
// 4. Detach from parent
if c.parent != nil {
c.parent.removeChild(c)
}
return errors.Join(errs...)
}
func (c *Context) removeChild(target *Context) {
c.mu.Lock()
defer c.mu.Unlock()
for i, child := range c.children {
if child == target {
c.children = append(c.children[:i], c.children[i+1:]...)
break
}
}
}
// IsDisposed returns true if this Context has been disposed.
func (c *Context) IsDisposed() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.disposed
}
// RegisterDriver registers a runtime driver engine on this Context.
func (c *Context) RegisterDriver(d Driver) error {
if d == nil {
return ErrNilService
}
c.mu.Lock()
defer c.mu.Unlock()
c.drivers = append(c.drivers, d)
return nil
}
// Drivers returns a copy of all drivers registered on this Context.
func (c *Context) Drivers() []Driver {
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]Driver, len(c.drivers))
copy(result, c.drivers)
return result
}
// Driver looks up a registered driver by its driver type.
func (c *Context) Driver(driverType DriverType) (Driver, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
for _, d := range c.drivers {
if d.Type() == driverType {
return d, true
}
}
return nil, false
}
+502
View File
@@ -0,0 +1,502 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Rain-kl/Wavelet/backend/core"
)
// Sample services for testing
type SampleService interface {
Greet(name string) string
}
type sampleServiceImpl struct {
prefix string
}
func (s *sampleServiceImpl) Greet(name string) string {
if s.prefix != "" {
return s.prefix + " " + name
}
return "Hello, " + name
}
type LogService interface {
Log(msg string)
}
type logServiceImpl struct {
logs []string
}
func (l *logServiceImpl) Log(msg string) {
l.logs = append(l.logs, msg)
}
type ConfigService interface {
Get(key string) string
}
type configServiceImpl struct {
data map[string]string
}
func (c *configServiceImpl) Get(key string) string {
return c.data[key]
}
// Sample plugin for testing
type samplePlugin struct {
name string
}
func (p *samplePlugin) Name() string {
return p.name
}
func (p *samplePlugin) Apply(ctx *core.Context) error {
core.Provide[SampleService](ctx, &sampleServiceImpl{prefix: "Plugin:"})
return nil
}
func (p *samplePlugin) Manifest() core.Manifest {
return core.Manifest{
Name: p.name,
Version: "1.0.0",
Description: "Sample plugin",
}
}
// Sample driver for testing
type mockDriver struct {
driverType core.DriverType
started bool
stopped bool
}
func (m *mockDriver) Type() core.DriverType {
return m.driverType
}
func (m *mockDriver) Start(ctx context.Context) error {
m.started = true
return nil
}
func (m *mockDriver) Stop(ctx context.Context) error {
m.stopped = true
return nil
}
func TestContextProvideAndInject(t *testing.T) {
ctx := core.NewContext(context.Background())
// Before providing, Inject should fail
_, err := core.Inject[SampleService](ctx)
require.Error(t, err)
assert.True(t, errors.Is(err, core.ErrServiceNotFound))
assert.False(t, core.Has[SampleService](ctx))
// MustInject should panic
assert.Panics(t, func() {
core.MustInject[SampleService](ctx)
})
// Provide service
svcImpl := &sampleServiceImpl{prefix: "Hello,"}
core.Provide[SampleService](ctx, svcImpl)
// Inject should succeed
assert.True(t, core.Has[SampleService](ctx))
svc, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Hello, Wavelet", svc.Greet("Wavelet"))
// MustInject should succeed
mustSvc := core.MustInject[SampleService](ctx)
assert.Equal(t, "Hello, Cordis", mustSvc.Greet("Cordis"))
}
func TestContextProvideNilPanics(t *testing.T) {
ctx := core.NewContext(context.Background())
assert.Panics(t, func() {
core.Provide[SampleService](nil, &sampleServiceImpl{})
})
assert.Panics(t, func() {
var nilSvc SampleService
core.Provide[SampleService](ctx, nilSvc)
})
assert.Panics(t, func() {
var nilImpl *sampleServiceImpl
core.Provide[*sampleServiceImpl](ctx, nilImpl)
})
// Inject with nil context
var nilCtx *core.Context
_, err := core.Inject[SampleService](nilCtx)
assert.ErrorIs(t, err, core.ErrNilContext)
}
func TestContextUsing(t *testing.T) {
ctx := core.NewContext(context.Background())
var called bool
// Using when service not ready should return ErrServiceNotReady
err := core.Using(ctx, func(s SampleService) {
called = true
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
})
assert.Error(t, err)
assert.True(t, errors.Is(err, core.ErrServiceNotReady))
assert.False(t, called)
// Provide service and try Using again
core.Provide[SampleService](ctx, &sampleServiceImpl{})
err = core.Using(ctx, func(s SampleService) {
called = true
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
})
assert.NoError(t, err)
assert.True(t, called)
}
func TestContextUsingMultiple(t *testing.T) {
ctx := core.NewContext(context.Background())
// Using2 with missing dependencies
var called2 bool
err := core.Using2(ctx, func(s SampleService, l LogService) {
called2 = true
})
assert.Error(t, err)
assert.False(t, called2)
// Provide 1 of 2
core.Provide[SampleService](ctx, &sampleServiceImpl{})
err = core.Using2(ctx, func(s SampleService, l LogService) {
called2 = true
})
assert.Error(t, err)
assert.False(t, called2)
// Provide 2 of 2
logSvc := &logServiceImpl{}
core.Provide[LogService](ctx, logSvc)
err = core.Using2(ctx, func(s SampleService, l LogService) {
called2 = true
l.Log(s.Greet("World"))
})
assert.NoError(t, err)
assert.True(t, called2)
assert.Equal(t, []string{"Hello, World"}, logSvc.logs)
// Using3 test - error condition
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {})
assert.Error(t, err)
// Using3 test - success condition
var called3 bool
cfgSvc := &configServiceImpl{data: map[string]string{"env": "test"}}
core.Provide[ConfigService](ctx, cfgSvc)
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {
called3 = true
assert.Equal(t, "test", c.Get("env"))
})
assert.NoError(t, err)
assert.True(t, called3)
}
func TestContextHierarchyAndFork(t *testing.T) {
parent := core.NewContext(nil) // nil base context test
core.Provide[SampleService](parent, &sampleServiceImpl{prefix: "Parent:"})
child := parent.ForkWithContext(nil) // nil child context test
require.NotNil(t, child)
assert.Equal(t, parent, child.Parent())
// Child can resolve service from parent
svc, err := core.Inject[SampleService](child)
require.NoError(t, err)
assert.Equal(t, "Parent: Ryan", svc.Greet("Ryan"))
// Child provides LogService
childLog := &logServiceImpl{}
core.Provide[LogService](child, childLog)
// Child has LogService, parent does not
assert.True(t, core.Has[LogService](child))
assert.False(t, core.Has[LogService](parent))
// Child overrides SampleService
core.Provide[SampleService](child, &sampleServiceImpl{prefix: "Child:"})
childSvc, err := core.Inject[SampleService](child)
require.NoError(t, err)
assert.Equal(t, "Child: Ryan", childSvc.Greet("Ryan"))
parentSvc, err := core.Inject[SampleService](parent)
require.NoError(t, err)
assert.Equal(t, "Parent: Ryan", parentSvc.Greet("Ryan"))
}
func TestContextReactiveWhen(t *testing.T) {
ctx := core.NewContext(context.Background())
assert.Panics(t, func() {
core.When[SampleService](nil, func(s SampleService) {})
})
var whenCalled atomic.Bool
var greeted string
// Register When before service is provided
core.When[SampleService](ctx, func(s SampleService) {
whenCalled.Store(true)
greeted = s.Greet("Reactive")
})
assert.False(t, whenCalled.Load())
// Now Provide the service - listener should trigger
core.Provide[SampleService](ctx, &sampleServiceImpl{})
assert.True(t, whenCalled.Load())
assert.Equal(t, "Hello, Reactive", greeted)
// Register another When after service is already provided - should trigger immediately
var immediateCalled bool
core.When[SampleService](ctx, func(s SampleService) {
immediateCalled = true
})
assert.True(t, immediateCalled)
}
func TestContextDisposerLifecycle(t *testing.T) {
parent := core.NewContext(context.Background())
child := parent.Fork()
var order []string
// Test nil disposer
parent.OnDispose(nil)
// Test Disposer type
var customDisposer core.Disposer = func() error {
order = append(order, "parent-custom")
return nil
}
parent.OnDispose(customDisposer)
parent.OnDispose(func() error {
order = append(order, "parent-1")
return nil
})
parent.OnDispose(func() {
order = append(order, "parent-2")
})
child.OnDispose(func() error {
order = append(order, "child-1")
return errors.New("child-1 error")
})
child.OnDispose(func() {
order = append(order, "child-2")
})
assert.Panics(t, func() {
parent.OnDispose("invalid-func")
})
assert.False(t, parent.IsDisposed())
assert.False(t, child.IsDisposed())
// Disposing parent should cascade to children first, and execute disposers in LIFO order
err := parent.Dispose()
assert.Error(t, err) // child-1 error should be joined
assert.Contains(t, err.Error(), "child-1 error")
assert.True(t, parent.IsDisposed())
assert.True(t, child.IsDisposed())
// Child disposers run in LIFO: child-2, child-1
// Parent disposers run in LIFO: parent-2, parent-1, parent-custom
expected := []string{"child-2", "child-1", "parent-2", "parent-1", "parent-custom"}
assert.Equal(t, expected, order)
// Disposing again should be idempotent and return nil
err = parent.Dispose()
assert.NoError(t, err)
}
func TestContextStandardGoContext(t *testing.T) {
baseCtx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
defer cancel()
parentCtx := core.NewContext(baseCtx)
parentCtx.Set("parent_key", "parent_val")
childCtx := parentCtx.Fork()
// Deadline
dl, ok := childCtx.Deadline()
assert.True(t, ok)
assert.False(t, dl.IsZero())
// Value fallback: child has no key, falls back to parentCtx
assert.Equal(t, "parent_val", childCtx.Value("parent_key"))
// GoContext getter
assert.NotNil(t, childCtx.GoContext())
// Value not found in either
assert.Nil(t, childCtx.Value("non_existent_key"))
// Cancellation propagation
select {
case <-childCtx.Done():
t.Fatal("ctx should not be done yet")
default:
}
cancel()
select {
case <-childCtx.Done():
assert.Equal(t, context.Canceled, childCtx.Err())
case <-time.After(100 * time.Millisecond):
t.Fatal("ctx should be cancelled")
}
}
func TestManifestValidation(t *testing.T) {
mValid := core.Manifest{
Name: "auth",
Version: "1.0.0",
Description: "Auth plugin",
}
assert.NoError(t, mValid.Validate())
mInvalid := core.Manifest{
Version: "1.0.0",
}
assert.Error(t, mInvalid.Validate())
}
func TestDriverRegistration(t *testing.T) {
ctx := core.NewContext(context.Background())
// Register nil driver returns error
assert.ErrorIs(t, ctx.RegisterDriver(nil), core.ErrNilService)
dHTTP := &mockDriver{driverType: core.DriverTypeHTTP}
dWorker := &mockDriver{driverType: core.DriverTypeWorker}
require.NoError(t, ctx.RegisterDriver(dHTTP))
require.NoError(t, ctx.RegisterDriver(dWorker))
drivers := ctx.Drivers()
assert.Len(t, drivers, 2)
foundHTTP, ok := ctx.Driver(core.DriverTypeHTTP)
assert.True(t, ok)
assert.Equal(t, dHTTP, foundHTTP)
foundWorker, ok := ctx.Driver(core.DriverTypeWorker)
assert.True(t, ok)
assert.Equal(t, dWorker, foundWorker)
_, ok = ctx.Driver(core.DriverTypeScheduler)
assert.False(t, ok)
}
func TestPluginInterfaces(t *testing.T) {
ctx := core.NewContext(context.Background())
var p core.Plugin = &samplePlugin{name: "sample"}
assert.Equal(t, "sample", p.Name())
require.NoError(t, p.Apply(ctx))
svc, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Plugin: Ryan", svc.Greet("Ryan"))
var pwm core.PluginWithManifest = &samplePlugin{name: "sample"}
manifest := pwm.Manifest()
assert.Equal(t, "sample", manifest.Name)
assert.Equal(t, "1.0.0", manifest.Version)
}
func TestConcurrentAccess(t *testing.T) {
ctx := core.NewContext(context.Background())
var wg sync.WaitGroup
// Concurrently provide, inject, fork, set, and get
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx.Set(fmt.Sprintf("key-%d", idx), idx)
_, _ = ctx.Get(fmt.Sprintf("key-%d", idx))
child := ctx.Fork()
child.Set("child_key", idx)
}(i)
}
core.Provide[SampleService](ctx, &sampleServiceImpl{})
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
svc, err := core.Inject[SampleService](ctx)
if err == nil {
_ = svc.Greet("Concurrency")
}
_ = core.Using(ctx, func(s SampleService) {
_ = s.Greet("Safe")
})
}()
}
wg.Wait()
}
func TestContextExtensionPointsAccessors(t *testing.T) {
ctx := core.NewContext(nil)
assert.NotNil(t, ctx.Events())
assert.NotNil(t, ctx.Router())
assert.NotNil(t, ctx.Migrations())
assert.NotNil(t, ctx.Tasks())
assert.NotNil(t, ctx.Task())
assert.NotNil(t, ctx.Schedules())
assert.NotNil(t, ctx.Schedule())
assert.NotNil(t, ctx.Settings())
assert.NotNil(t, ctx.Setting())
child := ctx.Fork()
assert.Equal(t, ctx.Events(), child.Events())
assert.Equal(t, ctx.Router(), child.Router())
assert.Equal(t, ctx.Migrations(), child.Migrations())
assert.Equal(t, ctx.Tasks(), child.Tasks())
assert.Equal(t, ctx.Task(), child.Task())
assert.Equal(t, ctx.Schedules(), child.Schedules())
assert.Equal(t, ctx.Schedule(), child.Schedule())
assert.Equal(t, ctx.Settings(), child.Settings())
assert.Equal(t, ctx.Setting(), child.Setting())
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"time"
)
// UserDTO represents a unified user data transfer object across plugins.
type UserDTO struct {
ID uint64 `json:"id,string"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password,omitempty"`
Bio string `json:"bio,omitempty"`
Phone string `json:"phone,omitempty"`
Gender string `json:"gender,omitempty"`
Website string `json:"website,omitempty"`
Location string `json:"location,omitempty"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName returns w_users.
func (UserDTO) TableName() string {
return "w_users"
}
// OAuthUserInfoDTO contains user identity claims obtained from an OAuth provider.
type OAuthUserInfoDTO struct {
ID uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarURL string `json:"avatar_url"`
}
// OAuthProvider defines the pluggable OAuth provider contract.
type OAuthProvider interface {
Name() string
GetAuthURL(state string) string
ExchangeCode(ctx context.Context, code string) (*OAuthUserInfoDTO, error)
}
// AuthService defines the contract for authentication, session verification, and token management.
type AuthService interface {
// RequireAuthMiddleware returns a middleware handler (compatible with gin.HandlerFunc or standard middleware).
RequireAuthMiddleware() any
// RequireAdminMiddleware returns an admin authorization middleware.
RequireAdminMiddleware() any
// GetCurrentUser retrieves the authenticated UserDTO from context.
GetCurrentUser(ctx context.Context) (*UserDTO, error)
// GetCurrentUserID retrieves the authenticated user ID from session/context.
GetCurrentUserID(ctx context.Context) (uint64, error)
// VerifyToken validates an access token and returns the associated user DTO.
VerifyToken(ctx context.Context, token string) (*UserDTO, error)
// CreateSession establishes an authenticated session for the given user ID.
CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error)
// RevokeToken invalidates a specific access token by its hash.
RevokeToken(ctx context.Context, tokenHash string) error
// RevokeUserSessions revokes all active sessions and cached tokens for a user.
RevokeUserSessions(ctx context.Context, userID uint64) error
// DisallowTokenAuthMiddleware returns a middleware that rejects requests authenticated via access token.
DisallowTokenAuthMiddleware() any
}
// AuthRegistry allows downstream and domain plugins to register custom authentication providers.
type AuthRegistry interface {
RegisterOAuthProvider(name string, provider OAuthProvider)
GetOAuthProvider(name string) (OAuthProvider, bool)
ListOAuthProviders() []string
}
// Auth context keys — stored in Gin context by auth middleware, consumed by domain plugins.
const (
AuthUserIDKey = "user_id"
AuthUserNameKey = "username"
AuthUserObjKey = "user_obj"
AuthTokenAuthKey = "token_auth" // marks if request uses access token auth
AuthTokenAdminKey = "token_admin" // whether the access token has admin privileges
)
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"errors"
"time"
)
// ErrCacheMiss is returned when an item is not found in the cache.
var ErrCacheMiss = errors.New("contracts/cache: key not found")
// CacheService defines the contract for multi-layer cache operations (RAM L1 + Redis L2 + Pub/Sub invalidation).
type CacheService interface {
// Get retrieves an item from cache into target. Returns ErrCacheMiss if not found.
Get(ctx context.Context, key string, target any) error
// Set stores an item into cache with a specified time-to-live duration.
Set(ctx context.Context, key string, value any, ttl time.Duration) error
// Delete evicts a key from local and remote cache tiers and broadcasts invalidation.
Delete(ctx context.Context, key string) error
// GetOrSet retrieves an item from cache, or calls loader to populate and return if missing.
GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error
// Invalidate is a semantic alias for Delete.
Invalidate(ctx context.Context, key string) error
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"gorm.io/gorm"
)
// DBService defines the standard contract for relational database access and multi-datasource routing.
type DBService interface {
// GORM returns the underlying GORM database instance.
GORM() *gorm.DB
// DB returns the GORM database instance bound to the given context.
DB(ctx context.Context) *gorm.DB
// Named returns a named database connection if multiple data sources or replicas are configured.
Named(name string) *gorm.DB
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
// ======================================================================
// Domain Event Topic Constants
// ======================================================================
//
// All cross-plugin domain event topics MUST be declared here so that
// producers and consumers share the same string values without importing
// each other's implementation packages.
// ======================================================================
// --- Auth & User Events ---
const (
// EventTopicAdminLoggedIn fires when an admin user logs in.
EventTopicAdminLoggedIn = "admin:logged_in"
// EventTopicUserCreated fires when a new user account is created.
EventTopicUserCreated = "user:created"
// EventTopicUserUpdated fires when a user profile is updated.
EventTopicUserUpdated = "user:updated"
// EventTopicUserDeleted fires when a user account is deleted.
EventTopicUserDeleted = "user:deleted"
)
// --- Admin & System Events ---
const (
// EventTopicConfigChanged fires when a system configuration value changes.
EventTopicConfigChanged = "admin:config_changed"
// EventTopicSystemCleanup fires when a periodic system cleanup completes.
EventTopicSystemCleanup = "admin:system_cleanup"
)
// --- Upload / Storage Events ---
const (
// EventTopicUploadCreated fires when a new file upload is recorded.
EventTopicUploadCreated = "upload:created"
// EventTopicUploadDeleted fires when a file upload is removed.
EventTopicUploadDeleted = "upload:deleted"
// EventTopicIngestComplete fires when a programmatic file ingest finishes.
EventTopicIngestComplete = "upload:ingest_complete"
)
// --- Message Gateway Events ---
const (
// EventTopicNotificationSent fires when a push notification is dispatched.
EventTopicNotificationSent = "message:notification_sent"
// EventTopicChannelBound fires when a user binds a messaging channel.
EventTopicChannelBound = "message:channel_bound"
// EventTopicChannelUnbound fires when a user unbinds a messaging channel.
EventTopicChannelUnbound = "message:channel_unbound"
)
// --- Risk Control Events ---
const (
// EventTopicAccessLogRecorded fires when a user access log entry is recorded.
EventTopicAccessLogRecorded = "risk:access_log_recorded"
)
// ======================================================================
// Domain Event Payload DTOs
// ======================================================================
// AdminLoggedIn 管理员登录领域事件载荷
type AdminLoggedIn struct {
User *UserDTO `json:"user"`
IP string `json:"ip"`
}
// UserCreatedEvent fires when a new user account is created.
type UserCreatedEvent struct {
User *UserDTO `json:"user"`
Password string `json:"-"`
}
// ConfigChangedEvent fires when a system configuration value changes.
type ConfigChangedEvent struct {
Key string `json:"key"`
OldVal any `json:"old_val,omitempty"`
NewVal any `json:"new_val,omitempty"`
}
// UploadCreatedEvent fires when a new file upload is recorded.
type UploadCreatedEvent struct {
UploadID uint64 `json:"upload_id,string"`
UserID uint64 `json:"user_id,string"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
}
// NotificationSentEvent fires when a push notification is dispatched.
type NotificationSentEvent struct {
UserID uint64 `json:"user_id,string"`
Channel string `json:"channel"`
Title string `json:"title"`
Success bool `json:"success"`
ErrorInfo string `json:"error_info,omitempty"`
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
)
// LoggerService defines the contract for structured logging with trace ID and context correlation.
type LoggerService interface {
// Debug logs a debug message with optional key-value structured fields.
Debug(ctx context.Context, msg string, keysAndValues ...any)
// Info logs an informational message with optional key-value structured fields.
Info(ctx context.Context, msg string, keysAndValues ...any)
// Warn logs a warning message with optional key-value structured fields.
Warn(ctx context.Context, msg string, keysAndValues ...any)
// Error logs an error message with optional key-value structured fields.
Error(ctx context.Context, msg string, keysAndValues ...any)
// Debugf logs a formatted debug message.
Debugf(ctx context.Context, format string, args ...any)
// Infof logs a formatted informational message.
Infof(ctx context.Context, format string, args ...any)
// Warnf logs a formatted warning message.
Warnf(ctx context.Context, format string, args ...any)
// Errorf logs a formatted error message.
Errorf(ctx context.Context, format string, args ...any)
// With returns a child logger enriched with additional key-value attributes.
With(keysAndValues ...any) LoggerService
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"io"
)
// StorageObject represents a retrieved file object from the storage backend.
type StorageObject struct {
Key string
CachePath string
Body io.ReadCloser
ContentLength int64
ContentType string
}
// StoragePutResult describes the output of a successful Put operation.
type StoragePutResult struct {
Key string
Bucket string
}
// IngestOptions configures programmatic ingest of files into the platform storage.
type IngestOptions struct {
UserID uint64
Type string
FileName string
MimeType string
Extension string
Size int64
Policy int
Metadata map[string]any
}
// IngestResult reports the outcome of a programmatic file ingest operation.
type IngestResult struct {
ID uint64
Key string
URL string
Created bool
Stored bool
Resolved bool
}
// StorageService defines the contract for unified object storage and managed file ingestion.
type StorageService interface {
// Put writes an object to storage.
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (StoragePutResult, error)
// Get retrieves an object from storage.
Get(ctx context.Context, key string) (*StorageObject, error)
// Delete removes an object from storage.
Delete(ctx context.Context, key string) error
// Ingest performs managed file ingestion into the platform storage domain with deduplication and metadata tracking.
Ingest(ctx context.Context, reader io.Reader, opts IngestOptions) (*IngestResult, error)
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
)
// CreateUserRequest contains fields to register or create a new user.
type CreateUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
}
// UpdateUserProfileRequest contains fields for updating a user's profile.
type UpdateUserProfileRequest struct {
Nickname *string `json:"nickname,omitempty"`
Email *string `json:"email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
Bio *string `json:"bio,omitempty"`
Phone *string `json:"phone,omitempty"`
Gender *string `json:"gender,omitempty"`
Website *string `json:"website,omitempty"`
Location *string `json:"location,omitempty"`
}
// UserService defines the contract for user account management and profile queries.
type UserService interface {
// GetUserByID retrieves a user by ID.
GetUserByID(ctx context.Context, id uint64) (*UserDTO, error)
// GetUserByUsername retrieves a user by username.
GetUserByUsername(ctx context.Context, username string) (*UserDTO, error)
// GetUserByEmail retrieves a user by email.
GetUserByEmail(ctx context.Context, email string) (*UserDTO, error)
// CreateUser registers or creates a new user account.
CreateUser(ctx context.Context, req CreateUserRequest) (*UserDTO, error)
// UpdateProfile updates the profile of the specified user.
UpdateProfile(ctx context.Context, id uint64, req UpdateUserProfileRequest) (*UserDTO, error)
// UpdatePassword updates the password for the specified user after verifying the old password.
UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error
// VerifyPassword verifies if the given password matches the user's password.
VerifyPassword(ctx context.Context, id uint64, password string) bool
// UpdateLastLogin updates the user's last login timestamp.
UpdateLastLogin(ctx context.Context, id uint64, ip string) error
// ListUsers returns a paginated list of users with optional keyword search.
ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*UserDTO, int64, error)
// SetUserActive sets the active/banned status for a user.
SetUserActive(ctx context.Context, id uint64, active bool) error
// SetUserAdmin sets the admin role status for a user.
SetUserAdmin(ctx context.Context, id uint64, admin bool) error
// VerifyAccessToken verifies an access token hash and returns the user DTO and isAdmin flag.
VerifyAccessToken(ctx context.Context, tokenHash string) (*UserDTO, bool, error)
// DeleteUser removes a user and related access tokens.
DeleteUser(ctx context.Context, id uint64) error
// CountUsers returns total user count.
CountUsers(ctx context.Context) (int64, error)
// CountActiveUsers returns active user count.
CountActiveUsers(ctx context.Context) (int64, error)
// GetFirstAdminUser returns the earliest admin user.
GetFirstAdminUser(ctx context.Context) (*UserDTO, error)
// UniqueUsername generates a unique username candidate based on base.
UniqueUsername(ctx context.Context, base string) (string, error)
}
+278
View File
@@ -0,0 +1,278 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"reflect"
"sync"
"sync/atomic"
)
const maxHandlerParams = 2
var ctxInterfaceType = reflect.TypeFor[context.Context]()
var errInterfaceType = reflect.TypeFor[error]()
type eventListener struct {
id uint64
fnVal reflect.Value
numIn int
hasCtx bool
hasPayload bool
argType reflect.Type
returnsErr bool
}
// EventBus is a thread-safe, strongly-typed in-process domain event bus.
type EventBus struct {
mu sync.RWMutex
nextID atomic.Uint64
handlers map[string][]eventListener
}
// NewEventBus creates a new EventBus instance.
func NewEventBus() *EventBus {
return &EventBus{
handlers: make(map[string][]eventListener),
}
}
// On registers an event handler for the given topic.
//
// Supported handler signatures:
// - func(ctx context.Context, event T) error
// - func(ctx context.Context, event T)
// - func(event T) error
// - func(event T)
// - func(ctx context.Context) error
// - func(ctx context.Context)
// - func() error
// - func()
//
// Returns a Disposer function that unregisters the handler when called.
func (b *EventBus) On(topic string, handler any) Disposer {
if handler == nil {
panic("core/events: handler cannot be nil")
}
fnVal := reflect.ValueOf(handler)
fnType := fnVal.Type()
if fnType.Kind() != reflect.Func {
panic(fmt.Sprintf("core/events: expected func, got %s", fnType.Kind()))
}
numIn := fnType.NumIn()
if numIn > maxHandlerParams {
panic(fmt.Sprintf("core/events: handler has %d parameters, maximum 2 supported (ctx, event)", numIn))
}
numOut := fnType.NumOut()
if numOut > 1 {
panic(fmt.Sprintf("core/events: handler has %d return values, maximum 1 supported (error)", numOut))
}
returnsErr := false
if numOut == 1 {
outType := fnType.Out(0)
if !outType.Implements(errInterfaceType) {
panic(fmt.Sprintf("core/events: handler return type must be error, got %v", outType))
}
returnsErr = true
}
listener := eventListener{
id: b.nextID.Add(1),
fnVal: fnVal,
numIn: numIn,
returnsErr: returnsErr,
}
switch numIn {
case 0:
// func() or func() error
case 1:
in0 := fnType.In(0)
if in0.Implements(ctxInterfaceType) {
listener.hasCtx = true
} else {
listener.hasPayload = true
listener.argType = in0
}
case 2:
in0 := fnType.In(0)
if !in0.Implements(ctxInterfaceType) {
panic(fmt.Sprintf("core/events: first parameter must implement context.Context, got %v", in0))
}
listener.hasCtx = true
listener.hasPayload = true
listener.argType = fnType.In(1)
}
b.mu.Lock()
b.handlers[topic] = append(b.handlers[topic], listener)
b.mu.Unlock()
listenerID := listener.id
var disposed atomic.Bool
return func() error {
if disposed.Swap(true) {
return nil
}
b.mu.Lock()
defer b.mu.Unlock()
list := b.handlers[topic]
for i, l := range list {
if l.id == listenerID {
b.handlers[topic] = append(list[:i], list[i+1:]...)
break
}
}
if len(b.handlers[topic]) == 0 {
delete(b.handlers, topic)
}
return nil
}
}
// Subscribe registers a strongly-typed generic event listener on the given EventBus.
func Subscribe[T any](bus *EventBus, topic string, handler func(ctx context.Context, event T) error) Disposer {
if bus == nil {
panic("core/events: nil EventBus provided to Subscribe")
}
return bus.On(topic, handler)
}
// Emit publishes an event to all subscribers of the specified topic.
// Handlers are executed synchronously. If any handler panics or returns an error,
// the error is collected and returned via errors.Join.
//
//nolint:contextcheck
func (b *EventBus) Emit(ctx context.Context, topic string, payload any) error {
if ctx == nil {
ctx = context.Background()
}
b.mu.RLock()
rawListeners := b.handlers[topic]
if len(rawListeners) == 0 {
b.mu.RUnlock()
return nil
}
listeners := make([]eventListener, len(rawListeners))
copy(listeners, rawListeners)
b.mu.RUnlock()
var payloadVal reflect.Value
if payload != nil {
payloadVal = reflect.ValueOf(payload)
}
var errs []error
for _, l := range listeners {
args := b.buildArgs(ctx, l, payloadVal)
err := func() (resErr error) {
defer func() {
if r := recover(); r != nil {
resErr = fmt.Errorf("core/events: panic in handler for topic %q: %v", topic, r)
}
}()
results := l.fnVal.Call(args)
if l.returnsErr && len(results) > 0 && !results[0].IsNil() {
resErr = results[0].Interface().(error)
}
return resErr
}()
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (b *EventBus) buildArgs(ctx context.Context, l eventListener, payloadVal reflect.Value) []reflect.Value {
if l.numIn == 0 {
return nil
}
args := make([]reflect.Value, 0, l.numIn)
if l.hasCtx {
args = append(args, reflect.ValueOf(ctx))
}
if l.hasPayload {
arg := b.convertPayload(payloadVal, l.argType)
args = append(args, arg)
}
return args
}
func (b *EventBus) convertPayload(payloadVal reflect.Value, targetType reflect.Type) reflect.Value {
if !payloadVal.IsValid() {
return reflect.Zero(targetType)
}
valType := payloadVal.Type()
// 1. Direct assignable
if valType.AssignableTo(targetType) {
return payloadVal
}
// 2. Direct convertible
if valType.ConvertibleTo(targetType) {
return payloadVal.Convert(targetType)
}
// 3. Payload is pointer *T, target expects T
if valType.Kind() == reflect.Pointer && valType.Elem().AssignableTo(targetType) {
if !payloadVal.IsNil() {
return payloadVal.Elem()
}
return reflect.Zero(targetType)
}
// 4. Payload is value T, target expects *T
if targetType.Kind() == reflect.Pointer && valType.AssignableTo(targetType.Elem()) {
ptr := reflect.New(valType)
ptr.Elem().Set(payloadVal)
return ptr
}
// Fallback to zero value of targetType
return reflect.Zero(targetType)
}
// Listeners returns the number of active listeners for a topic.
func (b *EventBus) Listeners(topic string) int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.handlers[topic])
}
// Topics returns all topics that have registered listeners.
func (b *EventBus) Topics() []string {
b.mu.RLock()
defer b.mu.RUnlock()
topics := make([]string, 0, len(b.handlers))
for t := range b.handlers {
topics = append(topics, t)
}
return topics
}
+318
View File
@@ -0,0 +1,318 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Rain-kl/Wavelet/backend/core"
)
type UserRegisteredEvent struct {
UserID string `json:"user_id"`
Username string `json:"username"`
}
type OrderCreatedEvent struct {
OrderID string `json:"order_id"`
Amount float64 `json:"amount"`
}
func TestEventBusPublishSubscribe(t *testing.T) {
bus := core.NewEventBus()
var receivedID string
disposer := bus.On("user:registered", func(ctx context.Context, e UserRegisteredEvent) error {
receivedID = e.UserID
return nil
})
require.NotNil(t, disposer)
assert.Equal(t, []string{"user:registered"}, bus.Topics())
err := bus.Emit(context.Background(), "user:registered", UserRegisteredEvent{UserID: "u_999", Username: "alice"})
assert.NoError(t, err)
assert.Equal(t, "u_999", receivedID)
// Emit to empty topic returns nil error
err = bus.Emit(nil, "empty:topic", nil)
assert.NoError(t, err)
}
func TestEventBusGenericSubscribe(t *testing.T) {
bus := core.NewEventBus()
var receivedOrder string
assert.Panics(t, func() {
core.Subscribe[OrderCreatedEvent](nil, "order:created", func(ctx context.Context, e OrderCreatedEvent) error {
return nil
})
})
disposer := core.Subscribe(bus, "order:created", func(ctx context.Context, e OrderCreatedEvent) error {
receivedOrder = e.OrderID
return nil
})
require.NotNil(t, disposer)
err := bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_123", Amount: 99.5})
assert.NoError(t, err)
assert.Equal(t, "ord_123", receivedOrder)
// Test unsubscribe via disposer
err = disposer()
assert.NoError(t, err)
receivedOrder = ""
err = bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_456", Amount: 100})
assert.NoError(t, err)
assert.Empty(t, receivedOrder, "handler should not be called after disposal")
}
func TestEventBusHandlerSignatures(t *testing.T) {
bus := core.NewEventBus()
var (
calledWithCtxPayloadErr atomic.Bool
calledWithCtxPayload atomic.Bool
calledWithPayloadErr atomic.Bool
calledWithPayload atomic.Bool
calledWithCtxErr atomic.Bool
calledWithCtx atomic.Bool
calledWithNoArgsErr atomic.Bool
calledWithNoArgs atomic.Bool
)
bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) error {
calledWithCtxPayloadErr.Store(true)
assert.Equal(t, "u_1", e.UserID)
return nil
})
bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) {
calledWithCtxPayload.Store(true)
assert.Equal(t, "u_1", e.UserID)
})
bus.On("test:sig", func(e UserRegisteredEvent) error {
calledWithPayloadErr.Store(true)
assert.Equal(t, "u_1", e.UserID)
return nil
})
bus.On("test:sig", func(e UserRegisteredEvent) {
calledWithPayload.Store(true)
assert.Equal(t, "u_1", e.UserID)
})
bus.On("test:sig", func(ctx context.Context) error {
calledWithCtxErr.Store(true)
return nil
})
bus.On("test:sig", func(ctx context.Context) {
calledWithCtx.Store(true)
})
bus.On("test:sig", func() error {
calledWithNoArgsErr.Store(true)
return nil
})
bus.On("test:sig", func() {
calledWithNoArgs.Store(true)
})
err := bus.Emit(context.Background(), "test:sig", UserRegisteredEvent{UserID: "u_1", Username: "test"})
assert.NoError(t, err)
assert.True(t, calledWithCtxPayloadErr.Load())
assert.True(t, calledWithCtxPayload.Load())
assert.True(t, calledWithPayloadErr.Load())
assert.True(t, calledWithPayload.Load())
assert.True(t, calledWithCtxErr.Load())
assert.True(t, calledWithCtx.Load())
assert.True(t, calledWithNoArgsErr.Load())
assert.True(t, calledWithNoArgs.Load())
}
func TestEventBusPointerAndValueConversion(t *testing.T) {
bus := core.NewEventBus()
var (
receivedFromValueToPtr atomic.Bool
receivedFromPtrToValue atomic.Bool
receivedFromNilPtr atomic.Bool
)
// Handler expects pointer, payload emitted as value
bus.On("test:ptr", func(ctx context.Context, e *UserRegisteredEvent) error {
if e != nil && e.UserID == "u_ptr" {
receivedFromValueToPtr.Store(true)
}
return nil
})
err := bus.Emit(context.Background(), "test:ptr", UserRegisteredEvent{UserID: "u_ptr"})
assert.NoError(t, err)
assert.True(t, receivedFromValueToPtr.Load())
// Handler expects value, payload emitted as pointer
bus.On("test:val", func(ctx context.Context, e UserRegisteredEvent) error {
if e.UserID == "u_val" {
receivedFromPtrToValue.Store(true)
}
return nil
})
err = bus.Emit(context.Background(), "test:val", &UserRegisteredEvent{UserID: "u_val"})
assert.NoError(t, err)
assert.True(t, receivedFromPtrToValue.Load())
// Handler expects value, payload is nil pointer
var nilEvent *UserRegisteredEvent
bus.On("test:nil_ptr", func(ctx context.Context, e UserRegisteredEvent) error {
assert.Equal(t, "", e.UserID)
receivedFromNilPtr.Store(true)
return nil
})
err = bus.Emit(context.Background(), "test:nil_ptr", nilEvent)
assert.NoError(t, err)
assert.True(t, receivedFromNilPtr.Load())
// Convertible type test (int to int64)
var receivedConvert int64
bus.On("test:conv", func(e int64) {
receivedConvert = e
})
err = bus.Emit(context.Background(), "test:conv", int(42))
assert.NoError(t, err)
assert.Equal(t, int64(42), receivedConvert)
}
func TestEventBusErrorCollectionAndPanicRecovery(t *testing.T) {
bus := core.NewEventBus()
errHandler1 := errors.New("handler 1 failed")
errHandler2 := errors.New("handler 2 failed")
bus.On("test:err", func() error {
return errHandler1
})
bus.On("test:err", func() {
panic("something went horribly wrong")
})
bus.On("test:err", func() error {
return errHandler2
})
err := bus.Emit(context.Background(), "test:err", nil)
require.Error(t, err)
assert.True(t, errors.Is(err, errHandler1) || errors.Is(err, errHandler2))
assert.Contains(t, err.Error(), "handler 1 failed")
assert.Contains(t, err.Error(), "handler 2 failed")
assert.Contains(t, err.Error(), "panic")
}
func TestEventBusInvalidHandlerPanics(t *testing.T) {
bus := core.NewEventBus()
assert.Panics(t, func() {
bus.On("test:invalid", nil)
})
assert.Panics(t, func() {
bus.On("test:invalid", "not-a-func")
})
assert.Panics(t, func() {
// More than 2 arguments
bus.On("test:invalid", func(a, b, c string) {})
})
assert.Panics(t, func() {
// 2 args, but first is not context
bus.On("test:invalid", func(a string, b int) {})
})
assert.Panics(t, func() {
// More than 1 return value
bus.On("test:invalid", func() (int, error) { return 0, nil })
})
assert.Panics(t, func() {
// Return value is not error
bus.On("test:invalid", func() int { return 0 })
})
}
func TestEventBusListenersCountAndDisposerIdempotence(t *testing.T) {
bus := core.NewEventBus()
assert.Equal(t, 0, bus.Listeners("topic1"))
d1 := bus.On("topic1", func() {})
d2 := bus.On("topic1", func() {})
assert.Equal(t, 2, bus.Listeners("topic1"))
_ = d1()
assert.Equal(t, 1, bus.Listeners("topic1"))
// Calling disposer again should be no-op
_ = d1()
assert.Equal(t, 1, bus.Listeners("topic1"))
_ = d2()
assert.Equal(t, 0, bus.Listeners("topic1"))
}
func TestEventBusConcurrentAccess(t *testing.T) {
bus := core.NewEventBus()
var wg sync.WaitGroup
var receivedCount atomic.Int64
// Concurrently subscribe and emit
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
topic := fmt.Sprintf("topic:%d", idx%5)
disposer := bus.On(topic, func(ctx context.Context, e UserRegisteredEvent) error {
receivedCount.Add(1)
return nil
})
// Emit some events
_ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)})
// Randomly dispose
if idx%2 == 0 {
_ = disposer()
}
}(i)
}
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
topic := fmt.Sprintf("topic:%d", idx%5)
_ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)})
}(i)
}
wg.Wait()
assert.Greater(t, receivedCount.Load(), int64(0))
}
+280
View File
@@ -0,0 +1,280 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints_test
import (
"context"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Rain-kl/Wavelet/backend/core"
"github.com/Rain-kl/Wavelet/backend/core/extpoints"
)
func TestRouterExtension(t *testing.T) {
r := extpoints.NewRouterRegistry()
require.NotNil(t, r)
mGlobal := "global_middleware"
r.Use(mGlobal)
assert.Equal(t, []any{mGlobal}, r.Middlewares())
// Test root methods
hRoot := "root_handler"
r.GET("/", hRoot)
r.POST("/root_post", hRoot)
r.PUT("/root_put", hRoot)
r.DELETE("/root_del", hRoot)
r.PATCH("/root_patch", hRoot)
r.HEAD("/root_head", hRoot)
r.OPTIONS("/root_opt", hRoot)
anyRootDefs := r.Any("/root_any", hRoot)
assert.Len(t, anyRootDefs, 7)
// Group and Group.Use
mAPI := "api_middleware"
api := r.Group("/api/v1", mAPI)
api.Use("api_extra_middleware")
assert.Len(t, api.Middlewares(), 2)
hList := "list_orders_handler"
hCreate := "create_order_handler"
api.GET("/orders", hList)
api.POST("/orders", hCreate)
mAdmin := "admin_middleware"
admin := api.Group("admin", mAdmin)
hUserGet := "get_user_handler"
hUserPut := "put_user_handler"
hUserDel := "del_user_handler"
hUserPatch := "patch_user_handler"
hUserHead := "head_user_handler"
hUserOptions := "options_user_handler"
admin.GET("/users/:id", hUserGet)
admin.PUT("/users/:id", hUserPut)
admin.DELETE("/users/:id", hUserDel)
admin.PATCH("/users/:id", hUserPatch)
admin.HEAD("/users/:id", hUserHead)
admin.OPTIONS("/users/:id", hUserOptions)
hCustom := "custom_handler"
admin.Handle("CUSTOM", "/custom", hCustom)
hAny := "any_handler"
anyRoutes := admin.Any("/all", hAny)
assert.NotEmpty(t, anyRoutes)
// Group.Routes() returns root routes
assert.Equal(t, r.Routes(), admin.Routes())
routes := r.Routes()
// Verify route paths and middlewares
var foundOrderGet bool
var foundUserPut bool
for _, route := range routes {
if route.Method == "GET" && route.Path == "/api/v1/orders" {
foundOrderGet = true
assert.Equal(t, []any{mGlobal, mAPI, "api_extra_middleware"}, route.Middlewares)
assert.Equal(t, []any{hList}, route.Handlers)
}
if route.Method == "PUT" && route.Path == "/api/v1/admin/users/:id" {
foundUserPut = true
assert.Equal(t, []any{mGlobal, mAPI, "api_extra_middleware", mAdmin}, route.Middlewares)
assert.Equal(t, []any{hUserPut}, route.Handlers)
}
}
assert.True(t, foundOrderGet)
assert.True(t, foundUserPut)
}
func TestMigrationExtension(t *testing.T) {
m := extpoints.NewMigrationRegistry()
require.NotNil(t, m)
fs1 := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t1(id int);")},
}
fs2 := fstest.MapFS{
"custom/001_order.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t2(id int);")},
}
m.Register("auth", fs1)
m.Register("order", fs2, "custom")
// Update existing entry
fs1Updated := fstest.MapFS{
"migrations/002_update.sql": &fstest.MapFile{Data: []byte("ALTER TABLE t1 ADD col int;")},
}
m.Register("auth", fs1Updated, "")
entries := m.Entries()
require.Len(t, entries, 2)
assert.Equal(t, "auth", entries[0].PluginID)
assert.Equal(t, "migrations", entries[0].Dir)
assert.Equal(t, "order", entries[1].PluginID)
assert.Equal(t, "custom", entries[1].Dir)
authEntry, ok := m.Get("auth")
assert.True(t, ok)
assert.Equal(t, "auth", authEntry.PluginID)
_, ok = m.Get("non_existent")
assert.False(t, ok)
}
func TestTaskExtension(t *testing.T) {
tr := extpoints.NewTaskRegistry()
require.NotNil(t, tr)
handler := func(ctx context.Context, payload []byte) error { return nil }
tr.Register("order:cancel_timeout", handler,
extpoints.WithTaskConcurrency(5),
extpoints.WithTaskRetry(3),
extpoints.WithTaskTimeout(10*time.Second),
extpoints.WithTaskMetadata("queue", "critical"),
nil, // test nil option
)
// Re-register to test update
tr.Register("order:cancel_timeout", handler,
extpoints.WithTaskConcurrency(10),
extpoints.WithTaskMetadata("queue", "high"),
)
tasks := tr.Tasks()
require.Len(t, tasks, 1)
assert.Equal(t, "order:cancel_timeout", tasks[0].Pattern)
assert.Equal(t, 10, tasks[0].Concurrency)
assert.Equal(t, "high", tasks[0].Metadata["queue"])
task, ok := tr.Get("order:cancel_timeout")
assert.True(t, ok)
assert.Equal(t, "order:cancel_timeout", task.Pattern)
_, ok = tr.Get("unknown")
assert.False(t, ok)
}
func TestScheduleExtension(t *testing.T) {
sr := extpoints.NewScheduleRegistry()
require.NotNil(t, sr)
type ReportPayload struct {
Type string `json:"type"`
}
sr.RegisterCron("0 2 * * *", "report:daily_summary", ReportPayload{Type: "daily"})
sr.Register("@every 1h", "cleanup:expired_sessions", nil,
extpoints.WithScheduleOption("retry", 2),
nil, // test nil option
)
// Re-register to test update
sr.RegisterCron("0 3 * * *", "report:daily_summary", ReportPayload{Type: "all"})
schedules := sr.Schedules()
require.Len(t, schedules, 2)
assert.Equal(t, "0 3 * * *", schedules[0].Spec)
assert.Equal(t, "report:daily_summary", schedules[0].TaskType)
assert.Equal(t, ReportPayload{Type: "all"}, schedules[0].Payload)
assert.Equal(t, "@every 1h", schedules[1].Spec)
assert.Equal(t, "cleanup:expired_sessions", schedules[1].TaskType)
assert.Equal(t, 2, schedules[1].Options["retry"])
sched, ok := sr.Get("report:daily_summary")
assert.True(t, ok)
assert.Equal(t, "0 3 * * *", sched.Spec)
_, ok = sr.Get("unknown")
assert.False(t, ok)
}
func TestSettingExtension(t *testing.T) {
sr := extpoints.NewSettingRegistry()
require.NotNil(t, sr)
assert.Panics(t, func() {
sr.Register(extpoints.SettingSchema{}) // empty key panics
})
sr.Register(extpoints.SettingSchema{
Key: "order.auto_cancel_mins",
Default: 15,
Description: "Order auto cancellation timeout in minutes",
Category: "order",
Public: true,
})
// Re-register to test update
sr.Register(extpoints.SettingSchema{
Key: "order.auto_cancel_mins",
Default: 30,
Description: "Updated timeout",
})
sr.Register(extpoints.SettingSchema{
Key: "auth.jwt_secret",
Default: "default-secret",
Description: "JWT secret key",
Category: "auth",
ReadOnly: true,
})
schemas := sr.Schemas()
require.Len(t, schemas, 2)
schema, ok := sr.Get("order.auto_cancel_mins")
assert.True(t, ok)
assert.Equal(t, 30, schema.Default)
_, ok = sr.Get("unknown")
assert.False(t, ok)
}
func TestContextExtensionPointsIntegration(t *testing.T) {
ctx := core.NewContext(context.Background())
require.NotNil(t, ctx.Events())
require.NotNil(t, ctx.Router())
require.NotNil(t, ctx.Migrations())
require.NotNil(t, ctx.Tasks())
require.NotNil(t, ctx.Task())
require.NotNil(t, ctx.Schedules())
require.NotNil(t, ctx.Schedule())
require.NotNil(t, ctx.Settings())
require.NotNil(t, ctx.Setting())
// Register from child context and verify shared application registry
child := ctx.Fork()
child.Router().GET("/ping", "pong_handler")
child.Task().Register("sample:task", "handler")
child.Schedule().RegisterCron("@hourly", "sample:cron", nil)
child.Settings().Register(extpoints.SettingSchema{
Key: "app.name",
Default: "Wavelet",
})
assert.Len(t, ctx.Router().Routes(), 1)
assert.Len(t, ctx.Tasks().Tasks(), 1)
assert.Len(t, ctx.Schedules().Schedules(), 1)
assert.Len(t, ctx.Settings().Schemas(), 1)
// Child and root events
var eventReceived bool
child.Events().On("app:ready", func() {
eventReceived = true
})
err := ctx.Events().Emit(context.Background(), "app:ready", nil)
assert.NoError(t, err)
assert.True(t, eventReceived)
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package extpoints defines extension points for router, migrations, tasks, schedules, and settings.
package extpoints
import (
"io/fs"
"sync"
)
// MigrationEntry contains the migration filesystem and configuration for a plugin.
type MigrationEntry struct {
PluginID string
FS fs.FS
Dir string
}
// MigrationExtension defines the interface for registering and querying plugin migrations.
type MigrationExtension interface {
Register(pluginID string, fsys fs.FS, dir ...string)
Entries() []MigrationEntry
Get(pluginID string) (MigrationEntry, bool)
}
// MigrationRegistry collects and stores migration entries from plugins.
type MigrationRegistry struct {
mu sync.RWMutex
entries []MigrationEntry
lookup map[string]MigrationEntry
}
// NewMigrationRegistry creates a new migration registry.
func NewMigrationRegistry() *MigrationRegistry {
return &MigrationRegistry{
lookup: make(map[string]MigrationEntry),
}
}
// Register adds a migration entry for a plugin.
// If dir is not specified, it defaults to "migrations".
func (m *MigrationRegistry) Register(pluginID string, fsys fs.FS, dir ...string) {
m.mu.Lock()
defer m.mu.Unlock()
migrationDir := "migrations"
if len(dir) > 0 && dir[0] != "" {
migrationDir = dir[0]
}
entry := MigrationEntry{
PluginID: pluginID,
FS: fsys,
Dir: migrationDir,
}
// If entry already exists, update in-place; otherwise append
if _, exists := m.lookup[pluginID]; exists {
for i, e := range m.entries {
if e.PluginID == pluginID {
m.entries[i] = entry
break
}
}
} else {
m.entries = append(m.entries, entry)
}
m.lookup[pluginID] = entry
}
// Entries returns a copy of all registered migration entries in registration order.
func (m *MigrationRegistry) Entries() []MigrationEntry {
m.mu.RLock()
defer m.mu.RUnlock()
res := make([]MigrationEntry, len(m.entries))
copy(res, m.entries)
return res
}
// Get retrieves the migration entry for a specific plugin ID.
func (m *MigrationRegistry) Get(pluginID string) (MigrationEntry, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
e, ok := m.lookup[pluginID]
return e, ok
}
+269
View File
@@ -0,0 +1,269 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"strings"
"sync"
)
// RouteDefinition holds the metadata and handler list for a single HTTP route.
type RouteDefinition struct {
Method string
Path string
Handlers []any
Middlewares []any
}
// RouterExtension defines the interface for registering routes and middlewares.
type RouterExtension interface {
Use(middlewares ...any)
Group(prefix string, middlewares ...any) RouterExtension
Handle(method string, path string, handlers ...any) RouteDefinition
GET(path string, handlers ...any) RouteDefinition
POST(path string, handlers ...any) RouteDefinition
PUT(path string, handlers ...any) RouteDefinition
DELETE(path string, handlers ...any) RouteDefinition
PATCH(path string, handlers ...any) RouteDefinition
HEAD(path string, handlers ...any) RouteDefinition
OPTIONS(path string, handlers ...any) RouteDefinition
Any(path string, handlers ...any) []RouteDefinition
Routes() []RouteDefinition
Middlewares() []any
}
// RouterRegistry implements RouterExtension as the root route and middleware collector.
type RouterRegistry struct {
mu sync.RWMutex
routes []RouteDefinition
middlewares []any
}
// NewRouterRegistry creates a new root router collector.
func NewRouterRegistry() *RouterRegistry {
return &RouterRegistry{}
}
// Use registers global middlewares to the router.
func (r *RouterRegistry) Use(middlewares ...any) {
r.mu.Lock()
defer r.mu.Unlock()
r.middlewares = append(r.middlewares, middlewares...)
}
// Middlewares returns a copy of registered root middlewares.
func (r *RouterRegistry) Middlewares() []any {
r.mu.RLock()
defer r.mu.RUnlock()
res := make([]any, len(r.middlewares))
copy(res, r.middlewares)
return res
}
// Group creates a new RouteGroup under the router.
func (r *RouterRegistry) Group(prefix string, middlewares ...any) RouterExtension {
return &RouterGroup{
registry: r,
prefix: cleanPath(prefix),
middlewares: middlewares,
}
}
// Handle registers a route with a custom HTTP method and handlers.
func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefinition {
r.mu.Lock()
defer r.mu.Unlock()
rd := RouteDefinition{
Method: strings.ToUpper(method),
Path: cleanPath(path),
Handlers: handlers,
Middlewares: append([]any(nil), r.middlewares...),
}
r.routes = append(r.routes, rd)
return rd
}
// GET registers a GET route.
func (r *RouterRegistry) GET(path string, handlers ...any) RouteDefinition {
return r.Handle("GET", path, handlers...)
}
// POST registers a POST route.
func (r *RouterRegistry) POST(path string, handlers ...any) RouteDefinition {
return r.Handle("POST", path, handlers...)
}
// PUT registers a PUT route.
func (r *RouterRegistry) PUT(path string, handlers ...any) RouteDefinition {
return r.Handle("PUT", path, handlers...)
}
// DELETE registers a DELETE route.
func (r *RouterRegistry) DELETE(path string, handlers ...any) RouteDefinition {
return r.Handle("DELETE", path, handlers...)
}
// PATCH registers a PATCH route.
func (r *RouterRegistry) PATCH(path string, handlers ...any) RouteDefinition {
return r.Handle("PATCH", path, handlers...)
}
// HEAD registers a HEAD route.
func (r *RouterRegistry) HEAD(path string, handlers ...any) RouteDefinition {
return r.Handle("HEAD", path, handlers...)
}
// OPTIONS registers an OPTIONS route.
func (r *RouterRegistry) OPTIONS(path string, handlers ...any) RouteDefinition {
return r.Handle("OPTIONS", path, handlers...)
}
// Any registers a route for standard HTTP methods.
func (r *RouterRegistry) Any(path string, handlers ...any) []RouteDefinition {
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
defs := make([]RouteDefinition, 0, len(methods))
for _, m := range methods {
defs = append(defs, r.Handle(m, path, handlers...))
}
return defs
}
// Routes returns a copy of all collected RouteDefinitions.
func (r *RouterRegistry) Routes() []RouteDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
res := make([]RouteDefinition, len(r.routes))
copy(res, r.routes)
return res
}
// RouterGroup represents a scoped route group with a path prefix and group-level middlewares.
type RouterGroup struct {
registry *RouterRegistry
prefix string
middlewares []any
}
// Use adds middlewares to this group.
func (g *RouterGroup) Use(middlewares ...any) {
g.middlewares = append(g.middlewares, middlewares...)
}
// Group creates a nested RouteGroup.
func (g *RouterGroup) Group(prefix string, middlewares ...any) RouterExtension {
combinedPrefix := joinPaths(g.prefix, prefix)
combinedMiddlewares := make([]any, 0, len(g.middlewares)+len(middlewares))
combinedMiddlewares = append(combinedMiddlewares, g.middlewares...)
combinedMiddlewares = append(combinedMiddlewares, middlewares...)
return &RouterGroup{
registry: g.registry,
prefix: combinedPrefix,
middlewares: combinedMiddlewares,
}
}
// Handle registers a route under this group.
func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefinition {
g.registry.mu.Lock()
defer g.registry.mu.Unlock()
fullPath := joinPaths(g.prefix, path)
allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares))
allMiddlewares = append(allMiddlewares, g.registry.middlewares...)
allMiddlewares = append(allMiddlewares, g.middlewares...)
rd := RouteDefinition{
Method: strings.ToUpper(method),
Path: fullPath,
Handlers: handlers,
Middlewares: allMiddlewares,
}
g.registry.routes = append(g.registry.routes, rd)
return rd
}
// GET registers a GET route in this group.
func (g *RouterGroup) GET(path string, handlers ...any) RouteDefinition {
return g.Handle("GET", path, handlers...)
}
// POST registers a POST route in this group.
func (g *RouterGroup) POST(path string, handlers ...any) RouteDefinition {
return g.Handle("POST", path, handlers...)
}
// PUT registers a PUT route in this group.
func (g *RouterGroup) PUT(path string, handlers ...any) RouteDefinition {
return g.Handle("PUT", path, handlers...)
}
// DELETE registers a DELETE route in this group.
func (g *RouterGroup) DELETE(path string, handlers ...any) RouteDefinition {
return g.Handle("DELETE", path, handlers...)
}
// PATCH registers a PATCH route in this group.
func (g *RouterGroup) PATCH(path string, handlers ...any) RouteDefinition {
return g.Handle("PATCH", path, handlers...)
}
// HEAD registers a HEAD route in this group.
func (g *RouterGroup) HEAD(path string, handlers ...any) RouteDefinition {
return g.Handle("HEAD", path, handlers...)
}
// OPTIONS registers an OPTIONS route in this group.
func (g *RouterGroup) OPTIONS(path string, handlers ...any) RouteDefinition {
return g.Handle("OPTIONS", path, handlers...)
}
// Any registers a route in this group for standard HTTP methods.
func (g *RouterGroup) Any(path string, handlers ...any) []RouteDefinition {
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
defs := make([]RouteDefinition, 0, len(methods))
for _, m := range methods {
defs = append(defs, g.Handle(m, path, handlers...))
}
return defs
}
// Routes returns all routes from the parent registry.
func (g *RouterGroup) Routes() []RouteDefinition {
return g.registry.Routes()
}
// Middlewares returns a copy of the group's middlewares.
func (g *RouterGroup) Middlewares() []any {
res := make([]any, len(g.middlewares))
copy(res, g.middlewares)
return res
}
func cleanPath(p string) string {
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if len(p) > 1 && strings.HasSuffix(p, "/") {
p = strings.TrimSuffix(p, "/")
}
return p
}
func joinPaths(base, relative string) string {
if base == "" || base == "/" {
return cleanPath(relative)
}
if relative == "" || relative == "/" {
return cleanPath(base)
}
base = strings.TrimSuffix(base, "/")
relative = strings.TrimPrefix(relative, "/")
return cleanPath(base + "/" + relative)
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "sync"
// ScheduleDefinition holds the configuration for a scheduled/cron task.
type ScheduleDefinition struct {
Spec string
TaskType string
Payload any
Options map[string]any
}
// ScheduleOption configures a ScheduleDefinition.
type ScheduleOption func(*ScheduleDefinition)
// WithScheduleOption adds a custom option to the schedule definition.
func WithScheduleOption(key string, val any) ScheduleOption {
return func(sd *ScheduleDefinition) {
if sd.Options == nil {
sd.Options = make(map[string]any)
}
sd.Options[key] = val
}
}
// ScheduleExtension defines the interface for registering and querying cron/scheduled tasks.
type ScheduleExtension interface {
Register(spec string, taskType string, payload any, opts ...ScheduleOption)
RegisterCron(spec string, taskType string, payload any, opts ...ScheduleOption)
Schedules() []ScheduleDefinition
Get(taskType string) (ScheduleDefinition, bool)
}
// ScheduleRegistry collects and manages schedule registrations.
type ScheduleRegistry struct {
mu sync.RWMutex
schedules []ScheduleDefinition
lookup map[string]ScheduleDefinition
}
// NewScheduleRegistry creates a new schedule registry.
func NewScheduleRegistry() *ScheduleRegistry {
return &ScheduleRegistry{
lookup: make(map[string]ScheduleDefinition),
}
}
// Register adds a schedule definition.
func (s *ScheduleRegistry) Register(spec string, taskType string, payload any, opts ...ScheduleOption) {
s.mu.Lock()
defer s.mu.Unlock()
sd := ScheduleDefinition{
Spec: spec,
TaskType: taskType,
Payload: payload,
Options: make(map[string]any),
}
for _, opt := range opts {
if opt != nil {
opt(&sd)
}
}
if _, exists := s.lookup[taskType]; exists {
for i, item := range s.schedules {
if item.TaskType == taskType {
s.schedules[i] = sd
break
}
}
} else {
s.schedules = append(s.schedules, sd)
}
s.lookup[taskType] = sd
}
// RegisterCron is an alias for Register.
func (s *ScheduleRegistry) RegisterCron(spec string, taskType string, payload any, opts ...ScheduleOption) {
s.Register(spec, taskType, payload, opts...)
}
// Schedules returns a copy of all registered ScheduleDefinitions.
func (s *ScheduleRegistry) Schedules() []ScheduleDefinition {
s.mu.RLock()
defer s.mu.RUnlock()
res := make([]ScheduleDefinition, len(s.schedules))
copy(res, s.schedules)
return res
}
// Get retrieves a schedule definition by its task type.
func (s *ScheduleRegistry) Get(taskType string) (ScheduleDefinition, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
sd, ok := s.lookup[taskType]
return sd, ok
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "sync"
// SettingSchema defines the configuration schema and metadata for a system or plugin setting.
type SettingSchema struct {
Key string `json:"key"`
Default any `json:"default"`
Description string `json:"description"`
Type string `json:"type,omitempty"`
ReadOnly bool `json:"read_only,omitempty"`
Public bool `json:"public,omitempty"`
Category string `json:"category,omitempty"`
Validation string `json:"validation,omitempty"`
}
// SettingExtension defines the interface for registering and querying setting configuration schemas.
type SettingExtension interface {
Register(schema SettingSchema)
Schemas() []SettingSchema
Get(key string) (SettingSchema, bool)
}
// SettingRegistry collects and manages setting configuration schemas.
type SettingRegistry struct {
mu sync.RWMutex
schemas []SettingSchema
lookup map[string]SettingSchema
}
// NewSettingRegistry creates a new setting schema registry.
func NewSettingRegistry() *SettingRegistry {
return &SettingRegistry{
lookup: make(map[string]SettingSchema),
}
}
// Register registers a SettingSchema into the registry.
// Panics if the schema Key is empty.
func (s *SettingRegistry) Register(schema SettingSchema) {
if schema.Key == "" {
panic("core/extpoints: setting schema key cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.lookup[schema.Key]; exists {
for i, item := range s.schemas {
if item.Key == schema.Key {
s.schemas[i] = schema
break
}
}
} else {
s.schemas = append(s.schemas, schema)
}
s.lookup[schema.Key] = schema
}
// Schemas returns a copy of all registered SettingSchemas.
func (s *SettingRegistry) Schemas() []SettingSchema {
s.mu.RLock()
defer s.mu.RUnlock()
res := make([]SettingSchema, len(s.schemas))
copy(res, s.schemas)
return res
}
// Get retrieves a SettingSchema by its key.
func (s *SettingRegistry) Get(key string) (SettingSchema, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
schema, ok := s.lookup[key]
return schema, ok
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"sync"
"time"
)
// TaskDefinition holds the definition and runtime options for an asynchronous background task.
type TaskDefinition struct {
Pattern string
Handler any
Concurrency int
Retry int
Timeout time.Duration
Metadata map[string]any
}
// TaskOption configures a TaskDefinition.
type TaskOption func(*TaskDefinition)
// WithTaskConcurrency sets the concurrency limit for the task.
func WithTaskConcurrency(concurrency int) TaskOption {
return func(td *TaskDefinition) {
td.Concurrency = concurrency
}
}
// WithTaskRetry sets the maximum retry count for the task.
func WithTaskRetry(retry int) TaskOption {
return func(td *TaskDefinition) {
td.Retry = retry
}
}
// WithTaskTimeout sets the execution timeout for the task.
func WithTaskTimeout(timeout time.Duration) TaskOption {
return func(td *TaskDefinition) {
td.Timeout = timeout
}
}
// WithTaskMetadata adds a key-value pair to the task metadata.
func WithTaskMetadata(key string, val any) TaskOption {
return func(td *TaskDefinition) {
if td.Metadata == nil {
td.Metadata = make(map[string]any)
}
td.Metadata[key] = val
}
}
// TaskExtension defines the interface for registering and querying background task handlers.
type TaskExtension interface {
Register(pattern string, handler any, opts ...TaskOption)
Tasks() []TaskDefinition
Get(pattern string) (TaskDefinition, bool)
}
// TaskRegistry collects and manages task registrations.
type TaskRegistry struct {
mu sync.RWMutex
tasks []TaskDefinition
lookup map[string]TaskDefinition
}
// NewTaskRegistry creates a new task registry.
func NewTaskRegistry() *TaskRegistry {
return &TaskRegistry{
lookup: make(map[string]TaskDefinition),
}
}
// Register registers a task pattern and its handler with optional configuration.
func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption) {
t.mu.Lock()
defer t.mu.Unlock()
td := TaskDefinition{
Pattern: pattern,
Handler: handler,
Metadata: make(map[string]any),
}
for _, opt := range opts {
if opt != nil {
opt(&td)
}
}
if _, exists := t.lookup[pattern]; exists {
for i, item := range t.tasks {
if item.Pattern == pattern {
t.tasks[i] = td
break
}
}
} else {
t.tasks = append(t.tasks, td)
}
t.lookup[pattern] = td
}
// Tasks returns a copy of all registered TaskDefinitions.
func (t *TaskRegistry) Tasks() []TaskDefinition {
t.mu.RLock()
defer t.mu.RUnlock()
res := make([]TaskDefinition, len(t.tasks))
copy(res, t.tasks)
return res
}
// Get retrieves a task definition by its pattern.
func (t *TaskRegistry) Get(pattern string) (TaskDefinition, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
td, ok := t.lookup[pattern]
return td, ok
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"fmt"
"strings"
)
// Manifest defines the metadata and dependency declarations for a plugin.
type Manifest struct {
// Name is the unique identifier for the plugin (e.g. "auth", "user", "order").
Name string `json:"name" yaml:"name"`
// Version is the semantic version string of the plugin (e.g. "1.0.0").
Version string `json:"version,omitempty" yaml:"version,omitempty"`
// Description gives a brief summary of the plugin capabilities.
Description string `json:"description,omitempty" yaml:"description,omitempty"`
// Author specifies the author or maintainer of the plugin.
Author string `json:"author,omitempty" yaml:"author,omitempty"`
// Dependencies lists the plugin names that this plugin depends on.
Dependencies []string `json:"dependencies,omitempty" yaml:"dependencies,omitempty"`
// Metadata holds arbitrary plugin-specific metadata.
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
}
// Validate checks whether the manifest satisfies basic integrity requirements.
func (m Manifest) Validate() error {
if strings.TrimSpace(m.Name) == "" {
return fmt.Errorf("%w: %w", ErrInvalidManifest, ErrInvalidManifestName)
}
return nil
}
+150
View File
@@ -0,0 +1,150 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"github.com/Rain-kl/Wavelet/backend/core/extpoints"
)
// Standard sentinel errors returned by core operations.
var (
// ErrServiceNotFound is returned when a requested service is not registered in the IoC container.
ErrServiceNotFound = errors.New("core: service not found")
// ErrServiceNotReady is returned when one or more required services are not ready in Using/UsingN.
ErrServiceNotReady = errors.New("core: service not ready")
// ErrNilContext is returned when a nil Context is passed to an operation requiring a valid Context.
ErrNilContext = errors.New("core: context is nil")
// ErrNilService is returned when attempting to provide a nil service implementation.
ErrNilService = errors.New("core: service is nil")
// ErrInvalidManifest is returned when a plugin manifest fails validation.
ErrInvalidManifest = errors.New("core: invalid manifest")
// ErrInvalidManifestName is returned when a plugin manifest has an empty name.
ErrInvalidManifestName = errors.New("core: manifest name is required")
// ErrDriverNotFound is returned when a requested driver type is not registered.
ErrDriverNotFound = errors.New("core: driver not found")
// ErrAppRunning is returned when attempting to start an already running App.
ErrAppRunning = errors.New("core: app is already running")
// ErrAppNotRunning is returned when attempting to operate on an App that is not running.
ErrAppNotRunning = errors.New("core: app is not running")
)
// Plugin is the unified contract for all core and downstream plugins.
type Plugin interface {
// Name returns the globally unique identifier of the plugin (e.g. "auth", "database").
Name() string
// Apply is the core mounting entrypoint: provides services, registers routes, tasks, and event listeners.
Apply(ctx *Context) error
}
// PluginWithManifest is an optional extension interface for plugins that declare metadata.
type PluginWithManifest interface {
Plugin
Manifest() Manifest
}
// DriverType identifies the category of a runtime driver engine.
type DriverType string
const (
// DriverTypeHTTP represents HTTP web server drivers (e.g. Gin).
DriverTypeHTTP DriverType = "http"
// DriverTypeWorker represents asynchronous background worker drivers (e.g. Asynq worker server).
DriverTypeWorker DriverType = "worker"
// DriverTypeScheduler represents cron and timer schedule drivers (e.g. Asynq scheduler).
DriverTypeScheduler DriverType = "schedule"
)
// Driver is a runtime engine that manages an event loop or listening port.
type Driver interface {
// Type returns the category of this driver engine.
Type() DriverType
// Start starts the driver lifecycle loop.
Start(ctx context.Context) error
// Stop gracefully shuts down the driver.
Stop(ctx context.Context) error
}
// Profile identifies the runtime aspect or execution mode of an application.
type Profile string
const (
// ProfileAPI runs HTTP API server drivers.
ProfileAPI Profile = "api"
// ProfileWorker runs asynchronous background worker drivers.
ProfileWorker Profile = "worker"
// ProfileSchedule runs cron and timer schedule drivers.
ProfileSchedule Profile = "schedule"
// ProfileAll runs all registered drivers concurrently in fused mode.
ProfileAll Profile = "all"
)
// MigrationEngine is the interface for executing database migrations across registered plugins.
// The ctx parameter is the root micro-kernel Context, allowing the engine to resolve
// services from the IoC container via core.Inject or core.Using.
type MigrationEngine interface {
Migrate(ctx *Context, entries []MigrationEntry) error
}
// MigrationRunner is a function adapter implementing MigrationEngine.
type MigrationRunner func(ctx *Context, entries []MigrationEntry) error
// Migrate calls the underlying migration function.
func (fn MigrationRunner) Migrate(ctx *Context, entries []MigrationEntry) error {
return fn(ctx, entries)
}
// Disposer is a cleanup function executed when a Context is disposed.
type Disposer func() error
// RouterExtension re-exports extpoints.RouterExtension.
type RouterExtension = extpoints.RouterExtension
// RouteDefinition re-exports extpoints.RouteDefinition.
type RouteDefinition = extpoints.RouteDefinition
// MigrationExtension re-exports extpoints.MigrationExtension.
type MigrationExtension = extpoints.MigrationExtension
// MigrationEntry re-exports extpoints.MigrationEntry.
type MigrationEntry = extpoints.MigrationEntry
// TaskExtension re-exports extpoints.TaskExtension.
type TaskExtension = extpoints.TaskExtension
// TaskDefinition re-exports extpoints.TaskDefinition.
type TaskDefinition = extpoints.TaskDefinition
// TaskOption re-exports extpoints.TaskOption.
type TaskOption = extpoints.TaskOption
// ScheduleExtension re-exports extpoints.ScheduleExtension.
type ScheduleExtension = extpoints.ScheduleExtension
// ScheduleDefinition re-exports extpoints.ScheduleDefinition.
type ScheduleDefinition = extpoints.ScheduleDefinition
// ScheduleOption re-exports extpoints.ScheduleOption.
type ScheduleOption = extpoints.ScheduleOption
// SettingExtension re-exports extpoints.SettingExtension.
type SettingExtension = extpoints.SettingExtension
// SettingSchema re-exports extpoints.SettingSchema.
type SettingSchema = extpoints.SettingSchema