diff --git a/backend/core/context.go b/backend/core/context.go index c83ef624..e11f1db7 100644 --- a/backend/core/context.go +++ b/backend/core/context.go @@ -175,9 +175,22 @@ func (c *Context) Events() *EventBus { return c.events } -// Router returns the RouterExtension registry. +// On registers an event listener on the EventBus and automatically attaches its Disposer +// to this Context's teardown stack for automatic revocation when disposed. +func (c *Context) On(topic string, handler any) Disposer { + disposer := c.events.On(topic, handler) + c.OnDispose(disposer) + return disposer +} + +// Effect registers a reversible side-effect cleanup callback on this Context. +func (c *Context) Effect(fn any) { + c.OnDispose(fn) +} + +// Router returns the scoped RouterExtension registry with automatic disposer tracking. func (c *Context) Router() extpoints.RouterExtension { - return c.router + return newScopedRouterExtension(c, c.router) } // Migrations returns the MigrationExtension registry. @@ -185,34 +198,34 @@ func (c *Context) Migrations() extpoints.MigrationExtension { return c.migrations } -// Tasks returns the TaskExtension registry. +// Tasks returns the scoped TaskExtension registry with automatic disposer tracking. func (c *Context) Tasks() extpoints.TaskExtension { - return c.tasks + return newScopedTaskExtension(c, c.tasks) } // Task is an alias for Tasks(). func (c *Context) Task() extpoints.TaskExtension { - return c.tasks + return c.Tasks() } -// Schedules returns the ScheduleExtension registry. +// Schedules returns the scoped ScheduleExtension registry with automatic disposer tracking. func (c *Context) Schedules() extpoints.ScheduleExtension { - return c.schedules + return newScopedScheduleExtension(c, c.schedules) } // Schedule is an alias for Schedules(). func (c *Context) Schedule() extpoints.ScheduleExtension { - return c.schedules + return c.Schedules() } -// Settings returns the SettingExtension registry. +// Settings returns the scoped SettingExtension registry with automatic disposer tracking. func (c *Context) Settings() extpoints.SettingExtension { - return c.settings + return newScopedSettingExtension(c, c.settings) } // Setting is an alias for Settings(). func (c *Context) Setting() extpoints.SettingExtension { - return c.settings + return c.Settings() } // DB returns the contracts.DBService registered in the IoC container, or nil if not registered. diff --git a/backend/core/context_test.go b/backend/core/context_test.go index de30c758..ba5baf21 100644 --- a/backend/core/context_test.go +++ b/backend/core/context_test.go @@ -491,12 +491,45 @@ func TestContextExtensionPointsAccessors(t *testing.T) { 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()) + assert.NotNil(t, child.Router()) + assert.NotNil(t, child.Tasks()) + assert.NotNil(t, child.Task()) + assert.NotNil(t, child.Schedules()) + assert.NotNil(t, child.Schedule()) + assert.NotNil(t, child.Settings()) + assert.NotNil(t, child.Setting()) } + +func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) { + root := core.NewContext(context.Background()) + child := root.Fork() + + // Register route, task, schedule, setting, event on child + child.Router().GET("/test-route", func() {}) + assert.Equal(t, 1, len(root.Router().Routes())) + + child.Tasks().Register("test:task", func() {}) + assert.Equal(t, 1, len(root.Tasks().Tasks())) + + child.Schedules().RegisterCron("@hourly", "test:cron", nil) + assert.Equal(t, 1, len(root.Schedules().Schedules())) + + child.Settings().Register(core.SettingSchema{Key: "test.key", Default: "val"}) + assert.Equal(t, 1, len(root.Settings().Schemas())) + + child.On("test:event", func() {}) + assert.Equal(t, 1, root.Events().Listeners("test:event")) + + // Dispose child + err := child.Dispose() + assert.NoError(t, err) + + // All child effects should be cleanly revoked in LIFO order + assert.Equal(t, 0, len(root.Router().Routes())) + assert.Equal(t, 0, len(root.Tasks().Tasks())) + assert.Equal(t, 0, len(root.Schedules().Schedules())) + assert.Equal(t, 0, len(root.Settings().Schemas())) + assert.Equal(t, 0, root.Events().Listeners("test:event")) +} + diff --git a/backend/core/scoped_extpoints.go b/backend/core/scoped_extpoints.go new file mode 100644 index 00000000..9edeee24 --- /dev/null +++ b/backend/core/scoped_extpoints.go @@ -0,0 +1,201 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "Wavelet/core/extpoints" +) + +// scopedRouterExtension wraps a RouterExtension to automatically register +// teardown disposers on the associated Context when routes are declared. +type scopedRouterExtension struct { + underlying extpoints.RouterExtension + ctx *Context +} + +func newScopedRouterExtension(ctx *Context, underlying extpoints.RouterExtension) extpoints.RouterExtension { + return &scopedRouterExtension{ + underlying: underlying, + ctx: ctx, + } +} + +func (s *scopedRouterExtension) Use(middlewares ...any) { + s.underlying.Use(middlewares...) +} + +func (s *scopedRouterExtension) Group(prefix string, middlewares ...any) extpoints.RouterExtension { + subGroup := s.underlying.Group(prefix, middlewares...) + return newScopedRouterExtension(s.ctx, subGroup) +} + +func (s *scopedRouterExtension) Handle(method string, path string, handlers ...any) extpoints.RouteDefinition { + rd := s.underlying.Handle(method, path, handlers...) + routeID := rd.ID + s.ctx.OnDispose(func() error { + s.underlying.UnregisterByID(routeID) + return nil + }) + return rd +} + +func (s *scopedRouterExtension) GET(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("GET", path, handlers...) +} + +func (s *scopedRouterExtension) POST(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("POST", path, handlers...) +} + +func (s *scopedRouterExtension) PUT(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("PUT", path, handlers...) +} + +func (s *scopedRouterExtension) DELETE(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("DELETE", path, handlers...) +} + +func (s *scopedRouterExtension) PATCH(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("PATCH", path, handlers...) +} + +func (s *scopedRouterExtension) HEAD(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("HEAD", path, handlers...) +} + +func (s *scopedRouterExtension) OPTIONS(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("OPTIONS", path, handlers...) +} + +func (s *scopedRouterExtension) Any(path string, handlers ...any) []extpoints.RouteDefinition { + methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + defs := make([]extpoints.RouteDefinition, 0, len(methods)) + for _, m := range methods { + defs = append(defs, s.Handle(m, path, handlers...)) + } + return defs +} + +func (s *scopedRouterExtension) Routes() []extpoints.RouteDefinition { + return s.underlying.Routes() +} + +func (s *scopedRouterExtension) Middlewares() []any { + return s.underlying.Middlewares() +} + +func (s *scopedRouterExtension) Unregister(method, path string) bool { + return s.underlying.Unregister(method, path) +} + +func (s *scopedRouterExtension) UnregisterByID(id uint64) bool { + return s.underlying.UnregisterByID(id) +} + +// scopedTaskExtension wraps a TaskExtension to automatically register +// teardown disposers on the associated Context when task handlers are declared. +type scopedTaskExtension struct { + underlying extpoints.TaskExtension + ctx *Context +} + +func newScopedTaskExtension(ctx *Context, underlying extpoints.TaskExtension) extpoints.TaskExtension { + return &scopedTaskExtension{ + underlying: underlying, + ctx: ctx, + } +} + +func (s *scopedTaskExtension) Register(pattern string, handler any, opts ...extpoints.TaskOption) { + s.underlying.Register(pattern, handler, opts...) + s.ctx.OnDispose(func() error { + s.underlying.Unregister(pattern) + return nil + }) +} + +func (s *scopedTaskExtension) Tasks() []extpoints.TaskDefinition { + return s.underlying.Tasks() +} + +func (s *scopedTaskExtension) Get(pattern string) (extpoints.TaskDefinition, bool) { + return s.underlying.Get(pattern) +} + +func (s *scopedTaskExtension) Unregister(pattern string) bool { + return s.underlying.Unregister(pattern) +} + +// scopedScheduleExtension wraps a ScheduleExtension to automatically register +// teardown disposers on the associated Context when cron/scheduled tasks are declared. +type scopedScheduleExtension struct { + underlying extpoints.ScheduleExtension + ctx *Context +} + +func newScopedScheduleExtension(ctx *Context, underlying extpoints.ScheduleExtension) extpoints.ScheduleExtension { + return &scopedScheduleExtension{ + underlying: underlying, + ctx: ctx, + } +} + +func (s *scopedScheduleExtension) Register(spec string, taskType string, payload any, opts ...extpoints.ScheduleOption) { + s.underlying.Register(spec, taskType, payload, opts...) + s.ctx.OnDispose(func() error { + s.underlying.Unregister(taskType) + return nil + }) +} + +func (s *scopedScheduleExtension) RegisterCron(spec string, taskType string, payload any, opts ...extpoints.ScheduleOption) { + s.Register(spec, taskType, payload, opts...) +} + +func (s *scopedScheduleExtension) Schedules() []extpoints.ScheduleDefinition { + return s.underlying.Schedules() +} + +func (s *scopedScheduleExtension) Get(taskType string) (extpoints.ScheduleDefinition, bool) { + return s.underlying.Get(taskType) +} + +func (s *scopedScheduleExtension) Unregister(taskType string) bool { + return s.underlying.Unregister(taskType) +} + +// scopedSettingExtension wraps a SettingExtension to automatically register +// teardown disposers on the associated Context when settings schemas are declared. +type scopedSettingExtension struct { + underlying extpoints.SettingExtension + ctx *Context +} + +func newScopedSettingExtension(ctx *Context, underlying extpoints.SettingExtension) extpoints.SettingExtension { + return &scopedSettingExtension{ + underlying: underlying, + ctx: ctx, + } +} + +func (s *scopedSettingExtension) Register(schema extpoints.SettingSchema) { + s.underlying.Register(schema) + key := schema.Key + s.ctx.OnDispose(func() error { + s.underlying.Unregister(key) + return nil + }) +} + +func (s *scopedSettingExtension) Schemas() []extpoints.SettingSchema { + return s.underlying.Schemas() +} + +func (s *scopedSettingExtension) Get(key string) (extpoints.SettingSchema, bool) { + return s.underlying.Get(key) +} + +func (s *scopedSettingExtension) Unregister(key string) bool { + return s.underlying.Unregister(key) +}