feat(core): add typed eventbus and domain extension points

This commit is contained in:
ryan
2026-08-27 23:47:57 +08:00
parent d853a41eae
commit c53a5461ab
11 changed files with 1625 additions and 9 deletions
+75 -9
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"sync"
"time"
"github.com/Rain-kl/Wavelet/core/extpoints"
)
// Context is the central micro-kernel service bus and runtime lifecycle container.
@@ -17,6 +19,13 @@ type Context struct {
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
@@ -34,10 +43,16 @@ func NewContext(base context.Context) *Context {
ctx, cancel := context.WithCancel(base)
return &Context{
goCtx: ctx,
cancel: cancel,
container: NewContainer(nil),
values: make(map[any]any),
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),
}
}
@@ -127,11 +142,17 @@ func (c *Context) ForkWithContext(base context.Context) *Context {
ctx, cancel := context.WithCancel(base)
child := &Context{
goCtx: ctx,
cancel: cancel,
parent: c,
container: NewContainer(c.container),
values: make(map[any]any),
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()
@@ -141,6 +162,51 @@ func (c *Context) ForkWithContext(base context.Context) *Context {
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) {
+24
View File
@@ -473,3 +473,27 @@ func TestConcurrentAccess(t *testing.T) {
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())
}
+271
View File
@@ -0,0 +1,271 @@
package core
import (
"context"
"errors"
"fmt"
"reflect"
"sync"
"sync/atomic"
)
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 > 2 {
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.
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
}
+315
View File
@@ -0,0 +1,315 @@
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/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))
}
+277
View File
@@ -0,0 +1,277 @@
package extpoints_test
import (
"context"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/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)
}
+83
View File
@@ -0,0 +1,83 @@
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
}
+266
View File
@@ -0,0 +1,266 @@
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)
}
+100
View File
@@ -0,0 +1,100 @@
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
}
+77
View File
@@ -0,0 +1,77 @@
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
}
+119
View File
@@ -0,0 +1,119 @@
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
}
+18
View File
@@ -3,6 +3,8 @@ package core
import (
"context"
"errors"
"github.com/Rain-kl/Wavelet/core/extpoints"
)
// Standard sentinel errors returned by core operations.
@@ -69,3 +71,19 @@ type Driver interface {
// Disposer is a cleanup function executed when a Context is disposed.
type Disposer func() error
// Re-exported extension point types from core/extpoints for convenient usage.
type (
RouterExtension = extpoints.RouterExtension
RouteDefinition = extpoints.RouteDefinition
MigrationExtension = extpoints.MigrationExtension
MigrationEntry = extpoints.MigrationEntry
TaskExtension = extpoints.TaskExtension
TaskDefinition = extpoints.TaskDefinition
TaskOption = extpoints.TaskOption
ScheduleExtension = extpoints.ScheduleExtension
ScheduleDefinition = extpoints.ScheduleDefinition
ScheduleOption = extpoints.ScheduleOption
SettingExtension = extpoints.SettingExtension
SettingSchema = extpoints.SettingSchema
)