chore(cordis): sync Wavelet core/pkg/plugins after W1-W9

This commit is contained in:
ryan
2026-08-30 11:46:58 +08:00
parent dd333643f2
commit e847a7adb3
39 changed files with 1018 additions and 751 deletions
+37 -30
View File
@@ -59,6 +59,14 @@ func WithMigrationRunner(runner MigrationRunner) AppOption {
}
}
// WithMigrationBaseline registers a hook the migration engine runs after the
// shared version table exists and before any plugin Up.
func WithMigrationBaseline(fn func(*Context) error) AppOption {
return func(a *App) {
a.migrationBaseline = fn
}
}
// WithShutdownTimeout sets the fallback timeout for graceful application shutdown.
func WithShutdownTimeout(timeout time.Duration) AppOption {
return func(a *App) {
@@ -99,23 +107,24 @@ func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
// 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
fibers []*Fiber
fiberMap map[string]*Fiber
applied bool
running bool
startedDrivers []Driver
migrationEngine MigrationEngine
shutdownTimeout time.Duration
configSource ConfigSource
hostDeclOwner string
hostDeclBindings []ConfigBinding
prepared bool
applyErr error
mu sync.RWMutex
ctx *Context
profile Profile
plugins []Plugin
pluginMap map[string]Plugin
fibers []*Fiber
fiberMap map[string]*Fiber
applied bool
running bool
startedDrivers []Driver
migrationEngine MigrationEngine
migrationBaseline func(*Context) error
shutdownTimeout time.Duration
configSource ConfigSource
hostDeclOwner string
hostDeclBindings []ConfigBinding
prepared bool
applyErr error
}
// NewApp creates a new Cordis application instance with default options.
@@ -373,22 +382,20 @@ func (a *App) prepareLocked() error {
if a.prepared {
return nil
}
if a.configSource == nil {
a.prepared = true
return nil
if a.configSource != nil {
config := a.ctx.Config()
config.SetSource(a.configSource)
if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil {
return err
}
if err := config.Resolve(); err != nil {
return err
}
}
config := a.ctx.Config()
config.SetSource(a.configSource)
if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil {
return err
}
if err := config.Resolve(); err != nil {
return err
}
a.ctx.setMigrationBaseline(a.migrationBaseline)
a.prepared = true
return nil
}
+58
View File
@@ -594,3 +594,61 @@ func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) {
app.SetShutdownTimeout(45 * time.Second)
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
}
func TestWithMigrationBaselineVisibleAfterPrepare(t *testing.T) {
var called bool
fn := func(*core.Context) error {
called = true
return nil
}
app := core.NewApp(core.WithMigrationBaseline(fn))
require.Nil(t, app.Context().MigrationBaseline(), "baseline must be copied during Prepare")
require.NoError(t, app.Prepare())
got := app.Context().MigrationBaseline()
require.NotNil(t, got, "Prepare must copy the baseline onto the root Context")
require.NoError(t, got(app.Context()))
assert.True(t, called)
}
func TestWithMigrationBaselineRunsBeforeEngineMigrate(t *testing.T) {
var order []string
engine := core.MigrationRunner(func(ctx *core.Context, _ []extpoints.MigrationEntry) error {
order = append(order, "engine")
if ctx.MigrationBaseline() == nil {
t.Fatal("baseline must be visible on context inside Migrate")
}
return ctx.MigrationBaseline()(ctx)
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("-- +goose Up\nSELECT 1;\n")},
}
app := core.NewApp(
core.WithMigrationEngine(engine),
core.WithMigrationBaseline(func(*core.Context) error {
order = append(order, "baseline")
return nil
}),
core.WithPlugins(&appMockPlugin{
name: "t",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("t", sqlFS)
return nil
},
}),
)
require.NoError(t, app.Start(context.Background()))
defer func() { _ = app.Stop(context.Background()) }()
assert.Equal(t, []string{"engine", "baseline"}, order)
}
func TestWithMigrationBaselineNilByDefault(t *testing.T) {
app := core.NewApp()
require.NoError(t, app.Prepare())
assert.Nil(t, app.Context().MigrationBaseline())
}
+47 -18
View File
@@ -29,12 +29,13 @@ type Context struct {
settings extpoints.SettingExtension
config extpoints.ConfigExtension
mu sync.RWMutex
children []*Context
disposers []Disposer
drivers []Driver
values map[any]any
disposed bool
mu sync.RWMutex
children []*Context
disposers []Disposer
drivers []Driver
values map[any]any
disposed bool
migrationBaseline func(*Context) error
}
// NewContext creates a new root Context wrapping a standard Go context.
@@ -159,18 +160,19 @@ 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),
events: c.events,
router: c.router,
migrations: c.migrations,
tasks: c.tasks,
schedules: c.schedules,
settings: c.settings,
config: c.config,
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,
config: c.config,
values: make(map[any]any),
migrationBaseline: c.MigrationBaseline(),
}
c.mu.Lock()
@@ -208,6 +210,33 @@ func (c *Context) Migrations() extpoints.MigrationExtension {
return c.migrations
}
// MigrationBaseline returns the hook copied onto this Context during App.Prepare.
// Child contexts fall back to their parent so forks still see the root hook.
func (c *Context) MigrationBaseline() func(*Context) error {
if c == nil {
return nil
}
c.mu.RLock()
fn := c.migrationBaseline
c.mu.RUnlock()
if fn != nil {
return fn
}
if c.parent != nil {
return c.parent.MigrationBaseline()
}
return nil
}
func (c *Context) setMigrationBaseline(fn func(*Context) error) {
if c == nil {
return
}
c.mu.Lock()
c.migrationBaseline = fn
c.mu.Unlock()
}
// Tasks returns the scoped TaskExtension registry with automatic disposer tracking.
func (c *Context) Tasks() extpoints.TaskExtension {
return newScopedTaskExtension(c, c.tasks)
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package contracts
// CaptchaService defines the contract for CAPTCHA challenge issuance,
// redemption, and scoped verification middleware.
type CaptchaService interface {
VerifyMiddleware(scope string) any
ChallengeHandler() any
RedeemHandler() any
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package contracts
import "context"
// PublicConfigProvider supplies the payload for GET /api/v1/config/public
// when a downstream plugin replaces Wavelet's default {configs, app} JSON.
type PublicConfigProvider interface {
PublicConfig(ctx context.Context) (any, error)
}
+26
View File
@@ -0,0 +1,26 @@
// 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"
type PushNotificationTemplate struct {
Title string
Content string
Level string
Ext map[string]any
}
type PushEventMeta struct {
Key string
Name string
Description string
DefaultTemplate PushNotificationTemplate
}
type PushRegistry interface {
RegisterBuiltInEvent(meta PushEventMeta)
SyncEvents(ctx context.Context) error
}
+7
View File
@@ -180,6 +180,13 @@ func formatEntryValue(value any, secret bool) string {
if value == nil {
return ""
}
rv := reflect.ValueOf(value)
if rv.Kind() == reflect.Ptr {
if rv.IsNil() {
return ""
}
return fmt.Sprint(rv.Elem().Interface())
}
return fmt.Sprint(value)
}
+48
View File
@@ -224,6 +224,54 @@ func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) {
assert.Equal(t, "86400", entries["app.session_age"].Value)
}
type optionalBoolConfig struct {
RedirectTrailingSlash *bool `config:"redirect_trailing_slash" env:"APP_REDIRECT_TRAILING_SLASH"`
}
func TestBindBoolPointerFromFileAndEnv(t *testing.T) {
t.Run("absent stays nil", func(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &optionalBoolConfig{}}))
require.NoError(t, r.Resolve())
var got optionalBoolConfig
require.NoError(t, r.Bind("app", &got))
assert.Nil(t, got.RedirectTrailingSlash)
assert.Equal(t, "", r.Origin("app.redirect_trailing_slash"))
})
t.Run("file false", func(t *testing.T) {
src := newFakeSource()
src.values["app.redirect_trailing_slash"] = false
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &optionalBoolConfig{}}))
require.NoError(t, r.Resolve())
var got optionalBoolConfig
require.NoError(t, r.Bind("app", &got))
require.NotNil(t, got.RedirectTrailingSlash)
assert.False(t, *got.RedirectTrailingSlash)
assert.Equal(t, extpoints.OriginFile, r.Origin("app.redirect_trailing_slash"))
assert.False(t, r.Bool("app.redirect_trailing_slash", true))
})
t.Run("env false", func(t *testing.T) {
src := newFakeSource()
src.env["APP_REDIRECT_TRAILING_SLASH"] = "false"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &optionalBoolConfig{}}))
require.NoError(t, r.Resolve())
var got optionalBoolConfig
require.NoError(t, r.Bind("app", &got))
require.NotNil(t, got.RedirectTrailingSlash)
assert.False(t, *got.RedirectTrailingSlash)
assert.Equal(t, extpoints.OriginEnv, r.Origin("app.redirect_trailing_slash"))
})
}
func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) {
r := extpoints.NewConfigRegistry(nil)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
+23
View File
@@ -33,15 +33,38 @@ func convertValue(raw any, typ reflect.Type) (any, error) {
return convertSlice(raw, typ)
case reflect.Struct:
return convertStruct(raw, typ)
case reflect.Ptr:
return convertPointer(raw, typ)
default:
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
}
}
// convertPointer decodes into the element type and returns a non-nil pointer to it.
// Nested pointers are rejected so configuration tags stay one level deep.
func convertPointer(raw any, typ reflect.Type) (any, error) {
elemType := typ.Elem()
if elemType.Kind() == reflect.Ptr {
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
}
elem, err := convertValue(raw, elemType)
if err != nil {
return nil, err
}
ptr := reflect.New(elemType)
ptr.Elem().Set(reflect.ValueOf(elem))
return ptr.Interface(), nil
}
func convertBool(raw any) (any, error) {
switch v := raw.(type) {
case bool:
return v, nil
case *bool:
if v == nil {
return nil, fmt.Errorf("%w: nil *bool is not a boolean", ErrConfigType)
}
return *v, nil
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
+1 -26
View File
@@ -1,12 +1,7 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "testing"
// TestHandleRawPreservesTrailingSlash 验证 HandleRaw 能表达 /x 与 /x/ 两条不同路由,
// 而 Handle 会归一化掉尾部斜杠(server 插件的 list 端点历史行为依赖这一点)。
func TestHandleRawPreservesTrailingSlash(t *testing.T) {
r := &RouterRegistry{}
g := r.Group("/api/v1/nodes")
@@ -24,29 +19,9 @@ func TestHandleRawPreservesTrailingSlash(t *testing.T) {
t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/")
}
if slashed.ID == slashless.ID {
t.Error("HandleRaw must allocate its own route ID so scoped teardown can unregister both")
t.Error("HandleRaw must allocate its own route ID")
}
if got := len(r.Routes()); got != 2 {
t.Errorf("registry routes = %d, want 2", got)
}
if !r.UnregisterByID(slashed.ID) {
t.Error("UnregisterByID(HandleRaw route) = false, want true")
}
if got := len(r.Routes()); got != 1 {
t.Errorf("routes after unregister = %d, want 1", got)
}
}
// TestRegistryHandleRawKeepsAbsolutePath 根注册表上 HandleRaw 只做绝对化处理。
func TestRegistryHandleRawKeepsAbsolutePath(t *testing.T) {
r := &RouterRegistry{}
if got := r.HandleRaw("GET", "/health/").Path; got != "/health/" {
t.Errorf("path = %q, want %q", got, "/health/")
}
if got := r.HandleRaw("POST", "submit").Path; got != "/submit" {
t.Errorf("path = %q, want %q", got, "/submit")
}
if got := r.BasePath(); got != "" {
t.Errorf("registry BasePath() = %q, want empty", got)
}
}