diff --git a/cmd/cmd_module.go b/cmd/cmd_module.go index 88446c6a0..6db650a12 100644 --- a/cmd/cmd_module.go +++ b/cmd/cmd_module.go @@ -16,8 +16,7 @@ import ( clicompat "github.com/choysum-dev/choysum/internal/cli/compat" clioutput "github.com/choysum-dev/choysum/internal/cli/output" cliruntime "github.com/choysum-dev/choysum/internal/cli/runtime" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" - + modmeta "github.com/choysum-dev/choysum/internal/module/meta" internalorigin "github.com/choysum-dev/choysum/internal/module/origin" sourceregistry "github.com/choysum-dev/choysum/internal/module/origin/registry" "github.com/choysum-dev/choysum/pkg/meta" @@ -493,12 +492,12 @@ func queryModuleIndexViews(runtimeScope scope.Scope, moduleName string) ([]modul return nil } db := txScope.Session().DB - if !db.Migrator().HasTable(&metadata.ModuleIndex{}) { + if !db.Migrator().HasTable(&modmeta.ModuleIndex{}) { return nil } hasIndex = true - q := db.Table((metadata.ModuleIndex{}).TableName() + " AS idx") + q := db.Table((modmeta.ModuleIndex{}).TableName() + " AS idx") if db.Migrator().HasTable(&meta.Module{}) { q = q. Select("idx.module_name, idx.origin_type, idx.origin_ref, idx.available, idx.version, idx.local_path, mod.status AS install_status"). diff --git a/cmd/cmd_module_test.go b/cmd/cmd_module_test.go index 7151e615c..30c028970 100644 --- a/cmd/cmd_module_test.go +++ b/cmd/cmd_module_test.go @@ -20,7 +20,7 @@ import ( clicompat "github.com/choysum-dev/choysum/internal/cli/compat" cliruntime "github.com/choysum-dev/choysum/internal/cli/runtime" "github.com/choysum-dev/choysum/internal/config/snapshot" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" internalorigin "github.com/choysum-dev/choysum/internal/module/origin" sourceregistry "github.com/choysum-dev/choysum/internal/module/origin/registry" "github.com/choysum-dev/choysum/internal/testing/scopetest" @@ -575,10 +575,10 @@ func TestQueryModuleIndexViewsBranches(t *testing.T) { t.Run("queries index without module table join", func(t *testing.T) { runtimeScope, db := newModuleQueryTestScope(t) - if err := db.AutoMigrate(&metadata.ModuleIndex{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModuleIndex{}); err != nil { t.Fatalf("auto migrate module index: %v", err) } - if err := db.Create(&metadata.ModuleIndex{ + if err := db.Create(&modmeta.ModuleIndex{ ModuleName: "auth", OriginType: "local", OriginRef: "modules/auth", @@ -604,10 +604,10 @@ func TestQueryModuleIndexViewsBranches(t *testing.T) { t.Run("joins module install status when module table exists", func(t *testing.T) { runtimeScope, db := newModuleQueryTestScope(t) - if err := db.AutoMigrate(&metadata.ModuleIndex{}, &meta.Module{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModuleIndex{}, &meta.Module{}); err != nil { t.Fatalf("auto migrate module tables: %v", err) } - if err := db.Create(&metadata.ModuleIndex{ + if err := db.Create(&modmeta.ModuleIndex{ ModuleName: "auth", OriginType: "local", OriginRef: "modules/auth", diff --git a/cmd/cmd_test_cmd_e2e_test.go b/cmd/cmd_test_cmd_e2e_test.go index 387048ca5..ded63baf0 100644 --- a/cmd/cmd_test_cmd_e2e_test.go +++ b/cmd/cmd_test_cmd_e2e_test.go @@ -10,34 +10,28 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io" "net" - - clicompat "github.com/choysum-dev/choysum/internal/cli/compat" - "github.com/choysum-dev/choysum/pkg/config" - "github.com/choysum-dev/choysum/pkg/meta" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "net/http" "net/http/httptest" "net/url" "os" "os/exec" - "path/filepath" "sort" "strconv" "strings" - "syscall" "testing" - "time" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + clicompat "github.com/choysum-dev/choysum/internal/cli/compat" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" internalorigin "github.com/choysum-dev/choysum/internal/module/origin" + "github.com/choysum-dev/choysum/pkg/config" + "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) func TestCLIErrorBlockLastOutput_InitInteractive(t *testing.T) { @@ -543,10 +537,10 @@ func writeInitializedSqliteDB(t *testing.T) string { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := db.AutoMigrate(&metadata.Setting{}); err != nil { + if err := db.AutoMigrate(&modmeta.Setting{}); err != nil { t.Fatalf("migrate: %v", err) } - if err := db.Create(&metadata.Setting{Key: "system.init.done", Value: "true"}).Error; err != nil { + if err := db.Create(&modmeta.Setting{Key: "system.init.done", Value: "true"}).Error; err != nil { t.Fatalf("insert init marker: %v", err) } return path @@ -1940,7 +1934,7 @@ func TestCLIRunDoesNotWriteInitArtifacts(t *testing.T) { if err != nil { t.Fatalf("open sqlite: %v", err) } - var settings []metadata.Setting + var settings []modmeta.Setting if err := db.Find(&settings).Error; err != nil { t.Fatalf("query settings: %v", err) } diff --git a/internal/bootstrap/service/coordinator.go b/internal/bootstrap/service/coordinator.go index f2698f295..5e1608efe 100644 --- a/internal/bootstrap/service/coordinator.go +++ b/internal/bootstrap/service/coordinator.go @@ -13,8 +13,7 @@ import ( "strings" "time" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" - leasemodel "github.com/choysum-dev/choysum/internal/state/lease/model" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/logger" modulestaging "github.com/choysum-dev/choysum/internal/module/artifact/staging" @@ -335,8 +334,8 @@ func (c *coordinator) defaultAcquireInitLease(ctx context.Context) (*leaseHandle return nil, newBootstrapError(bootstrapErrCodeGateError, "database session is not available", nil) } - if !session.Migrator().HasTable((&leasemodel.LockLease{}).TableName()) { - if err := session.AutoMigrate(&leasemodel.LockLease{}); err != nil { + if !session.Migrator().HasTable((&modmeta.LockLease{}).TableName()) { + if err := session.AutoMigrate(&modmeta.LockLease{}); err != nil { return nil, newBootstrapError(bootstrapErrCodeGateError, "failed to initialize the setup lock", err) } } @@ -641,7 +640,7 @@ func (c *coordinator) defaultUpdateAdminAndMarker(ctx context.Context, input ini now := c.now().UTC() txRoot := c.runtimeScope.WithContext(ctx) err = txRoot.Transactor().Required(ctx, func(txScope scope.Scope, _ scope.Transaction) error { - var modelData metadata.ModelData + var modelData modmeta.ModelData if err := txScope.Session().Where("module = ? AND name = ?", "auth", "user_admin").Take(&modelData).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errBootstrapAdminNameNotFound @@ -650,9 +649,9 @@ func (c *coordinator) defaultUpdateAdminAndMarker(ctx context.Context, input ini } var model meta.Model - lookedUp, err := meta.LookupEffectiveModel(txScope.Session().DB, "auth", "User") + lookedUp, err := modmeta.LookupEffectiveModel(txScope.Session().DB, "auth", "User") if err != nil { - if meta.IsEffectiveModelNotFound(err) || errors.Is(err, gorm.ErrRecordNotFound) { + if modmeta.IsEffectiveModelNotFound(err) || errors.Is(err, gorm.ErrRecordNotFound) { return errBootstrapAdminModelNotFound } return err @@ -737,11 +736,11 @@ func upsertBootstrapSetting(session *scope.Session, key, value string) error { return errors.New("database session is not available") } - var setting metadata.Setting + var setting modmeta.Setting err := session.Unscoped().Where("key = ?", key).Take(&setting).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return session.Create(&metadata.Setting{Key: key, Value: value}).Error + return session.Create(&modmeta.Setting{Key: key, Value: value}).Error } return err } diff --git a/internal/bootstrap/service/coordinator_admin_coverage_test.go b/internal/bootstrap/service/coordinator_admin_coverage_test.go index 02506a464..fbcac54d0 100644 --- a/internal/bootstrap/service/coordinator_admin_coverage_test.go +++ b/internal/bootstrap/service/coordinator_admin_coverage_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" statepkg "github.com/choysum-dev/choysum/pkg/state" @@ -99,7 +99,7 @@ func TestDefaultUpdateAdminAndMarkerDBLookupFailures(t *testing.T) { t.Run("model_data name not found", func(t *testing.T) { c, db := newFreshnessTestCoordinator(t) c.now = func() time.Time { return fixedNow } - if err := db.AutoMigrate(&metadata.ModelData{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModelData{}); err != nil { t.Fatalf("auto migrate model_data: %v", err) } @@ -144,10 +144,10 @@ func TestDefaultUpdateAdminAndMarkerDBLookupFailures(t *testing.T) { t.Run("effective model not found", func(t *testing.T) { c, db := newFreshnessTestCoordinator(t) c.now = func() time.Time { return fixedNow } - if err := db.AutoMigrate(&metadata.ModelData{}, &meta.Model{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModelData{}, &meta.Model{}); err != nil { t.Fatalf("auto migrate: %v", err) } - if err := db.Create(&metadata.ModelData{ + if err := db.Create(&modmeta.ModelData{ Module: "auth", Name: "user_admin", Application: "auth", ModelName: "User", ModelId: "missing", ResID: "user-1", }).Error; err != nil { t.Fatalf("seed model_data: %v", err) @@ -171,7 +171,7 @@ func TestDefaultUpdateAdminAndMarkerDBLookupFailures(t *testing.T) { t.Run("effective model table missing", func(t *testing.T) { c, db := newFreshnessTestCoordinator(t) c.now = func() time.Time { return fixedNow } - if err := db.AutoMigrate(&metadata.ModelData{}, &meta.Model{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModelData{}, &meta.Model{}); err != nil { t.Fatalf("auto migrate: %v", err) } authUserModel := &meta.Model{ @@ -183,7 +183,7 @@ func TestDefaultUpdateAdminAndMarkerDBLookupFailures(t *testing.T) { if err := db.Create(authUserModel).Error; err != nil { t.Fatalf("seed auth.User: %v", err) } - if err := db.Create(&metadata.ModelData{ + if err := db.Create(&modmeta.ModelData{ Module: "auth", Name: "user_admin", Application: "auth", ModelName: "User", ModelId: authUserModel.Id.String, ResID: "user-1", }).Error; err != nil { t.Fatalf("seed model_data: %v", err) @@ -211,7 +211,7 @@ func TestDefaultUpdateAdminAndMarkerSuccessUpsertsBootstrapSettings(t *testing.T c.now = func() time.Time { return fixedNow } wirePassword := "$CH$" + strings.Repeat("cd", 32) - if err := db.AutoMigrate(&metadata.ModelData{}, &meta.Model{}, &metadata.Setting{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModelData{}, &meta.Model{}, &modmeta.Setting{}); err != nil { t.Fatalf("auto migrate admin tables: %v", err) } mustExec(t, db, `CREATE TABLE auth_user ( @@ -232,12 +232,12 @@ func TestDefaultUpdateAdminAndMarkerSuccessUpsertsBootstrapSettings(t *testing.T if err := db.Create(authUserModel).Error; err != nil { t.Fatalf("seed auth.User model: %v", err) } - if err := db.Create(&metadata.ModelData{ + if err := db.Create(&modmeta.ModelData{ Module: "auth", Name: "user_admin", Application: "auth", ModelName: "User", ModelId: authUserModel.Id.String, ResID: resID, }).Error; err != nil { t.Fatalf("seed model_data: %v", err) } - if err := db.Create(&metadata.Setting{Key: "system.init.done", Value: "false"}).Error; err != nil { + if err := db.Create(&modmeta.Setting{Key: "system.init.done", Value: "false"}).Error; err != nil { t.Fatalf("seed existing bootstrap setting: %v", err) } @@ -260,7 +260,7 @@ func TestDefaultUpdateAdminAndMarkerSuccessUpsertsBootstrapSettings(t *testing.T } for _, key := range []string{"system.init.done", "system.init.at"} { - var setting metadata.Setting + var setting modmeta.Setting if err := db.Where("key = ?", key).Take(&setting).Error; err != nil { t.Fatalf("query setting %q: %v", key, err) } @@ -281,7 +281,7 @@ func TestUpsertBootstrapSettingBranches(t *testing.T) { }) c, db := newFreshnessTestCoordinator(t) - if err := db.AutoMigrate(&metadata.Setting{}); err != nil { + if err := db.AutoMigrate(&modmeta.Setting{}); err != nil { t.Fatalf("auto migrate settings: %v", err) } session := c.runtimeScope.Session() diff --git a/internal/bootstrap/service/workspace_freshness.go b/internal/bootstrap/service/workspace_freshness.go index 6480904f6..0e5b0915b 100644 --- a/internal/bootstrap/service/workspace_freshness.go +++ b/internal/bootstrap/service/workspace_freshness.go @@ -6,8 +6,7 @@ package service import ( "context" "errors" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" - + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" "gorm.io/gorm" @@ -50,8 +49,8 @@ func (c *coordinator) defaultCheckWorkspaceFreshness(ctx context.Context) error } } - if session.Migrator().HasTable((&metadata.ModelData{}).TableName()) { - var modelData metadata.ModelData + if session.Migrator().HasTable((&modmeta.ModelData{}).TableName()) { + var modelData modmeta.ModelData err := session.Select("id").Where("module = ? AND name = ?", "auth", "user_admin").Take(&modelData).Error if err == nil { return newBootstrapError(bootstrapErrCodeWorkspaceNotFresh, "initial setup has already been completed: administrator setup data already exists", nil) diff --git a/internal/module/artifact/build/backend/builder.go b/internal/module/artifact/build/backend/builder.go index 657c96a3d..521cf9d06 100644 --- a/internal/module/artifact/build/backend/builder.go +++ b/internal/module/artifact/build/backend/builder.go @@ -22,6 +22,7 @@ import ( modulegenerator "github.com/choysum-dev/choysum/internal/module/artifact/generate" module "github.com/choysum-dev/choysum/internal/module/artifact/result" "github.com/choysum-dev/choysum/internal/module/artifact/staging" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/parser" "github.com/choysum-dev/choysum/internal/parser/backendtsparser" "github.com/choysum-dev/choysum/pkg/jsexecutor" @@ -30,6 +31,7 @@ import ( "github.com/evanw/esbuild/pkg/api" xfmt "golang.org/x/exp/errors/fmt" "gorm.io/gorm" + ) type ModuleBuilder struct { @@ -319,11 +321,11 @@ func (b *ModuleBuilder) getNewExtends(model *meta.Model) (*meta.Model, error) { if application == "" && b.module != nil { application = strings.TrimSpace(b.module.ApplicationStr) } - q := meta.DeclarationQuery{Name: model.Name} + q := modmeta.DeclarationQuery{Name: model.Name} if application != "" { q.Application = application } - extendsDecls, err := meta.ListDeclarations(b.runtimeScope.Session().DB, q) + extendsDecls, err := modmeta.ListDeclarations(b.runtimeScope.Session().DB, q) if err != nil { return nil, xfmt.Errorf("error getting last models: %w", err) } @@ -881,7 +883,7 @@ func (b *ModuleBuilder) persistModuleModels(moduleID string, models []*meta.Mode } db := b.runtimeScope.Session() - keys, err := meta.ReplaceModuleDeclarations(db.DB, moduleID, models) + keys, err := modmeta.ReplaceModuleDeclarations(db.DB, moduleID, models) if err != nil { return err } @@ -893,13 +895,13 @@ func (b *ModuleBuilder) persistModuleModels(moduleID string, models []*meta.Mode if app != "" { for _, spec := range b.injectSession.Registry().Specs() { if b.injectSession.Plan(spec.ModelName).SupersedeInject { - keys = append(keys, meta.LogicalKey{Application: app, Name: spec.ModelName}) + keys = append(keys, modmeta.LogicalKey{Application: app, Name: spec.ModelName}) } } } } - if err := meta.FlushEffective(db.DB, keys); err != nil { + if err := modmeta.FlushEffective(db.DB, keys); err != nil { return xfmt.Errorf("flush effective models: %w", err) } return nil diff --git a/internal/module/artifact/build/backend/builder_test.go b/internal/module/artifact/build/backend/builder_test.go index 141b8a683..6e3f439f5 100644 --- a/internal/module/artifact/build/backend/builder_test.go +++ b/internal/module/artifact/build/backend/builder_test.go @@ -21,6 +21,7 @@ import ( "github.com/choysum-dev/choysum/internal/module/artifact/build/injectappmodel" modulegenerator "github.com/choysum-dev/choysum/internal/module/artifact/generate" module "github.com/choysum-dev/choysum/internal/module/artifact/result" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/parser" "github.com/choysum-dev/choysum/internal/testing/scopetest" "github.com/choysum-dev/choysum/pkg/config" @@ -30,6 +31,7 @@ import ( "github.com/evanw/esbuild/pkg/api" "gorm.io/driver/sqlite" "gorm.io/gorm" + ) type builderTestScope struct { @@ -454,19 +456,18 @@ func TestGetNewExtendsAndUpdatePrebuildResult(t *testing.T) { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := meta.EnsureDualStoreTables(db); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("ensure dual store: %v", err) } + seedMod := sql.NullString{String: "seed-extends", Valid: true} rows := []*meta.Model{ - {Name: "Partner", Path: "/models/base", Application: "auth", BaseModel: meta.BaseModel{Id: sql.NullString{String: "base", Valid: true}}}, - {Name: "Partner", Path: "/models/latest", Application: "auth", BaseModel: meta.BaseModel{Id: sql.NullString{String: "latest", Valid: true}}}, + {Name: "Partner", Path: "/models/base", Application: "auth", BaseModel: meta.BaseModel{Id: sql.NullString{String: "base", Valid: true}}, ModuleId: seedMod}, + {Name: "Partner", Path: "/models/latest", Application: "auth", BaseModel: meta.BaseModel{Id: sql.NullString{String: "latest", Valid: true}}, ModuleId: seedMod}, // Newer same-name declaration in another application must not win. - {Name: "Partner", Path: "/models/foreign-latest", Application: "crm", BaseModel: meta.BaseModel{Id: sql.NullString{String: "foreign", Valid: true}}}, + {Name: "Partner", Path: "/models/foreign-latest", Application: "crm", BaseModel: meta.BaseModel{Id: sql.NullString{String: "foreign", Valid: true}}, ModuleId: seedMod}, } - for _, row := range rows { - if err := meta.PersistModelTreeAsRaw(db, row); err != nil { - t.Fatalf("seed models: %v", err) - } + if _, err := modmeta.ReplaceModuleDeclarations(db, seedMod.String, rows); err != nil { + t.Fatalf("seed models: %v", err) } testRuntimeScope := newBuilderTestScope() @@ -545,7 +546,7 @@ func TestPersistHelpersAndBuild(t *testing.T) { if err := db.AutoMigrate(&meta.Application{}, &meta.Module{}); err != nil { t.Fatalf("auto migrate: %v", err) } - if err := meta.EnsureDualStoreTables(db); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("ensure dual store: %v", err) } @@ -565,13 +566,13 @@ func TestPersistHelpersAndBuild(t *testing.T) { if err := db.Create(seedModel).Error; err != nil { t.Fatalf("seed stale model: %v", err) } - if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + if _, err := modmeta.ReplaceModuleDeclarations(db, "module-1", []*meta.Model{{ BaseModel: meta.BaseModel{Id: sql.NullString{String: "prev-raw", Valid: true}}, Name: "LegacyRaw", Path: "/models/legacy-raw", Application: "partner", ModuleId: sql.NullString{String: "module-1", Valid: true}, - }); err != nil { + }}); err != nil { t.Fatalf("seed previous raw model: %v", err) } models := []*meta.Model{ @@ -583,7 +584,7 @@ func TestPersistHelpersAndBuild(t *testing.T) { if err := builder.persistModuleModels("module-1", models); err != nil { t.Fatalf("persistModuleModels() error = %v", err) } - persisted, err := meta.ListDeclarations(db, meta.DeclarationQuery{ModuleID: "module-1"}) + persisted, err := modmeta.ListDeclarations(db, modmeta.DeclarationQuery{ModuleID: "module-1"}) if err != nil { t.Fatalf("query persisted raw models: %v", err) } @@ -599,17 +600,19 @@ func TestPersistHelpersAndBuild(t *testing.T) { t.Fatalf("unexpected effective models: %#v", effective) } - if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + if _, err := modmeta.ReplaceModuleDeclarations(db, "hist-old", []*meta.Model{{ BaseModel: meta.BaseModel{Id: sql.NullString{String: "aaa", Valid: true}}, Name: "Partner", Path: "/models/history", - }); err != nil { + ModuleId: sql.NullString{String: "hist-old", Valid: true}, + }}); err != nil { t.Fatalf("seed older history model: %v", err) } - if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + if _, err := modmeta.ReplaceModuleDeclarations(db, "hist-new", []*meta.Model{{ BaseModel: meta.BaseModel{Id: sql.NullString{String: "zzz", Valid: true}}, Name: "Partner", Path: "/models/history", + ModuleId: sql.NullString{String: "hist-new", Valid: true}, Fields: []*meta.Field{{ BaseModel: meta.BaseModel{Id: sql.NullString{String: "field1", Valid: true}}, Name: "Name", @@ -644,11 +647,11 @@ func TestPersistHelpersAndBuild(t *testing.T) { {BaseModel: meta.BaseModel{Id: sql.NullString{String: "param2", Valid: true}}, Name: "query"}, }, }}, - }); err != nil { + }}); err != nil { t.Fatalf("seed latest history model: %v", err) } - decls, err := meta.ListDeclarations(db, meta.DeclarationQuery{Path: "/models/history", PreloadTree: true}) + decls, err := modmeta.ListDeclarations(db, modmeta.DeclarationQuery{Path: "/models/history", PreloadTree: true}) if err != nil { t.Fatalf("ListDeclarations() error = %v", err) } @@ -663,17 +666,17 @@ func TestPersistHelpersAndBuild(t *testing.T) { t.Fatalf("unexpected loaded model services: %#v", loaded.Services) } - missing, err := meta.ListDeclarations(db, meta.DeclarationQuery{Path: "/models/does-not-exist", PreloadTree: true}) + missing, err := modmeta.ListDeclarations(db, modmeta.DeclarationQuery{Path: "/models/does-not-exist", PreloadTree: true}) if err != nil { t.Fatalf("missing path error = %v", err) } if len(missing) != 0 { t.Fatalf("expected empty for missing path, got %#v", missing) } - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } - if _, err := meta.ListDeclarations(db, meta.DeclarationQuery{Path: "/models/history", PreloadTree: true}); err == nil || !strings.Contains(err.Error(), "list declarations") { + if _, err := modmeta.ListDeclarations(db, modmeta.DeclarationQuery{Path: "/models/history", PreloadTree: true}); err == nil || !strings.Contains(err.Error(), "list declarations") { t.Fatalf("expected list error after drop, got %v", err) } } @@ -1284,11 +1287,11 @@ func TestPersistApplicationLookupAndModelDeleteErrors(t *testing.T) { if err != nil { t.Fatalf("reopen sqlite: %v", err) } - if err := meta.EnsureDualStoreTables(db); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("ensure dual store: %v", err) } testRuntimeScope.session = &scope.Session{DB: db} - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } err = builder.persistModuleModels("module-1", []*meta.Model{{Name: "Partner", Path: "/models/partner"}}) @@ -1303,7 +1306,7 @@ func TestPersistApplicationLookupAndModelDeleteErrors(t *testing.T) { if err := db.AutoMigrate(&meta.Application{}, &meta.Module{}); err != nil { t.Fatalf("auto migrate fallback db: %v", err) } - if err := meta.EnsureDualStoreTables(db); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("ensure dual store fallback: %v", err) } testRuntimeScope.session = &scope.Session{DB: db} @@ -1322,7 +1325,7 @@ func TestGetNewExtends_FindError(t *testing.T) { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := meta.EnsureDualStoreTables(db); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("ensure dual store: %v", err) } testRuntimeScope := newBuilderTestScope() @@ -1331,7 +1334,7 @@ func TestGetNewExtends_FindError(t *testing.T) { runtimeScope: testRuntimeScope, module: &meta.Module{ApplicationStr: "auth"}, } - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } current := &meta.Model{Name: "Partner", Path: "/models/current", Extends: "/models/base"} @@ -1347,7 +1350,7 @@ func TestPersistModuleModels_ErrorBranches(t *testing.T) { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := meta.EnsureDualStoreTables(db); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("ensure dual store: %v", err) } testRuntimeScope := newBuilderTestScope() @@ -1358,7 +1361,7 @@ func TestPersistModuleModels_ErrorBranches(t *testing.T) { t.Run("list previous raw models", func(t *testing.T) { db, builder := openPersistDB("prev-raw") - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatal(err) } err := builder.persistModuleModels("module-1", models) @@ -1380,13 +1383,13 @@ func TestPersistModuleModels_ErrorBranches(t *testing.T) { t.Run("delete previous raw models", func(t *testing.T) { db, builder := openPersistDB("delete-raw") - if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + if _, err := modmeta.ReplaceModuleDeclarations(db, "module-1", []*meta.Model{{ BaseModel: meta.BaseModel{Id: sql.NullString{String: "del-raw", Valid: true}}, Name: "Old", Path: "/models/old", Application: "partner", ModuleId: sql.NullString{String: "module-1", Valid: true}, - }); err != nil { + }}); err != nil { t.Fatal(err) } boom := errors.New("forced raw delete") diff --git a/internal/module/artifact/build/backend/inject_test.go b/internal/module/artifact/build/backend/inject_test.go index e88681ded..44f0c21d5 100644 --- a/internal/module/artifact/build/backend/inject_test.go +++ b/internal/module/artifact/build/backend/inject_test.go @@ -10,14 +10,17 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/choysum-dev/choysum/internal/module/artifact/build/injectappmodel" module "github.com/choysum-dev/choysum/internal/module/artifact/result" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/parser" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" "gorm.io/driver/sqlite" "gorm.io/gorm" + ) func newInjectTestBuilder(t *testing.T, mod *meta.Module, results []*parser.ParserResult) (*ModuleBuilder, *stubEsbPlugin, *gorm.DB) { @@ -29,7 +32,7 @@ func newInjectTestBuilder(t *testing.T, mod *meta.Module, results []*parser.Pars if err != nil { t.Fatal(err) } - if err := meta.EnsureDualStoreTables(db); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatal(err) } _ = db.AutoMigrate(&meta.Application{}, &meta.Module{}) @@ -318,7 +321,7 @@ func TestPersist_SupersedeInjectError(t *testing.T) { mod.Id = sql.NullString{String: "mod-sup-err", Valid: true} builder, _, db := newInjectTestBuilder(t, mod, nil) builder.ensureInjectSession().SetPlan("FieldDefault", injectappmodel.Plan{SupersedeInject: true}) - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatal(err) } result := &module.BuildResult{Module: mod} @@ -417,7 +420,7 @@ func TestBundleInjectAppModels_PropagatesError(t *testing.T) { ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", } builder, _, db := newInjectTestBuilder(t, mod, nil) - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatal(err) } if err := builder.BundleInjectAppModels([]*meta.Module{mod}); err == nil { @@ -453,21 +456,29 @@ func TestBuildWithoutPersist_UpdateAndValidateRelease(t *testing.T) { Model: &meta.Model{Name: "Partner", Path: curPath, Extends: oldPath, Application: "partner"}, Imports: map[string]*parser.Import{}, }}) - for _, seed := range []struct{ id, path string }{ - {"raw-old", oldPath}, - {"raw-tip", tipPath}, - } { - if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: seed.id, Valid: true}}, + if _, err := modmeta.ReplaceModuleDeclarations(db, "seed-tip", []*meta.Model{ + { + BaseModel: meta.BaseModel{Id: sql.NullString{String: "raw-old", Valid: true}}, + Name: "Partner", + Path: oldPath, + Application: "partner", + ModuleId: sql.NullString{String: "seed-tip", Valid: true}, + }, + { + BaseModel: meta.BaseModel{Id: sql.NullString{String: "raw-tip", Valid: true}}, Name: "Partner", - Path: seed.path, + Path: tipPath, Application: "partner", - }); err != nil { - t.Fatal(err) - } + ModuleId: sql.NullString{String: "seed-tip", Valid: true}, + }, + }); err != nil { + t.Fatal(err) } - // Tip ordering: prefer tipPath by bumping timestamps via PreferDeclarationTip. - if err := meta.PreferDeclarationTip(db, "partner", "Partner", tipPath); err != nil { + // Tip ordering: prefer tipPath by bumping timestamps via UpdateColumns. + tip := time.Now().UTC() + if err := db.Table("meta_raw_model"). + Where("path = ? AND application = ?", tipPath, "partner"). + UpdateColumns(map[string]any{"created_at": tip, "updated_at": tip}).Error; err != nil { t.Fatal(err) } builder2.tsParser = fixedParser{err: sql.ErrConnDone} @@ -483,7 +494,7 @@ func TestInjectAppModels_LifecycleReleasesSchedule(t *testing.T) { if err != nil { t.Fatal(err) } - if err := meta.EnsureDualStoreTables(db); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&meta.Application{}, &meta.Module{}); err != nil { diff --git a/internal/module/artifact/build/injectappmodel/coverage_test.go b/internal/module/artifact/build/injectappmodel/coverage_test.go index 52d97145a..4a705e442 100644 --- a/internal/module/artifact/build/injectappmodel/coverage_test.go +++ b/internal/module/artifact/build/injectappmodel/coverage_test.go @@ -8,8 +8,10 @@ import ( "strings" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/parser" "github.com/choysum-dev/choysum/pkg/meta" + ) func TestNilSessionGuards(t *testing.T) { @@ -173,7 +175,7 @@ func TestDecidePlan_Branches(t *testing.T) { // Existing virt owned by another module → skip. sess.Registry().ResetClaims() - _ = meta.DeleteDeclarationTrees(db, []string{"other-hand"}) + _ = modmeta.DeleteDeclarationTrees(db, []string{"other-hand"}) otherVirt := "/virtual/modules/other/service/models/__generated__/field_default.ts" seedDeclaration(t, db, "FieldDefault", "ov", otherVirt, "partner") if plan, err := decidePlan(spec, sess, nil); err != nil || plan.NeedInject { @@ -182,7 +184,7 @@ func TestDecidePlan_Branches(t *testing.T) { // DB load error. sess.Registry().ResetClaims() - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatal(err) } if _, err := decidePlan(spec, sess, nil); err == nil { @@ -539,7 +541,7 @@ func TestBundleInject_GuardsAndErrors(t *testing.T) { t.Fatal("expected inject paths for partner") } - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatal(err) } sess3, _ := newTestSession(t, mod) @@ -597,7 +599,7 @@ func TestSupersede_GuardsAndErrors(t *testing.T) { t.Fatal(err) } - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatal(err) } if err := supersedeGenerated(specByNameOrPanic("AppSetting"), db, "partner"); err == nil { @@ -631,7 +633,7 @@ func TestDecideOne_DecidePlanError(t *testing.T) { ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", } sess, db := newTestSession(t, mod) - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatal(err) } if _, err := DecideOne(sess, "FieldDefault", nil); err == nil { @@ -695,7 +697,7 @@ func TestSupersedeInjectAppModels_PropagatesError(t *testing.T) { sess, db := newTestSession(t, mod) sess.SetPlan("FieldDefault", Plan{SupersedeInject: true}) sess.SetPlan("AppSetting", Plan{SupersedeInject: true}) - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatal(err) } if err := SupersedeInjectAppModels(sess); err == nil { @@ -711,7 +713,7 @@ func TestSupersedeGenerated_DeleteError(t *testing.T) { _, db := newTestSession(t, mod) virt := "/virtual/modules/partner/service/models/__generated__/app_setting.ts" seedDeclaration(t, db, "AppSetting", "del-fail", virt, "partner") - if err := db.Migrator().DropTable(meta.RawServiceTable); err != nil { + if err := db.Migrator().DropTable("meta_raw_service"); err != nil { t.Fatal(err) } if err := supersedeGenerated(specByNameOrPanic("AppSetting"), db, "partner"); err == nil { diff --git a/internal/module/artifact/build/injectappmodel/helpers.go b/internal/module/artifact/build/injectappmodel/helpers.go index 0db77db6a..1509e556c 100644 --- a/internal/module/artifact/build/injectappmodel/helpers.go +++ b/internal/module/artifact/build/injectappmodel/helpers.go @@ -10,9 +10,11 @@ import ( "strings" "github.com/choysum-dev/choysum/internal/esbplugins" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/parser" "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/gorm" + ) func pathWithinModuleRoot(path, root string) bool { @@ -150,7 +152,7 @@ func dbLoadModels(spec *Spec, db *gorm.DB, app string) ([]*meta.Model, error) { return nil, nil } absFalse := false - return meta.ListDeclarations(db, meta.DeclarationQuery{ + return modmeta.ListDeclarations(db, modmeta.DeclarationQuery{ Application: app, Name: spec.ModelName, Abstract: &absFalse, diff --git a/internal/module/artifact/build/injectappmodel/injectappmodel_test.go b/internal/module/artifact/build/injectappmodel/injectappmodel_test.go index 518b24372..13da046b6 100644 --- a/internal/module/artifact/build/injectappmodel/injectappmodel_test.go +++ b/internal/module/artifact/build/injectappmodel/injectappmodel_test.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/parser" "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/driver/sqlite" @@ -33,8 +34,8 @@ func newTestSession(t *testing.T, mod *meta.Module) (*Session, *gorm.DB) { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := meta.EnsureDualStoreTables(db); err != nil { - t.Fatalf("EnsureDualStoreTables: %v", err) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("AutoMigrate CatalogEntities: %v", err) } reg := NewRegistryWithDefaults() sess := NewSession(BuildCtx{ @@ -47,11 +48,26 @@ func newTestSession(t *testing.T, mod *meta.Module) (*Session, *gorm.DB) { func seedDeclaration(t *testing.T, db *gorm.DB, name, id, path, application string) { t.Helper() - m := &meta.Model{Name: name, Path: path, Application: application} + moduleID := "seed-" + application + m := &meta.Model{ + Name: name, Path: path, Application: application, + ModuleId: sql.NullString{String: moduleID, Valid: true}, + } if id != "" { m.Id = sql.NullString{String: id, Valid: true} } - if err := meta.PersistModelTreeAsRaw(db, m); err != nil { + existing, err := modmeta.ListDeclarations(db, modmeta.DeclarationQuery{ModuleID: moduleID, PreloadTree: true}) + if err != nil { + t.Fatalf("list declarations for seed: %v", err) + } + out := make([]*meta.Model, 0, len(existing)+1) + for _, e := range existing { + if e != nil && e.Path != path { + out = append(out, e) + } + } + out = append(out, m) + if _, err := modmeta.ReplaceModuleDeclarations(db, moduleID, out); err != nil { t.Fatalf("seed %s: %v", name, err) } } @@ -590,7 +606,7 @@ func TestSupersedeInjectAppModels_DeletesGeneratedOnly(t *testing.T) { t.Fatalf("supersede: %v", err) } - remaining, err := meta.ListDeclarations(db, meta.DeclarationQuery{ + remaining, err := modmeta.ListDeclarations(db, modmeta.DeclarationQuery{ Application: "partner", Name: "AppSetting", }) diff --git a/internal/module/artifact/build/injectappmodel/supersede.go b/internal/module/artifact/build/injectappmodel/supersede.go index b3bab0de1..04221dd3d 100644 --- a/internal/module/artifact/build/injectappmodel/supersede.go +++ b/internal/module/artifact/build/injectappmodel/supersede.go @@ -6,13 +6,14 @@ package injectappmodel import ( "strings" - "github.com/choysum-dev/choysum/pkg/meta" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" xfmt "golang.org/x/exp/errors/fmt" "gorm.io/gorm" + ) // SupersedeInjectAppModels deletes generated-path declaration trees when Decide -// set SupersedeInject. Does not call meta.RecomputeKeys (EDS-opt-2). +// set SupersedeInject. Does not call modmeta.FlushEffective (EDS-opt-2). func SupersedeInjectAppModels(sess *Session) error { if sess == nil { return nil @@ -52,7 +53,7 @@ func SupersedeOne(sess *Session, modelName string) error { func supersedeGenerated(spec *Spec, db *gorm.DB, app string) error { absFalse := false - existing, err := meta.ListDeclarations(db, meta.DeclarationQuery{ + existing, err := modmeta.ListDeclarations(db, modmeta.DeclarationQuery{ Application: app, Name: spec.ModelName, Abstract: &absFalse, @@ -74,7 +75,7 @@ func supersedeGenerated(spec *Spec, db *gorm.DB, app string) error { if len(ids) == 0 { return nil } - if err := meta.DeleteDeclarationTrees(db, ids); err != nil { + if err := modmeta.DeleteDeclarationTrees(db, ids); err != nil { return xfmt.Errorf("delete superseded generated %s rows: %w", spec.ModelName, err) } return nil diff --git a/internal/module/artifact/generate/generator.go b/internal/module/artifact/generate/generator.go index 083321fa8..ab822131b 100644 --- a/internal/module/artifact/generate/generator.go +++ b/internal/module/artifact/generate/generator.go @@ -9,6 +9,7 @@ import ( "strings" module "github.com/choysum-dev/choysum/internal/module/artifact/result" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" "gorm.io/gorm" @@ -162,7 +163,7 @@ func selectSameNameModelsInPrimaryExtensionChain(models []*meta.Model) []*meta.M } func mergeSameNameModelsByExtensionChain(models []*meta.Model) (*meta.Model, error) { - return meta.MergeSameNameModelsByExtensionChain(models) + return modmeta.MergeSameNameModelsByExtensionChain(models) } func (g *grpcGenerator) Generate() ([]*module.GeneratorResult, error) { diff --git a/internal/module/evolution/data/loader.go b/internal/module/evolution/data/loader.go index 6d5264123..079fe9f67 100644 --- a/internal/module/evolution/data/loader.go +++ b/internal/module/evolution/data/loader.go @@ -16,8 +16,7 @@ import ( "sync" "time" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" - + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" "github.com/ettle/strcase" @@ -762,8 +761,8 @@ func (l *Loader) planBatchRecordOrder(tx *gorm.DB, owner *meta.Module, records [ ids = append(ids, id) } sort.Strings(ids) - var rows []metadata.ModelData - if err := tx.Model(&metadata.ModelData{}).Select("name").Where("module = ? AND name IN ?", mod, ids).Find(&rows).Error; err != nil { + var rows []modmeta.ModelData + if err := tx.Model(&modmeta.ModelData{}).Select("name").Where("module = ? AND name IN ?", mod, ids).Find(&rows).Error; err != nil { return nil, xfmt.Errorf("lookup model_data for refs: %w", err) } m := map[string]struct{}{} @@ -1032,8 +1031,8 @@ func (l *Loader) planRecordOrder(tx *gorm.DB, owner *meta.Module, filePath strin ids = append(ids, id) } sort.Strings(ids) - var rows []metadata.ModelData - if err := tx.Model(&metadata.ModelData{}).Select("name").Where("module = ? AND name IN ?", mod, ids).Find(&rows).Error; err != nil { + var rows []modmeta.ModelData + if err := tx.Model(&modmeta.ModelData{}).Select("name").Where("module = ? AND name IN ?", mod, ids).Find(&rows).Error; err != nil { return nil, xfmt.Errorf("lookup model_data for refs: %w", err) } m := map[string]struct{}{} @@ -1232,7 +1231,7 @@ func (l *Loader) applyRecord(tx *gorm.DB, filePath string, recordIndex int, rec return err } modelFull := app + "." + modelName - model, err := meta.LookupEffectiveModel(tx, app, modelName) + model, err := modmeta.LookupEffectiveModel(tx, app, modelName) if err != nil { return wrapLoadErrorWithCode(xfmt.Errorf("resolve model %s: %w", modelFull, err), filePath, recordIndex, rec, LoadErrorKindDB, LoadErrorCodeDBResolveModel, "resolve model") } @@ -1251,7 +1250,7 @@ func (l *Loader) applyRecord(tx *gorm.DB, filePath string, recordIndex int, rec return err } - mapping := &metadata.ModelData{} + mapping := &modmeta.ModelData{} err = tx.Where("module = ? AND name = ?", moduleName, localName).First(mapping).Error if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -1502,7 +1501,7 @@ func (l *Loader) resolveRef(tx *gorm.DB, ref string) (string, error) { if err != nil { return "", err } - mapping := &metadata.ModelData{} + mapping := &modmeta.ModelData{} if err := tx.Where("module = ? AND name = ?", mod, localName).First(mapping).Error; err != nil { return "", xfmt.Errorf("resolve ref %s: %w", ref, err) } @@ -1876,7 +1875,7 @@ func resolveSearchModel(tx *gorm.DB, modelFull string) (*meta.Model, string, err if err != nil { return nil, "", xfmt.Errorf("resolve search model %s: %w", modelFull, err) } - model, err := meta.LookupEffectiveModel(tx, app, modelName) + model, err := modmeta.LookupEffectiveModel(tx, app, modelName) if err != nil { return nil, "", xfmt.Errorf("resolve search model %s: %w", modelFull, err) } @@ -2131,7 +2130,7 @@ func (l *Loader) detectFieldCardinality(tx *gorm.DB, modelFull string, fieldName l.mu.RUnlock() if !ok { - model, err := meta.LookupEffectiveModel(tx, app, modelName) + model, err := modmeta.LookupEffectiveModel(tx, app, modelName) if err != nil { l.mu.Lock() l.fieldCardinalityCache[cacheKey] = refCardinalityManyToOne @@ -2208,7 +2207,7 @@ func (l *Loader) resolveModelRef(tx *gorm.DB, modelRef string) (string, error) { if err != nil { return "", xfmt.Errorf("resolve modelRef %s: %w", modelRef, err) } - model, err := meta.LookupEffectiveModel(tx, app, modelName) + model, err := modmeta.LookupEffectiveModel(tx, app, modelName) if err != nil { return "", xfmt.Errorf("resolve modelRef %s: %w", modelRef, err) } diff --git a/internal/module/evolution/data/loader_test.go b/internal/module/evolution/data/loader_test.go index 526aed1b1..280692752 100644 --- a/internal/module/evolution/data/loader_test.go +++ b/internal/module/evolution/data/loader_test.go @@ -15,9 +15,8 @@ import ( "testing" "time" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" - "github.com/choysum-dev/choysum/internal/defaultscope" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/testing/scopetest" "github.com/choysum-dev/choysum/pkg/config" "github.com/choysum-dev/choysum/pkg/meta" @@ -400,7 +399,7 @@ func newDefaultLoaderScope(t *testing.T) scope.Scope { func seedLoaderTestSchema(t *testing.T, db *gorm.DB) { t.Helper() - if err := db.AutoMigrate(&meta.Module{}, &meta.Model{}, &meta.Field{}, &metadata.ModelData{}, &testAuthGroup{}, &testAuthUser{}, &testModuleDependency{}); err != nil { + if err := db.AutoMigrate(&meta.Module{}, &meta.Model{}, &meta.Field{}, &modmeta.ModelData{}, &testAuthGroup{}, &testAuthUser{}, &testModuleDependency{}); err != nil { t.Fatalf("migrate test schema: %v", err) } // Seed models so ApplyModule can resolve model -> table. @@ -439,7 +438,7 @@ func seedLoaderTestSchema(t *testing.T, db *gorm.DB) { func loaderTestModelID(t *testing.T, db *gorm.DB, app, name string) string { t.Helper() - m, err := meta.LookupEffectiveModel(db, app, name) + m, err := modmeta.LookupEffectiveModel(db, app, name) if err != nil { t.Fatalf("lookup effective meta_model %s.%s: %v", app, name, err) } @@ -928,7 +927,7 @@ func TestValueResolutionHelpers(t *testing.T) { if err := db.Create(app).Error; err != nil { t.Fatalf("seed meta_application(auth): %v", err) } - if err := db.Create(&metadata.ModelData{Module: "auth", Name: "group_admin", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: "gid-1"}).Error; err != nil { + if err := db.Create(&modmeta.ModelData{Module: "auth", Name: "group_admin", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: "gid-1"}).Error; err != nil { t.Fatalf("seed model_data: %v", err) } @@ -1072,7 +1071,7 @@ func TestValueResolutionHelpers(t *testing.T) { func TestResolveAndMapValues_SkipsSystemFieldsAndNormalizes(t *testing.T) { l, db := newTestLoader(t) rec := record{Module: "auth", Name: "u", Application: "auth", Model: "User"} - if err := db.Create(&metadata.ModelData{Module: "auth", Name: "group_admin", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: "gid-1"}).Error; err != nil { + if err := db.Create(&modmeta.ModelData{Module: "auth", Name: "group_admin", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: "gid-1"}).Error; err != nil { t.Fatalf("seed model_data: %v", err) } @@ -1154,10 +1153,10 @@ func TestApplyRecord_DirectBranches(t *testing.T) { if err := db.Table("auth_user").Create(&user).Error; err != nil { t.Fatalf("seed user: %v", err) } - if err := db.Create(&metadata.ModelData{Module: "auth", Name: "user_admin", Application: "auth", ModelName: "User", ModelId: loaderTestModelID(t, db, "auth", "User"), ResID: user.ID, NoUpdate: false}).Error; err != nil { + if err := db.Create(&modmeta.ModelData{Module: "auth", Name: "user_admin", Application: "auth", ModelName: "User", ModelId: loaderTestModelID(t, db, "auth", "User"), ResID: user.ID, NoUpdate: false}).Error; err != nil { t.Fatalf("seed user mapping: %v", err) } - if err := db.Create(&metadata.ModelData{Module: "auth", Name: "group_admin", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: group2.ID, NoUpdate: false}).Error; err != nil { + if err := db.Create(&modmeta.ModelData{Module: "auth", Name: "group_admin", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: group2.ID, NoUpdate: false}).Error; err != nil { t.Fatalf("seed group mapping: %v", err) } freeze := true @@ -1177,7 +1176,7 @@ func TestApplyRecord_DirectBranches(t *testing.T) { if updatedUser.GroupID != group2.ID { t.Fatalf("expected updated group id %q, got %#v", group2.ID, updatedUser) } - var mapping metadata.ModelData + var mapping modmeta.ModelData if err := db.Where("module = ? AND name = ?", "auth", "user_admin").First(&mapping).Error; err != nil { t.Fatalf("query updated mapping: %v", err) } @@ -1471,7 +1470,7 @@ func TestApplyModule_OmitsModuleAndApplicationDefaultsToOwner(t *testing.T) { if err := l.ApplyModule(context.Background(), mod, ApplyOptions{}); err != nil { t.Fatalf("expected no error, got %v", err) } - var mapping metadata.ModelData + var mapping modmeta.ModelData if err := db.Where("module = ? AND name = ?", "auth", "x").First(&mapping).Error; err != nil { t.Fatalf("lookup mapping: %v", err) } @@ -1526,15 +1525,15 @@ func TestApplyModule_NoUpdateFreezesSubsequentUpdates(t *testing.T) { t.Fatalf("expected no error on first apply, got %T: %v", err, err) } - var g1 metadata.ModelData + var g1 modmeta.ModelData if err := db.Where("module = ? AND name = ?", "auth", "g1").First(&g1).Error; err != nil { t.Fatalf("lookup g1 mapping: %v", err) } - var g2 metadata.ModelData + var g2 modmeta.ModelData if err := db.Where("module = ? AND name = ?", "auth", "g2").First(&g2).Error; err != nil { t.Fatalf("lookup g2 mapping: %v", err) } - var u metadata.ModelData + var u modmeta.ModelData if err := db.Where("module = ? AND name = ?", "auth", "u").First(&u).Error; err != nil { t.Fatalf("lookup u mapping: %v", err) } @@ -1618,7 +1617,7 @@ func TestApplyModule_RefCanResolveExistingDBMappingOutsideBatch(t *testing.T) { }).Error; err != nil { t.Fatalf("seed auth_group: %v", err) } - if err := db.Create(&metadata.ModelData{Module: "auth", Name: "pre_group", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: "pre_group", NoUpdate: true}).Error; err != nil { + if err := db.Create(&modmeta.ModelData{Module: "auth", Name: "pre_group", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: "pre_group", NoUpdate: true}).Error; err != nil { t.Fatalf("seed meta_model_data: %v", err) } @@ -1641,7 +1640,7 @@ func TestApplyModule_RefCanResolveExistingDBMappingOutsideBatch(t *testing.T) { t.Fatalf("expected no error, got %T: %v", err, err) } - var u metadata.ModelData + var u modmeta.ModelData if err := db.Where("module = ? AND name = ?", "auth", "u").First(&u).Error; err != nil { t.Fatalf("lookup u mapping: %v", err) } @@ -1923,7 +1922,7 @@ func TestResolveValue_SearchShapeErrorIncludesFieldPath(t *testing.T) { } // ref and refBy still work (backward compat) - if err := db.Create(&metadata.ModelData{Module: "auth", Name: "group_admin", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: "gid-1"}).Error; err != nil { + if err := db.Create(&modmeta.ModelData{Module: "auth", Name: "group_admin", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: "gid-1"}).Error; err != nil { t.Fatalf("seed model_data: %v", err) } got, err = l.resolveValue(db, "/tmp/data.json", 0, rec, "values.group_id", map[string]any{"ref": "auth.group_admin"}) @@ -3219,7 +3218,7 @@ func TestApplyRecordModelDataDBFailures(t *testing.T) { t.Run("insert model_data db error", func(t *testing.T) { l, db := newTestLoader(t) execLoaderTestSQL(t, db, "DROP TABLE meta_model_data") - if err := db.AutoMigrate(&metadata.ModelData{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModelData{}); err != nil { t.Fatalf("remigrate meta_model_data: %v", err) } execLoaderTestSQL(t, db, `CREATE TRIGGER block_model_data_insert BEFORE INSERT ON meta_model_data BEGIN SELECT RAISE(ABORT, 'blocked'); END`) @@ -3242,7 +3241,7 @@ func TestApplyRecordModelDataDBFailures(t *testing.T) { if err := db.Table("auth_user").Create(&user).Error; err != nil { t.Fatalf("seed user: %v", err) } - if err := db.Create(&metadata.ModelData{ + if err := db.Create(&modmeta.ModelData{ Module: "auth", Name: "user_no_update", Application: "auth", ModelName: "User", ModelId: loaderTestModelID(t, db, "auth", "User"), ResID: user.ID, NoUpdate: false, }).Error; err != nil { t.Fatalf("seed mapping: %v", err) @@ -3340,7 +3339,7 @@ func TestApplyRecord_SyncsStaleMappingTarget(t *testing.T) { t.Fatalf("seed user: %v", err) } wantModelID := loaderTestModelID(t, db, "auth", "User") - if err := db.Create(&metadata.ModelData{ + if err := db.Create(&modmeta.ModelData{ Module: "auth", Name: "user_sync_target", Application: "stale_app", ModelName: "stale_name", ModelId: "stale_model_id", ResID: user.ID, NoUpdate: false, }).Error; err != nil { t.Fatalf("seed stale mapping: %v", err) @@ -3351,7 +3350,7 @@ func TestApplyRecord_SyncsStaleMappingTarget(t *testing.T) { }, now); err != nil { t.Fatalf("applyRecord sync: %v", err) } - var mapping metadata.ModelData + var mapping modmeta.ModelData if err := db.Where("module = ? AND name = ?", "auth", "user_sync_target").First(&mapping).Error; err != nil { t.Fatalf("lookup mapping: %v", err) } @@ -3367,7 +3366,7 @@ func TestApplyRecord_SyncMappingTargetDBFailure(t *testing.T) { if err := db.Table("auth_user").Create(&user).Error; err != nil { t.Fatalf("seed user: %v", err) } - if err := db.Create(&metadata.ModelData{ + if err := db.Create(&modmeta.ModelData{ Module: "auth", Name: "user_sync_fail", Application: "stale_app", ModelName: "stale_name", ModelId: "stale_model_id", ResID: user.ID, NoUpdate: false, }).Error; err != nil { t.Fatalf("seed mapping: %v", err) @@ -3456,7 +3455,7 @@ func TestPlanRecordOrder_DuplicateInvalidAndExternalRef(t *testing.T) { }) t.Run("external ref exists", func(t *testing.T) { - if err := db.Create(&metadata.ModelData{ + if err := db.Create(&modmeta.ModelData{ Module: "auth", Name: "pre_group", Application: "auth", ModelName: "group", ModelId: loaderTestModelID(t, db, "auth", "group"), ResID: "gid-pre", }).Error; err != nil { t.Fatalf("seed model_data: %v", err) diff --git a/internal/module/evolution/data/translated_seed.go b/internal/module/evolution/data/translated_seed.go index a2cdc6221..1b0440925 100644 --- a/internal/module/evolution/data/translated_seed.go +++ b/internal/module/evolution/data/translated_seed.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/gorm" ) @@ -66,9 +67,9 @@ func (l *Loader) languageCodeExists(tx *gorm.DB, code string) (bool, error) { if code == "" { return false, nil } - model, err := meta.LookupEffectiveModel(tx, "base", "Language") + model, err := modmeta.LookupEffectiveModel(tx, "base", "Language") if err != nil { - if meta.IsEffectiveModelNotFound(err) || errors.Is(err, gorm.ErrRecordNotFound) { + if modmeta.IsEffectiveModelNotFound(err) { return false, nil } return false, err diff --git a/internal/module/evolution/schema/foreignkey.go b/internal/module/evolution/schema/foreignkey.go index c5b61c57f..9226b28fc 100644 --- a/internal/module/evolution/schema/foreignkey.go +++ b/internal/module/evolution/schema/foreignkey.go @@ -6,6 +6,7 @@ package schema import ( "encoding/json" "fmt" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" @@ -160,7 +161,7 @@ func (m *foreignKeyMigrator) resolveTargetModelByPath(targetModelPath string) (* // Declaration fallback: Path is unique per ModuleId, so cross-module duplicates are possible. // Do not filter by the current module — FK targets often live in dependencies. - decls, err := meta.ListDeclarations(session.DB, meta.DeclarationQuery{ + decls, err := modmeta.ListDeclarations(session.DB, modmeta.DeclarationQuery{ Path: targetModelPath, }) if err != nil { diff --git a/internal/module/evolution/schema/foreignkey_test.go b/internal/module/evolution/schema/foreignkey_test.go index b8d7c7756..2df51fe1f 100644 --- a/internal/module/evolution/schema/foreignkey_test.go +++ b/internal/module/evolution/schema/foreignkey_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" "gorm.io/gorm" @@ -81,7 +82,7 @@ func TestForeignKeyMigratorRuntimePaths(t *testing.T) { t.Fatalf("create module: %v", err) } target := &meta.Model{Name: "User", Path: "sales/user.ts", ModelTable: "sales_user", ModuleId: module.Id} - if err := meta.PersistModelTreeAsRaw(runtimeScope.Session().DB, target); err != nil { + if _, err := modmeta.ReplaceModuleDeclarations(runtimeScope.Session().DB, module.Id.String, []*meta.Model{target}); err != nil { t.Fatalf("persist target declaration: %v", err) } source := &meta.Model{Name: "Order", Path: "sales/order.ts", ModelTable: "sales_order", ModuleId: module.Id, Fields: []*meta.Field{newRelationField("OwnerId", "sales/user", `{"type":"ManyToOne","relation":{"onDelete":"CASCADE"}}`)}} @@ -132,13 +133,15 @@ func TestForeignKeyMigratorRuntimePaths(t *testing.T) { if err := runtimeScope.Session().Create(modB).Error; err != nil { t.Fatalf("create modB: %v", err) } - for _, row := range []*meta.Model{ + if _, err := modmeta.ReplaceModuleDeclarations(runtimeScope.Session().DB, modA.Id.String, []*meta.Model{ {Name: "User", Path: "shared/user.ts", ModelTable: "mod_a_user", ModuleId: modA.Id}, + }); err != nil { + t.Fatalf("persist declaration: %v", err) + } + if _, err := modmeta.ReplaceModuleDeclarations(runtimeScope.Session().DB, modB.Id.String, []*meta.Model{ {Name: "User", Path: "shared/user.ts", ModelTable: "mod_b_user", ModuleId: modB.Id}, - } { - if err := meta.PersistModelTreeAsRaw(runtimeScope.Session().DB, row); err != nil { - t.Fatalf("persist declaration: %v", err) - } + }); err != nil { + t.Fatalf("persist declaration: %v", err) } fkMigrator := newForeignKeyMigrator(runtimeScope, modA, nil).(*foreignKeyMigrator) if _, err := fkMigrator.resolveTargetModelByPath("shared/user.ts"); err == nil || !strings.Contains(err.Error(), "ambiguous model path") { diff --git a/internal/module/evolution/schema/helpers_test.go b/internal/module/evolution/schema/helpers_test.go index 23279a418..d15cdc245 100644 --- a/internal/module/evolution/schema/helpers_test.go +++ b/internal/module/evolution/schema/helpers_test.go @@ -14,6 +14,7 @@ import ( "testing" dynamicstruct "github.com/Chise1/dynamic-struct" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/testing/scopetest" "github.com/choysum-dev/choysum/pkg/config" "github.com/choysum-dev/choysum/pkg/meta" @@ -76,7 +77,7 @@ func newSchemaTestScope(t *testing.T) *schemaTestScope { func migrateSchemaMetaTables(t *testing.T, session *scope.Session) { t.Helper() - if err := meta.EnsureDualStoreTables(session.DB); err != nil { + if err := session.DB.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("ensure dual store: %v", err) } if err := session.AutoMigrate(&meta.Module{}); err != nil { diff --git a/internal/module/evolution/schema/migrator.go b/internal/module/evolution/schema/migrator.go index 5190e7119..11b1ee1da 100644 --- a/internal/module/evolution/schema/migrator.go +++ b/internal/module/evolution/schema/migrator.go @@ -5,6 +5,7 @@ package schema import ( "fmt" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" @@ -32,7 +33,7 @@ func newMigrator(runtimeScope scope.Scope, module *meta.Module) (*migrator, erro func getModuleModels(runtimeScope scope.Scope, module *meta.Module) ([]*meta.Model, error) { absFalse := false - moduleModels, err := meta.ListDeclarations(runtimeScope.Session().DB, meta.DeclarationQuery{ + moduleModels, err := modmeta.ListDeclarations(runtimeScope.Session().DB, modmeta.DeclarationQuery{ ModuleID: module.Id.String, Abstract: &absFalse, PreloadTree: true, @@ -42,7 +43,7 @@ func getModuleModels(runtimeScope scope.Scope, module *meta.Module) ([]*meta.Mod } // Declaration-only raw rows omit inherited columns; expand Extends in memory for DDL. - if err := meta.ExpandModelsAlongExtends(runtimeScope.Session().DB, moduleModels); err != nil { + if err := modmeta.ExpandModelsAlongExtends(runtimeScope.Session().DB, moduleModels); err != nil { return nil, xfmt.Errorf("error expanding model extends for schema: %w", err) } diff --git a/internal/module/evolution/schema/migrator_test.go b/internal/module/evolution/schema/migrator_test.go index 7df3c9652..9b85cf3b9 100644 --- a/internal/module/evolution/schema/migrator_test.go +++ b/internal/module/evolution/schema/migrator_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" ) @@ -80,10 +81,8 @@ func TestGetModuleModelsFiltersAndWrapsDBErrors(t *testing.T) { {Name: "Disabled", Path: "sales/disabled.ts", ModelTable: "sales_disabled", ModuleId: module.Id, AutoMigrate: &disabledAutoMigrate}, {Name: "Abstract", Path: "sales/abstract.ts", ModelTable: "sales_abstract", ModuleId: module.Id, Abstract: true}, } - for _, model := range decls { - if err := meta.PersistModelTreeAsRaw(runtimeScope.Session().DB, model); err != nil { - t.Fatalf("persist declaration %s: %v", model.Name, err) - } + if _, err := modmeta.ReplaceModuleDeclarations(runtimeScope.Session().DB, module.Id.String, decls); err != nil { + t.Fatalf("persist declarations: %v", err) } loaded, err := getModuleModels(runtimeScope, module) @@ -125,10 +124,8 @@ func TestGetModuleModels_CircularExtends(t *testing.T) { {Name: "A", Path: "/a.ts", ModelTable: "sales_a", ModuleId: module.Id, Extends: "/b.ts"}, {Name: "B", Path: "/b.ts", ModelTable: "sales_b", ModuleId: module.Id, Extends: "/a.ts"}, } - for _, model := range decls { - if err := meta.PersistModelTreeAsRaw(runtimeScope.Session().DB, model); err != nil { - t.Fatalf("persist declaration %s: %v", model.Name, err) - } + if _, err := modmeta.ReplaceModuleDeclarations(runtimeScope.Session().DB, module.Id.String, decls); err != nil { + t.Fatalf("persist declarations: %v", err) } if _, err := getModuleModels(runtimeScope, module); err == nil || !strings.Contains(err.Error(), "expanding model extends") { @@ -159,10 +156,8 @@ func TestNewMigratorPropagatesLoadError(t *testing.T) { {Name: "A", Path: "/a.ts", ModelTable: "sales_a", ModuleId: module.Id, Extends: "/b.ts"}, {Name: "B", Path: "/b.ts", ModelTable: "sales_b", ModuleId: module.Id, Extends: "/a.ts"}, } - for _, model := range decls { - if err := meta.PersistModelTreeAsRaw(runtimeScope.Session().DB, model); err != nil { - t.Fatalf("persist declaration %s: %v", model.Name, err) - } + if _, err := modmeta.ReplaceModuleDeclarations(runtimeScope.Session().DB, module.Id.String, decls); err != nil { + t.Fatalf("persist declarations: %v", err) } if _, err := NewMigrator(runtimeScope, module); err == nil || !strings.Contains(err.Error(), "expanding model extends") { t.Fatalf("expected expand error from NewMigrator, got %v", err) diff --git a/internal/module/evolution/scripts/context_test.go b/internal/module/evolution/scripts/context_test.go index e59b1ba34..c3bdc3da7 100644 --- a/internal/module/evolution/scripts/context_test.go +++ b/internal/module/evolution/scripts/context_test.go @@ -13,8 +13,7 @@ import ( "testing" _ "github.com/choysum-dev/choysum/internal/defaultjsexecutor" - modulemetadata "github.com/choysum-dev/choysum/internal/module/metadata" - + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/testing/scopetest" "github.com/choysum-dev/choysum/pkg/auth" "github.com/choysum-dev/choysum/pkg/config" @@ -103,7 +102,7 @@ func newScriptsTestScope(t *testing.T) *scriptsTestScope { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := db.AutoMigrate(&modulemetadata.ModuleMigrationHistory{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModuleMigrationHistory{}); err != nil { t.Fatalf("migrate history table: %v", err) } return &scriptsTestScope{ diff --git a/internal/module/evolution/scripts/history.go b/internal/module/evolution/scripts/history.go index d0ed39568..6d30fed3a 100644 --- a/internal/module/evolution/scripts/history.go +++ b/internal/module/evolution/scripts/history.go @@ -6,12 +6,13 @@ package scripts import ( "context" "errors" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" "time" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/scope" "go.opentelemetry.io/otel/trace" "gorm.io/gorm" + ) type HistoryStore struct { @@ -33,7 +34,7 @@ func (s *HistoryStore) session(ctx context.Context) *scope.Session { return sess } -func (s *HistoryStore) Prepare(ctx context.Context, script Script) (*metadata.ModuleMigrationHistory, bool, error) { +func (s *HistoryStore) Prepare(ctx context.Context, script Script) (*modmeta.ModuleMigrationHistory, bool, error) { if s == nil || s.runtimeScope == nil { return nil, false, nil } @@ -42,7 +43,7 @@ func (s *HistoryStore) Prepare(ctx context.Context, script Script) (*metadata.Mo return nil, false, gorm.ErrInvalidDB } - var existing metadata.ModuleMigrationHistory + var existing modmeta.ModuleMigrationHistory q := db.WithContext(ctx).Where( "module_name = ? AND version = ? AND phase = ? AND script = ?", script.ModuleName, script.Version, string(script.Phase), script.Name, @@ -58,7 +59,7 @@ func (s *HistoryStore) Prepare(ctx context.Context, script Script) (*metadata.Mo return nil, false, err } - entry := &metadata.ModuleMigrationHistory{ + entry := &modmeta.ModuleMigrationHistory{ ModuleName: script.ModuleName, Version: script.Version, Phase: string(script.Phase), @@ -74,7 +75,7 @@ func (s *HistoryStore) Prepare(ctx context.Context, script Script) (*metadata.Mo return entry, false, nil } -func (s *HistoryStore) markRunning(ctx context.Context, entry *metadata.ModuleMigrationHistory, script Script) (*metadata.ModuleMigrationHistory, bool, error) { +func (s *HistoryStore) markRunning(ctx context.Context, entry *modmeta.ModuleMigrationHistory, script Script) (*modmeta.ModuleMigrationHistory, bool, error) { db := s.session(ctx) if db == nil { return nil, false, gorm.ErrInvalidDB @@ -91,7 +92,7 @@ func (s *HistoryStore) markRunning(ctx context.Context, entry *metadata.ModuleMi return entry, false, nil } -func (s *HistoryStore) MarkSuccess(ctx context.Context, entry *metadata.ModuleMigrationHistory) error { +func (s *HistoryStore) MarkSuccess(ctx context.Context, entry *modmeta.ModuleMigrationHistory) error { if s == nil || s.runtimeScope == nil || entry == nil { return nil } @@ -105,7 +106,7 @@ func (s *HistoryStore) MarkSuccess(ctx context.Context, entry *metadata.ModuleMi return db.WithContext(ctx).Save(entry).Error } -func (s *HistoryStore) MarkFailed(ctx context.Context, entry *metadata.ModuleMigrationHistory, msg string) error { +func (s *HistoryStore) MarkFailed(ctx context.Context, entry *modmeta.ModuleMigrationHistory, msg string) error { if s == nil || s.runtimeScope == nil || entry == nil { return nil } diff --git a/internal/module/evolution/scripts/history_test.go b/internal/module/evolution/scripts/history_test.go index c41616dce..224f32808 100644 --- a/internal/module/evolution/scripts/history_test.go +++ b/internal/module/evolution/scripts/history_test.go @@ -5,18 +5,19 @@ package scripts import ( "context" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" "io" "log/slog" "path/filepath" "strings" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/testing/scopetest" "github.com/choysum-dev/choysum/pkg/config" "github.com/choysum-dev/choysum/pkg/scope" "gorm.io/driver/sqlite" "gorm.io/gorm" + ) type testScope struct { @@ -44,7 +45,7 @@ func newTestScope(t *testing.T) scope.Scope { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := db.AutoMigrate(&metadata.ModuleMigrationHistory{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModuleMigrationHistory{}); err != nil { t.Fatalf("migrate test schema: %v", err) } return &testScope{db: db} @@ -146,7 +147,7 @@ func TestHistoryStore_MarkFailedPersistsError(t *testing.T) { t.Fatalf("mark failed: %v", err) } - var stored metadata.ModuleMigrationHistory + var stored modmeta.ModuleMigrationHistory if err := runtimeScope.Session().WithContext(ctx).Where("module_name = ? AND version = ? AND script = ?", "meta", "0.1.0", "init").Take(&stored).Error; err != nil { t.Fatalf("reload stored history: %v", err) } @@ -169,7 +170,7 @@ func TestHistoryStore_PrefersContextScope(t *testing.T) { if err != nil { t.Fatalf("open runtime sqlite: %v", err) } - if err := runtimeDB.AutoMigrate(&metadata.ModuleMigrationHistory{}); err != nil { + if err := runtimeDB.AutoMigrate(&modmeta.ModuleMigrationHistory{}); err != nil { t.Fatalf("migrate runtime schema: %v", err) } runtimeSession := &scope.Session{DB: runtimeDB} @@ -191,14 +192,14 @@ func TestHistoryStore_PrefersContextScope(t *testing.T) { } var baseCount int64 - if err := runtimeScope.Session().WithContext(context.Background()).Model(&metadata.ModuleMigrationHistory{}).Count(&baseCount).Error; err != nil { + if err := runtimeScope.Session().WithContext(context.Background()).Model(&modmeta.ModuleMigrationHistory{}).Count(&baseCount).Error; err != nil { t.Fatalf("count base history rows: %v", err) } if baseCount != 0 { t.Fatalf("base history row count = %d, want 0", baseCount) } - var runtimeRow metadata.ModuleMigrationHistory + var runtimeRow modmeta.ModuleMigrationHistory if err := runtimeSession.WithContext(context.Background()).Where("module_name = ? AND version = ? AND script = ?", "meta", "0.1.0", "init").Take(&runtimeRow).Error; err != nil { t.Fatalf("load runtime history row: %v", err) } diff --git a/internal/module/evolution/scripts/runner_decorators_test.go b/internal/module/evolution/scripts/runner_decorators_test.go index 037d3755b..cd74d004b 100644 --- a/internal/module/evolution/scripts/runner_decorators_test.go +++ b/internal/module/evolution/scripts/runner_decorators_test.go @@ -12,10 +12,9 @@ import ( "strings" "testing" - module "github.com/choysum-dev/choysum/internal/module/artifact/result" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" - modulegenerator "github.com/choysum-dev/choysum/internal/module/artifact/generate" + module "github.com/choysum-dev/choysum/internal/module/artifact/result" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/testing/jsexecutortest" "github.com/choysum-dev/choysum/pkg/jsengine" "github.com/choysum-dev/choysum/pkg/meta" @@ -334,7 +333,7 @@ func TestRunnerRunPhaseExecutesMigrationsAndRestoresScripts(t *testing.T) { t.Fatalf("unexpected migration args: %#v", migrationArgs) } - var history []metadata.ModuleMigrationHistory + var history []modmeta.ModuleMigrationHistory if err := testRuntimeScope.session.WithContext(context.Background()).Order("version asc, script asc").Find(&history).Error; err != nil { t.Fatalf("load migration history: %v", err) } @@ -425,7 +424,7 @@ func TestRunnerValidateAndRunPhaseFailurePaths(t *testing.T) { t.Fatalf("expected migration failure, got %v", err) } - var row metadata.ModuleMigrationHistory + var row modmeta.ModuleMigrationHistory if err := testRuntimeScope.session.WithContext(context.Background()).Where("script = ?", "broken").Take(&row).Error; err != nil { t.Fatalf("load failed history row: %v", err) } diff --git a/internal/module/lifecycle/bundles_app_setting_test.go b/internal/module/lifecycle/bundles_app_setting_test.go index 15c197b2c..4f7feea5d 100644 --- a/internal/module/lifecycle/bundles_app_setting_test.go +++ b/internal/module/lifecycle/bundles_app_setting_test.go @@ -11,7 +11,9 @@ import ( "strings" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" + ) func TestPickAppSettingOwnerModule_LastEligible(t *testing.T) { @@ -100,7 +102,7 @@ func TestEnsureBundleC2VirtualImports_BundleInject(t *testing.T) { func TestBuildBackendBundlesToDir_AppSettingOwnerAndEnsureError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } modPath := filepath.Join(modulesPath, "crm_partner") @@ -123,7 +125,7 @@ func TestBuildBackendBundlesToDir_AppSettingOwnerAndEnsureError(t *testing.T) { distBundlesDir := t.TempDir() // Drop meta_raw_model so BundleInjectAppModels fails while owners were collected. - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } err := manager.buildBackendBundlesToDir(context.Background(), distBundlesDir, nil) diff --git a/internal/module/lifecycle/bundles_field_default_test.go b/internal/module/lifecycle/bundles_field_default_test.go index 231166838..a2d8b5935 100644 --- a/internal/module/lifecycle/bundles_field_default_test.go +++ b/internal/module/lifecycle/bundles_field_default_test.go @@ -10,7 +10,9 @@ import ( "strings" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" + ) func TestPickFieldDefaultOwnerModule_LastEligible(t *testing.T) { @@ -44,7 +46,7 @@ func TestPickFieldDefaultOwnerModule_LastEligible(t *testing.T) { func TestBuildBackendBundlesToDir_FieldDefaultOwnerAndEnsureError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } modPath := filepath.Join(modulesPath, "crm_partner") @@ -67,7 +69,7 @@ func TestBuildBackendBundlesToDir_FieldDefaultOwnerAndEnsureError(t *testing.T) distBundlesDir := t.TempDir() // Drop meta_raw_model so BundleInjectAppModels fails while owners were collected. - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } err := manager.buildBackendBundlesToDir(context.Background(), distBundlesDir, nil) diff --git a/internal/module/lifecycle/bundles_translation_term_test.go b/internal/module/lifecycle/bundles_translation_term_test.go index fdd58e609..343b16e8a 100644 --- a/internal/module/lifecycle/bundles_translation_term_test.go +++ b/internal/module/lifecycle/bundles_translation_term_test.go @@ -12,7 +12,9 @@ import ( "testing" module "github.com/choysum-dev/choysum/internal/module/artifact/result" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" + ) func TestPickTranslationTermOwnerModule_EmptyEntryAllowed(t *testing.T) { @@ -133,7 +135,7 @@ func TestWriteBackendBundleToDir(t *testing.T) { func TestBuildBackendBundlesToDir_NoRepresentativeReturnsNil(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } runtimeScope := newModuleIndexSyncScope(modulesPath, db) @@ -148,7 +150,7 @@ func TestBuildBackendBundlesToDir_NoRepresentativeReturnsNil(t *testing.T) { func TestBuildBackendBundlesToDir_SuccessfulWrite(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } modPath := filepath.Join(modulesPath, "core") @@ -184,7 +186,7 @@ func TestBuildBackendBundlesToDir_SuccessfulWrite(t *testing.T) { func TestBuildBackendBundlesToDir_EmptyEntryOnlyStillInjectsTranslationTerm(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } modPath := filepath.Join(modulesPath, "web") @@ -216,7 +218,7 @@ func TestBuildBackendBundlesToDir_EmptyEntryOnlyStillInjectsTranslationTerm(t *t // Drop meta_raw_model so BundleInjectAppModels fails after owners were collected // and the Ensure-only representative path was selected. - if err := meta.DropRawModelTable(db); err != nil { + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } err := manager.buildBackendBundlesToDir(context.Background(), distBundlesDir, nil) diff --git a/internal/module/lifecycle/install_module_test.go b/internal/module/lifecycle/install_module_test.go index 98358c376..14a3ec928 100644 --- a/internal/module/lifecycle/install_module_test.go +++ b/internal/module/lifecycle/install_module_test.go @@ -9,15 +9,17 @@ import ( "path/filepath" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" statepkg "github.com/choysum-dev/choysum/pkg/state" + ) func TestPrepareInstallAliasesPrefetchInstallModules(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } diff --git a/internal/module/lifecycle/install_prefetch_test.go b/internal/module/lifecycle/install_prefetch_test.go index 1eabde573..2edeca14f 100644 --- a/internal/module/lifecycle/install_prefetch_test.go +++ b/internal/module/lifecycle/install_prefetch_test.go @@ -9,9 +9,11 @@ import ( "path/filepath" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" statepkg "github.com/choysum-dev/choysum/pkg/state" + ) type countingPrefetchOriginCoordinator struct { @@ -50,7 +52,7 @@ func (c *countingPrefetchOriginCoordinator) lookup(input string) *meta.Module { func TestPrefetchInstallModulesThenResolveUsesCache(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } diff --git a/internal/module/lifecycle/installer_commit_test.go b/internal/module/lifecycle/installer_commit_test.go index 913dc0649..b8d81bc77 100644 --- a/internal/module/lifecycle/installer_commit_test.go +++ b/internal/module/lifecycle/installer_commit_test.go @@ -13,6 +13,7 @@ import ( i18nmodels "github.com/choysum-dev/choysum/internal/i18n/models" moduleresult "github.com/choysum-dev/choysum/internal/module/artifact/result" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" internaltask "github.com/choysum-dev/choysum/internal/task" "github.com/choysum-dev/choysum/pkg/meta" "github.com/rs/xid" @@ -160,13 +161,11 @@ func TestCommitInstallNewMigratorError(t *testing.T) { if err := runtimeScope.Session().Create(mod).Error; err != nil { t.Fatalf("create module: %v", err) } - for _, row := range []*meta.Model{ + if _, err := modmeta.ReplaceModuleDeclarations(runtimeScope.Session().DB, mod.Id.String, []*meta.Model{ {Name: "A", Path: "/a.ts", ModelTable: "a", ModuleId: mod.Id, Extends: "/b.ts"}, {Name: "B", Path: "/b.ts", ModelTable: "b", ModuleId: mod.Id, Extends: "/a.ts"}, - } { - if err := meta.PersistModelTreeAsRaw(runtimeScope.Session().DB, row); err != nil { - t.Fatalf("create circular declaration: %v", err) - } + }); err != nil { + t.Fatalf("create circular declaration: %v", err) } installer := &moduleInstaller{ module: mod, @@ -200,13 +199,11 @@ func TestCommitUpgradeNewMigratorError(t *testing.T) { ApplicationStr: "auth", } target.Id = mod.Id - for _, row := range []*meta.Model{ + if _, err := modmeta.ReplaceModuleDeclarations(runtimeScope.Session().DB, target.Id.String, []*meta.Model{ {Name: "A", Path: "/ua.ts", ModelTable: "ua", ModuleId: target.Id, Extends: "/ub.ts"}, {Name: "B", Path: "/ub.ts", ModelTable: "ub", ModuleId: target.Id, Extends: "/ua.ts"}, - } { - if err := meta.PersistModelTreeAsRaw(runtimeScope.Session().DB, row); err != nil { - t.Fatalf("create circular declaration: %v", err) - } + }); err != nil { + t.Fatalf("create circular declaration: %v", err) } upgrader := &moduleUpgrader{ runtimeScope: runtimeScope, @@ -305,8 +302,8 @@ func TestCommitInstallMetaAndDocumentSchedules(t *testing.T) { if err := db.AutoMigrate(&internaltask.Schedule{}, &meta.Module{}); err != nil { t.Fatal(err) } - if err := meta.EnsureDualStoreTables(db); err != nil { - t.Fatalf("EnsureDualStoreTables: %v", err) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("AutoMigrate CatalogEntities: %v", err) } now := time.Now().UTC() if err := db.Create(&internaltask.Schedule{ diff --git a/internal/module/lifecycle/module_index_sync.go b/internal/module/lifecycle/module_index_sync.go index 611c95e96..57aa68df4 100644 --- a/internal/module/lifecycle/module_index_sync.go +++ b/internal/module/lifecycle/module_index_sync.go @@ -12,7 +12,7 @@ import ( "strings" "time" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/module/origin/contract" "github.com/choysum-dev/choysum/pkg/oerrors" "github.com/choysum-dev/choysum/pkg/scope" @@ -147,7 +147,7 @@ func SyncLocalModuleIndex(ctx context.Context, runtimeScope scope.Scope, lockerF if !hasError { if err := withModuleIndexWriteRetry(ctx, runtimeScope, session, func(txSession *scope.Session) error { return txSession.WithContext(ctx). - Model(&metadata.ModuleIndex{}). + Model(&modmeta.ModuleIndex{}). Where("origin_type = ? AND origin_ref = ?", "local", "local"). Updates(map[string]any{"last_batch_sync_at": now}).Error }); err != nil { @@ -176,7 +176,7 @@ func moduleIndexLockTTL(ctx context.Context, runtimeScope scope.Scope) time.Dura return fallbackTime } - var setting metadata.Setting + var setting modmeta.Setting res := sess.WithContext(ctx).Where("key = ?", settingKey).Take(&setting) if res.Error != nil { return fallbackTime @@ -241,7 +241,7 @@ func upsertModuleIndexSuccess(ctx context.Context, runtimeScope scope.Scope, ses if ctx == nil { ctx = context.Background() } - entry := metadata.ModuleIndex{ + entry := modmeta.ModuleIndex{ ModuleName: moduleName, OriginType: "local", OriginRef: "local", @@ -274,7 +274,7 @@ func upsertModuleIndexFailure(ctx context.Context, runtimeScope scope.Scope, ses ctx = context.Background() } msg := SanitizeModuleIndexError(runtimeScope, cause) - entry := metadata.ModuleIndex{ + entry := modmeta.ModuleIndex{ ModuleName: moduleName, OriginType: "local", OriginRef: "local", @@ -302,7 +302,7 @@ func reconcileMissingModules(ctx context.Context, runtimeScope scope.Scope, sess } return withModuleIndexWriteRetry(ctx, runtimeScope, session, func(txSession *scope.Session) error { query := txSession.WithContext(ctx). - Model(&metadata.ModuleIndex{}). + Model(&modmeta.ModuleIndex{}). Where("origin_type = ? AND origin_ref = ?", "local", "local") if len(names) > 0 { query = query.Where("module_name NOT IN ?", names) diff --git a/internal/module/lifecycle/module_index_sync_registry.go b/internal/module/lifecycle/module_index_sync_registry.go index 0a11152ad..2713a6f29 100644 --- a/internal/module/lifecycle/module_index_sync_registry.go +++ b/internal/module/lifecycle/module_index_sync_registry.go @@ -13,7 +13,7 @@ import ( "strings" "time" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/config" "github.com/choysum-dev/choysum/pkg/oerrors" "github.com/choysum-dev/choysum/pkg/scope" @@ -113,7 +113,7 @@ func SyncRegistryModuleIndex(ctx context.Context, runtimeScope scope.Scope, lock seen := make(map[string]struct{}) now := time.Now().UTC() hasError := false - records := make([]metadata.ModuleIndex, 0, len(index.Modules)) + records := make([]modmeta.ModuleIndex, 0, len(index.Modules)) for moduleName, module := range index.Modules { select { @@ -144,7 +144,7 @@ func SyncRegistryModuleIndex(ctx context.Context, runtimeScope scope.Scope, lock continue } - records = append(records, metadata.ModuleIndex{ + records = append(records, modmeta.ModuleIndex{ ModuleName: name, OriginType: "registry", OriginRef: originRef, @@ -158,7 +158,7 @@ func SyncRegistryModuleIndex(ctx context.Context, runtimeScope scope.Scope, lock if len(records) > 0 { if err := withModuleIndexWriteRetry(ctx, runtimeScope, session, func(txSession *scope.Session) error { return txSession.WithContext(ctx). - Model(&metadata.ModuleIndex{}). + Model(&modmeta.ModuleIndex{}). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "module_name"}, {Name: "origin_type"}, {Name: "origin_ref"}}, DoUpdates: clause.AssignmentColumns([]string{"available", "version", "manifest_json", "last_sync_at"}), @@ -174,7 +174,7 @@ func SyncRegistryModuleIndex(ctx context.Context, runtimeScope scope.Scope, lock entry := record if rowErr := withModuleIndexWriteRetry(ctx, runtimeScope, session, func(txSession *scope.Session) error { return txSession.WithContext(ctx). - Model(&metadata.ModuleIndex{}). + Model(&modmeta.ModuleIndex{}). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "module_name"}, {Name: "origin_type"}, {Name: "origin_ref"}}, DoUpdates: clause.AssignmentColumns([]string{"available", "version", "manifest_json", "last_sync_at"}), @@ -195,9 +195,9 @@ func SyncRegistryModuleIndex(ctx context.Context, runtimeScope scope.Scope, lock // Mark registry entries absent from the current catalog snapshot as unavailable. if len(seen) > 0 { - existing := make([]metadata.ModuleIndex, 0) + existing := make([]modmeta.ModuleIndex, 0) if err := session.WithContext(ctx). - Model(&metadata.ModuleIndex{}). + Model(&modmeta.ModuleIndex{}). Where("origin_type = ?", "registry"). Find(&existing).Error; err != nil { hasError = true @@ -223,7 +223,7 @@ func SyncRegistryModuleIndex(ctx context.Context, runtimeScope scope.Scope, lock if len(orphanedIDs) > 0 { if err := withModuleIndexWriteRetry(ctx, runtimeScope, session, func(txSession *scope.Session) error { return txSession.WithContext(ctx). - Model(&metadata.ModuleIndex{}). + Model(&modmeta.ModuleIndex{}). Where("id IN ?", orphanedIDs). Updates(map[string]any{"available": false, "last_sync_at": now}).Error }); err != nil { @@ -237,7 +237,7 @@ func SyncRegistryModuleIndex(ctx context.Context, runtimeScope scope.Scope, lock originRef := key.originRef if err := withModuleIndexWriteRetry(ctx, runtimeScope, session, func(txSession *scope.Session) error { return txSession.WithContext(ctx). - Model(&metadata.ModuleIndex{}). + Model(&modmeta.ModuleIndex{}). Where("module_name = ? AND origin_type = ? AND origin_ref = ?", moduleName, "registry", originRef). Updates(map[string]any{"available": false, "last_sync_at": now}).Error }); err != nil { @@ -251,7 +251,7 @@ func SyncRegistryModuleIndex(ctx context.Context, runtimeScope scope.Scope, lock if !hasError { if err := withModuleIndexWriteRetry(ctx, runtimeScope, session, func(txSession *scope.Session) error { return txSession.WithContext(ctx). - Model(&metadata.ModuleIndex{}). + Model(&modmeta.ModuleIndex{}). Where("origin_type = ?", "registry"). Updates(map[string]any{"last_batch_sync_at": now}).Error }); err != nil { diff --git a/internal/module/lifecycle/module_index_sync_registry_test.go b/internal/module/lifecycle/module_index_sync_registry_test.go index b9fc402a8..8be7babaf 100644 --- a/internal/module/lifecycle/module_index_sync_registry_test.go +++ b/internal/module/lifecycle/module_index_sync_registry_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/config" "github.com/choysum-dev/choysum/pkg/scope" statepkg "github.com/choysum-dev/choysum/pkg/state" @@ -74,13 +74,13 @@ func TestSyncRegistryModuleIndexUpsertsVersionAndReconcilesByOriginRef(t *testin defer testServer.Close() runtimeScope.cfg.ModuleCatalogIndexURL = testServer.URL + "/v1/index.json" - if err := db.Create(&metadata.ModuleIndex{ModuleName: "auth", OriginType: "registry", OriginRef: "@legacy/choysum-auth", Available: true, Version: nullString("1.0.0")}).Error; err != nil { + if err := db.Create(&modmeta.ModuleIndex{ModuleName: "auth", OriginType: "registry", OriginRef: "@legacy/choysum-auth", Available: true, Version: nullString("1.0.0")}).Error; err != nil { t.Fatalf("seed legacy auth row: %v", err) } - if err := db.Create(&metadata.ModuleIndex{ModuleName: "auth", OriginType: "registry", OriginRef: "@choysum-dev/auth", Available: false, Version: nullString("0.9.0")}).Error; err != nil { + if err := db.Create(&modmeta.ModuleIndex{ModuleName: "auth", OriginType: "registry", OriginRef: "@choysum-dev/auth", Available: false, Version: nullString("0.9.0")}).Error; err != nil { t.Fatalf("seed current auth row: %v", err) } - if err := db.Create(&metadata.ModuleIndex{ModuleName: "orphan", OriginType: "registry", OriginRef: "@legacy/orphan", Available: true, Version: nullString("0.1.0")}).Error; err != nil { + if err := db.Create(&modmeta.ModuleIndex{ModuleName: "orphan", OriginType: "registry", OriginRef: "@legacy/orphan", Available: true, Version: nullString("0.1.0")}).Error; err != nil { t.Fatalf("seed orphan row: %v", err) } @@ -94,7 +94,7 @@ func TestSyncRegistryModuleIndexUpsertsVersionAndReconcilesByOriginRef(t *testin t.Fatalf("unexpected stats = %#v", stats) } - var current metadata.ModuleIndex + var current modmeta.ModuleIndex if err := db.Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "auth", "registry", "@choysum-dev/auth").Take(¤t).Error; err != nil { t.Fatalf("load current auth row: %v", err) } @@ -105,7 +105,7 @@ func TestSyncRegistryModuleIndexUpsertsVersionAndReconcilesByOriginRef(t *testin t.Fatalf("current version = %#v, want 2.0.0", current.Version) } - var legacy metadata.ModuleIndex + var legacy modmeta.ModuleIndex if err := db.Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "auth", "registry", "@legacy/choysum-auth").Take(&legacy).Error; err != nil { t.Fatalf("load legacy auth row: %v", err) } @@ -113,7 +113,7 @@ func TestSyncRegistryModuleIndexUpsertsVersionAndReconcilesByOriginRef(t *testin t.Fatal("expected legacy auth row to be marked unavailable") } - var orphan metadata.ModuleIndex + var orphan modmeta.ModuleIndex if err := db.Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "orphan", "registry", "@legacy/orphan").Take(&orphan).Error; err != nil { t.Fatalf("load orphan row: %v", err) } @@ -186,7 +186,7 @@ VALUES ('orphan', 'registry', '@legacy/orphan', 1, datetime('now'), datetime('no t.Fatalf("unexpected stats = %#v", stats) } - var orphan metadata.ModuleIndex + var orphan modmeta.ModuleIndex if err := db.Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "orphan", "registry", "@legacy/orphan").Take(&orphan).Error; err != nil { t.Fatalf("load orphan row: %v", err) } diff --git a/internal/module/lifecycle/module_index_sync_test.go b/internal/module/lifecycle/module_index_sync_test.go index c4f0d8900..e4a0eff63 100644 --- a/internal/module/lifecycle/module_index_sync_test.go +++ b/internal/module/lifecycle/module_index_sync_test.go @@ -17,7 +17,7 @@ import ( "time" "github.com/choysum-dev/choysum/internal/module/artifact/pipeline" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" internalorigin "github.com/choysum-dev/choysum/internal/module/origin" "github.com/choysum-dev/choysum/internal/testing/scopetest" "github.com/choysum-dev/choysum/pkg/config" @@ -29,6 +29,7 @@ import ( "google.golang.org/grpc/status" "gorm.io/driver/sqlite" "gorm.io/gorm" + ) type moduleIndexSyncTestScope struct { @@ -89,7 +90,7 @@ func newModuleIndexSyncDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("open sqlite db: %v", err) } - if err := db.AutoMigrate(&metadata.ModuleIndex{}, &metadata.Setting{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModuleIndex{}, &modmeta.Setting{}); err != nil { t.Fatalf("auto migrate meta tables: %v", err) } return db @@ -140,7 +141,7 @@ func TestModuleManagerBuildGlobalWebToDirResolvesRelativeEntryPath(t *testing.T) modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -181,7 +182,7 @@ func TestModuleManagerRefreshModuleIndexForLocalModules(t *testing.T) { t.Fatalf("refreshModuleIndexForLocalModules() error = %v", err) } - var partner metadata.ModuleIndex + var partner modmeta.ModuleIndex if err := db.Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "partner", "local", "local").Take(&partner).Error; err != nil { t.Fatalf("load partner module index row: %v", err) } @@ -195,7 +196,7 @@ func TestModuleManagerRefreshModuleIndexForLocalModules(t *testing.T) { t.Fatalf("expected partner last error to be empty, got %#v", partner.LastErrorMessage) } - var missing metadata.ModuleIndex + var missing modmeta.ModuleIndex if err := db.Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "missing", "local", "local").Take(&missing).Error; err != nil { t.Fatalf("load missing module index row: %v", err) } @@ -213,7 +214,7 @@ func TestModuleManagerRefreshModuleIndexForLocalModules(t *testing.T) { func TestModuleManagerBuildBackendAppToDirWritesModuleBasedEntryImports(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -250,7 +251,7 @@ func TestModuleManagerBuildBackendAppToDirWritesModuleBasedEntryImports(t *testi func TestModuleManagerGenerateAppToDirsPropagatesGeneratorError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -276,7 +277,7 @@ func TestModuleManagerGenerateAppToDirsPropagatesGeneratorError(t *testing.T) { func TestModuleManagerBuildBackendBundlesToDirWritesModuleBasedEntryImports(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -402,7 +403,7 @@ func TestSyncLocalModuleIndex_SyncsRowsAndReconcilesMissingModules(t *testing.T) } db := newModuleIndexSyncDB(t) - if err := db.Create(&metadata.ModuleIndex{ + if err := db.Create(&modmeta.ModuleIndex{ ModuleName: "stale", OriginType: "local", OriginRef: "local", @@ -427,7 +428,7 @@ func TestSyncLocalModuleIndex_SyncsRowsAndReconcilesMissingModules(t *testing.T) t.Fatalf("locker Release calls = %d, want 1", locker.released) } - var partner metadata.ModuleIndex + var partner modmeta.ModuleIndex if err := db.Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "partner", "local", "local").Take(&partner).Error; err != nil { t.Fatalf("load partner row: %v", err) } @@ -441,7 +442,7 @@ func TestSyncLocalModuleIndex_SyncsRowsAndReconcilesMissingModules(t *testing.T) t.Fatalf("expected empty partner error, got %#v", partner.LastErrorMessage) } - var broken metadata.ModuleIndex + var broken modmeta.ModuleIndex if err := db.Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "broken", "local", "local").Take(&broken).Error; err != nil { t.Fatalf("load broken row: %v", err) } @@ -452,7 +453,7 @@ func TestSyncLocalModuleIndex_SyncsRowsAndReconcilesMissingModules(t *testing.T) t.Fatalf("expected broken error message, got %#v", broken.LastErrorMessage) } - var stale metadata.ModuleIndex + var stale modmeta.ModuleIndex if err := db.Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "stale", "local", "local").Take(&stale).Error; err != nil { t.Fatalf("load stale row: %v", err) } @@ -484,7 +485,7 @@ func TestSyncLocalModuleIndex_AllSuccessUpdatesBatchSyncAt(t *testing.T) { t.Fatalf("unexpected stats = %+v", stats) } - var rows []metadata.ModuleIndex + var rows []modmeta.ModuleIndex if err := db.Where("origin_type = ? AND origin_ref = ?", "local", "local").Find(&rows).Error; err != nil { t.Fatalf("query local rows: %v", err) } @@ -526,14 +527,14 @@ func TestModuleIndexLockTTL_UsesSettingAndClampsRange(t *testing.T) { db := newModuleIndexSyncDB(t) runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) - if err := db.Create(&metadata.Setting{Key: "meta.module_index.sync_lock_ttl_ms", Value: "999999"}).Error; err != nil { + if err := db.Create(&modmeta.Setting{Key: "meta.module_index.sync_lock_ttl_ms", Value: "999999"}).Error; err != nil { t.Fatalf("seed setting: %v", err) } if got := moduleIndexLockTTL(context.Background(), runtimeScope); got != 120*time.Second { t.Fatalf("ttl = %v, want %v", got, 120*time.Second) } - if err := db.Model(&metadata.Setting{}).Where("key = ?", "meta.module_index.sync_lock_ttl_ms").Update("value", "10").Error; err != nil { + if err := db.Model(&modmeta.Setting{}).Where("key = ?", "meta.module_index.sync_lock_ttl_ms").Update("value", "10").Error; err != nil { t.Fatalf("update setting low value: %v", err) } if got := moduleIndexLockTTL(context.Background(), runtimeScope); got != 1*time.Second { @@ -556,7 +557,7 @@ func TestModuleIndexLockTTL_FallbackCases(t *testing.T) { if err != nil { t.Fatalf("open sqlite db: %v", err) } - if err := db.AutoMigrate(&metadata.ModuleIndex{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModuleIndex{}); err != nil { t.Fatalf("auto migrate module index table: %v", err) } runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) @@ -567,7 +568,7 @@ func TestModuleIndexLockTTL_FallbackCases(t *testing.T) { t.Run("invalid setting value", func(t *testing.T) { db := newModuleIndexSyncDB(t) - if err := db.Create(&metadata.Setting{Key: "meta.module_index.sync_lock_ttl_ms", Value: "invalid"}).Error; err != nil { + if err := db.Create(&modmeta.Setting{Key: "meta.module_index.sync_lock_ttl_ms", Value: "invalid"}).Error; err != nil { t.Fatalf("seed invalid setting: %v", err) } runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) @@ -766,7 +767,7 @@ func TestWithModuleIndexWriteRetrySQLiteRetriesAtTransactionBoundary(t *testing. err := withModuleIndexWriteRetry(context.Background(), runtimeScope, session, func(txSession *scope.Session) error { attempt++ if attempt == 1 { - if err := txSession.Create(&metadata.ModuleIndex{ + if err := txSession.Create(&modmeta.ModuleIndex{ ModuleName: "auth", OriginType: "local", OriginRef: "local", @@ -778,7 +779,7 @@ func TestWithModuleIndexWriteRetrySQLiteRetriesAtTransactionBoundary(t *testing. } var count int64 - if err := txSession.Model(&metadata.ModuleIndex{}). + if err := txSession.Model(&modmeta.ModuleIndex{}). Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "auth", "local", "local"). Count(&count).Error; err != nil { return err @@ -787,7 +788,7 @@ func TestWithModuleIndexWriteRetrySQLiteRetriesAtTransactionBoundary(t *testing. return errors.New("retry observed rows from failed attempt") } - return txSession.Create(&metadata.ModuleIndex{ + return txSession.Create(&modmeta.ModuleIndex{ ModuleName: "auth", OriginType: "local", OriginRef: "local", @@ -802,7 +803,7 @@ func TestWithModuleIndexWriteRetrySQLiteRetriesAtTransactionBoundary(t *testing. } var persisted int64 - if err := db.Model(&metadata.ModuleIndex{}). + if err := db.Model(&modmeta.ModuleIndex{}). Where("module_name = ? AND origin_type = ? AND origin_ref = ?", "auth", "local", "local"). Count(&persisted).Error; err != nil { t.Fatalf("count persisted rows: %v", err) @@ -912,7 +913,7 @@ func TestModuleManagerInstallRunsAppStageCallbacks(t *testing.T) { defaultChoysumPath := filepath.Join(t.TempDir(), ".choysum") db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -980,7 +981,7 @@ func TestModuleManagerUninstallRunsAppStageCallbacks(t *testing.T) { defaultChoysumPath := filepath.Join(t.TempDir(), ".choysum") db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -1049,7 +1050,7 @@ func TestModuleManagerInstallPropagatesGeneratedAPIRootError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -1098,7 +1099,7 @@ func TestModuleManagerUninstallPropagatesGeneratedAPIRootError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -1138,7 +1139,7 @@ func TestModuleManagerUpgradePropagatesGeneratedAPIRootError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -1178,7 +1179,7 @@ func TestModuleManagerUpgradeRunsAppStageCallbacks(t *testing.T) { defaultChoysumPath := filepath.Join(t.TempDir(), ".choysum") db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } diff --git a/internal/module/lifecycle/modulemanager.go b/internal/module/lifecycle/modulemanager.go index cfd8dbf8a..581fb3c5f 100644 --- a/internal/module/lifecycle/modulemanager.go +++ b/internal/module/lifecycle/modulemanager.go @@ -17,8 +17,7 @@ import ( "sync" "time" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" - leasemodel "github.com/choysum-dev/choysum/internal/state/lease/model" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/distmanifest" logutil "github.com/choysum-dev/choysum/internal/logger" @@ -208,7 +207,13 @@ func (m *ModuleManager) ensureMetaTables() error { func (m *ModuleManager) bootstrapMetaTables(txScope scope.Scope) error { migrator := txScope.Session().Migrator() - if migrator.HasTable(&meta.Module{}) && migrator.HasTable(&leasemodel.LockLease{}) { + if migrator.HasTable(&meta.Module{}) && migrator.HasTable(&modmeta.LockLease{}) { + // Tables may predate SPL12; ensure the live unique index once meta_model exists. + if migrator.HasTable(&meta.Model{}) { + if err := modmeta.EnsureEffectiveAppNameUniqueIndex(txScope.Session().DB); err != nil { + return xfmt.Errorf("ensure effective app/name unique index: %w", err) + } + } return nil } @@ -254,6 +259,9 @@ func (m *ModuleManager) bootstrapMetaTables(txScope scope.Scope) error { if err := txScope.Session().AutoMigrate(m.entities...); err != nil { return xfmt.Errorf("auto migrate meta entities: %w", err) } + if err := modmeta.EnsureEffectiveAppNameUniqueIndex(txScope.Session().DB); err != nil { + return xfmt.Errorf("ensure effective app/name unique index: %w", err) + } if logger != nil { logger.Info("meta base table bootstrap completed", "duration_ms", time.Since(startedAt).Milliseconds()) } @@ -353,6 +361,9 @@ func (m *ModuleManager) migrateBaseModule() error { ); err != nil { return xfmt.Errorf("error migrating base module: %w", err) } + if err := modmeta.EnsureEffectiveAppNameUniqueIndex(m.runtimeScope.Session().DB); err != nil { + return xfmt.Errorf("ensure effective app/name unique index: %w", err) + } return nil } @@ -602,7 +613,7 @@ func (m *ModuleManager) refreshModuleIndexForLocalModules(ctx context.Context, m packageJSONPath := filepath.Join(modulesPath, name, "package.json") packageJSONData, readErr := os.ReadFile(packageJSONPath) - entry := metadata.ModuleIndex{ + entry := modmeta.ModuleIndex{ ModuleName: name, OriginType: "local", OriginRef: "local", @@ -1646,10 +1657,8 @@ func NewModuleManager(runtimeScope scope.Scope, jsExecutor jsexecutor.ScriptExec moduleIndexSyncLocal: SyncLocalModuleIndex, } - // Default entity set: shared IR (effective + declaration dual-store), module admin/ops, and lease models. - m.entities = append(meta.Entities(), meta.DualStoreRawEntities()...) - m.entities = append(m.entities, metadata.Entities()...) - m.entities = append(m.entities, leasemodel.Entities()...) + // Default entity set: full catalog (pkg IR + dual-store raw + ops, incl. lock lease). + m.entities = append([]any{}, modmeta.CatalogEntities()...) // Apply external injections. for _, opt := range opts { diff --git a/internal/module/lifecycle/modulemanager_coverage_test.go b/internal/module/lifecycle/modulemanager_coverage_test.go index 0e784c796..b7f569d2c 100644 --- a/internal/module/lifecycle/modulemanager_coverage_test.go +++ b/internal/module/lifecycle/modulemanager_coverage_test.go @@ -5,16 +5,19 @@ package lifecycle import ( "context" + "database/sql" "strings" "testing" + "time" - leasemodel "github.com/choysum-dev/choysum/internal/state/lease/model" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/gorm" ) func TestBootstrapMetaTablesSkipsWhenBaseTablesExist(t *testing.T) { db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(&meta.Module{}, &leasemodel.LockLease{}); err != nil { + if err := db.AutoMigrate(&meta.Module{}, &modmeta.LockLease{}); err != nil { t.Fatalf("auto migrate base tables: %v", err) } @@ -26,9 +29,105 @@ func TestBootstrapMetaTablesSkipsWhenBaseTablesExist(t *testing.T) { } } +func TestBootstrapMetaTablesEnsuresUniqueIndexWhenModelExists(t *testing.T) { + db := newModuleIndexSyncDB(t) + if err := db.AutoMigrate(&meta.Module{}, &modmeta.LockLease{}, &meta.Model{}); err != nil { + t.Fatalf("auto migrate base tables: %v", err) + } + + runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) + manager := NewModuleManager(runtimeScope, nil) + + if err := manager.bootstrapMetaTables(runtimeScope); err != nil { + t.Fatalf("bootstrapMetaTables() error = %v", err) + } + if !db.Migrator().HasIndex(&meta.Model{}, "uidx_meta_model_app_name_alive") { + t.Fatal("expected live unique index after early-path bootstrap") + } +} + +func TestBootstrapMetaTablesEnsureUniqueIndexErrorWhenModelExists(t *testing.T) { + db := newModuleIndexSyncDB(t) + if err := db.AutoMigrate(&meta.Module{}, &modmeta.LockLease{}, &meta.Model{}); err != nil { + t.Fatalf("auto migrate base tables: %v", err) + } + seedDuplicateLiveModelsForTest(t, db) + + runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) + manager := NewModuleManager(runtimeScope, nil) + err := manager.bootstrapMetaTables(runtimeScope) + if err == nil || !strings.Contains(err.Error(), "ensure effective app/name unique index") { + t.Fatalf("bootstrapMetaTables() error = %v, want unique index failure", err) + } +} + +func TestBootstrapMetaTablesFullPathEnsuresUniqueIndex(t *testing.T) { + db := newModuleIndexSyncDB(t) + runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) + manager := NewModuleManager(runtimeScope, nil) + + if err := manager.bootstrapMetaTables(runtimeScope); err != nil { + t.Fatalf("bootstrapMetaTables() error = %v", err) + } + if !db.Migrator().HasIndex(&meta.Model{}, "uidx_meta_model_app_name_alive") { + t.Fatal("expected live unique index after full bootstrap") + } +} + +func TestBootstrapMetaTablesFullPathEnsureUniqueIndexError(t *testing.T) { + db := newModuleIndexSyncDB(t) + runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) + manager := NewModuleManager(runtimeScope, nil) + // Skip catalog tables so Ensure runs against a missing meta_model. + manager.entities = nil + + err := manager.bootstrapMetaTables(runtimeScope) + if err == nil || !strings.Contains(err.Error(), "ensure effective app/name unique index") { + t.Fatalf("bootstrapMetaTables() error = %v, want unique index failure", err) + } +} + +func TestModuleManagerMigrateBaseModuleEnsureUniqueIndexError(t *testing.T) { + db := newModuleIndexSyncDB(t) + runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) + manager := NewModuleManager(runtimeScope, nil) + manager.bootstrapOnce.Do(func() {}) + + if err := manager.migrateBaseModule(); err != nil { + t.Fatalf("migrateBaseModule() error = %v", err) + } + if err := db.Exec(`DROP INDEX IF EXISTS "uidx_meta_model_app_name_alive"`).Error; err != nil { + t.Fatalf("drop unique index: %v", err) + } + seedDuplicateLiveModelsForTest(t, db) + + err := manager.migrateBaseModule() + if err == nil || !strings.Contains(err.Error(), "ensure effective app/name unique index") { + t.Fatalf("migrateBaseModule() error = %v, want unique index failure", err) + } +} + +// seedDuplicateLiveModelsForTest inserts two live meta_model rows that share +// (application, name) so EnsureEffectiveAppNameUniqueIndex must fail. +func seedDuplicateLiveModelsForTest(t *testing.T, db *gorm.DB) { + t.Helper() + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + for _, id := range []string{"dup-a", "dup-b"} { + row := &meta.Model{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: id, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Partner", + Application: "partner", + Path: "/" + id + ".ts", + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(row).Error; err != nil { + t.Fatalf("seed duplicate %s: %v", id, err) + } + } +} + func TestModuleManagerListInstalledNonWebApps(t *testing.T) { db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } for _, row := range []meta.Module{ @@ -63,7 +162,7 @@ func TestModuleManagerListInstalledNonWebApps(t *testing.T) { func TestModuleManagerListInstalledNonWebAppsQueryError(t *testing.T) { db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } if err := db.Migrator().DropTable(&meta.Module{}); err != nil { @@ -81,7 +180,7 @@ func TestModuleManagerListInstalledNonWebAppsQueryError(t *testing.T) { func TestModuleOpCtxBinderDelegatesToCtxMethods(t *testing.T) { db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } diff --git a/internal/module/lifecycle/modulemanager_fastfail_test.go b/internal/module/lifecycle/modulemanager_fastfail_test.go index 22b7a8567..502edc56b 100644 --- a/internal/module/lifecycle/modulemanager_fastfail_test.go +++ b/internal/module/lifecycle/modulemanager_fastfail_test.go @@ -9,9 +9,11 @@ import ( "strings" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" statepkg "github.com/choysum-dev/choysum/pkg/state" + ) type fastFailOriginCoordinator struct { @@ -68,7 +70,7 @@ func TestModuleManagerUninstallFastFailWhenModuleMissing(t *testing.T) { manager := NewModuleManager(runtimeScope, nil, WithLockerFactory(func(scope.Scope) statepkg.Locker { return locker })) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } manager.bootstrapOnce.Do(func() {}) @@ -104,7 +106,7 @@ func TestModuleManagerUpgradeFastFailWhenLocalModuleMissing(t *testing.T) { manager := NewModuleManager(runtimeScope, nil, WithLockerFactory(func(scope.Scope) statepkg.Locker { return locker })) - if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } manager.bootstrapOnce.Do(func() {}) diff --git a/internal/module/lifecycle/uninstaller.go b/internal/module/lifecycle/uninstaller.go index 3f788c60f..60f0ae38c 100644 --- a/internal/module/lifecycle/uninstaller.go +++ b/internal/module/lifecycle/uninstaller.go @@ -10,7 +10,7 @@ import ( "time" "github.com/choysum-dev/choysum/internal/module/evolution/hooks" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/module/plan" "github.com/choysum-dev/choysum/pkg/jsengine" "github.com/choysum-dev/choysum/pkg/meta" @@ -53,12 +53,6 @@ func (m *moduleUninstaller) cleanModels() error { return xfmt.Errorf("error updating module status: %w", err) } - // Collect logical names from declaration-layer rows before hard-delete (align with persist). - decls, err := meta.ListDeclarations(db.DB, meta.DeclarationQuery{ModuleID: moduleID}) - if err != nil { - return xfmt.Errorf("error listing raw models for uninstall: %w", err) - } - componentIDs := db.Model(&meta.Component{}).Select("id").Where("module_id = ?", moduleID) uiResourceIDs := db.Model(&meta.UiResource{}).Select("id").Where("module_id = ?", moduleID) // Component decorators still live on the shared meta_decorator table. @@ -67,20 +61,13 @@ func (m *moduleUninstaller) cleanModels() error { componentIDs, ) - if err := meta.DeleteRawModelsForModule(db.DB, moduleID); err != nil { - return xfmt.Errorf("error deleting raw models: %w", err) - } - - // Rebuild effective projections for touched logical names (EDS-2; no tip rebind). - keys := make([]meta.LogicalKey, 0, len(decls)) - for _, d := range decls { - if d == nil { - continue - } - keys = append(keys, meta.LogicalKey{Application: d.Application, Name: d.Name}) + // Remove declaration trees then rebuild effective projections (symmetric with Replace+Flush). + keys, err := modmeta.RemoveModuleDeclarations(db.DB, moduleID) + if err != nil { + return xfmt.Errorf("error removing module declarations: %w", err) } - if err := meta.RecomputeKeys(db.DB, keys); err != nil { - return xfmt.Errorf("error recomputing effective models after uninstall: %w", err) + if err := modmeta.FlushEffective(db.DB, keys); err != nil { + return xfmt.Errorf("error flushing effective models after uninstall: %w", err) } if err := db.Where("decorator_id IN (?)", componentDecoratorIDs).Delete(&meta.Argument{}).Error; err != nil { @@ -114,8 +101,8 @@ func (m *moduleUninstaller) cleanModels() error { // Soft-delete would leave (module, name) unique-index collisions that block reinstall // (loader scoped First misses soft-deleted rows, then Create fails). Do not cascade- // delete the business seed rows those mappings point at (V1). - if db.Migrator().HasTable((&metadata.ModelData{}).TableName()) { - if err := db.Unscoped().Where("module = ?", m.module.Name).Delete(&metadata.ModelData{}).Error; err != nil { + if db.Migrator().HasTable((&modmeta.ModelData{}).TableName()) { + if err := db.Unscoped().Where("module = ?", m.module.Name).Delete(&modmeta.ModelData{}).Error; err != nil { return xfmt.Errorf("error deleting meta model data mappings: %w", err) } } diff --git a/internal/module/lifecycle/uninstaller_clean_models_test.go b/internal/module/lifecycle/uninstaller_clean_models_test.go index bf831897e..69bf4b988 100644 --- a/internal/module/lifecycle/uninstaller_clean_models_test.go +++ b/internal/module/lifecycle/uninstaller_clean_models_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/rs/xid" "gorm.io/gorm" @@ -49,7 +50,7 @@ func seedCleanModelsFixture(t *testing.T, db *gorm.DB) cleanModelsSeed { rawArgumentID := xid.New().String() rawTypeParamID := xid.New().String() rawParameterID := xid.New().String() - if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + tree := &meta.Model{ BaseModel: meta.BaseModel{Id: sql.NullString{String: rawModelID, Valid: true}}, Name: "demo.model", Path: "demo/model", @@ -80,7 +81,8 @@ func seedCleanModelsFixture(t *testing.T, db *gorm.DB) cleanModelsSeed { Value: "true", }}, }}, - }); err != nil { + } + if _, err := modmeta.ReplaceModuleDeclarations(db, mod.Id.String, []*meta.Model{tree}); err != nil { t.Fatalf("persist raw model tree: %v", err) } @@ -187,18 +189,18 @@ func deleteRawRow(t *testing.T, db *gorm.DB, table, id string) { func deleteRawCleanModelsPrefix(t *testing.T, db *gorm.DB, seed cleanModelsSeed) { t.Helper() - deleteRawRow(t, db, meta.RawArgumentTable, seed.rawArgumentID) - deleteRawRow(t, db, meta.RawDecoratorTable, seed.rawDecoratorID) - deleteRawRow(t, db, meta.RawTypeParameterTable, seed.rawTypeParamID) - deleteRawRow(t, db, meta.RawParameterTable, seed.rawParameterID) + deleteRawRow(t, db, "meta_raw_argument", seed.rawArgumentID) + deleteRawRow(t, db, "meta_raw_decorator", seed.rawDecoratorID) + deleteRawRow(t, db, "meta_raw_type_parameter", seed.rawTypeParamID) + deleteRawRow(t, db, "meta_raw_parameter", seed.rawParameterID) } func deleteRawModelTree(t *testing.T, db *gorm.DB, seed cleanModelsSeed) { t.Helper() deleteRawCleanModelsPrefix(t, db, seed) - deleteRawRow(t, db, meta.RawServiceTable, seed.rawServiceID) - deleteRawRow(t, db, meta.RawFieldTable, seed.rawFieldID) - deleteRawRow(t, db, meta.RawModelTable, seed.rawModelID) + deleteRawRow(t, db, "meta_raw_service", seed.rawServiceID) + deleteRawRow(t, db, "meta_raw_field", seed.rawFieldID) + deleteRawRow(t, db, "meta_raw_model", seed.rawModelID) } func TestModuleUninstallerCleanModelsErrorPaths(t *testing.T) { @@ -223,9 +225,9 @@ END`).Error; err != nil { }, { name: "raw models", - wantMsg: "error deleting raw models", + wantMsg: "error removing module declarations", setup: func(t *testing.T, db *gorm.DB, _ cleanModelsSeed) { - dropMetaTable(t, db, meta.RawArgumentTable) + dropMetaTable(t, db, "meta_raw_argument") }, }, { @@ -249,9 +251,23 @@ END`).Error; err != nil { }, { name: "recompute effective", - wantMsg: "error recomputing effective models after uninstall", + wantMsg: "error flushing effective models after uninstall", setup: func(t *testing.T, db *gorm.DB, seed cleanModelsSeed) { - dropMetaTable(t, db, "meta_model") + // Materialize effective so Flush has a tree to delete; block hard deletes to fail Flush. + if err := modmeta.FlushEffective(db, []modmeta.LogicalKey{{ + Application: "demo", + Name: "demo.model", + }}); err != nil { + t.Fatalf("seed effective projection: %v", err) + } + if err := db.Exec(` +CREATE TRIGGER block_meta_model_delete +BEFORE DELETE ON meta_model +BEGIN + SELECT RAISE(ABORT, 'flush blocked'); +END`).Error; err != nil { + t.Fatalf("create meta_model delete trigger: %v", err) + } }, }, { diff --git a/internal/module/lifecycle/uninstaller_model_data_test.go b/internal/module/lifecycle/uninstaller_model_data_test.go index 37ed15ed3..ef5ab6344 100644 --- a/internal/module/lifecycle/uninstaller_model_data_test.go +++ b/internal/module/lifecycle/uninstaller_model_data_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "github.com/rs/xid" ) @@ -16,7 +16,7 @@ import ( func TestModuleUninstallerCleanModelsClearsMetaModelDataOnly(t *testing.T) { runtimeScope := newLifecycleCommitTestScope(t) db := runtimeScope.Session().DB - if err := db.AutoMigrate(&metadata.ModelData{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModelData{}); err != nil { t.Fatalf("AutoMigrate ModelData: %v", err) } @@ -39,10 +39,10 @@ func TestModuleUninstallerCleanModelsClearsMetaModelDataOnly(t *testing.T) { t.Fatalf("insert other business row: %v", err) } - demoMapping := &metadata.ModelData{ + demoMapping := &modmeta.ModelData{ Module: seed.module.Name, Name: "seed_row", Application: "demo", ModelName: "Item", ModelId: xid.New().String(), ResID: resID, } - otherMapping := &metadata.ModelData{ + otherMapping := &modmeta.ModelData{ Module: otherModule.Name, Name: "other_row", Application: "other", ModelName: "Item", ModelId: xid.New().String(), ResID: otherResID, } if err := db.Create(demoMapping).Error; err != nil { @@ -63,14 +63,14 @@ func TestModuleUninstallerCleanModelsClearsMetaModelDataOnly(t *testing.T) { } var remaining int64 - if err := db.Model(&metadata.ModelData{}).Where("module = ?", seed.module.Name).Count(&remaining).Error; err != nil { + if err := db.Model(&modmeta.ModelData{}).Where("module = ?", seed.module.Name).Count(&remaining).Error; err != nil { t.Fatalf("count demo mappings: %v", err) } if remaining != 0 { t.Fatalf("demo mappings remaining = %d, want 0", remaining) } - var other metadata.ModelData + var other modmeta.ModelData if err := db.Where("module = ? AND name = ?", otherModule.Name, "other_row").First(&other).Error; err != nil { t.Fatalf("other module mapping should remain: %v", err) } @@ -79,7 +79,7 @@ func TestModuleUninstallerCleanModelsClearsMetaModelDataOnly(t *testing.T) { } var hardDeleted int64 - if err := db.Unscoped().Model(&metadata.ModelData{}).Where("module = ? AND name = ?", seed.module.Name, "seed_row").Count(&hardDeleted).Error; err != nil { + if err := db.Unscoped().Model(&modmeta.ModelData{}).Where("module = ? AND name = ?", seed.module.Name, "seed_row").Count(&hardDeleted).Error; err != nil { t.Fatalf("count hard-deleted mapping: %v", err) } if hardDeleted != 0 { @@ -95,7 +95,7 @@ func TestModuleUninstallerCleanModelsClearsMetaModelDataOnly(t *testing.T) { } // Reinstall must be able to recreate the same (module, name) key after cleanup. - if err := db.Create(&metadata.ModelData{ + if err := db.Create(&modmeta.ModelData{ Module: seed.module.Name, Name: "seed_row", Application: "demo", ModelName: "Item", ModelId: xid.New().String(), ResID: xid.New().String(), }).Error; err != nil { t.Fatalf("recreate mapping after uninstall cleanup: %v", err) @@ -105,8 +105,8 @@ func TestModuleUninstallerCleanModelsClearsMetaModelDataOnly(t *testing.T) { func TestModuleUninstallerCleanModelsRecomputesAfterExtRawDelete(t *testing.T) { runtimeScope := newLifecycleCommitTestScope(t) db := runtimeScope.Session().DB - if err := meta.EnsureDualStoreTables(db); err != nil { - t.Fatalf("EnsureDualStoreTables: %v", err) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("AutoMigrate CatalogEntities: %v", err) } baseMod := &meta.Module{Name: "partner", Status: meta.Installed, Version: "1.0.0"} @@ -122,18 +122,18 @@ func TestModuleUninstallerCleanModelsRecomputesAfterExtRawDelete(t *testing.T) { basePath := "@/partner/service/models/partner.ts" baseRawID := xid.New().String() - if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + if _, err := modmeta.ReplaceModuleDeclarations(db, baseMod.Id.String, []*meta.Model{{ BaseModel: meta.BaseModel{Id: sql.NullString{String: baseRawID, Valid: true}}, Name: "Partner", Path: basePath, Application: "partner", ModelTable: "partner_partner", ModuleId: baseMod.Id, - }); err != nil { + }}); err != nil { t.Fatalf("create base raw model: %v", err) } extRawID := xid.New().String() - if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + if _, err := modmeta.ReplaceModuleDeclarations(db, extMod.Id.String, []*meta.Model{{ BaseModel: meta.BaseModel{Id: sql.NullString{String: extRawID, Valid: true}}, Name: "Partner", Path: "@/partner_commercial/service/models/partner.ts", @@ -141,7 +141,7 @@ func TestModuleUninstallerCleanModelsRecomputesAfterExtRawDelete(t *testing.T) { ModelTable: "partner_partner", ModuleId: extMod.Id, Extends: basePath, - }); err != nil { + }}); err != nil { t.Fatalf("create ext raw model: %v", err) } @@ -155,16 +155,16 @@ func TestModuleUninstallerCleanModelsRecomputesAfterExtRawDelete(t *testing.T) { t.Fatalf("cleanModels() error = %v", err) } - extCount, err := meta.CountRawModelsByID(db, extRawID, true) - if err != nil { + var extCount int64 + if err := db.Unscoped().Table("meta_raw_model").Where("id = ?", extRawID).Count(&extCount).Error; err != nil { t.Fatalf("count ext raw: %v", err) } if extCount != 0 { t.Fatalf("ext raw model should be hard-deleted, count=%d", extCount) } - baseRawCount, err := meta.CountRawModelsByID(db, baseRawID, false) - if err != nil { + var baseRawCount int64 + if err := db.Table("meta_raw_model").Where("id = ?", baseRawID).Count(&baseRawCount).Error; err != nil { t.Fatalf("count base raw: %v", err) } if baseRawCount != 1 { @@ -188,11 +188,11 @@ func TestModuleUninstallerCleanModelsRecomputesAfterExtRawDelete(t *testing.T) { func TestModuleUninstallerCleanModelsMetaModelDataError(t *testing.T) { runtimeScope := newLifecycleCommitTestScope(t) db := runtimeScope.Session().DB - if err := db.AutoMigrate(&metadata.ModelData{}); err != nil { + if err := db.AutoMigrate(&modmeta.ModelData{}); err != nil { t.Fatalf("AutoMigrate ModelData: %v", err) } seed := seedCleanModelsFixture(t, db) - if err := db.Create(&metadata.ModelData{ + if err := db.Create(&modmeta.ModelData{ Module: seed.module.Name, Name: "seed_row", Application: "demo", ModelName: "Item", ModelId: xid.New().String(), ResID: xid.New().String(), }).Error; err != nil { t.Fatalf("create mapping: %v", err) @@ -223,7 +223,7 @@ func TestModuleUninstallerCleanModelsListingRawModelsError(t *testing.T) { runtimeScope := newLifecycleCommitTestScope(t) db := runtimeScope.Session().DB seed := seedCleanModelsFixture(t, db) - dropMetaTable(t, db, meta.RawModelTable) + dropMetaTable(t, db, "meta_raw_model") uninstaller := &moduleUninstaller{ runtimeScope: runtimeScope, @@ -232,7 +232,7 @@ func TestModuleUninstallerCleanModelsListingRawModelsError(t *testing.T) { ctx: newOpContext(), } err := uninstaller.cleanModels() - if err == nil || !strings.Contains(err.Error(), "error listing raw models for uninstall") { - t.Fatalf("cleanModels() error = %v, want listing raw models failure", err) + if err == nil || !strings.Contains(err.Error(), "error removing module declarations") { + t.Fatalf("cleanModels() error = %v, want removing module declarations failure", err) } } diff --git a/internal/module/lifecycle/upgrade_uninstall_commit_test.go b/internal/module/lifecycle/upgrade_uninstall_commit_test.go index 508d3664e..7d01aa416 100644 --- a/internal/module/lifecycle/upgrade_uninstall_commit_test.go +++ b/internal/module/lifecycle/upgrade_uninstall_commit_test.go @@ -13,7 +13,7 @@ import ( "testing" "github.com/choysum-dev/choysum/internal/defaultscope" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/internal/testing/scopetest" "github.com/choysum-dev/choysum/pkg/config" "github.com/choysum-dev/choysum/pkg/meta" @@ -35,10 +35,10 @@ func newLifecycleCommitTestScope(t *testing.T) scope.Scope { scopetest.FactoryInputFromConfig(cfg), logger, ) - if err := runtimeScope.Session().AutoMigrate(meta.CatalogEntities()...); err != nil { + if err := runtimeScope.Session().AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("AutoMigrate: %v", err) } - if err := runtimeScope.Session().AutoMigrate(&metadata.ModelData{}); err != nil { + if err := runtimeScope.Session().AutoMigrate(&modmeta.ModelData{}); err != nil { t.Fatalf("AutoMigrate ModelData: %v", err) } return runtimeScope diff --git a/internal/module/meta/declaration.go b/internal/module/meta/declaration.go new file mode 100644 index 000000000..32ba3302e --- /dev/null +++ b/internal/module/meta/declaration.go @@ -0,0 +1,281 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +import ( + "database/sql" + "fmt" + "strings" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/gorm" +) + +// DeclarationQuery filters declaration-layer (meta_raw_*) rows for ListDeclarations. +// Callers receive []*pkgmeta.Model; Path / ModuleId / Id are preserved from raw. +type DeclarationQuery struct { + Application string + Name string + ModuleID string + Path string + Abstract *bool + // PreloadTree loads Fields / Services / Decorators trees (DDL, merge). + PreloadTree bool +} + +// ListDeclarations loads declaration rows matching q and returns them as pkgmeta.Model trees. +func ListDeclarations(db *gorm.DB, q DeclarationQuery) ([]*pkgmeta.Model, error) { + if db == nil { + return nil, fmt.Errorf("db is nil") + } + query := db.Model(&rawModel{}) + if app := strings.TrimSpace(q.Application); app != "" { + query = query.Where("application = ?", app) + } + if name := strings.TrimSpace(q.Name); name != "" { + query = query.Where("name = ?", name) + } + if moduleID := strings.TrimSpace(q.ModuleID); moduleID != "" { + query = query.Where("module_id = ?", moduleID) + } + if path := strings.TrimSpace(q.Path); path != "" { + query = query.Where("path = ?", path) + } + if q.Abstract != nil { + query = query.Where("abstract = ?", *q.Abstract) + } + if q.PreloadTree { + orderID := func(tx *gorm.DB) *gorm.DB { return tx.Order("id ASC") } + query = query. + Preload("Fields", orderID). + Preload("Fields.Decorators", orderID). + Preload("Fields.Decorators.Arguments", orderID). + Preload("Services", orderID). + Preload("Services.Parameters", orderID). + Preload("Services.TypeParameters", orderID). + Preload("Services.Decorators", orderID). + Preload("Services.Decorators.Arguments", orderID). + Preload("Decorators", orderID). + Preload("Decorators.Arguments", orderID) + } + + var raws []*rawModel + if err := query.Order("id DESC").Find(&raws).Error; err != nil { + return nil, fmt.Errorf("list declarations: %w", err) + } + return rawModelsAsModels(raws), nil +} + +// DeleteDeclarationTrees hard-deletes raw catalog trees for the given model ids +// (cascade: arguments → decorators → type/params → services → fields → models). +func DeleteDeclarationTrees(db *gorm.DB, modelIDs []string) error { + if db == nil { + return fmt.Errorf("db is nil") + } + ids := make([]string, 0, len(modelIDs)) + seen := map[string]struct{}{} + for _, id := range modelIDs { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return nil + } + + return db.Transaction(func(tx *gorm.DB) error { + fresh := func() *gorm.DB { return tx.Session(&gorm.Session{NewDB: true}).Unscoped() } + + var serviceIDs []string + if err := fresh().Model(&rawService{}).Where("model_id IN ?", ids).Pluck("id", &serviceIDs).Error; err != nil { + return fmt.Errorf("load declaration services: %w", err) + } + var fieldIDs []string + if err := fresh().Model(&rawField{}).Where("model_id IN ?", ids).Pluck("id", &fieldIDs).Error; err != nil { + return fmt.Errorf("load declaration fields: %w", err) + } + decoratorQ := fresh().Model(&rawDecorator{}).Where("model_id IN ?", ids) + if len(serviceIDs) > 0 { + decoratorQ = decoratorQ.Or("service_id IN ?", serviceIDs) + } + if len(fieldIDs) > 0 { + decoratorQ = decoratorQ.Or("field_id IN ?", fieldIDs) + } + var decoratorIDs []string + if err := decoratorQ.Pluck("id", &decoratorIDs).Error; err != nil { + return fmt.Errorf("load declaration decorators: %w", err) + } + + if len(decoratorIDs) > 0 { + if err := deleteWhereFn(fresh(), &rawArgument{}, "decorator_id IN ?", decoratorIDs); err != nil { + return fmt.Errorf("delete declaration arguments: %w", err) + } + if err := deleteWhereFn(fresh(), &rawDecorator{}, "id IN ?", decoratorIDs); err != nil { + return fmt.Errorf("delete declaration decorators: %w", err) + } + } + if len(serviceIDs) > 0 { + if err := deleteWhereFn(fresh(), &rawTypeParameter{}, "service_id IN ?", serviceIDs); err != nil { + return fmt.Errorf("delete declaration type parameters: %w", err) + } + if err := deleteWhereFn(fresh(), &rawParameter{}, "service_id IN ?", serviceIDs); err != nil { + return fmt.Errorf("delete declaration parameters: %w", err) + } + if err := deleteWhereFn(fresh(), &rawService{}, "id IN ?", serviceIDs); err != nil { + return fmt.Errorf("delete declaration services: %w", err) + } + } + if len(fieldIDs) > 0 { + if err := deleteWhereFn(fresh(), &rawField{}, "id IN ?", fieldIDs); err != nil { + return fmt.Errorf("delete declaration fields: %w", err) + } + } + if err := deleteWhereFn(fresh(), &rawModel{}, "id IN ?", ids); err != nil { + return fmt.Errorf("delete declaration models: %w", err) + } + return nil + }) +} + +// RemoveModuleDeclarations hard-deletes all declaration trees for moduleID and returns +// the logical keys that must be flushed afterward. Does not call FlushEffective. +// Keys include both live raw declarations and any legacy effective rows still tagged +// with moduleID so FlushEffective can clear shells that have no remaining raw. +func RemoveModuleDeclarations(db *gorm.DB, moduleID string) ([]LogicalKey, error) { + moduleID = strings.TrimSpace(moduleID) + if moduleID == "" { + return nil, nil + } + if db == nil { + return nil, fmt.Errorf("db is nil") + } + + keys := make([]LogicalKey, 0) + seen := map[string]struct{}{} + appendKey := func(application, name string) { + k := LogicalKey{Application: application, Name: name}.Normalized() + if !k.Valid() { + return + } + id := k.Application + "\x00" + k.Name + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + keys = append(keys, k) + } + + decls, err := ListDeclarations(db, DeclarationQuery{ModuleID: moduleID}) + if err != nil { + return nil, fmt.Errorf("list previous raw models: %w", err) + } + for _, row := range decls { + appendKey(row.Application, row.Name) + } + + if err := appendEffectiveKeysForModule(db, moduleID, appendKey); err != nil { + return nil, err + } + + if err := deleteRawModelsForModule(db, moduleID); err != nil { + return nil, fmt.Errorf("delete raw models: %w", err) + } + return keys, nil +} + +// ReplaceModuleDeclarations rewrites all declaration trees for moduleID from models +// and returns the logical keys that must be flushed afterward. +// +// Side effect: each non-nil model with a non-empty Path has ModuleId set to moduleID +// before persist (callers that share those pointers observe the mutation). +func ReplaceModuleDeclarations(db *gorm.DB, moduleID string, models []*pkgmeta.Model) ([]LogicalKey, error) { + moduleID = strings.TrimSpace(moduleID) + if moduleID == "" { + return nil, nil + } + if db == nil { + return nil, fmt.Errorf("db is nil") + } + + orderedPaths := make([]string, 0, len(models)) + modelByPath := make(map[string]*pkgmeta.Model, len(models)) + for _, m := range models { + if m == nil { + continue + } + path := strings.TrimSpace(m.Path) + if path == "" { + continue + } + if _, exists := modelByPath[path]; !exists { + orderedPaths = append(orderedPaths, path) + } + modelByPath[path] = m + } + + keys := make([]LogicalKey, 0) + appendKey := func(application, name string) { + k := LogicalKey{Application: application, Name: name}.Normalized() + if k.Valid() { + keys = append(keys, k) + } + } + + prevDecls, err := ListDeclarations(db, DeclarationQuery{ModuleID: moduleID}) + if err != nil { + return nil, fmt.Errorf("list previous raw models: %w", err) + } + for _, row := range prevDecls { + appendKey(row.Application, row.Name) + } + + if err := appendEffectiveKeysForModule(db, moduleID, appendKey); err != nil { + return nil, err + } + + for _, path := range orderedPaths { + m := modelByPath[path] + m.ModuleId = sql.NullString{String: moduleID, Valid: true} + appendKey(m.Application, m.Name) + } + + err = db.Transaction(func(tx *gorm.DB) error { + if err := deleteRawModelsForModule(tx, moduleID); err != nil { + return fmt.Errorf("delete previous raw models: %w", err) + } + for _, path := range orderedPaths { + m := modelByPath[path] + if err := persistModelTreeAsRaw(tx, m); err != nil { + return fmt.Errorf("persist raw model %s: %w", path, err) + } + } + return nil + }) + if err != nil { + return nil, err + } + return keys, nil +} + +// appendEffectiveKeysForModule collects (application, name) from live effective rows +// still tagged with moduleID (legacy shells) and feeds them to appendKey. +func appendEffectiveKeysForModule(db *gorm.DB, moduleID string, appendKey func(application, name string)) error { + var prevEff []pkgmeta.Model + if err := db.Model(&pkgmeta.Model{}). + Select("id, application, name"). + Where("module_id = ?", moduleID). + Find(&prevEff).Error; err != nil { + return fmt.Errorf("list previous effective models: %w", err) + } + for _, row := range prevEff { + appendKey(row.Application, row.Name) + } + return nil +} diff --git a/internal/module/meta/declaration_coverage_test.go b/internal/module/meta/declaration_coverage_test.go new file mode 100644 index 000000000..019d6d95f --- /dev/null +++ b/internal/module/meta/declaration_coverage_test.go @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func openDeclarationTestDB(t *testing.T, name string) *gorm.DB { + t.Helper() + dsn := filepath.Join(t.TempDir(), name+".sqlite") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + if err := ensureDualStoreTables(db); err != nil { + t.Fatal(err) + } + return db +} + +func TestReplaceModuleDeclarations_GuardsAndErrors(t *testing.T) { + keys, err := ReplaceModuleDeclarations(nil, " ", nil) + if err != nil || keys != nil { + t.Fatalf("empty moduleID: %v %#v", err, keys) + } + if _, err := ReplaceModuleDeclarations(nil, "mod", nil); err == nil || !strings.Contains(err.Error(), "db is nil") { + t.Fatalf("nil db: %v", err) + } + + db := openDeclarationTestDB(t, "replace-guards") + keys, err = ReplaceModuleDeclarations(db, "mod-1", []*pkgmeta.Model{ + nil, + {Name: "NoPath", Path: " ", Application: "a"}, + {Name: "A", Path: "/a.ts", Application: "a"}, + {Name: "A2", Path: "/a.ts", Application: "a"}, + }) + if err != nil { + t.Fatal(err) + } + if len(keys) == 0 { + t.Fatal("expected keys") + } + + if err := dropRawModelTable(db); err != nil { + t.Fatal(err) + } + if _, err := ReplaceModuleDeclarations(db, "mod-1", nil); err == nil { + t.Fatal("expected list previous raw error") + } + + db2 := openDeclarationTestDB(t, "replace-eff-err") + if err := db2.Migrator().DropTable(&pkgmeta.Model{}); err != nil { + t.Fatal(err) + } + if _, err := ReplaceModuleDeclarations(db2, "mod-2", nil); err == nil { + t.Fatal("expected list previous effective error") + } + + db3 := openDeclarationTestDB(t, "replace-del-err") + if err := persistModelTreeAsRaw(db3, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "p1", Valid: true}}, + Name: "P", + Path: "/p.ts", + Application: "a", + ModuleId: sql.NullString{String: "mod-3", Valid: true}, + }); err != nil { + t.Fatal(err) + } + if err := db3.Migrator().DropTable(rawServiceTable); err != nil { + t.Fatal(err) + } + if _, err := ReplaceModuleDeclarations(db3, "mod-3", []*pkgmeta.Model{ + {Name: "N", Path: "/n.ts", Application: "a"}, + }); err == nil { + t.Fatal("expected delete previous raw error") + } +} + +func TestReplaceModuleDeclarations_PrevEffectiveKeys(t *testing.T) { + db := openDeclarationTestDB(t, "replace-eff-keys") + modID := "mod-eff" + eff := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "eff-1", Valid: true}}, + Name: "OldEff", + Path: "/old.ts", + Application: "partner", + ModuleId: sql.NullString{String: modID, Valid: true}, + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&eff).Error; err != nil { + t.Fatal(err) + } + keys, err := ReplaceModuleDeclarations(db, modID, []*pkgmeta.Model{ + {Name: "New", Path: "/new.ts", Application: "partner"}, + }) + if err != nil { + t.Fatal(err) + } + found := false + for _, k := range keys { + if k.Name == "OldEff" && k.Application == "partner" { + found = true + } + } + if !found { + t.Fatalf("expected previous effective key, got %#v", keys) + } +} + +func TestReplaceModuleDeclarations_NilPrevDeclRow(t *testing.T) { + db := openDeclarationTestDB(t, "replace-nil-row") + // Cover appendKey invalid key (empty application/name) from prev effective zero pkgmeta.Model. + modID := "mod-zero" + eff := pkgmeta.Model{ModuleId: sql.NullString{String: modID, Valid: true}} + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&eff).Error; err != nil { + t.Fatal(err) + } + if _, err := ReplaceModuleDeclarations(db, modID, nil); err != nil { + t.Fatal(err) + } +} + +func TestListDeclarations_NilDBAndPathFilter(t *testing.T) { + if _, err := ListDeclarations(nil, DeclarationQuery{}); err == nil || !strings.Contains(err.Error(), "db is nil") { + t.Fatalf("nil db: %v", err) + } + + db := openDeclarationTestDB(t, "list-path") + modID := "mod-path" + if err := persistModelTreeAsRaw(db, &pkgmeta.Model{ + Name: "A", Path: "/only.ts", Application: "crm", + ModuleId: sql.NullString{String: modID, Valid: true}, + }); err != nil { + t.Fatal(err) + } + got, err := ListDeclarations(db, DeclarationQuery{Path: "/only.ts"}) + if err != nil || len(got) != 1 || got[0].Path != "/only.ts" { + t.Fatalf("path filter: n=%d err=%v got=%#v", len(got), err, got) + } + miss, err := ListDeclarations(db, DeclarationQuery{Path: "/missing.ts"}) + if err != nil || len(miss) != 0 { + t.Fatalf("missing path: n=%d err=%v", len(miss), err) + } +} + +func TestRemoveModuleDeclarations_GuardsAndErrors(t *testing.T) { + db := openDeclarationTestDB(t, "remove-guards") + modID := "mod-rm" + if err := persistModelTreeAsRaw(db, &pkgmeta.Model{ + Name: "Dup", Path: "/a.ts", Application: "crm", + ModuleId: sql.NullString{String: modID, Valid: true}, + }); err != nil { + t.Fatal(err) + } + if err := persistModelTreeAsRaw(db, &pkgmeta.Model{ + Name: "Dup", Path: "/b.ts", Application: "crm", + ModuleId: sql.NullString{String: modID, Valid: true}, + }); err != nil { + t.Fatal(err) + } + // Invalid key from effective shell with empty application/name is skipped. + eff := pkgmeta.Model{ModuleId: sql.NullString{String: modID, Valid: true}} + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&eff).Error; err != nil { + t.Fatal(err) + } + keys, err := RemoveModuleDeclarations(db, modID) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0].Application != "crm" || keys[0].Name != "Dup" { + t.Fatalf("expected deduped key, got %#v", keys) + } + + db2 := openDeclarationTestDB(t, "remove-list-err") + if err := dropRawModelTable(db2); err != nil { + t.Fatal(err) + } + if _, err := RemoveModuleDeclarations(db2, "mod-x"); err == nil || !strings.Contains(err.Error(), "list previous raw models") { + t.Fatalf("expected list previous raw error, got %v", err) + } + + db3 := openDeclarationTestDB(t, "remove-eff-err") + if err := db3.Migrator().DropTable(&pkgmeta.Model{}); err != nil { + t.Fatal(err) + } + if _, err := RemoveModuleDeclarations(db3, "mod-y"); err == nil || !strings.Contains(err.Error(), "list previous effective models") { + t.Fatalf("expected list previous effective error, got %v", err) + } + + db4 := openDeclarationTestDB(t, "remove-del-err") + if err := persistModelTreeAsRaw(db4, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "p1", Valid: true}}, + Name: "P", + Path: "/p.ts", + Application: "a", + ModuleId: sql.NullString{String: "mod-z", Valid: true}, + }); err != nil { + t.Fatal(err) + } + if err := db4.Migrator().DropTable(rawServiceTable); err != nil { + t.Fatal(err) + } + if _, err := RemoveModuleDeclarations(db4, "mod-z"); err == nil || !strings.Contains(err.Error(), "delete raw models") { + t.Fatalf("expected delete raw models error, got %v", err) + } +} diff --git a/internal/module/meta/declaration_test.go b/internal/module/meta/declaration_test.go new file mode 100644 index 000000000..84eb78dc4 --- /dev/null +++ b/internal/module/meta/declaration_test.go @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +import ( + "database/sql" + "strings" + "testing" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" + "github.com/rs/xid" + "gorm.io/gorm" +) + +func TestListDeclarations_FiltersAndPreload(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + falseVal := false + mod := xid.New().String() + if err := persistModelTreeAsRaw(db, &pkgmeta.Model{ + Name: "AppSetting", Path: "/a/virt", Application: "crm", Abstract: false, AutoMigrate: &falseVal, + ModuleId: sql.NullString{String: mod, Valid: true}, + Fields: []*pkgmeta.Field{{Name: "Key"}}, + }); err != nil { + t.Fatal(err) + } + if err := persistModelTreeAsRaw(db, &pkgmeta.Model{ + Name: "AppSetting", Path: "/a/hand", Application: "crm", Abstract: false, + ModuleId: sql.NullString{String: mod, Valid: true}, + }); err != nil { + t.Fatal(err) + } + if err := persistModelTreeAsRaw(db, &pkgmeta.Model{ + Name: "Other", Path: "/o", Application: "crm", Abstract: true, + }); err != nil { + t.Fatal(err) + } + + absFalse := false + got, err := ListDeclarations(db, DeclarationQuery{ + Application: "crm", Name: "AppSetting", Abstract: &absFalse, PreloadTree: true, + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d, want 2", len(got)) + } + var sawField bool + for _, m := range got { + if m.Path == "/a/virt" && len(m.Fields) == 1 && m.Fields[0].Name == "Key" { + sawField = true + } + } + if !sawField { + t.Fatalf("expected preloaded field on virt: %+v", got) + } + + byMod, err := ListDeclarations(db, DeclarationQuery{ModuleID: mod}) + if err != nil || len(byMod) != 2 { + t.Fatalf("by module: n=%d err=%v", len(byMod), err) + } +} + +func TestDeleteDeclarationTrees_Cascade(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + id := xid.New().String() + if err := persistModelTreeAsRaw(db, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: id, Valid: true}}, + Name: "X", Path: "/x", Application: "a", + Fields: []*pkgmeta.Field{{Name: "F"}}, + Services: []*pkgmeta.Service{{Name: "S", AccessibilityModifier: "public"}}, + }); err != nil { + t.Fatal(err) + } + if err := DeleteDeclarationTrees(db, []string{id, id, " ", ""}); err != nil { + t.Fatal(err) + } + var n int64 + if err := db.Unscoped().Model(&rawModel{}).Where("id = ?", id).Count(&n).Error; err != nil || n != 0 { + t.Fatalf("left=%d err=%v", n, err) + } + for name, dest := range map[string]any{"field": &rawField{}, "service": &rawService{}} { + var c int64 + if err := db.Unscoped().Model(dest).Where("model_id = ?", id).Count(&c).Error; err != nil || c != 0 { + t.Fatalf("%s rows left=%d err=%v", name, c, err) + } + } + if err := DeleteDeclarationTrees(nil, []string{"x"}); err == nil || !strings.Contains(err.Error(), "db is nil") { + t.Fatalf("nil db: %v", err) + } +} + +func TestReplaceModuleDeclarations(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatal(err) + } + + modID := "mod-1" + tree := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-partner", Valid: true}}, + Name: "Partner", + Path: "/m/partner/service/models/partner.ts", + Application: "partner", + ModuleId: sql.NullString{String: modID, Valid: true}, + } + keys, err := ReplaceModuleDeclarations(db, modID, []*pkgmeta.Model{tree}) + if err != nil { + t.Fatalf("ReplaceModuleDeclarations: %v", err) + } + if len(keys) == 0 { + t.Fatal("expected keys") + } + if err := FlushEffective(db, keys); err != nil { + t.Fatalf("flush partner: %v", err) + } + var n int64 + if err := db.Model(&pkgmeta.Model{}).Where("application = ? AND name = ?", "partner", "Partner").Count(&n).Error; err != nil || n != 1 { + t.Fatalf("effective Partner count=%d err=%v", n, err) + } +} + +func TestReplaceModuleDeclarations_RollsBackOnPersistFailure(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatal(err) + } + + modID := "mod-rollback" + prevPath := "/m/partner/service/models/old.ts" + prev := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-old", Valid: true}}, + Name: "OldPartner", + Path: prevPath, + Application: "partner", + ModuleId: sql.NullString{String: modID, Valid: true}, + } + if err := persistModelTreeAsRaw(db, prev); err != nil { + t.Fatalf("seed previous declaration: %v", err) + } + + _, err := ReplaceModuleDeclarations(db, modID, []*pkgmeta.Model{ + { + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dup-id", Valid: true}}, + Name: "First", + Path: "/m/partner/service/models/first.ts", + Application: "partner", + }, + { + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dup-id", Valid: true}}, + Name: "Second", + Path: "/m/partner/service/models/second.ts", + Application: "partner", + }, + }) + if err == nil { + t.Fatal("expected persist failure for duplicate id") + } + + decls, listErr := ListDeclarations(db, DeclarationQuery{ModuleID: modID}) + if listErr != nil { + t.Fatalf("list after failed replace: %v", listErr) + } + if len(decls) != 1 || decls[0] == nil || decls[0].Path != prevPath || decls[0].Name != "OldPartner" { + t.Fatalf("expected previous declaration retained, got %#v", decls) + } +} + +func TestRemoveModuleDeclarations(t *testing.T) { + db := openDeclarationTestDB(t, "remove-decls") + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + modID := "mod-remove" + if _, err := ReplaceModuleDeclarations(db, modID, []*pkgmeta.Model{{ + Name: "Partner", Path: "/models/partner", Application: "partner", + ModuleId: sql.NullString{String: modID, Valid: true}, + }}); err != nil { + t.Fatalf("seed: %v", err) + } + keys, err := RemoveModuleDeclarations(db, modID) + if err != nil { + t.Fatalf("RemoveModuleDeclarations: %v", err) + } + if len(keys) != 1 || keys[0].Application != "partner" || keys[0].Name != "Partner" { + t.Fatalf("unexpected keys: %#v", keys) + } + remaining, err := ListDeclarations(db, DeclarationQuery{ModuleID: modID}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(remaining) != 0 { + t.Fatalf("expected no declarations, got %#v", remaining) + } + // Empty / nil guards + if keys, err := RemoveModuleDeclarations(nil, modID); err == nil || keys != nil { + t.Fatalf("expected nil db error, got keys=%v err=%v", keys, err) + } + if keys, err := RemoveModuleDeclarations(db, ""); err != nil || keys != nil { + t.Fatalf("empty moduleID: keys=%v err=%v", keys, err) + } +} + +func TestRemoveModuleDeclarations_IncludesLegacyEffectiveKeys(t *testing.T) { + db := openDeclarationTestDB(t, "remove-eff-only") + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + modID := "mod-eff-only" + eff := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "eff-legacy", Valid: true}}, + Name: "LegacyShell", + Path: "/legacy.ts", + Application: "partner", + ModuleId: sql.NullString{String: modID, Valid: true}, + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&eff).Error; err != nil { + t.Fatalf("seed effective: %v", err) + } + keys, err := RemoveModuleDeclarations(db, modID) + if err != nil { + t.Fatalf("RemoveModuleDeclarations: %v", err) + } + found := false + for _, k := range keys { + if k.Application == "partner" && k.Name == "LegacyShell" { + found = true + } + } + if !found { + t.Fatalf("expected legacy effective key, got %#v", keys) + } +} diff --git a/pkg/meta/dual_store_migrate.go b/internal/module/meta/dual_store.go similarity index 58% rename from pkg/meta/dual_store_migrate.go rename to internal/module/meta/dual_store.go index e5c9ee949..5589c46c0 100644 --- a/pkg/meta/dual_store_migrate.go +++ b/internal/module/meta/dual_store.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "github.com/rs/xid" "gorm.io/gorm" ) @@ -22,170 +23,37 @@ var execDDL = func(db *gorm.DB, sql string) error { return db.Exec(sql).Error } -// Test hooks (production defaults). Override in *_test.go to force error paths. -var ( - countMetarawModels = func(db *gorm.DB) (int64, error) { - var n int64 - err := db.Unscoped().Model(&rawModel{}).Count(&n).Error - return n, err - } - loadIMDModelsForMigrate = func(tx *gorm.DB) ([]*Model, error) { - var sources []*Model - err := tx. - Preload("Fields"). - Preload("Fields.Decorators"). - Preload("Fields.Decorators.Arguments"). - Preload("Services"). - Preload("Services.Parameters"). - Preload("Services.TypeParameters"). - Preload("Services.Decorators"). - Preload("Services.Decorators.Arguments"). - Preload("Decorators"). - Preload("Decorators.Arguments"). - Find(&sources).Error - return sources, err - } - loadrawModelsForRecompute = func(tx *gorm.DB) ([]*rawModel, error) { - var raws []*rawModel - err := tx. - Preload("Fields"). - Preload("Fields.Decorators"). - Preload("Fields.Decorators.Arguments"). - Preload("Services"). - Preload("Services.Parameters"). - Preload("Services.TypeParameters"). - Preload("Services.Decorators"). - Preload("Services.Decorators.Arguments"). - Preload("Decorators"). - Preload("Decorators.Arguments"). - Find(&raws).Error - return raws, err - } - clearEffectiveShapeTreesFn = clearEffectiveShapeTrees - persistEffectiveProjectionFn = func(db *gorm.DB, merged *Model, effectiveID string) error { - return persistEffectiveProjection(db, merged, effectiveID, nil) - } - remapACLToEffectiveModelIDsFn = remapACLToEffectiveModelIDs -) - -// EnsureDualStoreTables creates raw + effective catalog tables (idempotent). -func EnsureDualStoreTables(db *gorm.DB) error { +// ensureDualStoreTables creates raw + effective catalog tables (idempotent) +// and ensures the live (application, name) unique index on meta_model (SPL12). +func ensureDualStoreTables(db *gorm.DB) error { if db == nil { return fmt.Errorf("db is nil") } - ents := append(DualStoreEffectiveEntities(), DualStoreRawEntities()...) + ents := append([]any{ + &pkgmeta.Model{}, + &pkgmeta.Field{}, + &pkgmeta.Service{}, + &pkgmeta.TypeParameter{}, + &pkgmeta.Parameter{}, + &pkgmeta.Decorator{}, + &pkgmeta.Argument{}, + }, DualStoreRawEntities()...) if err := db.AutoMigrate(ents...); err != nil { return fmt.Errorf("ensure dual-store catalog tables: %w", err) } - return nil -} - -// migrateIMDCatalogToDualStore copies today's live meta_model* IMD rows into meta_raw_*, -// then rebuilds meta_model* as E2 effective projections (one row per application+name). -// -// Intended for wipe/test and one-shot upgrades from legacy IMD catalogs. -// After EDS-2, Persist writes meta_raw_* and recomputes effective projections. -// -// Soft-deleted meta_model rows are skipped (default GORM scope). Materialized parent -// copies on source rows are preserved as-is (EDS-2 will stop writing them). Effective -// ids reuse the tip id (newest created_at, then id) when possible. An empty live -// catalog is a no-op (does not hard-delete existing effective rows). -// -// DDL (EnsureDualStoreTables + effective unique index) runs outside the transaction; -// copy + recompute run inside one transaction so failures roll back partial raw/effective -// writes. MySQL DDL would implicitly commit if run inside the transaction. -func migrateIMDCatalogToDualStore(db *gorm.DB) error { - if db == nil { - return fmt.Errorf("db is nil") - } - if err := EnsureDualStoreTables(db); err != nil { - return err - } - - rawCount, err := countMetarawModels(db) - if err != nil { - return fmt.Errorf("count meta_raw_model: %w", err) - } - if rawCount > 0 { - return fmt.Errorf("meta_raw_model already has %d rows (incl. soft-deleted); refuse migrate (wipe raw first)", rawCount) - } - - if err := db.Transaction(func(tx *gorm.DB) error { - sources, err := loadIMDModelsForMigrate(tx) - if err != nil { - return fmt.Errorf("load meta_model for dual-store migrate: %w", err) - } - - live := 0 - for _, src := range sources { - if src != nil { - live++ - } - } - // Empty live catalog: do not call recompute (it hard-deletes effective trees). - if live == 0 { - return nil - } - return migrateIMDSources(tx, sources) - }); err != nil { - return err - } if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { return err } - return remapACLToEffectiveModelIDsFn(db) -} - -// migrateIMDSources copies live IMD sources into meta_raw_* then rebuilds effective rows. -func migrateIMDSources(tx *gorm.DB, sources []*Model) error { - seenModulePath := map[string]string{} - for _, src := range sources { - if src == nil { - continue - } - if !src.ModuleId.Valid || strings.TrimSpace(src.ModuleId.String) == "" { - return fmt.Errorf("meta_model %s (%s/%s) missing module_id; required for meta_raw_model uniqueness", src.Id.String, src.Application, src.Name) - } - key := src.ModuleId.String + "\x00" + src.Path - if prev, ok := seenModulePath[key]; ok { - return fmt.Errorf("duplicate live (module_id, path) %q / %q (models %s and %s)", src.ModuleId.String, src.Path, prev, src.Id.String) - } - seenModulePath[key] = src.Id.String - } - - for _, src := range sources { - if src == nil { - continue - } - if err := copyModelTreeToRaw(tx, src); err != nil { - return err - } - } - - return recomputeAllEffectiveFromRawTx(tx) + return nil } -// recomputeAllEffectiveFromRaw hard-deletes all effective meta_model* shape rows, then -// rebuilds them from live meta_raw_* via E2. DML runs in a transaction; the effective -// unique-index DDL runs after commit. Callers with only a partial raw catalog will lose -// effective rows that have no raw counterpart. -func recomputeAllEffectiveFromRaw(db *gorm.DB) error { - if db == nil { - return fmt.Errorf("db is nil") - } - if err := db.Transaction(func(tx *gorm.DB) error { - return recomputeAllEffectiveFromRawTx(tx) - }); err != nil { - return err - } - if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { - return err - } - return remapACLToEffectiveModelIDsFn(db) +// EnsureEffectiveAppNameUniqueIndex enforces one live effective row per (application, name). +// Safe to call repeatedly after recomputes; call only when live duplicates are gone. +// Catalog AutoMigrate alone does not create this index — call after migrating CatalogEntities. +func EnsureEffectiveAppNameUniqueIndex(db *gorm.DB) error { + return ensureEffectiveAppNameUniqueIndex(db) } -// ensureEffectiveAppNameUniqueIndex enforces one live effective row per (application, name). -// Safe to call repeatedly after recomputes; call only when live duplicates are gone. func ensureEffectiveAppNameUniqueIndex(db *gorm.DB) error { if db == nil { return fmt.Errorf("db is nil") @@ -198,55 +66,14 @@ func ensureEffectiveAppNameUniqueIndex(db *gorm.DB) error { } } -func recomputeAllEffectiveFromRawTx(tx *gorm.DB) error { - raws, err := loadrawModelsForRecompute(tx) - if err != nil { - return fmt.Errorf("load meta_raw_model: %w", err) - } - - groups := map[string][]*rawModel{} - tipIDs := map[string]string{} - for _, raw := range raws { - key := logicalModelKey(raw.Application, raw.Name) - groups[key] = append(groups[key], raw) - prev := tipIDs[key] - if prev == "" || rawIsNewerTip(raw, findRawByID(groups[key], prev)) { - tipIDs[key] = raw.Id.String - } - } - - // Hard-delete (truncate) existing effective shape trees before rewrite. - if err := clearEffectiveShapeTreesFn(tx); err != nil { - return err - } - - for key, group := range groups { - merged, err := mergeEffectiveModel(group) - if err != nil { - return fmt.Errorf("E2 merge %s: %w", key, err) - } - effID := tipIDs[key] - if effID == "" { - effID = xid.New().String() - } - if err := persistEffectiveProjectionFn(tx, merged, effID); err != nil { - return fmt.Errorf("persist effective %s: %w", key, err) - } +func ensureBaseModelID(b *pkgmeta.BaseModel) { + if b == nil { + return } - return nil -} - -func logicalModelKey(application, name string) string { - return strings.TrimSpace(application) + "\x00" + strings.TrimSpace(name) -} - -func findRawByID(raws []*rawModel, id string) *rawModel { - for _, r := range raws { - if r != nil && r.Id.String == id { - return r - } + // Invalid NullString still writes NULL even when String is non-empty. + if !b.Id.Valid || strings.TrimSpace(b.Id.String) == "" { + b.Id = sql.NullString{String: xid.New().String(), Valid: true} } - return nil } func rawIsNewerTip(candidate, previous *rawModel) bool { @@ -262,18 +89,9 @@ func rawIsNewerTip(candidate, previous *rawModel) bool { return false } -func ensureBaseModelID(b *BaseModel) { - if b == nil { - return - } - if strings.TrimSpace(b.Id.String) == "" { - b.Id = sql.NullString{String: xid.New().String(), Valid: true} - } -} - -func copyModelTreeToRaw(db *gorm.DB, src *Model) error { +func copyModelTreeToRaw(db *gorm.DB, src *pkgmeta.Model) error { raw := &rawModel{ - BaseModel: BaseModel{Id: src.Id, CreatedAt: src.CreatedAt, UpdatedAt: src.UpdatedAt}, + BaseModel: pkgmeta.BaseModel{Id: src.Id, CreatedAt: src.CreatedAt, UpdatedAt: src.UpdatedAt}, Name: src.Name, Path: src.Path, Application: src.Application, @@ -322,7 +140,7 @@ func copyModelTreeToRaw(db *gorm.DB, src *Model) error { continue } rp := &rawParameter{ - BaseModel: BaseModel{Id: p.Id, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt}, + BaseModel: pkgmeta.BaseModel{Id: p.Id, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt}, Name: p.Name, TsTypeAnnotation: p.TsTypeAnnotation, ProtobufType: p.ProtobufType, @@ -338,7 +156,7 @@ func copyModelTreeToRaw(db *gorm.DB, src *Model) error { continue } rtp := &rawTypeParameter{ - BaseModel: BaseModel{Id: tp.Id, CreatedAt: tp.CreatedAt, UpdatedAt: tp.UpdatedAt}, + BaseModel: pkgmeta.BaseModel{Id: tp.Id, CreatedAt: tp.CreatedAt, UpdatedAt: tp.UpdatedAt}, Name: tp.Name, ModuleSpecPath: tp.ModuleSpecPath, ReferenceIdent: tp.ReferenceIdent, @@ -364,9 +182,9 @@ func copyModelTreeToRaw(db *gorm.DB, src *Model) error { return nil } -func rawFieldFromField(f *Field, modelID sql.NullString) *rawField { +func rawFieldFromField(f *pkgmeta.Field, modelID sql.NullString) *rawField { return &rawField{ - BaseModel: BaseModel{Id: f.Id, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt}, + BaseModel: pkgmeta.BaseModel{Id: f.Id, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt}, Name: f.Name, TsTypeAnnotation: f.TsTypeAnnotation, TsTypeReference: f.TsTypeReference, @@ -408,9 +226,9 @@ func rawFieldFromField(f *Field, modelID sql.NullString) *rawField { } } -func rawServiceFromService(s *Service, modelID sql.NullString) *rawService { +func rawServiceFromService(s *pkgmeta.Service, modelID sql.NullString) *rawService { return &rawService{ - BaseModel: BaseModel{Id: s.Id, CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt}, + BaseModel: pkgmeta.BaseModel{Id: s.Id, CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt}, Name: s.Name, OriginModelPath: s.OriginModelPath, AccessibilityModifier: s.AccessibilityModifier, @@ -421,12 +239,12 @@ func rawServiceFromService(s *Service, modelID sql.NullString) *rawService { } } -func copyDecoratorToRaw(db *gorm.DB, d *Decorator, modelID, fieldID, serviceID sql.NullString) error { +func copyDecoratorToRaw(db *gorm.DB, d *pkgmeta.Decorator, modelID, fieldID, serviceID sql.NullString) error { if d == nil { return nil } rd := &rawDecorator{ - BaseModel: BaseModel{Id: d.Id, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt}, + BaseModel: pkgmeta.BaseModel{Id: d.Id, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt}, Name: d.Name, ModuleSpecPath: d.ModuleSpecPath, ReferenceIdent: d.ReferenceIdent, @@ -443,7 +261,7 @@ func copyDecoratorToRaw(db *gorm.DB, d *Decorator, modelID, fieldID, serviceID s continue } ra := &rawArgument{ - BaseModel: BaseModel{Id: a.Id, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt}, + BaseModel: pkgmeta.BaseModel{Id: a.Id, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt}, Type: a.Type, Value: a.Value, ReferenceIdent: a.ReferenceIdent, @@ -458,49 +276,14 @@ func copyDecoratorToRaw(db *gorm.DB, d *Decorator, modelID, fieldID, serviceID s return nil } -func clearEffectiveShapeTrees(db *gorm.DB) error { - // Hard-delete order: arguments → decorators → parameters/type_parameters → fields/services → models. - for _, step := range []struct { - name string - fn func() error - }{ - {"meta_argument", func() error { - return db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&Argument{}).Error - }}, - {"meta_decorator", func() error { - return db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&Decorator{}).Error - }}, - {"meta_parameter", func() error { - return db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&Parameter{}).Error - }}, - {"meta_type_parameter", func() error { - return db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&TypeParameter{}).Error - }}, - {"meta_field", func() error { - return db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&Field{}).Error - }}, - {"meta_service", func() error { - return db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&Service{}).Error - }}, - {"meta_model", func() error { - return db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&Model{}).Error - }}, - } { - if err := step.fn(); err != nil { - return fmt.Errorf("clear %s: %w", step.name, err) - } - } - return nil -} - // persistEffectiveProjection inserts one effective model tree. // reuseServiceIDs maps service name → prior effective service id. Callers may pass a // non-nil map only after deleting existing effective rows for this logical key -// (RecomputeEffective does this via deleteEffectiveModelTree); otherwise reused ids +// (recomputeEffective does this via deleteEffectiveModelTree); otherwise reused ids // collide with still-present primary keys. -func persistEffectiveProjection(db *gorm.DB, merged *Model, effectiveID string, reuseServiceIDs map[string]string) error { - eff := &Model{ - BaseModel: BaseModel{ +func persistEffectiveProjection(db *gorm.DB, merged *pkgmeta.Model, effectiveID string, reuseServiceIDs map[string]string) error { + eff := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{ Id: sql.NullString{String: effectiveID, Valid: true}, CreatedAt: merged.CreatedAt, UpdatedAt: merged.UpdatedAt, @@ -612,12 +395,12 @@ func persistEffectiveProjection(db *gorm.DB, merged *Model, effectiveID string, return nil } -func persistDecoratorTree(db *gorm.DB, d *Decorator, modelID, fieldID, serviceID sql.NullString) error { +func persistDecoratorTree(db *gorm.DB, d *pkgmeta.Decorator, modelID, fieldID, serviceID sql.NullString) error { if d == nil { return nil } - nd := &Decorator{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, + nd := &pkgmeta.Decorator{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, Name: d.Name, ModuleSpecPath: d.ModuleSpecPath, ReferenceIdent: d.ReferenceIdent, @@ -632,8 +415,8 @@ func persistDecoratorTree(db *gorm.DB, d *Decorator, modelID, fieldID, serviceID if a == nil { continue } - na := &Argument{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, + na := &pkgmeta.Argument{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, Type: a.Type, Value: a.Value, ReferenceIdent: a.ReferenceIdent, @@ -649,7 +432,14 @@ func persistDecoratorTree(db *gorm.DB, d *Decorator, modelID, fieldID, serviceID // ensurePartialAliveAppNameUniqueIndex creates the live-only unique index without a // drop-before-create window: temp index first, then replace the final name. +// Idempotent: when the final index already exists, only leftover temp indexes are cleaned up. func ensurePartialAliveAppNameUniqueIndex(db *gorm.DB) error { + if db.Migrator().HasIndex("meta_model", effectiveAppNameUniqueIndex) { + if err := execDDL(db, fmt.Sprintf("DROP INDEX IF EXISTS %s", effectiveAppNameUniqueIndexTemp)); err != nil { + return fmt.Errorf("drop unique index %s: %w", effectiveAppNameUniqueIndexTemp, err) + } + return nil + } createPartial := func(name string) string { return fmt.Sprintf( "CREATE UNIQUE INDEX IF NOT EXISTS %s ON meta_model (application, name) WHERE deleted_at IS NULL", @@ -673,7 +463,16 @@ func ensurePartialAliveAppNameUniqueIndex(db *gorm.DB) error { } // ensureFullAppNameUniqueIndex is the MySQL path (no partial indexes). +// Idempotent: when the final index already exists, only leftover temp indexes are cleaned up. func ensureFullAppNameUniqueIndex(db *gorm.DB) error { + if db.Migrator().HasIndex("meta_model", effectiveAppNameUniqueIndex) { + if db.Migrator().HasIndex("meta_model", effectiveAppNameUniqueIndexTemp) { + if err := db.Migrator().DropIndex("meta_model", effectiveAppNameUniqueIndexTemp); err != nil { + return fmt.Errorf("drop unique index %s: %w", effectiveAppNameUniqueIndexTemp, err) + } + } + return nil + } createFull := func(name string) string { return fmt.Sprintf("CREATE UNIQUE INDEX %s ON meta_model (application, name)", name) } @@ -682,11 +481,6 @@ func ensureFullAppNameUniqueIndex(db *gorm.DB) error { return fmt.Errorf("create unique index %s: %w", effectiveAppNameUniqueIndexTemp, err) } } - if db.Migrator().HasIndex("meta_model", effectiveAppNameUniqueIndex) { - if err := db.Migrator().DropIndex("meta_model", effectiveAppNameUniqueIndex); err != nil { - return fmt.Errorf("drop unique index %s: %w", effectiveAppNameUniqueIndex, err) - } - } if !db.Migrator().HasIndex("meta_model", effectiveAppNameUniqueIndex) { if err := execDDL(db, createFull(effectiveAppNameUniqueIndex)); err != nil { // Temp index still enforces uniqueness. diff --git a/internal/module/meta/dual_store_coverage_test.go b/internal/module/meta/dual_store_coverage_test.go new file mode 100644 index 000000000..435b18dc7 --- /dev/null +++ b/internal/module/meta/dual_store_coverage_test.go @@ -0,0 +1,731 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "testing" + "time" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/gorm" +) + +type namedDialector struct { + gorm.Dialector + name string +} + +func (d namedDialector) Name() string { return d.name } + +func closeDualStoreDB(t *testing.T, db *gorm.DB) { + t.Helper() + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db.DB: %v", err) + } + if err := sqlDB.Close(); err != nil { + t.Fatalf("close: %v", err) + } +} +func TestEnsureEffectiveAppNameUniqueIndex_NilDB(t *testing.T) { + if err := EnsureEffectiveAppNameUniqueIndex(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { + t.Fatalf("expected nil db error, got %v", err) + } +} +func TestEnsureBaseModelID_Nil(t *testing.T) { + ensureBaseModelID(nil) // no panic +} +func TestEnsureDualStoreTables_NilAndClosed(t *testing.T) { + if err := ensureDualStoreTables(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { + t.Fatalf("expected nil db error, got %v", err) + } + db := openDualStoreTestDB(t) + closeDualStoreDB(t, db) + if err := ensureDualStoreTables(db); err == nil { + t.Fatal("expected AutoMigrate error on closed db") + } +} +func TestEnsureDualStoreTables_UniqueIndexError(t *testing.T) { + db := openDualStoreTestDB(t) + prev := execDDL + t.Cleanup(func() { execDDL = prev }) + execDDL = func(db *gorm.DB, sql string) error { + return errors.New("index boom") + } + if err := ensureDualStoreTables(db); err == nil || !strings.Contains(err.Error(), "index boom") { + t.Fatalf("expected unique index error, got %v", err) + } +} +func TestCopyModelTreeToRaw_CreateFailures(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + existing := &rawModel{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "existing", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "X", + Application: "a", + Path: "/x.ts", + ModuleId: sql.NullString{String: "mod", Valid: true}, + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(existing).Error; err != nil { + t.Fatalf("seed raw: %v", err) + } + src := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dup", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "X", + Application: "a", + Path: "/x.ts", + ModuleId: sql.NullString{String: "mod", Valid: true}, + } + if err := copyModelTreeToRaw(db, src); err == nil { + t.Fatal("expected unique violation creating raw model") + } +} +func TestCopyModelTreeToRaw_ChildTableFailures(t *testing.T) { + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + cases := []struct { + name string + drop any + src *pkgmeta.Model + }{ + { + name: "raw field", + drop: &rawField{}, + src: &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "m", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "X", + Application: "a", + Path: "/x.ts", + ModuleId: sql.NullString{String: "mod", Valid: true}, + Fields: []*pkgmeta.Field{{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "f", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Name", + }}, + }, + }, + { + name: "raw service", + drop: &rawService{}, + src: &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "m2", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Y", + Application: "a", + Path: "/y.ts", + ModuleId: sql.NullString{String: "mod2", Valid: true}, + Services: []*pkgmeta.Service{{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "s", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Create", + }}, + }, + }, + { + name: "raw parameter", + drop: &rawParameter{}, + src: &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "m3", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Z", + Application: "a", + Path: "/z.ts", + ModuleId: sql.NullString{String: "mod3", Valid: true}, + Services: []*pkgmeta.Service{{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "s3", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Create", + Parameters: []*pkgmeta.Parameter{{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "p", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals"}}, + }}, + }, + }, + { + name: "raw type parameter", + drop: &rawTypeParameter{}, + src: &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "m4", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "W", + Application: "a", + Path: "/w.ts", + ModuleId: sql.NullString{String: "mod4", Valid: true}, + Services: []*pkgmeta.Service{{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "s4", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Create", + TypeParameters: []*pkgmeta.TypeParameter{{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "tp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T"}}, + }}, + }, + }, + { + name: "raw decorator", + drop: &rawDecorator{}, + src: &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "m5", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "V", + Application: "a", + Path: "/v.ts", + ModuleId: sql.NullString{String: "mod5", Valid: true}, + Decorators: []*pkgmeta.Decorator{{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "d5", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Model", + }}, + }, + }, + { + name: "raw argument", + drop: &rawArgument{}, + src: &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "m6", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "U", + Application: "a", + Path: "/u.ts", + ModuleId: sql.NullString{String: "mod6", Valid: true}, + Decorators: []*pkgmeta.Decorator{{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "d6", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Model", + Arguments: []*pkgmeta.Argument{{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "a6", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Type: "string", Value: `"U"`}}, + }}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + if err := db.Migrator().DropTable(tc.drop); err != nil { + t.Fatalf("drop %s: %v", tc.name, err) + } + if err := copyModelTreeToRaw(db, tc.src); err == nil { + t.Fatalf("expected %s create failure", tc.name) + } + }) + } + + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure nil-decorator db: %v", err) + } + if err := copyDecoratorToRaw(db, nil, sql.NullString{}, sql.NullString{}, sql.NullString{}); err != nil { + t.Fatalf("nil decorator: %v", err) + } +} +func TestPersistEffectiveProjection_FailuresAndNils(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + merged := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{CreatedAt: ts, UpdatedAt: ts}, + Name: "X", + Application: "a", + Path: "/x.ts", + Decorators: []*pkgmeta.Decorator{nil}, + Fields: []*pkgmeta.Field{nil, {Name: "Name", OriginModelPath: "kept"}}, + Services: []*pkgmeta.Service{nil, { + Name: "Create", + OriginModelPath: "svc-origin", + Parameters: []*pkgmeta.Parameter{nil, {Name: "this"}, {Name: "vals"}}, + TypeParameters: []*pkgmeta.TypeParameter{nil, {Name: "T"}}, + Decorators: []*pkgmeta.Decorator{{Name: "Rpc"}}, + }}, + } + if err := persistEffectiveProjection(db, merged, "eff1", nil); err != nil { + t.Fatalf("persist: %v", err) + } + + // model create failure + if err := persistEffectiveProjection(db, merged, "eff1", nil); err == nil { + t.Fatal("expected duplicate effective id failure") + } + + db2 := openDualStoreTestDB(t) + _ = ensureDualStoreTables(db2) + _ = db2.Migrator().DropTable(&pkgmeta.Field{}) + if err := persistEffectiveProjection(db2, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", + Fields: []*pkgmeta.Field{{Name: "Name"}}, + }, "e2", nil); err == nil { + t.Fatal("expected field create failure") + } + + db3 := openDualStoreTestDB(t) + _ = ensureDualStoreTables(db3) + _ = db3.Migrator().DropTable(&pkgmeta.Service{}) + if err := persistEffectiveProjection(db3, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", + Services: []*pkgmeta.Service{{Name: "Create"}}, + }, "e3", nil); err == nil { + t.Fatal("expected service create failure") + } + + db4 := openDualStoreTestDB(t) + _ = ensureDualStoreTables(db4) + _ = db4.Migrator().DropTable(&pkgmeta.Parameter{}) + if err := persistEffectiveProjection(db4, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", + Services: []*pkgmeta.Service{{Name: "Create", Parameters: []*pkgmeta.Parameter{{Name: "vals"}}}}, + }, "e4", nil); err == nil { + t.Fatal("expected parameter create failure") + } + + db5 := openDualStoreTestDB(t) + _ = ensureDualStoreTables(db5) + _ = db5.Migrator().DropTable(&pkgmeta.TypeParameter{}) + if err := persistEffectiveProjection(db5, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", + Services: []*pkgmeta.Service{{Name: "Create", TypeParameters: []*pkgmeta.TypeParameter{{Name: "T"}}}}, + }, "e5", nil); err == nil { + t.Fatal("expected type parameter create failure") + } + + db6 := openDualStoreTestDB(t) + _ = ensureDualStoreTables(db6) + _ = db6.Migrator().DropTable(&pkgmeta.Decorator{}) + if err := persistEffectiveProjection(db6, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", + Decorators: []*pkgmeta.Decorator{{Name: "Model"}}, + }, "e6", nil); err == nil { + t.Fatal("expected decorator create failure") + } + + db7 := openDualStoreTestDB(t) + _ = ensureDualStoreTables(db7) + _ = db7.Migrator().DropTable(&pkgmeta.Argument{}) + if err := persistDecoratorTree(db7, &pkgmeta.Decorator{ + Name: "Model", Arguments: []*pkgmeta.Argument{nil, {Type: "string", Value: `"X"`}}, + }, sql.NullString{String: "mid", Valid: true}, sql.NullString{}, sql.NullString{}); err == nil { + t.Fatal("expected argument create failure after dropping meta_argument") + } + + if err := persistDecoratorTree(db7, nil, sql.NullString{}, sql.NullString{}, sql.NullString{}); err != nil { + t.Fatalf("nil decorator: %v", err) + } +} +func TestEnsureEffectiveAppNameUniqueIndex_Dialects(t *testing.T) { + // Smoke-only: namedDialector fakes Dialector.Name() while SQL still runs on SQLite. + // Does not prove MySQL/Postgres DDL semantics (partial vs full unique index). + // Each case opens a fresh DB so Dialector mutation does not leak across subtests. + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { + t.Fatalf("sqlite index: %v", err) + } + db.Dialector = namedDialector{Dialector: db.Dialector, name: "postgres"} + if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { + t.Fatalf("postgres index: %v", err) + } + + db2 := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db2); err != nil { + t.Fatalf("ensure2: %v", err) + } + db2.Dialector = namedDialector{Dialector: db2.Dialector, name: "mysql"} + if err := ensureEffectiveAppNameUniqueIndex(db2); err != nil { + t.Fatalf("mysql first: %v", err) + } + // second call is a no-op when the final index already exists + if err := ensureEffectiveAppNameUniqueIndex(db2); err != nil { + t.Fatalf("mysql second: %v", err) + } + + // Missing final index must recreate; drop final then fail create. + _ = db2.Migrator().DropTable(&pkgmeta.Model{}) + if err := ensureEffectiveAppNameUniqueIndex(db2); err == nil { + t.Fatal("expected mysql create index failure") + } + + // sqlite CREATE failure + db3 := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db3); err != nil { + t.Fatalf("ensure3: %v", err) + } + _ = db3.Migrator().DropTable(&pkgmeta.Model{}) + if err := ensureEffectiveAppNameUniqueIndex(db3); err == nil || !strings.Contains(err.Error(), "create unique index") { + t.Fatalf("expected sqlite create index failure, got %v", err) + } + + // sqlite/postgres DDL failure must surface (closed DB; first step is CREATE temp). + db4 := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db4); err != nil { + t.Fatalf("ensure4: %v", err) + } + closeDualStoreDB(t, db4) + if err := ensureEffectiveAppNameUniqueIndex(db4); err == nil || !strings.Contains(err.Error(), "create unique index") { + t.Fatalf("expected create unique index error on closed db, got %v", err) + } +} +func TestEnsureEffectiveAppNameUniqueIndex_MySQLDropTempError(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + db.Dialector = namedDialector{Dialector: db.Dialector, name: "mysql"} + if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { + t.Fatalf("create: %v", err) + } + // Leave a temp index behind (interrupted prior run); cleanup drop must surface errors. + if err := execDDL(db, fmt.Sprintf("CREATE UNIQUE INDEX %s ON meta_model (application, name)", effectiveAppNameUniqueIndexTemp)); err != nil { + t.Fatalf("seed temp index: %v", err) + } + db.Dialector = failingDropTempDialector{Dialector: db.Dialector, name: "mysql"} + if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "drop boom temp") { + t.Fatalf("expected drop temp error, got %v", err) + } +} +func TestEnsurePartialAliveAppNameUniqueIndex_DropTempWhenFinalExists(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + if err := execDDL(db, fmt.Sprintf( + "CREATE UNIQUE INDEX IF NOT EXISTS %s ON meta_model (application, name) WHERE deleted_at IS NULL", + effectiveAppNameUniqueIndexTemp, + )); err != nil { + t.Fatalf("seed temp: %v", err) + } + prev := execDDL + t.Cleanup(func() { execDDL = prev }) + execDDL = func(db *gorm.DB, sql string) error { + if strings.Contains(sql, "DROP INDEX") && strings.Contains(sql, effectiveAppNameUniqueIndexTemp) { + return errors.New("drop leftover temp boom") + } + return prev(db, sql) + } + if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "drop leftover temp boom") { + t.Fatalf("expected leftover temp drop error, got %v", err) + } +} +func TestEnsureFullAppNameUniqueIndex_RecreateSuccess(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + db.Dialector = namedDialector{Dialector: db.Dialector, name: "mysql"} + if err := execDDL(db, fmt.Sprintf("DROP INDEX IF EXISTS %s", effectiveAppNameUniqueIndex)); err != nil { + t.Fatalf("drop final: %v", err) + } + if err := EnsureEffectiveAppNameUniqueIndex(db); err != nil { + t.Fatalf("recreate: %v", err) + } + if !db.Migrator().HasIndex("meta_model", effectiveAppNameUniqueIndex) { + t.Fatal("expected final unique index after recreate") + } +} +func TestEnsurePartialAliveAppNameUniqueIndex_PreservesUniquenessOnCreateFinalFailure(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { + t.Fatalf("initial index: %v", err) + } + // Force recreate path: drop the final index so Ensure rebuilds via temp → final. + if err := execDDL(db, fmt.Sprintf("DROP INDEX IF EXISTS %s", effectiveAppNameUniqueIndex)); err != nil { + t.Fatalf("drop final index: %v", err) + } + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + soft := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "soft", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Partner", + Application: "partner", + Path: "/p1.ts", + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(soft).Error; err != nil { + t.Fatalf("create soft: %v", err) + } + if err := db.Delete(soft).Error; err != nil { + t.Fatalf("soft-delete: %v", err) + } + + prev := execDDL + t.Cleanup(func() { execDDL = prev }) + execDDL = func(db *gorm.DB, sql string) error { + // Allow temp create + drop of final; fail recreate of the final name. + if strings.Contains(sql, "CREATE UNIQUE INDEX") && + strings.Contains(sql, effectiveAppNameUniqueIndex) && + !strings.Contains(sql, effectiveAppNameUniqueIndexTemp) { + return errors.New("create final boom") + } + return prev(db, sql) + } + if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "create final boom") { + t.Fatalf("expected create final failure, got %v", err) + } + // Temp partial index must still protect live uniqueness and allow soft-deleted reuse. + live := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "live", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Partner", + Application: "partner", + Path: "/p2.ts", + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(live).Error; err != nil { + t.Fatalf("live row after soft-delete should succeed under temp partial index: %v", err) + } + dup := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dup", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Partner", + Application: "partner", + Path: "/p3.ts", + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(dup).Error; err == nil { + t.Fatal("expected live (application, name) uniqueness still enforced") + } +} +func TestEnsurePartialAliveAppNameUniqueIndex_DropErrors(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + prev := execDDL + t.Cleanup(func() { execDDL = prev }) + + if err := prev(db, fmt.Sprintf( + `DROP INDEX IF EXISTS "%s"`, + effectiveAppNameUniqueIndex, + )); err != nil { + t.Fatalf("drop final before recreate: %v", err) + } + execDDL = func(db *gorm.DB, sql string) error { + if strings.HasPrefix(strings.TrimSpace(sql), "DROP INDEX") && strings.Contains(sql, effectiveAppNameUniqueIndex) && !strings.Contains(sql, "_new") { + return errors.New("drop final boom") + } + return prev(db, sql) + } + if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "drop final boom") { + t.Fatalf("expected drop final error, got %v", err) + } + + if err := prev(db, fmt.Sprintf( + `DROP INDEX IF EXISTS "%s"`, + effectiveAppNameUniqueIndex, + )); err != nil { + t.Fatalf("drop final before temp-drop path: %v", err) + } + execDDL = func(db *gorm.DB, sql string) error { + if strings.HasPrefix(strings.TrimSpace(sql), "DROP INDEX") && strings.Contains(sql, effectiveAppNameUniqueIndexTemp) { + return errors.New("drop temp boom") + } + return prev(db, sql) + } + if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "drop temp boom") { + t.Fatalf("expected drop temp error, got %v", err) + } +} +func TestEnsureFullAppNameUniqueIndex_CreateFinalAndDropTempErrors(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + db.Dialector = namedDialector{Dialector: db.Dialector, name: "mysql"} + if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { + t.Fatalf("initial: %v", err) + } + + prev := execDDL + t.Cleanup(func() { execDDL = prev }) + if err := prev(db, fmt.Sprintf( + `DROP INDEX IF EXISTS "%s"`, + effectiveAppNameUniqueIndex, + )); err != nil { + t.Fatalf("drop final before recreate: %v", err) + } + execDDL = func(db *gorm.DB, sql string) error { + if strings.Contains(sql, "CREATE UNIQUE INDEX") && + strings.Contains(sql, effectiveAppNameUniqueIndex) && + !strings.Contains(sql, effectiveAppNameUniqueIndexTemp) { + return errors.New("mysql create final boom") + } + return prev(db, sql) + } + if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "mysql create final boom") { + t.Fatalf("expected mysql create final error, got %v", err) + } + + execDDL = prev + // Leave temp in place from the failed run, then force DropIndex(temp) to fail. + // Final index is still missing, so ensure must clean leftover temp before recreate. + db.Dialector = failingDropTempDialector{Dialector: db.Dialector, name: "mysql"} + if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "drop boom temp") { + t.Fatalf("expected drop temp error, got %v", err) + } +} + +type failingDropTempDialector struct { + gorm.Dialector + name string +} + +func (d failingDropTempDialector) Name() string { return d.name } + +func (d failingDropTempDialector) Migrator(db *gorm.DB) gorm.Migrator { + return failingDropTempMigrator{Migrator: d.Dialector.Migrator(db)} +} + +type failingDropTempMigrator struct { + gorm.Migrator +} + +func (m failingDropTempMigrator) HasIndex(dst interface{}, name string) bool { + return m.Migrator.HasIndex(dst, name) +} + +func (m failingDropTempMigrator) DropIndex(dst interface{}, name string) error { + if name == effectiveAppNameUniqueIndexTemp { + return errors.New("drop boom temp") + } + return m.Migrator.DropIndex(dst, name) +} + +func TestCopyDecoratorToRaw_NilArgument(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + d := &pkgmeta.Decorator{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "d", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Model", + Arguments: []*pkgmeta.Argument{nil}, + } + if err := copyDecoratorToRaw(db, d, sql.NullString{String: "m", Valid: true}, sql.NullString{}, sql.NullString{}); err != nil { + t.Fatalf("copyDecoratorToRaw: %v", err) + } +} +func TestCopyModelTreeToRaw_NestedDecoratorFailures(t *testing.T) { + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + src := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "mf", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "X", + Application: "a", + Path: "/xf.ts", + ModuleId: sql.NullString{String: "modf", Valid: true}, + Fields: []*pkgmeta.Field{{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Name", + Decorators: []*pkgmeta.Decorator{{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "df", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Field"}}, + }}, + } + if err := db.Migrator().DropTable(&rawDecorator{}); err != nil { + t.Fatalf("drop: %v", err) + } + if err := copyModelTreeToRaw(db, src); err == nil { + t.Fatal("expected field decorator create failure") + } + + db2 := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db2); err != nil { + t.Fatalf("ensure2: %v", err) + } + src2 := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ms", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Y", + Application: "a", + Path: "/ys.ts", + ModuleId: sql.NullString{String: "mods", Valid: true}, + Services: []*pkgmeta.Service{{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ss", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Create", + Decorators: []*pkgmeta.Decorator{{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ds", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Rpc"}}, + }}, + } + if err := db2.Migrator().DropTable(&rawDecorator{}); err != nil { + t.Fatalf("drop2: %v", err) + } + if err := copyModelTreeToRaw(db2, src2); err == nil { + t.Fatal("expected service decorator create failure") + } +} +func TestPersistEffectiveProjection_NestedDecoratorFailures(t *testing.T) { + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + + db := openDualStoreTestDB(t) + _ = ensureDualStoreTables(db) + _ = db.Migrator().DropTable(&pkgmeta.Decorator{}) + if err := persistEffectiveProjection(db, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", + Fields: []*pkgmeta.Field{{Name: "Name", Decorators: []*pkgmeta.Decorator{{Name: "Field"}}}}, + }, "ef", nil); err == nil { + t.Fatal("expected field decorator persist failure") + } + + db2 := openDualStoreTestDB(t) + _ = ensureDualStoreTables(db2) + _ = db2.Migrator().DropTable(&pkgmeta.Decorator{}) + if err := persistEffectiveProjection(db2, &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", + Services: []*pkgmeta.Service{{Name: "Create", Decorators: []*pkgmeta.Decorator{{Name: "Rpc"}}}}, + }, "es", nil); err == nil { + t.Fatal("expected service decorator persist failure") + } + + db3 := openDualStoreTestDB(t) + _ = ensureDualStoreTables(db3) + if err := persistDecoratorTree(db3, &pkgmeta.Decorator{ + Name: "Model", Arguments: []*pkgmeta.Argument{nil, {Type: "string", Value: `"X"`}}, + }, sql.NullString{String: "no-model", Valid: true}, sql.NullString{}, sql.NullString{}); err != nil { + t.Fatalf("persistDecoratorTree with nil arg should succeed on sqlite: %v", err) + } + _ = db3.Migrator().DropTable(&pkgmeta.Argument{}) + if err := persistDecoratorTree(db3, &pkgmeta.Decorator{ + Name: "Model2", Arguments: []*pkgmeta.Argument{{Type: "string", Value: `"Y"`}}, + }, sql.NullString{}, sql.NullString{}, sql.NullString{}); err == nil { + t.Fatal("expected argument create failure") + } +} + +func TestRawIsNewerTip(t *testing.T) { + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + a := &rawModel{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "a", Valid: true}, CreatedAt: ts}} + b := &rawModel{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "b", Valid: true}, CreatedAt: ts}} + if !rawIsNewerTip(a, nil) { + t.Fatal("nil previous") + } + if !rawIsNewerTip(b, a) { // equal time, higher id + t.Fatal("expected b newer by id") + } + older := &rawModel{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "z", Valid: true}, CreatedAt: ts.Add(-time.Hour)}} + if rawIsNewerTip(older, a) { + t.Fatal("older should not win") + } + newer := &rawModel{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "c", Valid: true}, CreatedAt: ts.Add(time.Hour)}} + if !rawIsNewerTip(newer, a) { + t.Fatal("newer CreatedAt should win") + } +} + +func TestCopyModelTreeToRaw_NilChildren(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + srcNil := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "mn", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "NilKids", + Application: "a", + Path: "/nil.ts", + ModuleId: sql.NullString{String: "modn", Valid: true}, + Fields: []*pkgmeta.Field{nil}, + Services: []*pkgmeta.Service{nil, { + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "sn", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "S", + Parameters: []*pkgmeta.Parameter{nil}, + TypeParameters: []*pkgmeta.TypeParameter{nil}, + }}, + Decorators: nil, + } + if err := copyModelTreeToRaw(db, srcNil); err != nil { + t.Fatalf("copy with nils: %v", err) + } +} diff --git a/internal/module/meta/dual_store_test.go b/internal/module/meta/dual_store_test.go new file mode 100644 index 000000000..b603a0f08 --- /dev/null +++ b/internal/module/meta/dual_store_test.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + "time" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func openDualStoreTestDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := filepath.Join(t.TempDir(), "dual-store.sqlite") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + return db +} + +func TestPersistModelTreeAsRaw_AssignsIDsWhenMissing(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + + // Parser-shaped trees often have empty primary keys; SkipHooks Create must still mint ids + // so Fields preload by model_id for schema migrator. + src := &pkgmeta.Model{ + Name: "Address", + Application: "base", + Path: "/base/service/models/address.ts", + ModelTable: "base_address", + ModuleId: sql.NullString{String: "mod-base", Valid: true}, + Fields: []*pkgmeta.Field{{ + Name: "Street", + ResolvedSpec: `{"fieldName":"Street","structural":{"name":"Street","fieldType":"varchar"},"migration":{"shouldCreateColumn":true,"resolvedColumnType":"varchar"}}`, + Decorators: []*pkgmeta.Decorator{{ + Name: "Field", + Arguments: []*pkgmeta.Argument{{ + Type: "object", + Value: `{"type":"varchar"}`, + }}, + }}, + }}, + Services: []*pkgmeta.Service{{ + Name: "Search", + Parameters: []*pkgmeta.Parameter{ + {Name: "this"}, + {Name: "domain"}, + }, + }}, + } + if err := persistModelTreeAsRaw(db, src); err != nil { + t.Fatalf("persistModelTreeAsRaw: %v", err) + } + + var raw rawModel + if err := db.Preload("Fields").Preload("Fields.Decorators").Preload("Fields.Decorators.Arguments"). + Preload("Services").Preload("Services.Parameters"). + Where("path = ?", src.Path).Take(&raw).Error; err != nil { + t.Fatalf("load raw: %v", err) + } + if strings.TrimSpace(raw.Id.String) == "" { + t.Fatal("expected non-empty raw model id") + } + if len(raw.Fields) != 1 || strings.TrimSpace(raw.Fields[0].Id.String) == "" { + t.Fatalf("expected linked field with id, got %#v", raw.Fields) + } + if raw.Fields[0].ModelId.String != raw.Id.String { + t.Fatalf("field.model_id=%q want %q", raw.Fields[0].ModelId.String, raw.Id.String) + } + if raw.Fields[0].ResolvedSpec == "" { + t.Fatal("expected ResolvedSpec preserved") + } + if len(raw.Fields[0].Decorators) != 1 || len(raw.Fields[0].Decorators[0].Arguments) != 1 { + t.Fatalf("expected decorator tree, got %#v", raw.Fields[0].Decorators) + } + if len(raw.Services) != 1 || len(raw.Services[0].Parameters) != 2 { + t.Fatalf("unexpected services: %#v", raw.Services) + } +} + +func TestEnsureDualStoreTables_EffectiveAppNameUniqueIndex(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { + t.Fatalf("ensure: %v", err) + } + ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + live := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "id-live", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Partner", + Application: "partner", + Path: "/partner/partner.ts", + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(live).Error; err != nil { + t.Fatalf("create live: %v", err) + } + + // Soft-deleted effective tip must not block a new live row (partial unique index). + if err := db.Delete(live).Error; err != nil { + t.Fatalf("soft-delete effective: %v", err) + } + replacement := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "id-relive", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Partner", + Application: "partner", + Path: "/partner/partner2.ts", + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(replacement).Error; err != nil { + t.Fatalf("create live row after soft-delete: %v", err) + } + + // Unique (application, name) still rejects a second live row. + dup := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "id-dup", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + Name: "Partner", + Application: "partner", + Path: "/other/partner.ts", + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(dup).Error; err == nil { + t.Fatal("expected unique (application, name) violation for second live row") + } +} diff --git a/pkg/meta/effective_merge.go b/internal/module/meta/effective_merge.go similarity index 83% rename from pkg/meta/effective_merge.go rename to internal/module/meta/effective_merge.go index 663663d94..f642fedba 100644 --- a/pkg/meta/effective_merge.go +++ b/internal/module/meta/effective_merge.go @@ -6,23 +6,25 @@ package meta import ( "fmt" "sort" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) // MergeSameNameModelsByExtensionChain merges same logical-name model rows (E2). // Ranking: extends-depth ascending, then UpdatedAt ascending, then Id ascending. -// Fields merge by name via ResolveSelectionFieldConflict; Services last-write wins. +// Fields merge by name via resolveSelectionFieldConflict; Services last-write wins. // Canonical model scalars and model-level Decorators come from the last ranked row -// (hangers follow the winning Field/Service/Model — EDS E2 §4.6). Cycles in Extends → error. +// (hangers follow the winning pkgmeta.Field/pkgmeta.Service/pkgmeta.Model — EDS E2 §4.6). Cycles in Extends → error. // // Callers typically pass all live rows for one (application, name) — including sibling // IMD branches (EDS2). Codegen may additionally pre-filter to a primary extension chain // before calling this when reading legacy multi-row IMD catalogs. -func MergeSameNameModelsByExtensionChain(models []*Model) (*Model, error) { +func MergeSameNameModelsByExtensionChain(models []*pkgmeta.Model) (*pkgmeta.Model, error) { if len(models) == 0 { return nil, nil } - byPath := make(map[string]*Model, len(models)) + byPath := make(map[string]*pkgmeta.Model, len(models)) for _, m := range models { if m != nil && m.Path != "" { byPath[m.Path] = m @@ -30,15 +32,15 @@ func MergeSameNameModelsByExtensionChain(models []*Model) (*Model, error) { } type rankedModel struct { - model *Model + model *pkgmeta.Model depth int } depthMemo := make(map[string]int) visiting := make(map[string]bool) var depthErr error - var depthOf func(*Model) int - depthOf = func(m *Model) int { + var depthOf func(*pkgmeta.Model) int + depthOf = func(m *pkgmeta.Model) int { if m.Path == "" { return 0 } @@ -88,9 +90,9 @@ func MergeSameNameModelsByExtensionChain(models []*Model) (*Model, error) { }) fieldIndex := make(map[string]int) - mergedFields := make([]*Field, 0) + mergedFields := make([]*pkgmeta.Field, 0) serviceIndex := make(map[string]int) - mergedServices := make([]*Service, 0) + mergedServices := make([]*pkgmeta.Service, 0) for _, item := range ranked { m := item.model @@ -99,13 +101,13 @@ func MergeSameNameModelsByExtensionChain(models []*Model) (*Model, error) { continue } if idx, ok := fieldIndex[f.Name]; ok { - resolved, err := ResolveSelectionFieldConflict(mergedFields[idx], f) + resolved, err := resolveSelectionFieldConflict(mergedFields[idx], f) if err != nil { return nil, err } mergedFields[idx] = resolved } else { - if FieldHasSelectionAdd(f) { + if fieldHasSelectionAdd(f) { return nil, fmt.Errorf("field %s selectionAdd requires an inherited static selection", f.Name) } fieldIndex[f.Name] = len(mergedFields) @@ -134,14 +136,14 @@ func MergeSameNameModelsByExtensionChain(models []*Model) (*Model, error) { } // mergeEffectiveModel runs E2 over declaration-layer rawModel rows. -func mergeEffectiveModel(raws []*rawModel) (*Model, error) { +func mergeEffectiveModel(raws []*rawModel) (*pkgmeta.Model, error) { return MergeSameNameModelsByExtensionChain(rawModelsAsModels(raws)) } -// rawModelsAsModels converts raw declaration trees into in-memory Model trees for E2 merge. +// rawModelsAsModels converts raw declaration trees into in-memory pkgmeta.Model trees for E2 merge. // IDs and timestamps are preserved for ranking; FK targets are not DB-backed. -func rawModelsAsModels(raws []*rawModel) []*Model { - out := make([]*Model, 0, len(raws)) +func rawModelsAsModels(raws []*rawModel) []*pkgmeta.Model { + out := make([]*pkgmeta.Model, 0, len(raws)) for _, raw := range raws { if raw == nil { continue @@ -151,8 +153,8 @@ func rawModelsAsModels(raws []*rawModel) []*Model { return out } -func rawModelAsModel(raw *rawModel) *Model { - m := &Model{ +func rawModelAsModel(raw *rawModel) *pkgmeta.Model { + m := &pkgmeta.Model{ BaseModel: raw.BaseModel, Name: raw.Name, Path: raw.Path, @@ -188,8 +190,8 @@ func rawModelAsModel(raw *rawModel) *Model { return m } -func rawFieldAsField(f *rawField) *Field { - out := &Field{ +func rawFieldAsField(f *rawField) *pkgmeta.Field { + out := &pkgmeta.Field{ BaseModel: f.BaseModel, Name: f.Name, TsTypeAnnotation: f.TsTypeAnnotation, @@ -238,8 +240,8 @@ func rawFieldAsField(f *rawField) *Field { return out } -func rawServiceAsService(s *rawService) *Service { - svc := &Service{ +func rawServiceAsService(s *rawService) *pkgmeta.Service { + svc := &pkgmeta.Service{ BaseModel: s.BaseModel, Name: s.Name, OriginModelPath: s.OriginModelPath, @@ -254,7 +256,7 @@ func rawServiceAsService(s *rawService) *Service { // (matches codegen getApplication and ModuleBuilder.loadLatestModelByPath). continue } - svc.Parameters = append(svc.Parameters, &Parameter{ + svc.Parameters = append(svc.Parameters, &pkgmeta.Parameter{ BaseModel: p.BaseModel, Name: p.Name, TsTypeAnnotation: p.TsTypeAnnotation, @@ -265,7 +267,7 @@ func rawServiceAsService(s *rawService) *Service { if tp == nil { continue } - svc.TypeParameters = append(svc.TypeParameters, &TypeParameter{ + svc.TypeParameters = append(svc.TypeParameters, &pkgmeta.TypeParameter{ BaseModel: tp.BaseModel, Name: tp.Name, ModuleSpecPath: tp.ModuleSpecPath, @@ -281,8 +283,8 @@ func rawServiceAsService(s *rawService) *Service { return svc } -func rawDecoratorAsDecorator(d *rawDecorator) *Decorator { - out := &Decorator{ +func rawDecoratorAsDecorator(d *rawDecorator) *pkgmeta.Decorator { + out := &pkgmeta.Decorator{ BaseModel: d.BaseModel, Name: d.Name, ModuleSpecPath: d.ModuleSpecPath, @@ -292,7 +294,7 @@ func rawDecoratorAsDecorator(d *rawDecorator) *Decorator { if a == nil { continue } - out.Arguments = append(out.Arguments, &Argument{ + out.Arguments = append(out.Arguments, &pkgmeta.Argument{ BaseModel: a.BaseModel, Type: a.Type, Value: a.Value, diff --git a/pkg/meta/effective_merge_coverage_test.go b/internal/module/meta/effective_merge_coverage_test.go similarity index 63% rename from pkg/meta/effective_merge_coverage_test.go rename to internal/module/meta/effective_merge_coverage_test.go index 3700fb374..e9f6c803d 100644 --- a/pkg/meta/effective_merge_coverage_test.go +++ b/internal/module/meta/effective_merge_coverage_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" "time" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) func TestMergeSameNameModelsByExtensionChain_EmptyAndNilGuards(t *testing.T) { @@ -15,11 +17,11 @@ func TestMergeSameNameModelsByExtensionChain_EmptyAndNilGuards(t *testing.T) { if err != nil || merged != nil { t.Fatalf("empty nil slice: got %#v %v", merged, err) } - merged, err = MergeSameNameModelsByExtensionChain([]*Model{}) + merged, err = MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{}) if err != nil || merged != nil { t.Fatalf("empty slice: got %#v %v", merged, err) } - merged, err = MergeSameNameModelsByExtensionChain([]*Model{nil, nil}) + merged, err = MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{nil, nil}) if err != nil || merged != nil { t.Fatalf("nil models only: got %#v %v", merged, err) } @@ -27,14 +29,14 @@ func TestMergeSameNameModelsByExtensionChain_EmptyAndNilGuards(t *testing.T) { func TestMergeSameNameModelsByExtensionChain_EmptyPathAndNilChildren(t *testing.T) { ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - m := &Model{ - BaseModel: BaseModel{UpdatedAt: ts, Id: sql.NullString{String: "m", Valid: true}}, + m := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: ts, Id: sql.NullString{String: "m", Valid: true}}, Name: "X", Path: "", - Fields: []*Field{nil, {Name: ""}, {Name: "Ok"}}, - Services: []*Service{nil, {Name: ""}, {Name: "Svc"}}, + Fields: []*pkgmeta.Field{nil, {Name: ""}, {Name: "Ok"}}, + Services: []*pkgmeta.Service{nil, {Name: ""}, {Name: "Svc"}}, } - merged, err := MergeSameNameModelsByExtensionChain([]*Model{m}) + merged, err := MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{m}) if err != nil { t.Fatalf("merge: %v", err) } @@ -48,20 +50,20 @@ func TestMergeSameNameModelsByExtensionChain_EmptyPathAndNilChildren(t *testing. func TestMergeSameNameModelsByExtensionChain_ServiceLastWriteWins(t *testing.T) { ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - base := &Model{ - BaseModel: BaseModel{UpdatedAt: ts, Id: sql.NullString{String: "a", Valid: true}}, + base := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: ts, Id: sql.NullString{String: "a", Valid: true}}, Name: "X", Path: "/a.ts", - Services: []*Service{{Name: "Create", TsTypeAnnotation: "base"}}, + Services: []*pkgmeta.Service{{Name: "Create", TsTypeAnnotation: "base"}}, } - ext := &Model{ - BaseModel: BaseModel{UpdatedAt: ts.Add(time.Hour), Id: sql.NullString{String: "b", Valid: true}}, + ext := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: ts.Add(time.Hour), Id: sql.NullString{String: "b", Valid: true}}, Name: "X", Path: "/b.ts", Extends: "/a.ts", - Services: []*Service{{Name: "Create", TsTypeAnnotation: "ext"}}, + Services: []*pkgmeta.Service{{Name: "Create", TsTypeAnnotation: "ext"}}, } - merged, err := MergeSameNameModelsByExtensionChain([]*Model{base, ext}) + merged, err := MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{base, ext}) if err != nil { t.Fatalf("merge: %v", err) } @@ -71,44 +73,44 @@ func TestMergeSameNameModelsByExtensionChain_ServiceLastWriteWins(t *testing.T) } func TestMergeSameNameModelsByExtensionChain_FieldConflictError(t *testing.T) { - baseField := &Field{Name: "Kind", FieldType: "selection"} - if err := baseField.SetResolvedSpec(&FieldResolvedSpec{ + baseField := &pkgmeta.Field{Name: "Kind", FieldType: "selection"} + if err := baseField.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Kind", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Kind", FieldType: "selection", - Selection: []FieldSelectionItem{{Value: "a", Label: "A"}}, + Selection: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }, }); err != nil { t.Fatalf("base spec: %v", err) } - extField := &Field{Name: "Kind", FieldType: "selection"} - if err := extField.SetResolvedSpec(&FieldResolvedSpec{ + extField := &pkgmeta.Field{Name: "Kind", FieldType: "selection"} + if err := extField.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Kind", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Kind", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "b", Label: "B"}}, - Selection: []FieldSelectionItem{{Value: "c", Label: "C"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "b", Label: "B"}}, + Selection: []pkgmeta.FieldSelectionItem{{Value: "c", Label: "C"}}, }, }); err != nil { t.Fatalf("ext spec: %v", err) } - base := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, + base := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, Name: "X", Path: "/base.ts", - Fields: []*Field{baseField}, + Fields: []*pkgmeta.Field{baseField}, } - ext := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "ext", Valid: true}}, + ext := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "ext", Valid: true}}, Name: "X", Path: "/ext.ts", Extends: "/base.ts", - Fields: []*Field{extField}, + Fields: []*pkgmeta.Field{extField}, } - _, err := MergeSameNameModelsByExtensionChain([]*Model{base, ext}) + _, err := MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{base, ext}) if err == nil || !strings.Contains(err.Error(), "cannot combine selection and selectionAdd") { t.Fatalf("expected selection+selectionAdd conflict, got %v", err) } @@ -116,19 +118,19 @@ func TestMergeSameNameModelsByExtensionChain_FieldConflictError(t *testing.T) { func TestMergeSameNameModelsByExtensionChain_SameUpdatedAtIdTieBreak(t *testing.T) { ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - a := &Model{ - BaseModel: BaseModel{UpdatedAt: ts, Id: sql.NullString{String: "id-a", Valid: true}}, + a := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: ts, Id: sql.NullString{String: "id-a", Valid: true}}, Name: "X", Path: "/a.ts", - Fields: []*Field{{Name: "F", TsTypeAnnotation: "a"}}, + Fields: []*pkgmeta.Field{{Name: "F", TsTypeAnnotation: "a"}}, } - b := &Model{ - BaseModel: BaseModel{UpdatedAt: ts, Id: sql.NullString{String: "id-b", Valid: true}}, + b := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: ts, Id: sql.NullString{String: "id-b", Valid: true}}, Name: "X", Path: "/b.ts", - Fields: []*Field{{Name: "F", TsTypeAnnotation: "b"}}, + Fields: []*pkgmeta.Field{{Name: "F", TsTypeAnnotation: "b"}}, } - merged, err := MergeSameNameModelsByExtensionChain([]*Model{b, a}) + merged, err := MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{b, a}) if err != nil { t.Fatalf("merge: %v", err) } @@ -145,7 +147,7 @@ func TestRawModelsAsModels_SkipsNilAndMapsTrees(t *testing.T) { raws := []*rawModel{ nil, { - BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "r1", Valid: true}}, Name: "X", Path: "/x.ts", Fields: []*rawField{ diff --git a/pkg/meta/effective_merge_test.go b/internal/module/meta/effective_merge_test.go similarity index 56% rename from pkg/meta/effective_merge_test.go rename to internal/module/meta/effective_merge_test.go index 299a2d080..47538ce93 100644 --- a/pkg/meta/effective_merge_test.go +++ b/internal/module/meta/effective_merge_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) func TestMergeSameNameModelsByExtensionChain_PartnerStyleUnion(t *testing.T) { @@ -16,28 +18,28 @@ func TestMergeSameNameModelsByExtensionChain_PartnerStyleUnion(t *testing.T) { bankPath := "@/partner_bank/service/models/partner.ts" commercialPath := "@/partner_commercial/service/models/partner.ts" - base := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, + base := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, Name: "Partner", Path: basePath, - Fields: []*Field{{Name: "Name"}, {Name: "Contacts"}}, + Fields: []*pkgmeta.Field{{Name: "Name"}, {Name: "Contacts"}}, } - bank := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "bank", Valid: true}}, + bank := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "bank", Valid: true}}, Name: "Partner", Path: bankPath, Extends: basePath, - Fields: []*Field{{Name: "BankAccounts"}}, + Fields: []*pkgmeta.Field{{Name: "BankAccounts"}}, } - commercial := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC), Id: sql.NullString{String: "commercial", Valid: true}}, + commercial := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC), Id: sql.NullString{String: "commercial", Valid: true}}, Name: "Partner", Path: commercialPath, Extends: basePath, - Fields: []*Field{{Name: "PartnerIdentifiers"}}, + Fields: []*pkgmeta.Field{{Name: "PartnerIdentifiers"}}, } - merged, err := MergeSameNameModelsByExtensionChain([]*Model{base, bank, commercial}) + merged, err := MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{base, bank, commercial}) if err != nil { t.Fatalf("MergeSameNameModelsByExtensionChain: %v", err) } @@ -62,13 +64,13 @@ func TestMergeEffectiveModel_FromRaw(t *testing.T) { bankPath := "@/partner_bank/service/models/partner.ts" raws := []*rawModel{ { - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, Name: "Partner", Path: basePath, Fields: []*rawField{{Name: "Name"}}, }, { - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "bank", Valid: true}}, + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "bank", Valid: true}}, Name: "Partner", Path: bankPath, Extends: basePath, @@ -92,7 +94,7 @@ func TestMergeEffectiveModel_FromRaw(t *testing.T) { func TestMergeEffectiveModel_OmitsThisParameter(t *testing.T) { raws := []*rawModel{{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, Name: "Partner", Application: "partner", Path: "@/partner/service/models/partner.ts", @@ -120,76 +122,76 @@ func TestMergeEffectiveModel_OmitsThisParameter(t *testing.T) { func TestMergeSameNameModelsByExtensionChain_CycleError(t *testing.T) { aPath := "@/a/partner.ts" bPath := "@/b/partner.ts" - a := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "a", Valid: true}}, + a := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "a", Valid: true}}, Name: "Partner", Path: aPath, Extends: bPath, } - b := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "b", Valid: true}}, + b := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "b", Valid: true}}, Name: "Partner", Path: bPath, Extends: aPath, } - _, err := MergeSameNameModelsByExtensionChain([]*Model{a, b}) + _, err := MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{a, b}) if err == nil || !strings.Contains(err.Error(), "extends cycle") { t.Fatalf("expected cycle error, got %v", err) } } func TestMergeSameNameModelsByExtensionChain_SelectionAddWithoutBaseRejected(t *testing.T) { - extField := &Field{Name: "Kind", FieldType: "selection"} - if err := extField.SetResolvedSpec(&FieldResolvedSpec{ + extField := &pkgmeta.Field{Name: "Kind", FieldType: "selection"} + if err := extField.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Kind", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Kind", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "vip", Label: "VIP"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "vip", Label: "VIP"}}, }, }); err != nil { t.Fatalf("SetResolvedSpec: %v", err) } - ext := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "ext", Valid: true}}, + ext := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "ext", Valid: true}}, Name: "Partner", Path: "@/partner_vip/service/models/partner.ts", - Fields: []*Field{extField}, + Fields: []*pkgmeta.Field{extField}, } - base := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, + base := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, Name: "Partner", Path: "@/partner/service/models/partner.ts", - Fields: []*Field{{Name: "Name"}}, + Fields: []*pkgmeta.Field{{Name: "Name"}}, } ext.Extends = base.Path - _, err := MergeSameNameModelsByExtensionChain([]*Model{base, ext}) + _, err := MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{base, ext}) if err == nil || !strings.Contains(err.Error(), "selectionAdd requires an inherited static selection") { t.Fatalf("expected selectionAdd-without-base rejection, got %v", err) } } func TestMergeSameNameModelsByExtensionChain_SoloSelectionAddRejected(t *testing.T) { - extField := &Field{Name: "Kind", FieldType: "selection"} - if err := extField.SetResolvedSpec(&FieldResolvedSpec{ + extField := &pkgmeta.Field{Name: "Kind", FieldType: "selection"} + if err := extField.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Kind", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Kind", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "vip", Label: "VIP"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "vip", Label: "VIP"}}, }, }); err != nil { t.Fatalf("SetResolvedSpec: %v", err) } - solo := &Model{ - BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "solo", Valid: true}}, + solo := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "solo", Valid: true}}, Name: "Partner", Path: "@/partner/service/models/partner.ts", - Fields: []*Field{extField}, + Fields: []*pkgmeta.Field{extField}, } - _, err := MergeSameNameModelsByExtensionChain([]*Model{solo}) + _, err := MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{solo}) if err == nil || !strings.Contains(err.Error(), "selectionAdd requires an inherited static selection") { t.Fatalf("expected solo selectionAdd rejection, got %v", err) } @@ -199,7 +201,7 @@ func TestRawFieldAndFieldExportedColumnsMatch(t *testing.T) { skip := map[string]bool{ "ModelId": true, "Model": true, "Decorators": true, } - fieldType := reflect.TypeOf(Field{}) + fieldType := reflect.TypeOf(pkgmeta.Field{}) rawType := reflect.TypeOf(rawField{}) fieldCols := map[string]reflect.Type{} for i := 0; i < fieldType.NumField(); i++ { @@ -218,7 +220,7 @@ func TestRawFieldAndFieldExportedColumnsMatch(t *testing.T) { rawCols[f.Name] = f.Type } if len(fieldCols) != len(rawCols) { - t.Fatalf("Field cols=%d rawField cols=%d", len(fieldCols), len(rawCols)) + t.Fatalf("pkgmeta.Field cols=%d rawField cols=%d", len(fieldCols), len(rawCols)) } for name, typ := range fieldCols { got, ok := rawCols[name] @@ -226,7 +228,7 @@ func TestRawFieldAndFieldExportedColumnsMatch(t *testing.T) { t.Fatalf("rawField missing column %s", name) } if got != typ { - t.Fatalf("rawField.%s type %v, Field.%s type %v", name, got, name, typ) + t.Fatalf("rawField.%s type %v, pkgmeta.Field.%s type %v", name, got, name, typ) } } } @@ -236,15 +238,15 @@ func TestMergeSameNameModelsByExtensionChain_SameDepthTieBreak(t *testing.T) { branchAPath := "@/partner_bank/service/models/partner.ts" branchBPath := "@/partner_commercial/service/models/partner.ts" - base := &Model{BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, Name: "Partner", Path: basePath, Fields: []*Field{{Name: "Name", TsTypeAnnotation: "base-name"}}} - branchA := &Model{BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "a-older", Valid: true}}, Name: "Partner", Path: branchAPath, Extends: basePath, Fields: []*Field{{Name: "Name", TsTypeAnnotation: "branch-a"}}} - branchBNewer := &Model{BaseModel: BaseModel{UpdatedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC), Id: sql.NullString{String: "b-newer", Valid: true}}, Name: "Partner", Path: branchBPath, Extends: basePath, Fields: []*Field{{Name: "Name", TsTypeAnnotation: "branch-b-newer"}}} + base := &pkgmeta.Model{BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), Id: sql.NullString{String: "base", Valid: true}}, Name: "Partner", Path: basePath, Fields: []*pkgmeta.Field{{Name: "Name", TsTypeAnnotation: "base-name"}}} + branchA := &pkgmeta.Model{BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), Id: sql.NullString{String: "a-older", Valid: true}}, Name: "Partner", Path: branchAPath, Extends: basePath, Fields: []*pkgmeta.Field{{Name: "Name", TsTypeAnnotation: "branch-a"}}} + branchBNewer := &pkgmeta.Model{BaseModel: pkgmeta.BaseModel{UpdatedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC), Id: sql.NullString{String: "b-newer", Valid: true}}, Name: "Partner", Path: branchBPath, Extends: basePath, Fields: []*pkgmeta.Field{{Name: "Name", TsTypeAnnotation: "branch-b-newer"}}} - merged, err := MergeSameNameModelsByExtensionChain([]*Model{base, branchA, branchBNewer}) + merged, err := MergeSameNameModelsByExtensionChain([]*pkgmeta.Model{base, branchA, branchBNewer}) if err != nil { t.Fatalf("merge: %v", err) } - var name *Field + var name *pkgmeta.Field for _, f := range merged.Fields { if f != nil && f.Name == "Name" { name = f diff --git a/internal/module/meta/entities.go b/internal/module/meta/entities.go new file mode 100644 index 000000000..930b5d57a --- /dev/null +++ b/internal/module/meta/entities.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +import ( + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" +) + +// DualStoreRawEntities returns declaration-layer tables only. +func DualStoreRawEntities() []any { + return []any{ + &rawModel{}, + &rawField{}, + &rawService{}, + &rawTypeParameter{}, + &rawParameter{}, + &rawDecorator{}, + &rawArgument{}, + } +} + +// OpsEntities returns module admin/ops satellite tables; not dual-store shape. +// Includes meta_lock_lease (LockLease); acquire/renew/release stay in internal/state/lease. +func OpsEntities() []any { + return []any{ + &ModuleIndex{}, + &ModelData{}, + &Setting{}, + &ModuleManagementLog{}, + &ModuleMigrationHistory{}, + &LockLease{}, + } +} + +// CatalogEntities returns pkg/meta Entities plus DualStoreRawEntities and +// OpsEntities for a single AutoMigrate of the full catalog (effective +// projection + declaration tables + module admin/ops satellite tables). +func CatalogEntities() []any { + out := append([]any{}, pkgmeta.Entities()...) + out = append(out, DualStoreRawEntities()...) + return append(out, OpsEntities()...) +} diff --git a/internal/module/meta/entities_test.go b/internal/module/meta/entities_test.go new file mode 100644 index 000000000..33d1d9f67 --- /dev/null +++ b/internal/module/meta/entities_test.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +import ( + "reflect" + "testing" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" +) + +func TestDualStoreRawAndCatalogEntities(t *testing.T) { + rawExpected := []reflect.Type{ + reflect.TypeOf(&rawModel{}), + reflect.TypeOf(&rawField{}), + reflect.TypeOf(&rawService{}), + reflect.TypeOf(&rawTypeParameter{}), + reflect.TypeOf(&rawParameter{}), + reflect.TypeOf(&rawDecorator{}), + reflect.TypeOf(&rawArgument{}), + } + rawEntities := DualStoreRawEntities() + if len(rawEntities) != len(rawExpected) { + t.Fatalf("DualStoreRawEntities() len = %d, want %d", len(rawEntities), len(rawExpected)) + } + for index, entity := range rawEntities { + if reflect.TypeOf(entity) != rawExpected[index] { + t.Fatalf("DualStoreRawEntities()[%d] type = %v, want %v", index, reflect.TypeOf(entity), rawExpected[index]) + } + } + + entities := pkgmeta.Entities() + opsEntities := OpsEntities() + catalog := CatalogEntities() + wantLen := len(entities) + len(rawEntities) + len(opsEntities) + if len(catalog) != wantLen { + t.Fatalf("CatalogEntities() len = %d, want %d", len(catalog), wantLen) + } + + tableNames := []struct { + name string + got string + want string + }{ + {name: "rawModel", got: (&rawModel{}).TableName(), want: "meta_raw_model"}, + {name: "rawField", got: (&rawField{}).TableName(), want: "meta_raw_field"}, + {name: "rawService", got: (&rawService{}).TableName(), want: "meta_raw_service"}, + {name: "rawTypeParameter", got: (&rawTypeParameter{}).TableName(), want: "meta_raw_type_parameter"}, + {name: "rawParameter", got: (&rawParameter{}).TableName(), want: "meta_raw_parameter"}, + {name: "rawDecorator", got: (&rawDecorator{}).TableName(), want: "meta_raw_decorator"}, + {name: "rawArgument", got: (&rawArgument{}).TableName(), want: "meta_raw_argument"}, + } + for _, check := range tableNames { + if check.got != check.want { + t.Fatalf("%s.TableName() = %q, want %q", check.name, check.got, check.want) + } + } +} + +func TestOpsEntitiesAndTableNames(t *testing.T) { + entities := OpsEntities() + if len(entities) != 6 { + t.Fatalf("OpsEntities() len = %d, want 6", len(entities)) + } + + wantTables := map[string]string{ + "ModuleIndex": "meta_module_index", + "ModelData": "meta_model_data", + "Setting": "meta_setting", + "ModuleMigrationHistory": "meta_module_migration_history", + "ModuleManagementLog": "meta_module_management_log", + "LockLease": "meta_lock_lease", + } + gotTables := map[string]string{ + "ModuleIndex": (&ModuleIndex{}).TableName(), + "ModelData": (&ModelData{}).TableName(), + "Setting": (&Setting{}).TableName(), + "ModuleMigrationHistory": (ModuleMigrationHistory{}).TableName(), + "ModuleManagementLog": (ModuleManagementLog{}).TableName(), + "LockLease": (&LockLease{}).TableName(), + } + for name, want := range wantTables { + got := gotTables[name] + if got != want { + t.Fatalf("%s.TableName() = %q, want %q", name, got, want) + } + } +} diff --git a/pkg/meta/extends_expand.go b/internal/module/meta/extends_expand.go similarity index 80% rename from pkg/meta/extends_expand.go rename to internal/module/meta/extends_expand.go index 2c7011958..31b566e01 100644 --- a/pkg/meta/extends_expand.go +++ b/internal/module/meta/extends_expand.go @@ -8,12 +8,13 @@ import ( "fmt" "strings" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/gorm" ) type expandsShape struct { - fields []*Field - services []*Service + fields []*pkgmeta.Field + services []*pkgmeta.Service } // expandsShapeKey scopes cache / cycle / local lookups by application + path so @@ -27,16 +28,16 @@ func expandsShapeKey(application, path string) string { // // Used by: // - schema migrator (DDL columns for thin subclasses) -// - RecomputeEffective (effective shape must include BaseModel methods/fields; +// - recomputeEffective (effective shape must include pkgmeta.BaseModel methods/fields; // E2 same-name union alone does not pull differently-named parents) // // Does not write to the database. -func ExpandModelsAlongExtends(db *gorm.DB, models []*Model) error { +func ExpandModelsAlongExtends(db *gorm.DB, models []*pkgmeta.Model) error { if db == nil { return fmt.Errorf("db is nil") } - localByAppPath := make(map[string]*Model, len(models)) - localByPath := make(map[string]*Model, len(models)) + localByAppPath := make(map[string]*pkgmeta.Model, len(models)) + localByPath := make(map[string]*pkgmeta.Model, len(models)) for _, m := range models { if m == nil || strings.TrimSpace(m.Path) == "" { continue @@ -62,9 +63,9 @@ func ExpandModelsAlongExtends(db *gorm.DB, models []*Model) error { func expandShapeAlongExtends( db *gorm.DB, - model *Model, - localByAppPath map[string]*Model, - localByPath map[string]*Model, + model *pkgmeta.Model, + localByAppPath map[string]*pkgmeta.Model, + localByPath map[string]*pkgmeta.Model, cache map[string]*expandsShape, visiting map[string]bool, ) (*expandsShape, error) { @@ -101,8 +102,8 @@ func expandShapeAlongExtends( } } - var parentFields []*Field - var parentServices []*Service + var parentFields []*pkgmeta.Field + var parentServices []*pkgmeta.Service if parentShape != nil { parentFields = parentShape.fields parentServices = parentShape.services @@ -123,10 +124,10 @@ func expandShapeAlongExtends( func resolveExtendsModel( db *gorm.DB, extendsPath string, - localByAppPath map[string]*Model, - localByPath map[string]*Model, + localByAppPath map[string]*pkgmeta.Model, + localByPath map[string]*pkgmeta.Model, preferredApp string, -) (*Model, error) { +) (*pkgmeta.Model, error) { if preferredApp != "" { if local, ok := localByAppPath[expandsShapeKey(preferredApp, extendsPath)]; ok { return local, nil @@ -174,8 +175,8 @@ func loadRawParentsByPath(db *gorm.DB, extendsPath, application string) ([]rawMo return raws, nil } -func mergeFieldsForSchema(parentFields, childFields []*Field, parentPath, childPath string) ([]*Field, error) { - result := make([]*Field, 0, len(parentFields)+len(childFields)) +func mergeFieldsForSchema(parentFields, childFields []*pkgmeta.Field, parentPath, childPath string) ([]*pkgmeta.Field, error) { + result := make([]*pkgmeta.Field, 0, len(parentFields)+len(childFields)) indexByName := make(map[string]int) for _, pf := range parentFields { @@ -202,7 +203,7 @@ func mergeFieldsForSchema(parentFields, childFields []*Field, parentPath, childP nf := cloneFieldShallow(cf) nf.OriginModelPath = childPath if idx, ok := indexByName[nf.Name]; ok { - merged, err := ResolveSelectionFieldConflict(result[idx], nf) + merged, err := resolveSelectionFieldConflict(result[idx], nf) if err != nil { return nil, err } @@ -212,7 +213,7 @@ func mergeFieldsForSchema(parentFields, childFields []*Field, parentPath, childP result[idx] = merged continue } - if FieldHasSelectionAdd(nf) { + if fieldHasSelectionAdd(nf) { return nil, fmt.Errorf("field %s selectionAdd requires an inherited static selection", nf.Name) } indexByName[nf.Name] = len(result) @@ -221,8 +222,8 @@ func mergeFieldsForSchema(parentFields, childFields []*Field, parentPath, childP return result, nil } -func mergeServicesForSchema(parentServices, childServices []*Service, parentPath, childPath string) []*Service { - result := make([]*Service, 0, len(parentServices)+len(childServices)) +func mergeServicesForSchema(parentServices, childServices []*pkgmeta.Service, parentPath, childPath string) []*pkgmeta.Service { + result := make([]*pkgmeta.Service, 0, len(parentServices)+len(childServices)) indexByName := make(map[string]int) for _, ps := range parentServices { @@ -258,22 +259,22 @@ func mergeServicesForSchema(parentServices, childServices []*Service, parentPath return result } -func cloneFieldShallow(src *Field) *Field { +func cloneFieldShallow(src *pkgmeta.Field) *pkgmeta.Field { if src == nil { return nil } dst := *src - dst.BaseModel = BaseModel{} + dst.BaseModel = pkgmeta.BaseModel{} dst.ModelId = sqlNullStringEmpty() dst.Model = nil if len(src.Decorators) > 0 { - dst.Decorators = make([]*Decorator, 0, len(src.Decorators)) + dst.Decorators = make([]*pkgmeta.Decorator, 0, len(src.Decorators)) for _, d := range src.Decorators { if d == nil { continue } dd := *d - dd.BaseModel = BaseModel{} + dd.BaseModel = pkgmeta.BaseModel{} dd.ModelId = sqlNullStringEmpty() dd.FieldId = sqlNullStringEmpty() dd.ServiceId = sqlNullStringEmpty() @@ -281,13 +282,13 @@ func cloneFieldShallow(src *Field) *Field { dd.Field = nil dd.Service = nil if len(d.Arguments) > 0 { - dd.Arguments = make([]*Argument, 0, len(d.Arguments)) + dd.Arguments = make([]*pkgmeta.Argument, 0, len(d.Arguments)) for _, a := range d.Arguments { if a == nil { continue } aa := *a - aa.BaseModel = BaseModel{} + aa.BaseModel = pkgmeta.BaseModel{} aa.DecoratorId = sqlNullStringEmpty() aa.Decorator = nil dd.Arguments = append(dd.Arguments, &aa) @@ -303,22 +304,22 @@ func cloneFieldShallow(src *Field) *Field { return &dst } -func cloneServiceShallow(src *Service) *Service { +func cloneServiceShallow(src *pkgmeta.Service) *pkgmeta.Service { if src == nil { return nil } dst := *src - dst.BaseModel = BaseModel{} + dst.BaseModel = pkgmeta.BaseModel{} dst.ModelId = sqlNullStringEmpty() dst.Model = nil if len(src.Parameters) > 0 { - dst.Parameters = make([]*Parameter, 0, len(src.Parameters)) + dst.Parameters = make([]*pkgmeta.Parameter, 0, len(src.Parameters)) for _, p := range src.Parameters { if p == nil { continue } pp := *p - pp.BaseModel = BaseModel{} + pp.BaseModel = pkgmeta.BaseModel{} pp.ServiceId = sqlNullStringEmpty() pp.Service = nil dst.Parameters = append(dst.Parameters, &pp) @@ -327,13 +328,13 @@ func cloneServiceShallow(src *Service) *Service { dst.Parameters = nil } if len(src.TypeParameters) > 0 { - dst.TypeParameters = make([]*TypeParameter, 0, len(src.TypeParameters)) + dst.TypeParameters = make([]*pkgmeta.TypeParameter, 0, len(src.TypeParameters)) for _, tp := range src.TypeParameters { if tp == nil { continue } tt := *tp - tt.BaseModel = BaseModel{} + tt.BaseModel = pkgmeta.BaseModel{} tt.ServiceId = sqlNullStringEmpty() tt.Service = nil dst.TypeParameters = append(dst.TypeParameters, &tt) @@ -342,13 +343,13 @@ func cloneServiceShallow(src *Service) *Service { dst.TypeParameters = nil } if len(src.Decorators) > 0 { - dst.Decorators = make([]*Decorator, 0, len(src.Decorators)) + dst.Decorators = make([]*pkgmeta.Decorator, 0, len(src.Decorators)) for _, d := range src.Decorators { if d == nil { continue } dd := *d - dd.BaseModel = BaseModel{} + dd.BaseModel = pkgmeta.BaseModel{} dd.ModelId = sqlNullStringEmpty() dd.FieldId = sqlNullStringEmpty() dd.ServiceId = sqlNullStringEmpty() @@ -357,13 +358,13 @@ func cloneServiceShallow(src *Service) *Service { dd.Service = nil dd.Arguments = nil if len(d.Arguments) > 0 { - dd.Arguments = make([]*Argument, 0, len(d.Arguments)) + dd.Arguments = make([]*pkgmeta.Argument, 0, len(d.Arguments)) for _, a := range d.Arguments { if a == nil { continue } aa := *a - aa.BaseModel = BaseModel{} + aa.BaseModel = pkgmeta.BaseModel{} aa.DecoratorId = sqlNullStringEmpty() aa.Decorator = nil dd.Arguments = append(dd.Arguments, &aa) diff --git a/pkg/meta/extends_expand_coverage_test.go b/internal/module/meta/extends_expand_coverage_test.go similarity index 68% rename from pkg/meta/extends_expand_coverage_test.go rename to internal/module/meta/extends_expand_coverage_test.go index 9fc51649a..e933c4cf2 100644 --- a/pkg/meta/extends_expand_coverage_test.go +++ b/internal/module/meta/extends_expand_coverage_test.go @@ -10,23 +10,24 @@ import ( "testing" "time" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/gorm" ) func TestExpandModelsAlongExtends_NilDB(t *testing.T) { - if err := ExpandModelsAlongExtends(nil, []*Model{{Name: "X"}}); err == nil || !strings.Contains(err.Error(), "db is nil") { + if err := ExpandModelsAlongExtends(nil, []*pkgmeta.Model{{Name: "X"}}); err == nil || !strings.Contains(err.Error(), "db is nil") { t.Fatalf("expected nil db error, got %v", err) } } func TestExpandModelsAlongExtends_SkipsNilAndEmptyPath(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } - child := &Model{Name: "Child", Path: "/child.ts", Fields: []*Field{{Name: "A"}}} - emptyPath := &Model{Name: "NoPath", Path: " ", Fields: []*Field{{Name: "B"}}} - if err := ExpandModelsAlongExtends(db, []*Model{nil, child, emptyPath}); err != nil { + child := &pkgmeta.Model{Name: "Child", Path: "/child.ts", Fields: []*pkgmeta.Field{{Name: "A"}}} + emptyPath := &pkgmeta.Model{Name: "NoPath", Path: " ", Fields: []*pkgmeta.Field{{Name: "B"}}} + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{nil, child, emptyPath}); err != nil { t.Fatalf("expand: %v", err) } if len(child.Fields) != 1 || child.Fields[0].Name != "A" { @@ -39,12 +40,12 @@ func TestExpandModelsAlongExtends_SkipsNilAndEmptyPath(t *testing.T) { func TestExpandModelsAlongExtends_CacheHitViaTwoChildren(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } parentPath := "/parent.ts" parent := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-p", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-p", Valid: true}}, Name: "Parent", Path: parentPath, ModuleId: sql.NullString{String: "mod", Valid: true}, @@ -53,26 +54,26 @@ func TestExpandModelsAlongExtends_CacheHitViaTwoChildren(t *testing.T) { t.Fatalf("create parent: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "pf", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "pf", Valid: true}}, Name: "Shared", ModelId: parent.Id, }).Error; err != nil { t.Fatalf("create parent field: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawService{ - BaseModel: BaseModel{Id: sql.NullString{String: "ps", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ps", Valid: true}}, Name: "ParentSvc", ModelId: parent.Id, }).Error; err != nil { t.Fatalf("create parent service: %v", err) } - childA := &Model{Name: "A", Path: "/a.ts", Extends: parentPath, Fields: []*Field{{Name: "AField"}}} - childB := &Model{Name: "B", Path: "/b.ts", Extends: parentPath, Fields: []*Field{{Name: "BField"}}} - if err := ExpandModelsAlongExtends(db, []*Model{childA, childB}); err != nil { + childA := &pkgmeta.Model{Name: "A", Path: "/a.ts", Extends: parentPath, Fields: []*pkgmeta.Field{{Name: "AField"}}} + childB := &pkgmeta.Model{Name: "B", Path: "/b.ts", Extends: parentPath, Fields: []*pkgmeta.Field{{Name: "BField"}}} + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{childA, childB}); err != nil { t.Fatalf("expand: %v", err) } - for _, m := range []*Model{childA, childB} { + for _, m := range []*pkgmeta.Model{childA, childB} { names := map[string]bool{} for _, f := range m.Fields { names[f.Name] = true @@ -92,28 +93,28 @@ func TestExpandModelsAlongExtends_CacheHitViaTwoChildren(t *testing.T) { func TestExpandModelsAlongExtends_CircularExtends(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } - a := &Model{Name: "A", Path: "/a.ts", Extends: "/b.ts"} - b := &Model{Name: "B", Path: "/b.ts", Extends: "/a.ts"} - if err := ExpandModelsAlongExtends(db, []*Model{a, b}); err == nil || !strings.Contains(err.Error(), "circular extends") { + a := &pkgmeta.Model{Name: "A", Path: "/a.ts", Extends: "/b.ts"} + b := &pkgmeta.Model{Name: "B", Path: "/b.ts", Extends: "/a.ts"} + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{a, b}); err == nil || !strings.Contains(err.Error(), "circular extends") { t.Fatalf("expected circular error, got %v", err) } } func TestExpandModelsAlongExtends_MissingParentInDB(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } - child := &Model{ + child := &pkgmeta.Model{ Name: "Child", Path: "/child.ts", Extends: "/missing/parent.ts", - Fields: []*Field{{Name: "OnlyChild"}}, + Fields: []*pkgmeta.Field{{Name: "OnlyChild"}}, } - if err := ExpandModelsAlongExtends(db, []*Model{child}); err != nil { + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{child}); err != nil { t.Fatalf("expand: %v", err) } if len(child.Fields) != 1 || child.Fields[0].Name != "OnlyChild" { @@ -123,21 +124,21 @@ func TestExpandModelsAlongExtends_MissingParentInDB(t *testing.T) { func TestResolveExtendsModel_DBError(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } if err := db.Migrator().DropTable(&rawModel{}); err != nil { t.Fatalf("drop: %v", err) } - child := &Model{Name: "Child", Path: "/c.ts", Extends: "/parent.ts", Fields: []*Field{{Name: "X"}}} - if err := ExpandModelsAlongExtends(db, []*Model{child}); err == nil || !strings.Contains(err.Error(), "load raw parent") { + child := &pkgmeta.Model{Name: "Child", Path: "/c.ts", Extends: "/parent.ts", Fields: []*pkgmeta.Field{{Name: "X"}}} + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{child}); err == nil || !strings.Contains(err.Error(), "load raw parent") { t.Fatalf("expected load raw parent error, got %v", err) } } func TestExpandModelsAlongExtends_PrefersSameApplicationParent(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } parentPath := "/shared/base.ts" @@ -145,7 +146,7 @@ func TestExpandModelsAlongExtends_PrefersSameApplicationParent(t *testing.T) { foreignCreated := homeCreated.Add(time.Minute) // Newer foreign-app parent with a distinctive field would win if path-only. foreign := &rawModel{ - BaseModel: BaseModel{ + BaseModel: pkgmeta.BaseModel{ Id: sql.NullString{String: "foreign-parent", Valid: true}, CreatedAt: foreignCreated, UpdatedAt: foreignCreated, @@ -156,7 +157,7 @@ func TestExpandModelsAlongExtends_PrefersSameApplicationParent(t *testing.T) { ModuleId: sql.NullString{String: "mod-other", Valid: true}, } home := &rawModel{ - BaseModel: BaseModel{ + BaseModel: pkgmeta.BaseModel{ Id: sql.NullString{String: "home-parent", Valid: true}, CreatedAt: homeCreated, UpdatedAt: homeCreated, @@ -172,27 +173,27 @@ func TestExpandModelsAlongExtends_PrefersSameApplicationParent(t *testing.T) { } } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "ff", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ff", Valid: true}}, Name: "ForeignOnly", ModelId: foreign.Id, }).Error; err != nil { t.Fatalf("create foreign field: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "hf", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "hf", Valid: true}}, Name: "HomeOnly", ModelId: home.Id, }).Error; err != nil { t.Fatalf("create home field: %v", err) } - child := &Model{ + child := &pkgmeta.Model{ Name: "Child", Path: "/home/child.ts", Application: "home", Extends: parentPath, - Fields: []*Field{{Name: "ChildField"}}, + Fields: []*pkgmeta.Field{{Name: "ChildField"}}, } - if err := ExpandModelsAlongExtends(db, []*Model{child}); err != nil { + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{child}); err != nil { t.Fatalf("expand: %v", err) } names := map[string]bool{} @@ -208,14 +209,14 @@ func TestExpandModelsAlongExtends_PrefersSameApplicationParent(t *testing.T) { func TestExpandModelsAlongExtends_BatchKeepsPerApplicationParentShapes(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } parentPath := "/shared/base-batch.ts" homeCreated := time.Now().UTC().Add(-time.Hour) foreignCreated := homeCreated.Add(time.Minute) foreign := &rawModel{ - BaseModel: BaseModel{ + BaseModel: pkgmeta.BaseModel{ Id: sql.NullString{String: "batch-foreign-parent", Valid: true}, CreatedAt: foreignCreated, UpdatedAt: foreignCreated, @@ -226,7 +227,7 @@ func TestExpandModelsAlongExtends_BatchKeepsPerApplicationParentShapes(t *testin ModuleId: sql.NullString{String: "mod-other", Valid: true}, } home := &rawModel{ - BaseModel: BaseModel{ + BaseModel: pkgmeta.BaseModel{ Id: sql.NullString{String: "batch-home-parent", Valid: true}, CreatedAt: homeCreated, UpdatedAt: homeCreated, @@ -242,36 +243,36 @@ func TestExpandModelsAlongExtends_BatchKeepsPerApplicationParentShapes(t *testin } } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "batch-ff", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "batch-ff", Valid: true}}, Name: "ForeignOnly", ModelId: foreign.Id, }).Error; err != nil { t.Fatalf("create foreign field: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "batch-hf", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "batch-hf", Valid: true}}, Name: "HomeOnly", ModelId: home.Id, }).Error; err != nil { t.Fatalf("create home field: %v", err) } - homeChild := &Model{ + homeChild := &pkgmeta.Model{ Name: "HomeChild", Path: "/home/batch-child.ts", Application: "home", Extends: parentPath, - Fields: []*Field{{Name: "HomeChildField"}}, + Fields: []*pkgmeta.Field{{Name: "HomeChildField"}}, } - foreignChild := &Model{ + foreignChild := &pkgmeta.Model{ Name: "ForeignChild", Path: "/other/batch-child.ts", Application: "other", Extends: parentPath, - Fields: []*Field{{Name: "ForeignChildField"}}, + Fields: []*pkgmeta.Field{{Name: "ForeignChildField"}}, } // Expand both in one call so path-keyed caching would cross-contaminate. - if err := ExpandModelsAlongExtends(db, []*Model{homeChild, foreignChild}); err != nil { + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{homeChild, foreignChild}); err != nil { t.Fatalf("expand: %v", err) } @@ -297,12 +298,12 @@ func TestExpandModelsAlongExtends_BatchKeepsPerApplicationParentShapes(t *testin func TestExpandModelsAlongExtends_CrossAppParentFallback(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } parentPath := "/shared/only-other.ts" foreign := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "only-other", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "only-other", Valid: true}}, Name: "Base", Path: parentPath, Application: "other", @@ -312,20 +313,20 @@ func TestExpandModelsAlongExtends_CrossAppParentFallback(t *testing.T) { t.Fatalf("create foreign parent: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "of", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "of", Valid: true}}, Name: "OtherField", ModelId: foreign.Id, }).Error; err != nil { t.Fatalf("create field: %v", err) } - child := &Model{ + child := &pkgmeta.Model{ Name: "Child", Path: "/home/child2.ts", Application: "home", Extends: parentPath, - Fields: []*Field{{Name: "ChildField"}}, + Fields: []*pkgmeta.Field{{Name: "ChildField"}}, } - if err := ExpandModelsAlongExtends(db, []*Model{child}); err != nil { + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{child}); err != nil { t.Fatalf("expand: %v", err) } names := map[string]bool{} @@ -341,7 +342,7 @@ func TestExpandModelsAlongExtends_CrossAppParentFallback(t *testing.T) { func TestResolveExtendsModel_CrossAppFallbackLoadError(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } calls := 0 @@ -372,8 +373,8 @@ func TestMergeFieldsForSchema_Coverage(t *testing.T) { // nil/empty name fields skipped got, err := mergeFieldsForSchema( - []*Field{nil, {Name: ""}, {Name: "P1"}}, - []*Field{nil, {Name: ""}, {Name: "C1"}}, + []*pkgmeta.Field{nil, {Name: ""}, {Name: "P1"}}, + []*pkgmeta.Field{nil, {Name: ""}, {Name: "C1"}}, parentPath, childPath, ) if err != nil || len(got) != 2 { @@ -382,7 +383,7 @@ func TestMergeFieldsForSchema_Coverage(t *testing.T) { // duplicate parent names skipped got, err = mergeFieldsForSchema( - []*Field{{Name: "Dup"}, {Name: "Dup"}}, + []*pkgmeta.Field{{Name: "Dup"}, {Name: "Dup"}}, nil, parentPath, childPath, ) if err != nil || len(got) != 1 { @@ -391,7 +392,7 @@ func TestMergeFieldsForSchema_Coverage(t *testing.T) { // OriginModelPath already set on parent got, err = mergeFieldsForSchema( - []*Field{{Name: "F", OriginModelPath: "custom-origin"}}, + []*pkgmeta.Field{{Name: "F", OriginModelPath: "custom-origin"}}, nil, parentPath, childPath, ) if err != nil || got[0].OriginModelPath != "custom-origin" { @@ -400,8 +401,8 @@ func TestMergeFieldsForSchema_Coverage(t *testing.T) { // child override replaces parent field got, err = mergeFieldsForSchema( - []*Field{{Name: "F", FieldType: "varchar"}}, - []*Field{{Name: "F", FieldType: "int"}}, + []*pkgmeta.Field{{Name: "F", FieldType: "varchar"}}, + []*pkgmeta.Field{{Name: "F", FieldType: "int"}}, parentPath, childPath, ) if err != nil || got[0].FieldType != "int" || got[0].OriginModelPath != childPath { @@ -409,42 +410,42 @@ func TestMergeFieldsForSchema_Coverage(t *testing.T) { } // selectionAdd without parent → error - childAdd := &Field{Name: "Kind"} - _ = childAdd.SetResolvedSpec(&FieldResolvedSpec{ + childAdd := &pkgmeta.Field{Name: "Kind"} + _ = childAdd.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Kind", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Kind", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "vip", Label: "VIP"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "vip", Label: "VIP"}}, }, }) - if _, err := mergeFieldsForSchema(nil, []*Field{childAdd}, parentPath, childPath); err == nil || !strings.Contains(err.Error(), "selectionAdd") { + if _, err := mergeFieldsForSchema(nil, []*pkgmeta.Field{childAdd}, parentPath, childPath); err == nil || !strings.Contains(err.Error(), "selectionAdd") { t.Fatalf("expected selectionAdd error, got %v", err) } - // ResolveSelectionFieldConflict error: dynamic parent + selectionAdd child - base := &Field{Name: "Status", FieldType: "selection", SelectionKind: "dynamic", SelectionMethod: "Opts"} - _ = base.SetResolvedSpec(&FieldResolvedSpec{ + // resolveSelectionFieldConflict error: dynamic parent + selectionAdd child + base := &pkgmeta.Field{Name: "Status", FieldType: "selection", SelectionKind: "dynamic", SelectionMethod: "Opts"} + _ = base.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", SelectionKind: "dynamic", SelectionMethod: "Opts", }, }) - childConflict := &Field{Name: "Status"} - _ = childConflict.SetResolvedSpec(&FieldResolvedSpec{ + childConflict := &pkgmeta.Field{Name: "Status"} + _ = childConflict.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "x", Label: "X"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "x", Label: "X"}}, }, }) - if _, err := mergeFieldsForSchema([]*Field{base}, []*Field{childConflict}, parentPath, childPath); err == nil || !strings.Contains(err.Error(), "inherited static selection") { + if _, err := mergeFieldsForSchema([]*pkgmeta.Field{base}, []*pkgmeta.Field{childConflict}, parentPath, childPath); err == nil || !strings.Contains(err.Error(), "inherited static selection") { t.Fatalf("expected conflict error, got %v", err) } } @@ -454,8 +455,8 @@ func TestMergeServicesForSchema_Coverage(t *testing.T) { childPath := "/child.ts" got := mergeServicesForSchema( - []*Service{nil, {Name: ""}, {Name: "PSvc", OriginModelPath: "kept-origin"}}, - []*Service{nil, {Name: ""}, {Name: "CSvc"}}, + []*pkgmeta.Service{nil, {Name: ""}, {Name: "PSvc", OriginModelPath: "kept-origin"}}, + []*pkgmeta.Service{nil, {Name: ""}, {Name: "CSvc"}}, parentPath, childPath, ) if len(got) != 2 { @@ -470,7 +471,7 @@ func TestMergeServicesForSchema_Coverage(t *testing.T) { // duplicate parent skipped got = mergeServicesForSchema( - []*Service{{Name: "Dup"}, {Name: "Dup"}}, + []*pkgmeta.Service{{Name: "Dup"}, {Name: "Dup"}}, nil, parentPath, childPath, ) if len(got) != 1 { @@ -479,8 +480,8 @@ func TestMergeServicesForSchema_Coverage(t *testing.T) { // child override got = mergeServicesForSchema( - []*Service{{Name: "S", TsTypeAnnotation: "old"}}, - []*Service{{Name: "S", TsTypeAnnotation: "new"}}, + []*pkgmeta.Service{{Name: "S", TsTypeAnnotation: "old"}}, + []*pkgmeta.Service{{Name: "S", TsTypeAnnotation: "new"}}, parentPath, childPath, ) if len(got) != 1 || got[0].TsTypeAnnotation != "new" || got[0].OriginModelPath != childPath { @@ -494,16 +495,16 @@ func TestCloneFieldShallow_Coverage(t *testing.T) { } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - src := &Field{ - BaseModel: BaseModel{Id: sql.NullString{String: "f1", Valid: true}, CreatedAt: ts}, + src := &pkgmeta.Field{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "f1", Valid: true}, CreatedAt: ts}, Name: "Name", ModelId: sql.NullString{String: "mid", Valid: true}, - Decorators: []*Decorator{ + Decorators: []*pkgmeta.Decorator{ nil, {Name: "EmptyDec"}, { Name: "FullDec", - Arguments: []*Argument{ + Arguments: []*pkgmeta.Argument{ nil, {Type: "string", Value: `"x"`}, }, @@ -524,7 +525,7 @@ func TestCloneFieldShallow_Coverage(t *testing.T) { t.Fatalf("arguments: %#v", dst.Decorators[1].Arguments) } - emptyDec := &Field{Name: "NoDec", Decorators: []*Decorator{}} + emptyDec := &pkgmeta.Field{Name: "NoDec", Decorators: []*pkgmeta.Decorator{}} dstEmpty := cloneFieldShallow(emptyDec) if dstEmpty.Decorators != nil { t.Fatalf("empty decorators should be nil, got %#v", dstEmpty.Decorators) @@ -537,24 +538,24 @@ func TestCloneServiceShallow_Coverage(t *testing.T) { } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - src := &Service{ - BaseModel: BaseModel{Id: sql.NullString{String: "s1", Valid: true}, CreatedAt: ts}, + src := &pkgmeta.Service{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "s1", Valid: true}, CreatedAt: ts}, Name: "Create", ModelId: sql.NullString{String: "mid", Valid: true}, - Parameters: []*Parameter{ + Parameters: []*pkgmeta.Parameter{ nil, {Name: "vals"}, }, - TypeParameters: []*TypeParameter{ + TypeParameters: []*pkgmeta.TypeParameter{ nil, {Name: "T"}, }, - Decorators: []*Decorator{ + Decorators: []*pkgmeta.Decorator{ nil, {Name: "NoArgs"}, { Name: "WithArgs", - Arguments: []*Argument{ + Arguments: []*pkgmeta.Argument{ nil, {Type: "string", Value: `"y"`}, }, @@ -578,7 +579,7 @@ func TestCloneServiceShallow_Coverage(t *testing.T) { t.Fatalf("arguments: %#v", dst.Decorators[1].Arguments) } - empty := &Service{Name: "Bare"} + empty := &pkgmeta.Service{Name: "Bare"} dstEmpty := cloneServiceShallow(empty) if dstEmpty.Parameters != nil || dstEmpty.TypeParameters != nil || dstEmpty.Decorators != nil { t.Fatalf("empty slices should be nil: %#v", dstEmpty) @@ -594,24 +595,24 @@ func TestExpandShapeAlongExtends_NilModel(t *testing.T) { func TestExpandModelsAlongExtends_LocalParentOnly(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } parentPath := "/local/parent.ts" childPath := "/local/child.ts" - parent := &Model{ + parent := &pkgmeta.Model{ Name: "Parent", Path: parentPath, - Fields: []*Field{{Name: "Inherited"}}, - Services: []*Service{{Name: "InheritedSvc"}}, + Fields: []*pkgmeta.Field{{Name: "Inherited"}}, + Services: []*pkgmeta.Service{{Name: "InheritedSvc"}}, } - child := &Model{ + child := &pkgmeta.Model{ Name: "Child", Path: childPath, Extends: parentPath, - Fields: []*Field{{Name: "Own"}}, + Fields: []*pkgmeta.Field{{Name: "Own"}}, } - if err := ExpandModelsAlongExtends(db, []*Model{parent, child}); err != nil { + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{parent, child}); err != nil { t.Fatalf("expand: %v", err) } names := map[string]bool{} @@ -625,46 +626,46 @@ func TestExpandModelsAlongExtends_LocalParentOnly(t *testing.T) { func TestExpandModelsAlongExtends_MergeFieldsErrorPropagates(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } parentPath := "/err/parent.ts" childPath := "/err/child.ts" - base := &Field{Name: "Status", FieldType: "selection", SelectionKind: "dynamic", SelectionMethod: "Opts"} - _ = base.SetResolvedSpec(&FieldResolvedSpec{ + base := &pkgmeta.Field{Name: "Status", FieldType: "selection", SelectionKind: "dynamic", SelectionMethod: "Opts"} + _ = base.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", SelectionKind: "dynamic", SelectionMethod: "Opts", }, }) - childField := &Field{Name: "Status"} - _ = childField.SetResolvedSpec(&FieldResolvedSpec{ + childField := &pkgmeta.Field{Name: "Status"} + _ = childField.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "x", Label: "X"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "x", Label: "X"}}, }, }) - parent := &Model{Path: parentPath, Fields: []*Field{base}} - child := &Model{Path: childPath, Extends: parentPath, Fields: []*Field{childField}} - if err := ExpandModelsAlongExtends(db, []*Model{parent, child}); err == nil || !strings.Contains(err.Error(), "inherited static selection") { + parent := &pkgmeta.Model{Path: parentPath, Fields: []*pkgmeta.Field{base}} + child := &pkgmeta.Model{Path: childPath, Extends: parentPath, Fields: []*pkgmeta.Field{childField}} + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{parent, child}); err == nil || !strings.Contains(err.Error(), "inherited static selection") { t.Fatalf("expected merge error, got %v", err) } } func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } parentPath := "/db/parent.ts" parent := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "db-parent", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "db-parent", Valid: true}}, Name: "DbParent", Path: parentPath, ModuleId: sql.NullString{String: "mod", Valid: true}, @@ -673,7 +674,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { t.Fatalf("create parent: %v", err) } field := &rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "dbf", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dbf", Valid: true}}, Name: "F", ModelId: parent.Id, } @@ -681,7 +682,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { t.Fatalf("create field: %v", err) } fieldDec := &rawDecorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "dbfd", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dbfd", Valid: true}}, Name: "FieldDec", FieldId: sql.NullString{String: field.Id.String, Valid: true}, } @@ -689,7 +690,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { t.Fatalf("create field dec: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawArgument{ - BaseModel: BaseModel{Id: sql.NullString{String: "dbfa", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dbfa", Valid: true}}, Type: "string", Value: `"x"`, DecoratorId: fieldDec.Id, @@ -697,7 +698,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { t.Fatalf("create field arg: %v", err) } svc := &rawService{ - BaseModel: BaseModel{Id: sql.NullString{String: "dbs", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dbs", Valid: true}}, Name: "Svc", ModelId: parent.Id, } @@ -705,7 +706,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { t.Fatalf("create service: %v", err) } svcDec := &rawDecorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "dbsd", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dbsd", Valid: true}}, Name: "SvcDec", ServiceId: svc.Id, } @@ -713,7 +714,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { t.Fatalf("create svc dec: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawArgument{ - BaseModel: BaseModel{Id: sql.NullString{String: "dbsa", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dbsa", Valid: true}}, Type: "string", Value: `"y"`, DecoratorId: svcDec.Id, @@ -721,8 +722,8 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { t.Fatalf("create svc arg: %v", err) } - child := &Model{Name: "Child", Path: "/db/child.ts", Extends: parentPath} - if err := ExpandModelsAlongExtends(db, []*Model{child}); err != nil { + child := &pkgmeta.Model{Name: "Child", Path: "/db/child.ts", Extends: parentPath} + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{child}); err != nil { t.Fatalf("expand: %v", err) } if len(child.Fields) != 1 || len(child.Services) != 1 { diff --git a/pkg/meta/extends_expand_test.go b/internal/module/meta/extends_expand_test.go similarity index 80% rename from pkg/meta/extends_expand_test.go rename to internal/module/meta/extends_expand_test.go index 7ed759559..8104831c5 100644 --- a/pkg/meta/extends_expand_test.go +++ b/internal/module/meta/extends_expand_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -19,14 +20,14 @@ func TestExpandModelsAlongExtends_MergesParentFields(t *testing.T) { if err != nil { t.Fatalf("open: %v", err) } - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } parentPath := "/core/service/orm/model/model.ts" childPath := "/base/service/models/address.ts" parent := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-parent", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-parent", Valid: true}}, Name: "BaseModel", Path: parentPath, Abstract: true, @@ -36,7 +37,7 @@ func TestExpandModelsAlongExtends_MergesParentFields(t *testing.T) { t.Fatalf("create parent: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "f-id", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "f-id", Valid: true}}, Name: "Id", ResolvedSpec: `{"fieldName":"Id","structural":{"name":"Id","fieldType":"varchar"},"migration":{"shouldCreateColumn":true,"resolvedColumnType":"varchar"}}`, ModelId: parent.Id, @@ -44,25 +45,25 @@ func TestExpandModelsAlongExtends_MergesParentFields(t *testing.T) { t.Fatalf("create parent field: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawService{ - BaseModel: BaseModel{Id: sql.NullString{String: "s-create", Valid: true}}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "s-create", Valid: true}}, Name: "Create", ModelId: parent.Id, }).Error; err != nil { t.Fatalf("create parent service: %v", err) } - child := &Model{ + child := &pkgmeta.Model{ Name: "Address", Path: childPath, ModelTable: "base_address", Extends: parentPath, - Fields: []*Field{{ + Fields: []*pkgmeta.Field{{ Name: "Street1", ResolvedSpec: `{"fieldName":"Street1","structural":{"name":"Street1","fieldType":"varchar"},"migration":{"shouldCreateColumn":true,"resolvedColumnType":"varchar"}}`, }}, - Services: []*Service{{Name: "Normalize"}}, + Services: []*pkgmeta.Service{{Name: "Normalize"}}, } - if err := ExpandModelsAlongExtends(db, []*Model{child}); err != nil { + if err := ExpandModelsAlongExtends(db, []*pkgmeta.Model{child}); err != nil { t.Fatalf("expand: %v", err) } if len(child.Fields) != 2 { diff --git a/pkg/meta/lookup_effective.go b/internal/module/meta/lookup_effective.go similarity index 90% rename from pkg/meta/lookup_effective.go rename to internal/module/meta/lookup_effective.go index 42eea9634..ffdc9c1dc 100644 --- a/pkg/meta/lookup_effective.go +++ b/internal/module/meta/lookup_effective.go @@ -8,13 +8,14 @@ import ( "fmt" "strings" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/gorm" ) // LookupEffectiveModel returns the live effective meta_model row for (application, name). // Prefer empty module_id (E2 projection) when legacy declaration shells still coexist. // No tip Order — callers must not use created_at/id DESC to pick among same-name rows. -func LookupEffectiveModel(db *gorm.DB, application, name string) (*Model, error) { +func LookupEffectiveModel(db *gorm.DB, application, name string) (*pkgmeta.Model, error) { if db == nil { return nil, fmt.Errorf("db is nil") } @@ -24,7 +25,7 @@ func LookupEffectiveModel(db *gorm.DB, application, name string) (*Model, error) return nil, fmt.Errorf("lookup effective requires application and name") } - var rows []Model + var rows []pkgmeta.Model if err := db.Where("application = ? AND name = ?", application, name).Find(&rows).Error; err != nil { return nil, fmt.Errorf("lookup effective %s.%s: %w", application, name, err) } @@ -35,11 +36,11 @@ func LookupEffectiveModel(db *gorm.DB, application, name string) (*Model, error) return &picked, nil } -func pickEffectiveAmong(rows []Model) Model { +func pickEffectiveAmong(rows []pkgmeta.Model) pkgmeta.Model { if len(rows) == 1 { return rows[0] } - var best *Model + var best *pkgmeta.Model for i := range rows { row := &rows[i] emptyModule := !row.ModuleId.Valid || strings.TrimSpace(row.ModuleId.String) == "" diff --git a/pkg/meta/lookup_effective_test.go b/internal/module/meta/lookup_effective_test.go similarity index 55% rename from pkg/meta/lookup_effective_test.go rename to internal/module/meta/lookup_effective_test.go index e9f0d3db0..c08fad19f 100644 --- a/pkg/meta/lookup_effective_test.go +++ b/internal/module/meta/lookup_effective_test.go @@ -9,35 +9,21 @@ import ( "testing" "time" - "gorm.io/driver/sqlite" - "gorm.io/gorm" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) -func TestLookupEffectiveModel_PrefersEmptyModuleID(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:lookup-eff?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := EnsureDualStoreTables(db); err != nil { +func TestLookupEffectiveModel_FindsLiveRow(t *testing.T) { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure dual store: %v", err) } ts := time.Now().UTC() - shell := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "shell", Valid: true}, CreatedAt: ts, UpdatedAt: ts.Add(time.Hour)}, - Name: "Partner", - Path: "/shell", - Application: "partner", - ModuleId: sql.NullString{String: "mod-1", Valid: true}, - } - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + eff := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "eff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Partner", Path: "/eff", Application: "partner", } - if err := db.Create(shell).Error; err != nil { - t.Fatalf("create shell: %v", err) - } if err := db.Create(eff).Error; err != nil { t.Fatalf("create eff: %v", err) } @@ -47,7 +33,7 @@ func TestLookupEffectiveModel_PrefersEmptyModuleID(t *testing.T) { t.Fatalf("LookupEffectiveModel: %v", err) } if got.Id.String != "eff" { - t.Fatalf("expected empty-module_id row, got %#v", got) + t.Fatalf("expected effective row, got %#v", got) } if _, err := LookupEffectiveModel(db, "partner", "Missing"); !IsEffectiveModelNotFound(err) { @@ -63,11 +49,8 @@ func TestLookupEffectiveModel_PrefersEmptyModuleID(t *testing.T) { func TestLookupEffectiveModel_FindErrorAndPickBranches(t *testing.T) { t.Run("find_error_closed_db", func(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:lookup-eff-closed?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatalf("open: %v", err) - } - if err := EnsureDualStoreTables(db); err != nil { + db := openDualStoreTestDB(t) + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } sqlDB, err := db.DB() @@ -84,12 +67,12 @@ func TestLookupEffectiveModel_FindErrorAndPickBranches(t *testing.T) { t.Run("single_row_and_tie_breaks", func(t *testing.T) { ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - assertPick := func(want string, rows ...Model) { + assertPick := func(want string, rows ...pkgmeta.Model) { t.Helper() if got := pickEffectiveAmong(rows); got.Id.String != want { t.Fatalf("forward want %s, got %#v", want, got) } - rev := make([]Model, len(rows)) + rev := make([]pkgmeta.Model, len(rows)) for i := range rows { rev[len(rows)-1-i] = rows[i] } @@ -98,62 +81,62 @@ func TestLookupEffectiveModel_FindErrorAndPickBranches(t *testing.T) { } } - only := Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "only", Valid: true}, UpdatedAt: ts}, + only := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "only", Valid: true}, UpdatedAt: ts}, Name: "X", Application: "a", - ModuleId: sql.NullString{String: "mod", Valid: true}, + ModuleId: sql.NullString{String: "mod", Valid: true}, } assertPick("only", only) - older := Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "a-id", Valid: true}, UpdatedAt: ts}, + older := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "a-id", Valid: true}, UpdatedAt: ts}, ModuleId: sql.NullString{String: "m1", Valid: true}, } - newer := Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "b-id", Valid: true}, UpdatedAt: ts.Add(time.Hour)}, + newer := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "b-id", Valid: true}, UpdatedAt: ts.Add(time.Hour)}, ModuleId: sql.NullString{String: "m2", Valid: true}, } assertPick("b-id", older, newer) - low := Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "aaa", Valid: true}, UpdatedAt: ts}, + low := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "aaa", Valid: true}, UpdatedAt: ts}, } - high := Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "zzz", Valid: true}, UpdatedAt: ts}, + high := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "zzz", Valid: true}, UpdatedAt: ts}, } assertPick("zzz", low, high) // Whitespace ModuleId counts as empty; prefer it over shell. - shell := Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "shell", Valid: true}, UpdatedAt: ts.Add(time.Hour)}, + shell := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "shell", Valid: true}, UpdatedAt: ts.Add(time.Hour)}, ModuleId: sql.NullString{String: "mod", Valid: true}, } - wsEmpty := Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "ws", Valid: true}, UpdatedAt: ts}, + wsEmpty := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ws", Valid: true}, UpdatedAt: ts}, ModuleId: sql.NullString{String: " ", Valid: true}, } assertPick("ws", shell, wsEmpty) // Three-way fold must match all-at-once pick (map-iteration order independence). - mid := Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "mid", Valid: true}, UpdatedAt: ts.Add(30 * time.Minute)}, + mid := pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "mid", Valid: true}, UpdatedAt: ts.Add(30 * time.Minute)}, ModuleId: sql.NullString{String: "m3", Valid: true}, } - all := []Model{older, mid, newer} + all := []pkgmeta.Model{older, mid, newer} want := pickEffectiveAmong(all).Id.String - fold := func(rows []Model) string { + fold := func(rows []pkgmeta.Model) string { best := rows[0] for i := 1; i < len(rows); i++ { - best = pickEffectiveAmong([]Model{best, rows[i]}) + best = pickEffectiveAmong([]pkgmeta.Model{best, rows[i]}) } return best.Id.String } if got := fold(all); got != want { t.Fatalf("forward fold %s != all-at-once %s", got, want) } - rev := []Model{newer, mid, older} + rev := []pkgmeta.Model{newer, mid, older} if got := fold(rev); got != want { t.Fatalf("reverse fold %s != all-at-once %s", got, want) } }) -} \ No newline at end of file +} diff --git a/internal/state/lease/model/lease.go b/internal/module/meta/meta_lock_lease.go similarity index 54% rename from internal/state/lease/model/lease.go rename to internal/module/meta/meta_lock_lease.go index ea385c2da..70a05dbf0 100644 --- a/internal/state/lease/model/lease.go +++ b/internal/module/meta/meta_lock_lease.go @@ -1,27 +1,25 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: LGPL-3.0-or-later -package model +package meta import ( "time" - "github.com/choysum-dev/choysum/pkg/meta" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) -// LockLease provides a DB-agnostic mutual exclusion primitive. -// It is intentionally simple to support Postgres/MySQL/SQLite via GORM. +// LockLease provides a DB-agnostic mutual exclusion primitive (table meta_lock_lease). +// Acquire/Renew/Release logic lives in internal/state/lease. // // Resource should be globally unique (uniqueIndex) to represent one lock. // ExpiresAt enables lease semantics to recover from crashed owners. // OwnerId is an opaque identifier generated by the caller. -// -// NOTE: Acquire/Renew/Release logic lives in internal/state/lease. type LockLease struct { - meta.BaseModel - Resource string `gorm:"uniqueIndex"` - OwnerId string - ExpiresAt time.Time `gorm:"index"` + pkgmeta.BaseModel `gorm:"embedded"` + Resource string `gorm:"uniqueIndex"` + OwnerId string + ExpiresAt time.Time `gorm:"index"` } func (l *LockLease) TableName() string { diff --git a/internal/module/meta/meta_model_data.go b/internal/module/meta/meta_model_data.go new file mode 100644 index 000000000..690f0f75b --- /dev/null +++ b/internal/module/meta/meta_model_data.go @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +import pkgmeta "github.com/choysum-dev/choysum/pkg/meta" + +type ModelData struct { + pkgmeta.BaseModel `gorm:"embedded"` + Application string `gorm:"type:varchar(255);not null;index"` + Module string `gorm:"type:varchar(255);not null;index:idx_model_data_module_name,unique"` + Name string `gorm:"type:varchar(255);not null;index:idx_model_data_module_name,unique"` + ModelId string `gorm:"column:model_id;type:char(20);not null;index"` + ModelName string `gorm:"column:model_name;type:varchar(255);not null;index"` + ResID string `gorm:"type:char(20);not null;index"` + NoUpdate bool `gorm:"not null;default:false"` +} + +func (md *ModelData) TableName() string { + return "meta_model_data" +} diff --git a/internal/module/metadata/module_index.go b/internal/module/meta/meta_module_index.go similarity index 92% rename from internal/module/metadata/module_index.go rename to internal/module/meta/meta_module_index.go index f84df6ada..5d09ab32e 100644 --- a/internal/module/metadata/module_index.go +++ b/internal/module/meta/meta_module_index.go @@ -1,19 +1,19 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: LGPL-3.0-or-later -package metadata +package meta import ( "database/sql" "time" - "github.com/choysum-dev/choysum/pkg/meta" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/datatypes" ) // ModuleIndex stores module availability and manifest snapshots. type ModuleIndex struct { - meta.BaseModel `gorm:"embedded"` + pkgmeta.BaseModel `gorm:"embedded"` ModuleName string `gorm:"column:module_name;type:varchar(255);not null;uniqueIndex:idx_meta_module_index_origin,priority:1"` OriginType string `gorm:"column:origin_type;type:varchar(32);not null;uniqueIndex:idx_meta_module_index_origin,priority:2"` diff --git a/internal/module/metadata/module_management_log.go b/internal/module/meta/meta_module_management_log.go similarity index 93% rename from internal/module/metadata/module_management_log.go rename to internal/module/meta/meta_module_management_log.go index cd1cd495b..816359104 100644 --- a/internal/module/metadata/module_management_log.go +++ b/internal/module/meta/meta_module_management_log.go @@ -1,17 +1,17 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: LGPL-3.0-or-later -package metadata +package meta import ( "time" - "github.com/choysum-dev/choysum/pkg/meta" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/datatypes" ) type ModuleManagementLog struct { - meta.BaseModel + pkgmeta.BaseModel JobId string `gorm:"column:job_id;size:64;uniqueIndex"` ModuleName string `gorm:"column:module_name;size:255;index"` Action string `gorm:"column:action;size:32;index"` diff --git a/internal/module/metadata/module_migration_history.go b/internal/module/meta/meta_module_migration_history.go similarity index 92% rename from internal/module/metadata/module_migration_history.go rename to internal/module/meta/meta_module_migration_history.go index 4be82ef3d..0016e21a8 100644 --- a/internal/module/metadata/module_migration_history.go +++ b/internal/module/meta/meta_module_migration_history.go @@ -1,16 +1,16 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: LGPL-3.0-or-later -package metadata +package meta import ( "time" - "github.com/choysum-dev/choysum/pkg/meta" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) type ModuleMigrationHistory struct { - meta.BaseModel + pkgmeta.BaseModel ModuleName string `gorm:"column:module_name;size:255;index:idx_meta_module_migration_history,unique"` Version string `gorm:"column:version;size:64;index:idx_meta_module_migration_history,unique"` Phase string `gorm:"column:phase;size:16;index:idx_meta_module_migration_history,unique"` diff --git a/pkg/meta/meta_raw_argument.go b/internal/module/meta/meta_raw_argument.go similarity index 85% rename from pkg/meta/meta_raw_argument.go rename to internal/module/meta/meta_raw_argument.go index ceba66b03..4f2f43f06 100644 --- a/pkg/meta/meta_raw_argument.go +++ b/internal/module/meta/meta_raw_argument.go @@ -3,11 +3,14 @@ package meta -import "database/sql" +import ( + "database/sql" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" +) // rawArgument is a declaration-layer decorator argument. type rawArgument struct { - BaseModel `gorm:"embedded"` + pkgmeta.BaseModel `gorm:"embedded"` Type string `gorm:"type:varchar" json:"ts_type"` Value string `gorm:"type:varchar" json:"value"` diff --git a/pkg/meta/meta_raw_decorator.go b/internal/module/meta/meta_raw_decorator.go similarity index 81% rename from pkg/meta/meta_raw_decorator.go rename to internal/module/meta/meta_raw_decorator.go index 27f44d37d..c2702a729 100644 --- a/pkg/meta/meta_raw_decorator.go +++ b/internal/module/meta/meta_raw_decorator.go @@ -3,11 +3,14 @@ package meta -import "database/sql" +import ( + "database/sql" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" +) -// rawDecorator is a declaration-layer decorator under Raw Model/Field/Service. +// rawDecorator is a declaration-layer decorator under Raw pkgmeta.Model/pkgmeta.Field/pkgmeta.Service. type rawDecorator struct { - BaseModel `gorm:"embedded"` + pkgmeta.BaseModel `gorm:"embedded"` Name string `gorm:"type:varchar(255);not null" json:"name"` diff --git a/pkg/meta/meta_raw_field.go b/internal/module/meta/meta_raw_field.go similarity index 88% rename from pkg/meta/meta_raw_field.go rename to internal/module/meta/meta_raw_field.go index 7b1390c7d..19d27b384 100644 --- a/pkg/meta/meta_raw_field.go +++ b/internal/module/meta/meta_raw_field.go @@ -7,11 +7,13 @@ import ( "database/sql" "encoding/json" "strings" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) // rawField is a declaration-layer field row under rawModel (table meta_raw_field). type rawField struct { - BaseModel `gorm:"embedded"` + pkgmeta.BaseModel `gorm:"embedded"` Name string `gorm:"type:varchar(255);not null;index:idx_raw_model_field_name,unique" json:"name"` TsTypeAnnotation string `gorm:"type:varchar(1000);" json:"ts_type_annotation"` @@ -32,7 +34,7 @@ type rawField struct { // Selection stores the option list for selection fields as a JSON string. // Storage format keeps the existing labelText wire name for database and API compatibility. - // Its value is a TermReference; renaming the property requires a separate migration. + // Its value is a pkgmeta.TermReference; renaming the property requires a separate migration. // The parser writes it directly and the frontend consumes it without intermediate conversion. Selection string `gorm:"type:text" json:"selection,omitempty"` // SelectionKind is "static" or "dynamic" (P3). Dynamic fields omit inline Selection. @@ -42,11 +44,11 @@ type rawField struct { // FieldString is the field title msgid (English fallback). JSON wire name remains "string". FieldString string `gorm:"type:varchar(512)" json:"string,omitempty"` - // StringText stores the field title TermReference as JSON when authored with reference `_t(...)`. + // StringText stores the field title pkgmeta.TermReference as JSON when authored with reference `_t(...)`. StringText string `gorm:"type:text" json:"string_text,omitempty"` // FieldHelp is the field help msgid (English fallback). JSON wire name remains "help". FieldHelp string `gorm:"type:varchar(1024)" json:"help,omitempty"` - // HelpText stores the field help TermReference as JSON when authored with reference `_t(...)`. + // HelpText stores the field help pkgmeta.TermReference as JSON when authored with reference `_t(...)`. HelpText string `gorm:"type:text" json:"help_text,omitempty"` ReferenceIdent string `gorm:"type:varchar" json:"reference_ident"` @@ -82,13 +84,13 @@ func (field *rawField) TableName() string { return "meta_raw_field" } -// Overridable for tests (json.Marshal/Unmarshal rarely fail on FieldResolvedSpec). +// Overridable for tests (json.Marshal/Unmarshal rarely fail on pkgmeta.FieldResolvedSpec). var ( rawFieldJSONMarshal = json.Marshal rawFieldJSONUnmarshal = json.Unmarshal ) -func (field *rawField) SetResolvedSpec(spec *FieldResolvedSpec) error { +func (field *rawField) SetResolvedSpec(spec *pkgmeta.FieldResolvedSpec) error { if field == nil { return nil } @@ -104,7 +106,7 @@ func (field *rawField) SetResolvedSpec(spec *FieldResolvedSpec) error { return nil } -func (field *rawField) GetResolvedSpec() (*FieldResolvedSpec, error) { +func (field *rawField) GetResolvedSpec() (*pkgmeta.FieldResolvedSpec, error) { if field == nil { return nil, nil } @@ -112,7 +114,7 @@ func (field *rawField) GetResolvedSpec() (*FieldResolvedSpec, error) { if strings.TrimSpace(rawSpec) == "" { return nil, nil } - var spec FieldResolvedSpec + var spec pkgmeta.FieldResolvedSpec if err := rawFieldJSONUnmarshal([]byte(rawSpec), &spec); err != nil { return nil, err } diff --git a/pkg/meta/meta_raw_field_test.go b/internal/module/meta/meta_raw_field_test.go similarity index 85% rename from pkg/meta/meta_raw_field_test.go rename to internal/module/meta/meta_raw_field_test.go index 0852a35b2..362e1c51d 100644 --- a/pkg/meta/meta_raw_field_test.go +++ b/internal/module/meta/meta_raw_field_test.go @@ -7,12 +7,14 @@ import ( "encoding/json" "errors" "testing" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) func TestRawField_SetResolvedSpec(t *testing.T) { t.Run("nil receiver", func(t *testing.T) { var field *rawField - if err := field.SetResolvedSpec(&FieldResolvedSpec{FieldName: "x"}); err != nil { + if err := field.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{FieldName: "x"}); err != nil { t.Fatalf("nil receiver: unexpected error: %v", err) } }) @@ -29,9 +31,9 @@ func TestRawField_SetResolvedSpec(t *testing.T) { t.Run("valid spec serialises to JSON", func(t *testing.T) { field := &rawField{} - spec := &FieldResolvedSpec{ + spec := &pkgmeta.FieldResolvedSpec{ FieldName: "amount", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "amount", FieldType: "float", }, @@ -42,7 +44,7 @@ func TestRawField_SetResolvedSpec(t *testing.T) { if field.ResolvedSpec == "" { t.Fatal("expected non-empty ResolvedSpec") } - var roundtrip FieldResolvedSpec + var roundtrip pkgmeta.FieldResolvedSpec if err := json.Unmarshal([]byte(field.ResolvedSpec), &roundtrip); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -58,7 +60,7 @@ func TestRawField_SetResolvedSpec(t *testing.T) { return nil, errors.New("marshal boom") } field := &rawField{} - if err := field.SetResolvedSpec(&FieldResolvedSpec{FieldName: "x"}); err == nil || err.Error() != "marshal boom" { + if err := field.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{FieldName: "x"}); err == nil || err.Error() != "marshal boom" { t.Fatalf("expected marshal boom, got %v", err) } }) @@ -83,7 +85,7 @@ func TestRawField_GetResolvedSpec(t *testing.T) { t.Run("round trip", func(t *testing.T) { field := &rawField{} - want := &FieldResolvedSpec{FieldName: "n"} + want := &pkgmeta.FieldResolvedSpec{FieldName: "n"} if err := field.SetResolvedSpec(want); err != nil { t.Fatalf("set: %v", err) } diff --git a/pkg/meta/meta_raw_model.go b/internal/module/meta/meta_raw_model.go similarity index 86% rename from pkg/meta/meta_raw_model.go rename to internal/module/meta/meta_raw_model.go index 5935f8f89..ae85dfd54 100644 --- a/pkg/meta/meta_raw_model.go +++ b/internal/module/meta/meta_raw_model.go @@ -5,12 +5,13 @@ package meta import ( "database/sql" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) // rawModel is a per-module/file declaration row (IMD may have many rows per logical name). -// Effective projection lives in Model / meta_model (see MergeSameNameModelsByExtensionChain). +// Effective projection lives in pkgmeta.Model / meta_model (see MergeSameNameModelsByExtensionChain). type rawModel struct { - BaseModel `gorm:"embedded"` + pkgmeta.BaseModel `gorm:"embedded"` Name string `gorm:"type:varchar(255);not null;index:idx_raw_model_app_name" json:"name"` Path string `gorm:"type:varchar(512);not null;uniqueIndex:uidx_raw_model_module_path" json:"path"` @@ -31,7 +32,7 @@ type rawModel struct { Services []*rawService `gorm:"foreignKey:ModelId;constraint:OnDelete:CASCADE;" json:"services"` Fields []*rawField `gorm:"foreignKey:ModelId;constraint:OnDelete:CASCADE;" json:"fields"` ModuleId sql.NullString `gorm:"type:char(20);uniqueIndex:uidx_raw_model_module_path" json:"module_id"` - Module *Module `gorm:"foreignKey:ModuleId" json:"module"` + Module *pkgmeta.Module `gorm:"foreignKey:ModuleId" json:"module"` } func (model *rawModel) TableName() string { diff --git a/pkg/meta/meta_raw_parameter.go b/internal/module/meta/meta_raw_parameter.go similarity index 60% rename from pkg/meta/meta_raw_parameter.go rename to internal/module/meta/meta_raw_parameter.go index 084fd1b0e..118210d84 100644 --- a/pkg/meta/meta_raw_parameter.go +++ b/internal/module/meta/meta_raw_parameter.go @@ -5,14 +5,15 @@ package meta import ( "database/sql" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) // rawParameter is a declaration-layer parameter under rawService. type rawParameter struct { - BaseModel `gorm:"embedded"` - Name string `gorm:"type:varchar(255);not null" json:"name"` - TsTypeAnnotation string `gorm:"type:varchar(255);" json:"ts_type_annotation"` - ProtobufType string `gorm:"type:varchar(255);" json:"protobuf_type"` + pkgmeta.BaseModel `gorm:"embedded"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + TsTypeAnnotation string `gorm:"type:varchar(255);" json:"ts_type_annotation"` + ProtobufType string `gorm:"type:varchar(255);" json:"protobuf_type"` ServiceId sql.NullString `gorm:"type:char(20)" json:"service_id"` Service *rawService `gorm:"foreignKey:ServiceId" json:"service"` diff --git a/pkg/meta/meta_raw_service.go b/internal/module/meta/meta_raw_service.go similarity index 85% rename from pkg/meta/meta_raw_service.go rename to internal/module/meta/meta_raw_service.go index a6a8fcd35..b46b5951e 100644 --- a/pkg/meta/meta_raw_service.go +++ b/internal/module/meta/meta_raw_service.go @@ -5,12 +5,13 @@ package meta import ( "database/sql" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) // rawService is a declaration-layer service row under rawModel. type rawService struct { - BaseModel `gorm:"embedded"` - Name string `gorm:"type:varchar(255);not null;index:idx_raw_model_service_name,unique" json:"name"` + pkgmeta.BaseModel `gorm:"embedded"` + Name string `gorm:"type:varchar(255);not null;index:idx_raw_model_service_name,unique" json:"name"` OriginModelPath string `gorm:"type:varchar(512);" json:"origin_model_path"` diff --git a/pkg/meta/meta_raw_typeparameter.go b/internal/module/meta/meta_raw_typeparameter.go similarity index 60% rename from pkg/meta/meta_raw_typeparameter.go rename to internal/module/meta/meta_raw_typeparameter.go index 083fdacce..530b3fac6 100644 --- a/pkg/meta/meta_raw_typeparameter.go +++ b/internal/module/meta/meta_raw_typeparameter.go @@ -5,14 +5,15 @@ package meta import ( "database/sql" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) // rawTypeParameter is a declaration-layer type parameter under rawService. type rawTypeParameter struct { - BaseModel `gorm:"embedded"` - Name string `gorm:"type:varchar(255);not null" json:"name"` - ModuleSpecPath string `gorm:"type:varchar(255);" json:"module_spec_path"` - ReferenceIdent string `gorm:"type:varchar(255);" json:"reference_ident"` + pkgmeta.BaseModel `gorm:"embedded"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + ModuleSpecPath string `gorm:"type:varchar(255);" json:"module_spec_path"` + ReferenceIdent string `gorm:"type:varchar(255);" json:"reference_ident"` ServiceId sql.NullString `gorm:"type:char(20)" json:"service_id"` Service *rawService `gorm:"foreignKey:ServiceId" json:"service"` diff --git a/internal/module/metadata/setting.go b/internal/module/meta/meta_setting.go similarity index 77% rename from internal/module/metadata/setting.go rename to internal/module/meta/meta_setting.go index d0e4ae81e..5ffa1e729 100644 --- a/internal/module/metadata/setting.go +++ b/internal/module/meta/meta_setting.go @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: LGPL-3.0-or-later -package metadata +package meta -import "github.com/choysum-dev/choysum/pkg/meta" +import pkgmeta "github.com/choysum-dev/choysum/pkg/meta" // Setting stores key/value settings for stable system state. type Setting struct { - meta.BaseModel `gorm:"embedded"` + pkgmeta.BaseModel `gorm:"embedded"` Key string `gorm:"type:varchar(255);not null;uniqueIndex"` Value string `gorm:"type:text;not null"` diff --git a/internal/module/meta/raw_helpers.go b/internal/module/meta/raw_helpers.go new file mode 100644 index 000000000..46d736a52 --- /dev/null +++ b/internal/module/meta/raw_helpers.go @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +// Raw model / child table names (declaration layer). Prefer these over exporting Raw* types. +const ( + rawModelTable = "meta_raw_model" + rawFieldTable = "meta_raw_field" + rawServiceTable = "meta_raw_service" + rawDecoratorTable = "meta_raw_decorator" + rawArgumentTable = "meta_raw_argument" + rawParameterTable = "meta_raw_parameter" + rawTypeParameterTable = "meta_raw_type_parameter" +) diff --git a/internal/module/meta/raw_helpers_test.go b/internal/module/meta/raw_helpers_test.go new file mode 100644 index 000000000..940042b7f --- /dev/null +++ b/internal/module/meta/raw_helpers_test.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package meta + +import ( + "database/sql" + "fmt" + "strings" + "testing" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/gorm" +) + +// countRawModelsByID counts declaration rows with the given id (including soft-deleted when unscoped). +func countRawModelsByID(db *gorm.DB, id string, unscoped bool) (int64, error) { + if db == nil { + return 0, fmt.Errorf("db is nil") + } + id = strings.TrimSpace(id) + q := db.Model(&rawModel{}) + if unscoped { + q = q.Unscoped() + } + var n int64 + err := q.Where("id = ?", id).Count(&n).Error + return n, err +} + +// dropRawModelTable drops meta_raw_model (tests that force list/load failures). +func dropRawModelTable(db *gorm.DB) error { + if db == nil { + return fmt.Errorf("db is nil") + } + return db.Migrator().DropTable(rawModelTable) +} + +func TestRawHelpers_CountAndDrop(t *testing.T) { + if _, err := countRawModelsByID(nil, "x", false); err == nil || !strings.Contains(err.Error(), "db is nil") { + t.Fatalf("nil db: %v", err) + } + if err := dropRawModelTable(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { + t.Fatalf("nil drop: %v", err) + } + + db := openDeclarationTestDB(t, "raw-helpers") + m := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-1", Valid: true}}, + Name: "X", + Path: "/x.ts", + Application: "app", + } + if err := persistModelTreeAsRaw(db, m); err != nil { + t.Fatal(err) + } + n, err := countRawModelsByID(db, "raw-1", false) + if err != nil || n != 1 { + t.Fatalf("count=%d err=%v", n, err) + } + n, err = countRawModelsByID(db, " raw-1 ", true) + if err != nil || n != 1 { + t.Fatalf("unscoped count=%d err=%v", n, err) + } + if err := dropRawModelTable(db); err != nil { + t.Fatal(err) + } + if db.Migrator().HasTable(rawModelTable) { + t.Fatal("expected table dropped") + } +} + +func TestRawHelpers_SoftDeleteScopedCount(t *testing.T) { + db := openDeclarationTestDB(t, "raw-helpers-soft") + m := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-soft", Valid: true}}, + Name: "Soft", + Path: "/soft.ts", + Application: "app", + } + if err := persistModelTreeAsRaw(db, m); err != nil { + t.Fatal(err) + } + if err := db.Where("id = ?", "raw-soft").Delete(&rawModel{}).Error; err != nil { + t.Fatalf("soft delete: %v", err) + } + n, err := countRawModelsByID(db, "raw-soft", false) + if err != nil || n != 0 { + t.Fatalf("scoped count after soft delete = %d err=%v, want 0", n, err) + } + n, err = countRawModelsByID(db, "raw-soft", true) + if err != nil || n != 1 { + t.Fatalf("unscoped count after soft delete = %d err=%v, want 1", n, err) + } +} diff --git a/pkg/meta/recompute.go b/internal/module/meta/recompute.go similarity index 81% rename from pkg/meta/recompute.go rename to internal/module/meta/recompute.go index 62c9090d0..59f90dd9b 100644 --- a/pkg/meta/recompute.go +++ b/internal/module/meta/recompute.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "github.com/rs/xid" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -41,9 +42,9 @@ func (k LogicalKey) Valid() bool { return n.Application != "" && n.Name != "" } -// RecomputeKeys rebuilds effective projections for each logical key (deduped). +// recomputeKeys rebuilds effective projections for each logical key (deduped). // Safe to call inside an outer transaction. -func RecomputeKeys(tx *gorm.DB, keys []LogicalKey) error { +func recomputeKeys(tx *gorm.DB, keys []LogicalKey) error { if tx == nil { return fmt.Errorf("db is nil") } @@ -56,24 +57,24 @@ func RecomputeKeys(tx *gorm.DB, keys []LogicalKey) error { seen[n.Application+"\x00"+n.Name] = n } for _, key := range seen { - if err := RecomputeEffective(tx, key.Application, key.Name); err != nil { + if err := recomputeEffective(tx, key.Application, key.Name); err != nil { return err } } return nil } -// FlushEffective rebuilds effective projections for the given keys. Prefer this name -// at install/persist boundaries; RecomputeKeys remains as the historical alias. +// FlushEffective rebuilds effective projections for the given keys (deduped). +// Safe to call inside an outer transaction. This is the sole exported flush entrypoint. func FlushEffective(tx *gorm.DB, keys []LogicalKey) error { - return RecomputeKeys(tx, keys) + return recomputeKeys(tx, keys) } -// RecomputeEffective rebuilds one effective meta_model* tree from live meta_raw_* +// recomputeEffective rebuilds one effective meta_model* tree from live meta_raw_* // for (application, name). Preserves existing effective id when present (EDS5). // When no live raw remains, hard-deletes the effective tree. // Delete+persist runs in one transaction so a mid-rebuild failure cannot leave a hole. -func RecomputeEffective(db *gorm.DB, application, name string) error { +func recomputeEffective(db *gorm.DB, application, name string) error { if db == nil { return fmt.Errorf("db is nil") } @@ -109,7 +110,7 @@ func recomputeEffectiveTx(tx *gorm.DB, key LogicalKey) error { return fmt.Errorf("load meta_raw_model for %s/%s: %w", key.Application, key.Name, err) } - var existing []Model + var existing []pkgmeta.Model if err := tx.Where("application = ? AND name = ?", key.Application, key.Name).Find(&existing).Error; err != nil { return fmt.Errorf("load effective meta_model for %s/%s: %w", key.Application, key.Name, err) } @@ -126,7 +127,7 @@ func recomputeEffectiveTx(tx *gorm.DB, key LogicalKey) error { } models := rawModelsAsModels(raws) - // Pull differently-named Extends parents (e.g. BaseModel) into each declaration + // Pull differently-named Extends parents (e.g. pkgmeta.BaseModel) into each declaration // before E2 same-name union — replaces pre-persist materialize (EDS4). if err := expandModelsAlongExtendsFn(tx, models); err != nil { return fmt.Errorf("expand extends for %s/%s: %w", key.Application, key.Name, err) @@ -140,7 +141,7 @@ func recomputeEffectiveTx(tx *gorm.DB, key LogicalKey) error { } effID := "" - var tip Model + var tip pkgmeta.Model if len(existing) > 0 { // Prefer tip-like id: newest UpdatedAt then Id (stable across sibling IMD leftovers). tip = existing[0] @@ -189,7 +190,7 @@ func loadEffectiveServiceIDsByName(tx *gorm.DB, modelID string) (map[string]stri if modelID == "" { return nil, nil } - var services []Service + var services []pkgmeta.Service if err := tx.Select("id", "name").Where("model_id = ?", modelID).Find(&services).Error; err != nil { return nil, fmt.Errorf("load prior effective services: %w", err) } @@ -222,7 +223,7 @@ func lockLogicalKey(tx *gorm.DB, key LogicalKey) error { case "postgres": // Advisory lock keyed by app+name hash would be ideal; fall back to FOR UPDATE // on any existing effective row (no-op when missing). - var m Model + var m pkgmeta.Model err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("application = ? AND name = ?", key.Application, key.Name). Take(&m).Error @@ -246,15 +247,15 @@ func deleteEffectiveModelTree(db *gorm.DB, modelID string) error { fresh := func() *gorm.DB { return root.Session(&gorm.Session{NewDB: true}).Unscoped() } var serviceIDs []string - if err := fresh().Model(&Service{}).Where("model_id = ?", modelID).Pluck("id", &serviceIDs).Error; err != nil { + if err := fresh().Model(&pkgmeta.Service{}).Where("model_id = ?", modelID).Pluck("id", &serviceIDs).Error; err != nil { return fmt.Errorf("load effective services: %w", err) } var fieldIDs []string - if err := fresh().Model(&Field{}).Where("model_id = ?", modelID).Pluck("id", &fieldIDs).Error; err != nil { + if err := fresh().Model(&pkgmeta.Field{}).Where("model_id = ?", modelID).Pluck("id", &fieldIDs).Error; err != nil { return fmt.Errorf("load effective fields: %w", err) } - decoratorQ := fresh().Model(&Decorator{}).Where("model_id = ?", modelID) + decoratorQ := fresh().Model(&pkgmeta.Decorator{}).Where("model_id = ?", modelID) if len(serviceIDs) > 0 { decoratorQ = decoratorQ.Or("service_id IN ?", serviceIDs) } @@ -267,43 +268,43 @@ func deleteEffectiveModelTree(db *gorm.DB, modelID string) error { } if len(decoratorIDs) > 0 { - if err := deleteWhereFn(fresh(), &Argument{}, "decorator_id IN ?", decoratorIDs); err != nil { + if err := deleteWhereFn(fresh(), &pkgmeta.Argument{}, "decorator_id IN ?", decoratorIDs); err != nil { return fmt.Errorf("delete effective arguments: %w", err) } - if err := deleteWhereFn(fresh(), &Decorator{}, "id IN ?", decoratorIDs); err != nil { + if err := deleteWhereFn(fresh(), &pkgmeta.Decorator{}, "id IN ?", decoratorIDs); err != nil { return fmt.Errorf("delete effective decorators: %w", err) } } if len(serviceIDs) > 0 { - if err := deleteWhereFn(fresh(), &TypeParameter{}, "service_id IN ?", serviceIDs); err != nil { + if err := deleteWhereFn(fresh(), &pkgmeta.TypeParameter{}, "service_id IN ?", serviceIDs); err != nil { return fmt.Errorf("delete effective type parameters: %w", err) } - if err := deleteWhereFn(fresh(), &Parameter{}, "service_id IN ?", serviceIDs); err != nil { + if err := deleteWhereFn(fresh(), &pkgmeta.Parameter{}, "service_id IN ?", serviceIDs); err != nil { return fmt.Errorf("delete effective parameters: %w", err) } - if err := deleteWhereFn(fresh(), &Service{}, "id IN ?", serviceIDs); err != nil { + if err := deleteWhereFn(fresh(), &pkgmeta.Service{}, "id IN ?", serviceIDs); err != nil { return fmt.Errorf("delete effective services: %w", err) } } if len(fieldIDs) > 0 { - if err := deleteWhereFn(fresh(), &Field{}, "id IN ?", fieldIDs); err != nil { + if err := deleteWhereFn(fresh(), &pkgmeta.Field{}, "id IN ?", fieldIDs); err != nil { return fmt.Errorf("delete effective fields: %w", err) } } - if err := deleteWhereFn(fresh(), &Model{}, "id = ?", modelID); err != nil { + if err := deleteWhereFn(fresh(), &pkgmeta.Model{}, "id = ?", modelID); err != nil { return fmt.Errorf("delete effective model: %w", err) } return nil } -// PersistModelTreeAsRaw copies a declaration Model tree into meta_raw_*. -func PersistModelTreeAsRaw(db *gorm.DB, src *Model) error { +// persistModelTreeAsRaw copies a declaration pkgmeta.Model tree into meta_raw_*. +func persistModelTreeAsRaw(db *gorm.DB, src *pkgmeta.Model) error { return copyModelTreeToRaw(db, src) } -// DeleteRawModelsForModule hard-deletes all raw catalog rows for a module id. +// deleteRawModelsForModule hard-deletes all raw catalog rows for a module id. // IDs are plucked first so SQLite does not reject DELETE … WHERE id IN (SELECT … same table). -func DeleteRawModelsForModule(db *gorm.DB, moduleID string) error { +func deleteRawModelsForModule(db *gorm.DB, moduleID string) error { moduleID = strings.TrimSpace(moduleID) if moduleID == "" { return nil diff --git a/pkg/meta/recompute_coverage_test.go b/internal/module/meta/recompute_coverage_test.go similarity index 62% rename from pkg/meta/recompute_coverage_test.go rename to internal/module/meta/recompute_coverage_test.go index 394afa217..f3ec41ee1 100644 --- a/pkg/meta/recompute_coverage_test.go +++ b/internal/module/meta/recompute_coverage_test.go @@ -10,19 +10,20 @@ import ( "testing" "time" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "github.com/rs/xid" "gorm.io/gorm" ) func TestRecomputeKeys_NilAndFailure(t *testing.T) { - if err := RecomputeKeys(nil, []LogicalKey{{Application: "a", Name: "X"}}); err == nil || !strings.Contains(err.Error(), "db is nil") { + if err := recomputeKeys(nil, []LogicalKey{{Application: "a", Name: "X"}}); err == nil || !strings.Contains(err.Error(), "db is nil") { t.Fatalf("expected nil db error, got %v", err) } db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "r1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", @@ -32,17 +33,17 @@ func TestRecomputeKeys_NilAndFailure(t *testing.T) { t.Fatalf("create raw: %v", err) } closeDualStoreDB(t, db) - if err := RecomputeKeys(db, []LogicalKey{{Application: "a", Name: "X"}}); err == nil { - t.Fatal("expected RecomputeEffective failure on closed db") + if err := recomputeKeys(db, []LogicalKey{{Application: "a", Name: "X"}}); err == nil { + t.Fatal("expected recomputeEffective failure on closed db") } } func TestRecomputeEffective_NilAndInvalid(t *testing.T) { - if err := RecomputeEffective(nil, "a", "X"); err == nil || !strings.Contains(err.Error(), "db is nil") { + if err := recomputeEffective(nil, "a", "X"); err == nil || !strings.Contains(err.Error(), "db is nil") { t.Fatalf("expected nil db error, got %v", err) } db := openRecomputeTestDB(t) - if err := RecomputeEffective(db, " ", "X"); err == nil || !strings.Contains(err.Error(), "non-empty application and name") { + if err := recomputeEffective(db, " ", "X"); err == nil || !strings.Contains(err.Error(), "non-empty application and name") { t.Fatalf("expected invalid key error, got %v", err) } } @@ -52,7 +53,7 @@ func TestRecomputeEffective_PostgresLockBranch(t *testing.T) { db.Dialector = namedDialector{Dialector: db.Dialector, name: "postgres"} ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "pg-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "pg-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "PgModel", Application: "pg", Path: "/pg.ts", @@ -61,7 +62,7 @@ func TestRecomputeEffective_PostgresLockBranch(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - if err := RecomputeEffective(db, "pg", "PgModel"); err != nil { + if err := recomputeEffective(db, "pg", "PgModel"); err != nil { t.Fatalf("recompute with postgres dialector: %v", err) } } @@ -69,7 +70,7 @@ func TestRecomputeEffective_PostgresLockBranch(t *testing.T) { func TestLockLogicalKey_PostgresNonNotFoundError(t *testing.T) { db := openRecomputeTestDB(t) db.Dialector = namedDialector{Dialector: db.Dialector, name: "postgres"} - if err := db.Migrator().DropTable(&Model{}); err != nil { + if err := db.Migrator().DropTable(&pkgmeta.Model{}); err != nil { t.Fatalf("drop meta_model: %v", err) } err := lockLogicalKey(db, LogicalKey{Application: "a", Name: "X"}) @@ -78,80 +79,41 @@ func TestLockLogicalKey_PostgresNonNotFoundError(t *testing.T) { } } -func TestRecomputeEffective_TipSelectionAmongExisting(t *testing.T) { +func TestRecomputeEffective_PreservesExistingEffectiveID(t *testing.T) { + // Under the live (application, name) unique index only one effective row can exist; + // recompute must reuse that id (EDS5). db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - older := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff-older", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + existing := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "eff-keep", Valid: true}, CreatedAt: ts, UpdatedAt: ts.Add(time.Hour)}, Name: "TipPick", Application: "app", Path: "/older.ts", } - newer := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff-newer", Valid: true}, CreatedAt: ts, UpdatedAt: ts.Add(time.Hour)}, - Name: "TipPick", - Application: "app", - Path: "/newer.ts", - } - sameTimeHigherID := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "zzz", Valid: true}, CreatedAt: ts.Add(2 * time.Hour), UpdatedAt: ts.Add(2 * time.Hour)}, - Name: "SameTime", - Application: "app", - Path: "/zzz.ts", - } - sameTimeLowerID := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "aaa", Valid: true}, CreatedAt: ts.Add(2 * time.Hour), UpdatedAt: ts.Add(2 * time.Hour)}, - Name: "SameTime", - Application: "app", - Path: "/aaa.ts", - } - for _, m := range []*Model{older, newer, sameTimeHigherID, sameTimeLowerID} { - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(m).Error; err != nil { - t.Fatalf("create effective %s: %v", m.Id.String, err) - } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(existing).Error; err != nil { + t.Fatalf("create effective: %v", err) } - rawTip := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-tip", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-tip", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "TipPick", Application: "app", Path: "/raw.ts", ModuleId: sql.NullString{String: "mod", Valid: true}, } - rawSame := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-same", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "SameTime", - Application: "app", - Path: "/raw-same.ts", - ModuleId: sql.NullString{String: "mod2", Valid: true}, - } - for _, r := range []*rawModel{rawTip, rawSame} { - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(r).Error; err != nil { - t.Fatalf("create raw: %v", err) - } + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(rawTip).Error; err != nil { + t.Fatalf("create raw: %v", err) } - if err := RecomputeEffective(db, "app", "TipPick"); err != nil { + if err := recomputeEffective(db, "app", "TipPick"); err != nil { t.Fatalf("recompute TipPick: %v", err) } - var tipPick Model + var tipPick pkgmeta.Model if err := db.Where("application = ? AND name = ?", "app", "TipPick").Take(&tipPick).Error; err != nil { t.Fatalf("load TipPick: %v", err) } - if tipPick.Id.String != "eff-newer" { - t.Fatalf("TipPick id=%q want eff-newer (newest UpdatedAt)", tipPick.Id.String) - } - - if err := RecomputeEffective(db, "app", "SameTime"); err != nil { - t.Fatalf("recompute SameTime: %v", err) - } - var sameTime Model - if err := db.Where("application = ? AND name = ?", "app", "SameTime").Take(&sameTime).Error; err != nil { - t.Fatalf("load SameTime: %v", err) - } - if sameTime.Id.String != "zzz" { - t.Fatalf("SameTime id=%q want zzz (equal UpdatedAt, higher Id)", sameTime.Id.String) + if tipPick.Id.String != "eff-keep" { + t.Fatalf("TipPick id=%q want eff-keep", tipPick.Id.String) } } @@ -159,7 +121,7 @@ func TestRecomputeEffective_EffIDFromXidWhenNoExistingAndEmptyRawTip(t *testing. db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Fresh", Application: "fresh", Path: "/fresh.ts", @@ -168,10 +130,10 @@ func TestRecomputeEffective_EffIDFromXidWhenNoExistingAndEmptyRawTip(t *testing. if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - if err := RecomputeEffective(db, "fresh", "Fresh"); err != nil { + if err := recomputeEffective(db, "fresh", "Fresh"); err != nil { t.Fatalf("recompute: %v", err) } - var eff Model + var eff pkgmeta.Model if err := db.Where("application = ? AND name = ?", "fresh", "Fresh").Take(&eff).Error; err != nil { t.Fatalf("load effective: %v", err) } @@ -190,8 +152,8 @@ func TestLoadEffectiveServiceIDsByName_Coverage(t *testing.T) { } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff-svc", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + eff := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "eff-svc", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "SvcModel", Application: "svc", Path: "/svc.ts", @@ -199,10 +161,10 @@ func TestLoadEffectiveServiceIDsByName_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(eff).Error; err != nil { t.Fatalf("create model: %v", err) } - for _, s := range []*Service{ - {BaseModel: BaseModel{Id: sql.NullString{String: "s1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Good", ModelId: eff.Id}, - {BaseModel: BaseModel{Id: sql.NullString{String: "s2", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: " ", ModelId: eff.Id}, - {BaseModel: BaseModel{Id: sql.NullString{String: "", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "NoID", ModelId: eff.Id}, + for _, s := range []*pkgmeta.Service{ + {BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "s1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Good", ModelId: eff.Id}, + {BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "s2", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: " ", ModelId: eff.Id}, + {BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "NoID", ModelId: eff.Id}, } { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(s).Error; err != nil { t.Fatalf("create service: %v", err) @@ -213,7 +175,7 @@ func TestLoadEffectiveServiceIDsByName_Coverage(t *testing.T) { t.Fatalf("load services: got=%#v err=%v", got, err) } - if err := db.Migrator().DropTable(&Service{}); err != nil { + if err := db.Migrator().DropTable(&pkgmeta.Service{}); err != nil { t.Fatalf("drop service: %v", err) } if _, err := loadEffectiveServiceIDsByName(db, eff.Id.String); err == nil || !strings.Contains(err.Error(), "load prior effective services") { @@ -223,8 +185,8 @@ func TestLoadEffectiveServiceIDsByName_Coverage(t *testing.T) { func TestPickTipRaw_NilEntries(t *testing.T) { ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - a := &rawModel{BaseModel: BaseModel{Id: sql.NullString{String: "a", Valid: true}, CreatedAt: ts}} - b := &rawModel{BaseModel: BaseModel{Id: sql.NullString{String: "b", Valid: true}, CreatedAt: ts.Add(time.Hour)}} + a := &rawModel{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "a", Valid: true}, CreatedAt: ts}} + b := &rawModel{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "b", Valid: true}, CreatedAt: ts.Add(time.Hour)}} tip := pickTipRaw([]*rawModel{nil, a, nil, b}) if tip == nil || tip.Id.String != "b" { t.Fatalf("pickTipRaw: got %#v", tip) @@ -241,8 +203,8 @@ func TestDeleteEffectiveModelTree_Coverage(t *testing.T) { } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "del-eff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + eff := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "del-eff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Del", Application: "del", Path: "/del.ts", @@ -250,24 +212,24 @@ func TestDeleteEffectiveModelTree_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(eff).Error; err != nil { t.Fatalf("create model: %v", err) } - field := &Field{ - BaseModel: BaseModel{Id: sql.NullString{String: "df", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + field := &pkgmeta.Field{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "df", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: eff.Id, } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(field).Error; err != nil { t.Fatalf("create field: %v", err) } - fieldDec := &Decorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "dfd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + fieldDec := &pkgmeta.Decorator{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dfd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Field", FieldId: field.Id, } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(fieldDec).Error; err != nil { t.Fatalf("create field dec: %v", err) } - fieldArg := &Argument{ - BaseModel: BaseModel{Id: sql.NullString{String: "dfa", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + fieldArg := &pkgmeta.Argument{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dfa", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Type: "string", Value: `"x"`, DecoratorId: fieldDec.Id, @@ -275,40 +237,40 @@ func TestDeleteEffectiveModelTree_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(fieldArg).Error; err != nil { t.Fatalf("create field arg: %v", err) } - svc := &Service{ - BaseModel: BaseModel{Id: sql.NullString{String: "ds", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + svc := &pkgmeta.Service{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ds", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Create", ModelId: eff.Id, } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svc).Error; err != nil { t.Fatalf("create service: %v", err) } - param := &Parameter{ - BaseModel: BaseModel{Id: sql.NullString{String: "dp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + param := &pkgmeta.Parameter{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals", ServiceId: svc.Id, } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(param).Error; err != nil { t.Fatalf("create param: %v", err) } - tp := &TypeParameter{ - BaseModel: BaseModel{Id: sql.NullString{String: "dtp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + tp := &pkgmeta.TypeParameter{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dtp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T", ServiceId: svc.Id, } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(tp).Error; err != nil { t.Fatalf("create tp: %v", err) } - svcDec := &Decorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "dsd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + svcDec := &pkgmeta.Decorator{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dsd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Rpc", ServiceId: svc.Id, } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svcDec).Error; err != nil { t.Fatalf("create svc dec: %v", err) } - modelDec := &Decorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "dmd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + modelDec := &pkgmeta.Decorator{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "dmd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Model", ModelId: eff.Id, } @@ -320,7 +282,7 @@ func TestDeleteEffectiveModelTree_Coverage(t *testing.T) { t.Fatalf("delete tree: %v", err) } var count int64 - if err := db.Model(&Model{}).Where("id = ?", eff.Id.String).Count(&count).Error; err != nil { + if err := db.Model(&pkgmeta.Model{}).Where("id = ?", eff.Id.String).Count(&count).Error; err != nil { t.Fatalf("count model: %v", err) } if count != 0 { @@ -332,33 +294,33 @@ func TestDeleteEffectiveModelTree_Coverage(t *testing.T) { name string drop any }{ - {"services", &Service{}}, - {"fields", &Field{}}, - {"decorators", &Decorator{}}, - {"arguments", &Argument{}}, - {"type parameters", &TypeParameter{}}, - {"parameters", &Parameter{}}, - {"model", &Model{}}, + {"services", &pkgmeta.Service{}}, + {"fields", &pkgmeta.Field{}}, + {"decorators", &pkgmeta.Decorator{}}, + {"arguments", &pkgmeta.Argument{}}, + {"type parameters", &pkgmeta.TypeParameter{}}, + {"parameters", &pkgmeta.Parameter{}}, + {"model", &pkgmeta.Model{}}, } for _, tc := range dropCases { t.Run(tc.name, func(t *testing.T) { db2 := openRecomputeTestDB(t) - eff2 := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "e-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + eff2 := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "e-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "E", Application: "e", Path: "/e.ts", } _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(eff2) - f2 := &Field{BaseModel: BaseModel{Id: sql.NullString{String: "f-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "N", ModelId: eff2.Id} + f2 := &pkgmeta.Field{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "f-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "N", ModelId: eff2.Id} _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(f2) - s2 := &Service{BaseModel: BaseModel{Id: sql.NullString{String: "s-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "S", ModelId: eff2.Id} + s2 := &pkgmeta.Service{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "s-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "S", ModelId: eff2.Id} _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(s2) - d2 := &Decorator{BaseModel: BaseModel{Id: sql.NullString{String: "d-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "D", FieldId: f2.Id, ServiceId: s2.Id, ModelId: eff2.Id} + d2 := &pkgmeta.Decorator{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "d-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "D", FieldId: f2.Id, ServiceId: s2.Id, ModelId: eff2.Id} _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(d2) - _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&Argument{BaseModel: BaseModel{Id: sql.NullString{String: "a-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, DecoratorId: d2.Id}) - _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&Parameter{BaseModel: BaseModel{Id: sql.NullString{String: "p-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals", ServiceId: s2.Id}) - _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&TypeParameter{BaseModel: BaseModel{Id: sql.NullString{String: "tp-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T", ServiceId: s2.Id}) + _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&pkgmeta.Argument{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "a-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, DecoratorId: d2.Id}) + _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&pkgmeta.Parameter{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "p-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals", ServiceId: s2.Id}) + _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&pkgmeta.TypeParameter{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "tp-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T", ServiceId: s2.Id}) _ = db2.Migrator().DropTable(tc.drop) if err := deleteEffectiveModelTree(db2, eff2.Id.String); err == nil { t.Fatalf("expected delete error after dropping %s", tc.name) @@ -370,18 +332,18 @@ func TestDeleteEffectiveModelTree_Coverage(t *testing.T) { func TestPersistEffectiveProjection_PrivateHelper(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - merged := &Model{ - BaseModel: BaseModel{CreatedAt: ts, UpdatedAt: ts}, + merged := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "Wrap", Application: "wrap", Path: "/wrap.ts", - Fields: []*Field{{Name: "Name"}}, + Fields: []*pkgmeta.Field{{Name: "Name"}}, } if err := persistEffectiveProjection(db, merged, "wrap-eff", nil); err != nil { t.Fatalf("persistEffectiveProjection: %v", err) } var count int64 - if err := db.Model(&Model{}).Where("id = ?", "wrap-eff").Count(&count).Error; err != nil { + if err := db.Model(&pkgmeta.Model{}).Where("id = ?", "wrap-eff").Count(&count).Error; err != nil { t.Fatalf("count: %v", err) } if count != 1 { @@ -390,21 +352,21 @@ func TestPersistEffectiveProjection_PrivateHelper(t *testing.T) { } func TestDeleteRawModelsForModule_Coverage(t *testing.T) { - if err := DeleteRawModelsForModule(nil, ""); err != nil { + if err := deleteRawModelsForModule(nil, ""); err != nil { t.Fatalf("nil db empty module: %v", err) } db := openRecomputeTestDB(t) - if err := DeleteRawModelsForModule(db, " "); err != nil { + if err := deleteRawModelsForModule(db, " "); err != nil { t.Fatalf("whitespace module no-op: %v", err) } - if err := DeleteRawModelsForModule(db, "missing-mod"); err != nil { + if err := deleteRawModelsForModule(db, "missing-mod"); err != nil { t.Fatalf("empty module: %v", err) } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) modID := "mod-del" raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-del", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-del", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "RawDel", Application: "raw", Path: "/raw.ts", @@ -414,7 +376,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("create raw: %v", err) } field := &rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "rf", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rf", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: raw.Id, } @@ -422,7 +384,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("create field: %v", err) } fieldDec := &rawDecorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "rfd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rfd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Field", FieldId: sql.NullString{String: field.Id.String, Valid: true}, } @@ -430,7 +392,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("create field dec: %v", err) } fieldArg := &rawArgument{ - BaseModel: BaseModel{Id: sql.NullString{String: "rfa", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rfa", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Type: "string", Value: `"x"`, DecoratorId: sql.NullString{String: fieldDec.Id.String, Valid: true}, @@ -439,7 +401,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("create field arg: %v", err) } svc := &rawService{ - BaseModel: BaseModel{Id: sql.NullString{String: "rs", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rs", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Create", ModelId: raw.Id, } @@ -447,7 +409,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("create service: %v", err) } param := &rawParameter{ - BaseModel: BaseModel{Id: sql.NullString{String: "rp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals", ServiceId: svc.Id, } @@ -455,7 +417,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("create param: %v", err) } tp := &rawTypeParameter{ - BaseModel: BaseModel{Id: sql.NullString{String: "rtp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rtp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T", ServiceId: svc.Id, } @@ -463,7 +425,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("create tp: %v", err) } svcDec := &rawDecorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "rsd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rsd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Rpc", ServiceId: svc.Id, } @@ -471,7 +433,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("create svc dec: %v", err) } modelDec := &rawDecorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "rmd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rmd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Model", ModelId: raw.Id, } @@ -479,7 +441,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("create model dec: %v", err) } - if err := DeleteRawModelsForModule(db, modID); err != nil { + if err := deleteRawModelsForModule(db, modID); err != nil { t.Fatalf("delete raw module: %v", err) } var count int64 @@ -506,25 +468,25 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Run(tc.name, func(t *testing.T) { db2 := openRecomputeTestDB(t) r2 := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "r2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "r2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "R", Application: "r", Path: "/r.ts", ModuleId: sql.NullString{String: "m2", Valid: true}, } _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(r2) - f2 := &rawField{BaseModel: BaseModel{Id: sql.NullString{String: "rf2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "N", ModelId: r2.Id} + f2 := &rawField{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rf2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "N", ModelId: r2.Id} _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(f2) - s2 := &rawService{BaseModel: BaseModel{Id: sql.NullString{String: "rs2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "S", ModelId: r2.Id} + s2 := &rawService{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rs2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "S", ModelId: r2.Id} _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(s2) - d2 := &rawDecorator{BaseModel: BaseModel{Id: sql.NullString{String: "rd2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "D", FieldId: sql.NullString{String: f2.Id.String, Valid: true}, ServiceId: s2.Id, ModelId: r2.Id} + d2 := &rawDecorator{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rd2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "D", FieldId: sql.NullString{String: f2.Id.String, Valid: true}, ServiceId: s2.Id, ModelId: r2.Id} _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(d2) - _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&rawArgument{BaseModel: BaseModel{Id: sql.NullString{String: "ra2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, DecoratorId: d2.Id}) - _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&rawParameter{BaseModel: BaseModel{Id: sql.NullString{String: "rp2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals", ServiceId: s2.Id}) - _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&rawTypeParameter{BaseModel: BaseModel{Id: sql.NullString{String: "rtp2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T", ServiceId: s2.Id}) - // Drop before DeleteRawModelsForModule so the first failing pluck/delete surfaces. + _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&rawArgument{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ra2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, DecoratorId: d2.Id}) + _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&rawParameter{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rp2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals", ServiceId: s2.Id}) + _ = db2.Session(&gorm.Session{SkipHooks: true}).Create(&rawTypeParameter{BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rtp2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T", ServiceId: s2.Id}) + // Drop before deleteRawModelsForModule so the first failing pluck/delete surfaces. _ = db2.Migrator().DropTable(tc.drop) - if err := DeleteRawModelsForModule(db2, "m2"); err == nil { + if err := deleteRawModelsForModule(db2, "m2"); err == nil { t.Fatalf("expected delete error after dropping %s", tc.name) } }) @@ -536,17 +498,17 @@ func TestRecomputeEffective_LoadAndExpandErrors(t *testing.T) { if err := db.Migrator().DropTable(&rawModel{}); err != nil { t.Fatalf("drop raw: %v", err) } - if err := RecomputeEffective(db, "a", "X"); err == nil || !strings.Contains(err.Error(), "load meta_raw_model") { + if err := recomputeEffective(db, "a", "X"); err == nil || !strings.Contains(err.Error(), "load meta_raw_model") { t.Fatalf("expected load raw error, got %v", err) } db2 := openRecomputeTestDB(t) - if err := db2.Migrator().DropTable(&Model{}); err != nil { + if err := db2.Migrator().DropTable(&pkgmeta.Model{}); err != nil { t.Fatalf("drop model: %v", err) } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "r", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "r", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", @@ -555,7 +517,7 @@ func TestRecomputeEffective_LoadAndExpandErrors(t *testing.T) { if err := db2.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - if err := RecomputeEffective(db2, "a", "X"); err == nil || !strings.Contains(err.Error(), "load effective meta_model") { + if err := recomputeEffective(db2, "a", "X"); err == nil || !strings.Contains(err.Error(), "load effective meta_model") { t.Fatalf("expected load effective error, got %v", err) } } @@ -564,8 +526,8 @@ func TestRecomputeEffective_DeleteExistingError(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) // No raws → delete existing effective trees - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "orphan", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + eff := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "orphan", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Orphan", Application: "orph", Path: "/orph.ts", @@ -573,10 +535,10 @@ func TestRecomputeEffective_DeleteExistingError(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(eff).Error; err != nil { t.Fatalf("create effective: %v", err) } - if err := db.Migrator().DropTable(&Field{}); err != nil { + if err := db.Migrator().DropTable(&pkgmeta.Field{}); err != nil { t.Fatalf("drop field: %v", err) } - if err := RecomputeEffective(db, "orph", "Orphan"); err == nil { + if err := recomputeEffective(db, "orph", "Orphan"); err == nil { t.Fatal("expected delete effective tree error") } } @@ -585,7 +547,7 @@ func TestRecomputeEffective_ExpandExtendsError(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) a := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "ra", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ra", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Circ", Application: "circ", Path: "/a.ts", @@ -593,7 +555,7 @@ func TestRecomputeEffective_ExpandExtendsError(t *testing.T) { ModuleId: sql.NullString{String: "m1", Valid: true}, } b := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "rb", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rb", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Circ", Application: "circ", Path: "/b.ts", @@ -605,7 +567,7 @@ func TestRecomputeEffective_ExpandExtendsError(t *testing.T) { t.Fatalf("create raw: %v", err) } } - if err := RecomputeEffective(db, "circ", "Circ"); err == nil || !strings.Contains(err.Error(), "expand extends") { + if err := recomputeEffective(db, "circ", "Circ"); err == nil || !strings.Contains(err.Error(), "expand extends") { t.Fatalf("expected expand error, got %v", err) } } @@ -613,8 +575,8 @@ func TestRecomputeEffective_ExpandExtendsError(t *testing.T) { func TestRecomputeEffective_LoadPriorServicesError(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "prior-eff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + eff := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "prior-eff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Prior", Application: "prior", Path: "/prior.ts", @@ -623,7 +585,7 @@ func TestRecomputeEffective_LoadPriorServicesError(t *testing.T) { t.Fatalf("create effective: %v", err) } raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "prior-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "prior-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Prior", Application: "prior", Path: "/prior.ts", @@ -632,10 +594,10 @@ func TestRecomputeEffective_LoadPriorServicesError(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - if err := db.Migrator().DropTable(&Service{}); err != nil { + if err := db.Migrator().DropTable(&pkgmeta.Service{}); err != nil { t.Fatalf("drop service: %v", err) } - if err := RecomputeEffective(db, "prior", "Prior"); err == nil || !strings.Contains(err.Error(), "load prior effective services") { + if err := recomputeEffective(db, "prior", "Prior"); err == nil || !strings.Contains(err.Error(), "load prior effective services") { t.Fatalf("expected load prior services error, got %v", err) } } @@ -643,8 +605,8 @@ func TestRecomputeEffective_LoadPriorServicesError(t *testing.T) { func TestRecomputeEffective_DeleteExistingWithRawsError(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "del-with-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + eff := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "del-with-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "DelRaw", Application: "delraw", Path: "/del.ts", @@ -652,13 +614,13 @@ func TestRecomputeEffective_DeleteExistingWithRawsError(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(eff).Error; err != nil { t.Fatalf("create effective: %v", err) } - _ = db.Session(&gorm.Session{SkipHooks: true}).Create(&Field{ - BaseModel: BaseModel{Id: sql.NullString{String: "del-f", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + _ = db.Session(&gorm.Session{SkipHooks: true}).Create(&pkgmeta.Field{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "del-f", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "N", ModelId: eff.Id, }) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "del-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "del-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "DelRaw", Application: "delraw", Path: "/del.ts", @@ -667,10 +629,10 @@ func TestRecomputeEffective_DeleteExistingWithRawsError(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - if err := db.Migrator().DropTable(&Field{}); err != nil { + if err := db.Migrator().DropTable(&pkgmeta.Field{}); err != nil { t.Fatalf("drop field: %v", err) } - if err := RecomputeEffective(db, "delraw", "DelRaw"); err == nil { + if err := recomputeEffective(db, "delraw", "DelRaw"); err == nil { t.Fatal("expected delete existing error during recompute with raws") } } @@ -679,7 +641,7 @@ func TestRecomputeEffective_PersistError(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "persist-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "persist-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "PersistErr", Application: "perr", Path: "/p.ts", @@ -689,16 +651,16 @@ func TestRecomputeEffective_PersistError(t *testing.T) { t.Fatalf("create raw: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "pf", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "pf", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: raw.Id, }).Error; err != nil { t.Fatalf("create field: %v", err) } - if err := db.Migrator().DropTable(&Field{}); err != nil { + if err := db.Migrator().DropTable(&pkgmeta.Field{}); err != nil { t.Fatalf("drop field: %v", err) } - if err := RecomputeEffective(db, "perr", "PersistErr"); err == nil || !strings.Contains(err.Error(), "persist effective") { + if err := recomputeEffective(db, "perr", "PersistErr"); err == nil || !strings.Contains(err.Error(), "persist effective") { t.Fatalf("expected persist error, got %v", err) } } @@ -707,7 +669,7 @@ func TestRecomputeEffective_PersistFailureRollsBackExisting(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "rb-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rb-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Rollback", Application: "rb", Path: "/rb.ts", @@ -717,16 +679,16 @@ func TestRecomputeEffective_PersistFailureRollsBackExisting(t *testing.T) { t.Fatalf("create raw: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "rb-f", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rb-f", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: raw.Id, }).Error; err != nil { t.Fatalf("create field: %v", err) } - if err := RecomputeEffective(db, "rb", "Rollback"); err != nil { + if err := recomputeEffective(db, "rb", "Rollback"); err != nil { t.Fatalf("first recompute: %v", err) } - var before Model + var before pkgmeta.Model if err := db.Where("application = ? AND name = ?", "rb", "Rollback").Take(&before).Error; err != nil { t.Fatalf("load before: %v", err) } @@ -741,10 +703,10 @@ func TestRecomputeEffective_PersistFailureRollsBackExisting(t *testing.T) { } t.Cleanup(func() { _ = db.Callback().Create().Remove(cbTag) }) - if err := RecomputeEffective(db, "rb", "Rollback"); err == nil || !strings.Contains(err.Error(), "persist effective") { + if err := recomputeEffective(db, "rb", "Rollback"); err == nil || !strings.Contains(err.Error(), "persist effective") { t.Fatalf("expected persist error, got %v", err) } - var after Model + var after pkgmeta.Model if err := db.Preload("Fields").Where("application = ? AND name = ?", "rb", "Rollback").Take(&after).Error; err != nil { t.Fatalf("effective row missing after failed recompute: %v", err) } @@ -757,7 +719,7 @@ func TestRecomputeEffective_EffIDFromRawTipWhenNoExisting(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-tip-id", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-tip-id", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "RawTip", Application: "rtip", Path: "/rtip.ts", @@ -766,10 +728,10 @@ func TestRecomputeEffective_EffIDFromRawTipWhenNoExisting(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - if err := RecomputeEffective(db, "rtip", "RawTip"); err != nil { + if err := recomputeEffective(db, "rtip", "RawTip"); err != nil { t.Fatalf("recompute: %v", err) } - var eff Model + var eff pkgmeta.Model if err := db.Where("application = ? AND name = ?", "rtip", "RawTip").Take(&eff).Error; err != nil { t.Fatalf("load: %v", err) } @@ -782,7 +744,7 @@ func TestRecomputeEffective_HookErrorPaths(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "hook-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "hook-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Hook", Application: "hook", Path: "/hook.ts", @@ -795,27 +757,27 @@ func TestRecomputeEffective_HookErrorPaths(t *testing.T) { prevLock := lockLogicalKeyFn lockLogicalKeyFn = func(*gorm.DB, LogicalKey) error { return errors.New("lock boom") } t.Cleanup(func() { lockLogicalKeyFn = prevLock }) - if err := RecomputeEffective(db, "hook", "Hook"); err == nil || !strings.Contains(err.Error(), "lock boom") { + if err := recomputeEffective(db, "hook", "Hook"); err == nil || !strings.Contains(err.Error(), "lock boom") { t.Fatalf("expected lock error, got %v", err) } lockLogicalKeyFn = prevLock prevExpand := expandModelsAlongExtendsFn - expandModelsAlongExtendsFn = func(*gorm.DB, []*Model) error { return errors.New("expand boom") } + expandModelsAlongExtendsFn = func(*gorm.DB, []*pkgmeta.Model) error { return errors.New("expand boom") } t.Cleanup(func() { expandModelsAlongExtendsFn = prevExpand }) - if err := RecomputeEffective(db, "hook", "Hook"); err == nil || !strings.Contains(err.Error(), "expand extends") { + if err := recomputeEffective(db, "hook", "Hook"); err == nil || !strings.Contains(err.Error(), "expand extends") { t.Fatalf("expected expand error, got %v", err) } expandModelsAlongExtendsFn = prevExpand prevMerge := mergeSameNameModelsByExtensionChainFn t.Cleanup(func() { mergeSameNameModelsByExtensionChainFn = prevMerge }) - mergeSameNameModelsByExtensionChainFn = func([]*Model) (*Model, error) { return nil, errors.New("merge boom") } - if err := RecomputeEffective(db, "hook", "Hook"); err == nil || !strings.Contains(err.Error(), "E2 merge") { + mergeSameNameModelsByExtensionChainFn = func([]*pkgmeta.Model) (*pkgmeta.Model, error) { return nil, errors.New("merge boom") } + if err := recomputeEffective(db, "hook", "Hook"); err == nil || !strings.Contains(err.Error(), "E2 merge") { t.Fatalf("expected merge error, got %v", err) } - mergeSameNameModelsByExtensionChainFn = func([]*Model) (*Model, error) { return nil, nil } - if err := RecomputeEffective(db, "hook", "Hook"); err == nil || !strings.Contains(err.Error(), "returned nil") { + mergeSameNameModelsByExtensionChainFn = func([]*pkgmeta.Model) (*pkgmeta.Model, error) { return nil, nil } + if err := recomputeEffective(db, "hook", "Hook"); err == nil || !strings.Contains(err.Error(), "returned nil") { t.Fatalf("expected nil merge error, got %v", err) } mergeSameNameModelsByExtensionChainFn = prevMerge @@ -829,8 +791,8 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { t.Helper() ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) id := xid.New().String() - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: id, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + eff := &pkgmeta.Model{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: id, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "EffHook", Application: "eh", Path: "/eh-" + id + ".ts", @@ -838,44 +800,44 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(eff).Error; err != nil { t.Fatalf("create model: %v", err) } - svc := &Service{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + svc := &pkgmeta.Service{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Create", ModelId: eff.Id, } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svc).Error; err != nil { t.Fatalf("create service: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&Field{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&pkgmeta.Field{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: eff.Id, }).Error; err != nil { t.Fatalf("create field: %v", err) } - dec := &Decorator{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + dec := &pkgmeta.Decorator{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "D", ModelId: eff.Id, } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(dec).Error; err != nil { t.Fatalf("create decorator: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&Argument{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&pkgmeta.Argument{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, DecoratorId: dec.Id, }).Error; err != nil { t.Fatalf("create arg: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&Parameter{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&pkgmeta.Parameter{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "x", ServiceId: svc.Id, }).Error; err != nil { t.Fatalf("create param: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&TypeParameter{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&pkgmeta.TypeParameter{ + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T", ServiceId: svc.Id, }).Error; err != nil { @@ -889,12 +851,12 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { wantMsg string } cases := []failCase{ - {func(v interface{}) bool { _, ok := v.(*Decorator); return ok }, "delete effective decorators"}, - {func(v interface{}) bool { _, ok := v.(*TypeParameter); return ok }, "delete effective type parameters"}, - {func(v interface{}) bool { _, ok := v.(*Parameter); return ok }, "delete effective parameters"}, - {func(v interface{}) bool { _, ok := v.(*Service); return ok }, "delete effective services"}, - {func(v interface{}) bool { _, ok := v.(*Field); return ok }, "delete effective fields"}, - {func(v interface{}) bool { _, ok := v.(*Model); return ok }, "delete effective model"}, + {func(v interface{}) bool { _, ok := v.(*pkgmeta.Decorator); return ok }, "delete effective decorators"}, + {func(v interface{}) bool { _, ok := v.(*pkgmeta.TypeParameter); return ok }, "delete effective type parameters"}, + {func(v interface{}) bool { _, ok := v.(*pkgmeta.Parameter); return ok }, "delete effective parameters"}, + {func(v interface{}) bool { _, ok := v.(*pkgmeta.Service); return ok }, "delete effective services"}, + {func(v interface{}) bool { _, ok := v.(*pkgmeta.Field); return ok }, "delete effective fields"}, + {func(v interface{}) bool { _, ok := v.(*pkgmeta.Model); return ok }, "delete effective model"}, } for _, tc := range cases { db := openRecomputeTestDB(t) @@ -916,7 +878,7 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { t.Helper() ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "RawHook", Application: "rh", Path: "/rh-" + moduleID + ".ts", @@ -926,7 +888,7 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { t.Fatalf("create raw: %v", err) } rsvc := &rawService{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Create", ModelId: raw.Id, } @@ -934,14 +896,14 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { t.Fatalf("create raw service: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: raw.Id, }).Error; err != nil { t.Fatalf("create raw field: %v", err) } rdec := &rawDecorator{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "D", ModelId: raw.Id, } @@ -949,20 +911,20 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { t.Fatalf("create raw decorator: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawArgument{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, DecoratorId: rdec.Id, }).Error; err != nil { t.Fatalf("create raw arg: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawParameter{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "x", ServiceId: rsvc.Id, }).Error; err != nil { t.Fatalf("create raw param: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawTypeParameter{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T", ServiceId: rsvc.Id, }).Error; err != nil { @@ -988,7 +950,7 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { } return prev(db, value, query, args...) } - err := DeleteRawModelsForModule(db, mod) + err := deleteRawModelsForModule(db, mod) if err == nil || !strings.Contains(err.Error(), tc.wantMsg) { t.Fatalf("raw want %q, got %v", tc.wantMsg, err) } diff --git a/pkg/meta/recompute_test.go b/internal/module/meta/recompute_test.go similarity index 78% rename from pkg/meta/recompute_test.go rename to internal/module/meta/recompute_test.go index 6a160a4da..448b507c2 100644 --- a/pkg/meta/recompute_test.go +++ b/internal/module/meta/recompute_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -21,7 +22,7 @@ func openRecomputeTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("open: %v", err) } - if err := EnsureDualStoreTables(db); err != nil { + if err := ensureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } return db @@ -32,7 +33,7 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) base := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-base", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-base", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Partner", Application: "partner", Path: "/partner/partner.ts", @@ -42,17 +43,17 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { t.Fatalf("create base: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "f-name", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "f-name", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: base.Id, }).Error; err != nil { t.Fatalf("create field: %v", err) } - if err := RecomputeEffective(db, "partner", "Partner"); err != nil { + if err := recomputeEffective(db, "partner", "Partner"); err != nil { t.Fatalf("first recompute: %v", err) } - var first Model + var first pkgmeta.Model if err := db.Preload("Fields").Where("application = ? AND name = ?", "partner", "Partner").Take(&first).Error; err != nil { t.Fatalf("load first: %v", err) } @@ -62,7 +63,7 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { firstID := first.Id.String bank := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-bank", Valid: true}, CreatedAt: ts.Add(time.Hour), UpdatedAt: ts.Add(time.Hour)}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-bank", Valid: true}, CreatedAt: ts.Add(time.Hour), UpdatedAt: ts.Add(time.Hour)}, Name: "Partner", Application: "partner", Path: "/partner_bank/partner.ts", @@ -73,17 +74,17 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { t.Fatalf("create bank: %v", err) } if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "f-bank", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "f-bank", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "BankAccounts", ModelId: bank.Id, }).Error; err != nil { t.Fatalf("create bank field: %v", err) } - if err := RecomputeEffective(db, "partner", "Partner"); err != nil { + if err := recomputeEffective(db, "partner", "Partner"); err != nil { t.Fatalf("second recompute: %v", err) } - var second Model + var second pkgmeta.Model if err := db.Preload("Fields").Where("application = ? AND name = ?", "partner", "Partner").Take(&second).Error; err != nil { t.Fatalf("load second: %v", err) } @@ -107,10 +108,10 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { t.Fatalf("delete bank raw fields: %v", err) } - if err := RecomputeEffective(db, "partner", "Partner"); err != nil { + if err := recomputeEffective(db, "partner", "Partner"); err != nil { t.Fatalf("third recompute: %v", err) } - var third Model + var third pkgmeta.Model if err := db.Preload("Fields").Where("application = ? AND name = ?", "partner", "Partner").Take(&third).Error; err != nil { t.Fatalf("load third: %v", err) } @@ -127,11 +128,11 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { if err := db.Unscoped().Where("model_id = ?", base.Id.String).Delete(&rawField{}).Error; err != nil { t.Fatalf("delete base raw fields: %v", err) } - if err := RecomputeEffective(db, "partner", "Partner"); err != nil { + if err := recomputeEffective(db, "partner", "Partner"); err != nil { t.Fatalf("final recompute: %v", err) } var count int64 - if err := db.Model(&Model{}).Where("application = ? AND name = ?", "partner", "Partner").Count(&count).Error; err != nil { + if err := db.Model(&pkgmeta.Model{}).Where("application = ? AND name = ?", "partner", "Partner").Count(&count).Error; err != nil { t.Fatalf("count: %v", err) } if count != 0 { @@ -144,7 +145,7 @@ func TestRecomputeEffective_PreservesServiceIDsByName(t *testing.T) { ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-i18n", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "raw-i18n", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "I18n", Application: "base", Path: "go://i18n/base", @@ -156,7 +157,7 @@ func TestRecomputeEffective_PreservesServiceIDsByName(t *testing.T) { } for _, name := range []string{"SearchTerms", "UpdateTerm"} { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawService{ - BaseModel: BaseModel{Id: sql.NullString{String: "rs-" + name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "rs-" + name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: name, AccessibilityModifier: "public", IsStatic: true, @@ -166,10 +167,10 @@ func TestRecomputeEffective_PreservesServiceIDsByName(t *testing.T) { } } - if err := RecomputeEffective(db, "base", "I18n"); err != nil { + if err := recomputeEffective(db, "base", "I18n"); err != nil { t.Fatalf("first recompute: %v", err) } - var first Model + var first pkgmeta.Model if err := db.Preload("Services").Where("application = ? AND name = ?", "base", "I18n").Take(&first).Error; err != nil { t.Fatalf("load first: %v", err) } @@ -183,10 +184,10 @@ func TestRecomputeEffective_PreservesServiceIDsByName(t *testing.T) { t.Fatalf("missing services: %#v", firstByName) } - if err := RecomputeEffective(db, "base", "I18n"); err != nil { + if err := recomputeEffective(db, "base", "I18n"); err != nil { t.Fatalf("second recompute: %v", err) } - var second Model + var second pkgmeta.Model if err := db.Preload("Services").Where("application = ? AND name = ?", "base", "I18n").Take(&second).Error; err != nil { t.Fatalf("load second: %v", err) } @@ -210,7 +211,7 @@ func TestRecomputeKeys_Dedupes(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, + BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "r1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", @@ -219,15 +220,15 @@ func TestRecomputeKeys_Dedupes(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create: %v", err) } - if err := RecomputeKeys(db, []LogicalKey{ + if err := recomputeKeys(db, []LogicalKey{ {Application: "a", Name: "X"}, {Application: "a", Name: "X"}, {Application: " ", Name: "X"}, }); err != nil { - t.Fatalf("RecomputeKeys: %v", err) + t.Fatalf("recomputeKeys: %v", err) } var count int64 - if err := db.Model(&Model{}).Where("name = ?", "X").Count(&count).Error; err != nil { + if err := db.Model(&pkgmeta.Model{}).Where("name = ?", "X").Count(&count).Error; err != nil { t.Fatalf("count: %v", err) } if count != 1 { diff --git a/pkg/meta/selection_merge.go b/internal/module/meta/selection_merge.go similarity index 84% rename from pkg/meta/selection_merge.go rename to internal/module/meta/selection_merge.go index a26e4b6ab..efc0cfce2 100644 --- a/pkg/meta/selection_merge.go +++ b/internal/module/meta/selection_merge.go @@ -7,24 +7,26 @@ import ( "encoding/json" "fmt" "strings" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) // Test seams for defensive error paths that are otherwise unreachable with valid IR shapes. var ( selectionJSONMarshal = json.Marshal - selectionSetResolved = func(field *Field, spec *FieldResolvedSpec) error { + selectionSetResolved = func(field *pkgmeta.Field, spec *pkgmeta.FieldResolvedSpec) error { return field.SetResolvedSpec(spec) } ) -// MergeSelectionByValue merges static selection options by value (PR-P2-F4 / D4). +// mergeSelectionByValue merges static selection options by value (PR-P2-F4 / D4). // Same value → later label (and labelText) wins; order = base order then new values. // A plain-string override replaces the whole option and clears inherited LabelText. -func MergeSelectionByValue(base, addends []FieldSelectionItem) []FieldSelectionItem { - byValue := make(map[string]FieldSelectionItem) +func mergeSelectionByValue(base, addends []pkgmeta.FieldSelectionItem) []pkgmeta.FieldSelectionItem { + byValue := make(map[string]pkgmeta.FieldSelectionItem) order := make([]string, 0, len(base)) - add := func(item FieldSelectionItem) { + add := func(item pkgmeta.FieldSelectionItem) { value := strings.TrimSpace(item.Value) if value == "" { return @@ -43,16 +45,16 @@ func MergeSelectionByValue(base, addends []FieldSelectionItem) []FieldSelectionI add(item) } - out := make([]FieldSelectionItem, 0, len(order)) + out := make([]pkgmeta.FieldSelectionItem, 0, len(order)) for _, value := range order { out = append(out, byValue[value]) } return out } -func selectionItemsFromField(field *Field, spec *FieldResolvedSpec) []FieldSelectionItem { +func selectionItemsFromField(field *pkgmeta.Field, spec *pkgmeta.FieldResolvedSpec) []pkgmeta.FieldSelectionItem { if spec != nil && len(spec.Structural.Selection) > 0 { - return append([]FieldSelectionItem(nil), spec.Structural.Selection...) + return append([]pkgmeta.FieldSelectionItem(nil), spec.Structural.Selection...) } if field == nil { return nil @@ -61,14 +63,14 @@ func selectionItemsFromField(field *Field, spec *FieldResolvedSpec) []FieldSelec if raw == "" { return nil } - var items []FieldSelectionItem + var items []pkgmeta.FieldSelectionItem if err := json.Unmarshal([]byte(raw), &items); err != nil { return nil } return items } -func isDynamicSelectionField(field *Field, spec *FieldResolvedSpec) bool { +func isDynamicSelectionField(field *pkgmeta.Field, spec *pkgmeta.FieldResolvedSpec) bool { if spec != nil { if strings.TrimSpace(spec.Structural.SelectionKind) == "dynamic" { return true @@ -88,7 +90,7 @@ func isDynamicSelectionField(field *Field, spec *FieldResolvedSpec) bool { return false } -func childDeclaresFullSelection(spec *FieldResolvedSpec) bool { +func childDeclaresFullSelection(spec *pkgmeta.FieldResolvedSpec) bool { if spec == nil { return false } @@ -105,8 +107,8 @@ func childDeclaresFullSelection(spec *FieldResolvedSpec) bool { return false } -// FieldHasSelectionAdd reports whether the field was authored with selectionAdd. -func FieldHasSelectionAdd(field *Field) bool { +// fieldHasSelectionAdd reports whether the field was authored with selectionAdd. +func fieldHasSelectionAdd(field *pkgmeta.Field) bool { if field == nil { return false } @@ -117,13 +119,13 @@ func FieldHasSelectionAdd(field *Field) bool { return spec.Structural.HasSelectionAdd } -// ResolveSelectionFieldConflict applies PR-P2-F4 merge rules when a child field +// resolveSelectionFieldConflict applies PR-P2-F4 merge rules when a child field // overrides a parent field of the same name. // // - selectionAdd only on a static base → merge into a field derived from base // - full selection on child → replace with child (explicit fork) // - otherwise → replace with child (legacy behavior) -func ResolveSelectionFieldConflict(base, child *Field) (*Field, error) { +func resolveSelectionFieldConflict(base, child *pkgmeta.Field) (*pkgmeta.Field, error) { if child == nil { return base, nil } @@ -165,11 +167,11 @@ func ResolveSelectionFieldConflict(base, child *Field) (*Field, error) { return nil, fmt.Errorf("field %s selectionAdd requires an inherited static selection", child.Name) } - mergedItems := MergeSelectionByValue(baseItems, childSpec.Structural.SelectionAdd) + mergedItems := mergeSelectionByValue(baseItems, childSpec.Structural.SelectionAdd) // Preserve base field metadata, then overlay child structural options and merged selection. out := *base - out.BaseModel = BaseModel{} + out.BaseModel = pkgmeta.BaseModel{} out.ModelId = child.ModelId out.Model = nil out.Decorators = child.Decorators @@ -184,7 +186,7 @@ func ResolveSelectionFieldConflict(base, child *Field) (*Field, error) { out.TsTypeReference = child.TsTypeReference } - outSpec := &FieldResolvedSpec{} + outSpec := &pkgmeta.FieldResolvedSpec{} if baseSpec != nil { *outSpec = *baseSpec } else { @@ -210,7 +212,7 @@ func ResolveSelectionFieldConflict(base, child *Field) (*Field, error) { return &out, nil } -func overlayStructuralSelectionAdd(dst, src *FieldStructuralSpec) { +func overlayStructuralSelectionAdd(dst, src *pkgmeta.FieldStructuralSpec) { if dst == nil || src == nil { return } @@ -266,11 +268,11 @@ func overlayStructuralSelectionAdd(dst, src *FieldStructuralSpec) { // mergeStorageHints overlays non-nil child storage options onto a clone of the base hints // so a partial child StorageHints object does not drop inherited Size/Indexed/etc. -func mergeStorageHints(base, child *FieldStructuralStorageHints) *FieldStructuralStorageHints { +func mergeStorageHints(base, child *pkgmeta.FieldStructuralStorageHints) *pkgmeta.FieldStructuralStorageHints { if child == nil { return base } - out := &FieldStructuralStorageHints{} + out := &pkgmeta.FieldStructuralStorageHints{} if base != nil { *out = *base } @@ -310,7 +312,7 @@ func mergeStorageHints(base, child *FieldStructuralStorageHints) *FieldStructura return out } -func overlayBehaviorSelectionAdd(dst, src *FieldBehaviorSpec) { +func overlayBehaviorSelectionAdd(dst, src *pkgmeta.FieldBehaviorSpec) { if dst == nil || src == nil { return } @@ -328,7 +330,7 @@ func overlayBehaviorSelectionAdd(dst, src *FieldBehaviorSpec) { } } -func applySelectionLegacyColumns(field *Field, spec *FieldResolvedSpec) { +func applySelectionLegacyColumns(field *pkgmeta.Field, spec *pkgmeta.FieldResolvedSpec) { if field == nil || spec == nil { return } diff --git a/pkg/meta/selection_merge_test.go b/internal/module/meta/selection_merge_test.go similarity index 58% rename from pkg/meta/selection_merge_test.go rename to internal/module/meta/selection_merge_test.go index ceac2cc4a..2797d3ba8 100644 --- a/pkg/meta/selection_merge_test.go +++ b/internal/module/meta/selection_merge_test.go @@ -7,20 +7,22 @@ import ( "encoding/json" "strings" "testing" + + pkgmeta "github.com/choysum-dev/choysum/pkg/meta" ) func TestMergeSelectionByValue(t *testing.T) { - base := []FieldSelectionItem{ + base := []pkgmeta.FieldSelectionItem{ {Value: "a", Label: "A"}, {Value: "b", Label: "B"}, {Value: " ", Label: "skip"}, } - add := []FieldSelectionItem{ + add := []pkgmeta.FieldSelectionItem{ {Value: "c", Label: "C"}, {Value: "b", Label: "B2"}, {Value: "", Label: "empty"}, } - got := MergeSelectionByValue(base, add) + got := mergeSelectionByValue(base, add) if len(got) != 3 { t.Fatalf("expected 3 items, got %#v", got) } @@ -36,11 +38,11 @@ func TestMergeSelectionByValue(t *testing.T) { } func TestMergeSelectionByValue_PlainLabelClearsLabelText(t *testing.T) { - ref := NewTermReference("demo", "demo.status", "Done", "literal") - base := []FieldSelectionItem{ + ref := pkgmeta.NewTermReference("demo", "demo.status", "Done", "literal") + base := []pkgmeta.FieldSelectionItem{ {Value: "done", Label: "Done", LabelText: &ref}, } - got := MergeSelectionByValue(base, []FieldSelectionItem{ + got := mergeSelectionByValue(base, []pkgmeta.FieldSelectionItem{ {Value: "done", Label: "Finished"}, }) if len(got) != 1 || got[0].Label != "Finished" || got[0].LabelText != nil { @@ -52,19 +54,19 @@ func TestSelectionItemsFromFieldHelpers(t *testing.T) { if got := selectionItemsFromField(nil, nil); got != nil { t.Fatalf("expected nil for empty inputs, got %#v", got) } - spec := &FieldResolvedSpec{Structural: FieldStructuralSpec{ - Selection: []FieldSelectionItem{{Value: "a", Label: "A"}}, + spec := &pkgmeta.FieldResolvedSpec{Structural: pkgmeta.FieldStructuralSpec{ + Selection: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }} if got := selectionItemsFromField(nil, spec); len(got) != 1 || got[0].Value != "a" { t.Fatalf("expected spec selection, got %#v", got) } - if got := selectionItemsFromField(&Field{Selection: ""}, &FieldResolvedSpec{}); got != nil { + if got := selectionItemsFromField(&pkgmeta.Field{Selection: ""}, &pkgmeta.FieldResolvedSpec{}); got != nil { t.Fatalf("expected nil for empty selection JSON, got %#v", got) } - if got := selectionItemsFromField(&Field{Selection: "{bad"}, &FieldResolvedSpec{}); got != nil { + if got := selectionItemsFromField(&pkgmeta.Field{Selection: "{bad"}, &pkgmeta.FieldResolvedSpec{}); got != nil { t.Fatalf("expected nil for invalid JSON, got %#v", got) } - got := selectionItemsFromField(&Field{Selection: `[{"value":"x","label":"X"}]`}, &FieldResolvedSpec{}) + got := selectionItemsFromField(&pkgmeta.Field{Selection: `[{"value":"x","label":"X"}]`}, &pkgmeta.FieldResolvedSpec{}) if len(got) != 1 || got[0].Value != "x" { t.Fatalf("expected legacy JSON items, got %#v", got) } @@ -74,104 +76,104 @@ func TestIsDynamicAndChildDeclaresFullSelection(t *testing.T) { if isDynamicSelectionField(nil, nil) { t.Fatal("expected false for empty inputs") } - if !isDynamicSelectionField(nil, &FieldResolvedSpec{Structural: FieldStructuralSpec{SelectionKind: "dynamic"}}) { + if !isDynamicSelectionField(nil, &pkgmeta.FieldResolvedSpec{Structural: pkgmeta.FieldStructuralSpec{SelectionKind: "dynamic"}}) { t.Fatal("expected dynamic from spec kind") } - if !isDynamicSelectionField(nil, &FieldResolvedSpec{Structural: FieldStructuralSpec{SelectionMethod: "Opts"}}) { + if !isDynamicSelectionField(nil, &pkgmeta.FieldResolvedSpec{Structural: pkgmeta.FieldStructuralSpec{SelectionMethod: "Opts"}}) { t.Fatal("expected dynamic from selection method") } - if !isDynamicSelectionField(&Field{SelectionKind: "dynamic"}, nil) { + if !isDynamicSelectionField(&pkgmeta.Field{SelectionKind: "dynamic"}, nil) { t.Fatal("expected dynamic from field kind") } - if !isDynamicSelectionField(&Field{SelectionMethod: "Opts", Selection: ""}, nil) { + if !isDynamicSelectionField(&pkgmeta.Field{SelectionMethod: "Opts", Selection: ""}, nil) { t.Fatal("expected dynamic from field method") } if childDeclaresFullSelection(nil) { t.Fatal("expected false for nil spec") } - if !childDeclaresFullSelection(&FieldResolvedSpec{Structural: FieldStructuralSpec{ - Selection: []FieldSelectionItem{{Value: "a", Label: "A"}}, + if !childDeclaresFullSelection(&pkgmeta.FieldResolvedSpec{Structural: pkgmeta.FieldStructuralSpec{ + Selection: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }}) { t.Fatal("expected true for static selection list") } - if !childDeclaresFullSelection(&FieldResolvedSpec{Structural: FieldStructuralSpec{SelectionKind: "dynamic"}}) { + if !childDeclaresFullSelection(&pkgmeta.FieldResolvedSpec{Structural: pkgmeta.FieldStructuralSpec{SelectionKind: "dynamic"}}) { t.Fatal("expected true for dynamic kind") } - if !childDeclaresFullSelection(&FieldResolvedSpec{Structural: FieldStructuralSpec{SelectionMethod: "Opts"}}) { + if !childDeclaresFullSelection(&pkgmeta.FieldResolvedSpec{Structural: pkgmeta.FieldStructuralSpec{SelectionMethod: "Opts"}}) { t.Fatal("expected true for selection method") } - if childDeclaresFullSelection(&FieldResolvedSpec{Structural: FieldStructuralSpec{HasSelectionAdd: true}}) { + if childDeclaresFullSelection(&pkgmeta.FieldResolvedSpec{Structural: pkgmeta.FieldStructuralSpec{HasSelectionAdd: true}}) { t.Fatal("expected false for selectionAdd-only") } } func TestFieldHasSelectionAdd(t *testing.T) { - if FieldHasSelectionAdd(nil) { + if fieldHasSelectionAdd(nil) { t.Fatal("expected false for nil") } - field := &Field{Name: "Status"} - if FieldHasSelectionAdd(field) { + field := &pkgmeta.Field{Name: "Status"} + if fieldHasSelectionAdd(field) { t.Fatal("expected false without resolved spec") } field.ResolvedSpec = "{bad" - if FieldHasSelectionAdd(field) { + if fieldHasSelectionAdd(field) { t.Fatal("expected false for invalid resolved spec") } - spec := &FieldResolvedSpec{ + spec := &pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{}, + SelectionAdd: []pkgmeta.FieldSelectionItem{}, }, } raw, _ := json.Marshal(spec) field.ResolvedSpec = string(raw) - if !FieldHasSelectionAdd(field) { + if !fieldHasSelectionAdd(field) { t.Fatal("expected true for empty selectionAdd authoring") } } func TestResolveSelectionFieldConflict_NilAndReplacePaths(t *testing.T) { - base := &Field{Name: "Status"} - child := &Field{Name: "Status"} - if got, err := ResolveSelectionFieldConflict(base, nil); err != nil || got != base { + base := &pkgmeta.Field{Name: "Status"} + child := &pkgmeta.Field{Name: "Status"} + if got, err := resolveSelectionFieldConflict(base, nil); err != nil || got != base { t.Fatalf("nil child should return base, got %#v err=%v", got, err) } - if got, err := ResolveSelectionFieldConflict(nil, child); err != nil || got != child { + if got, err := resolveSelectionFieldConflict(nil, child); err != nil || got != child { t.Fatalf("nil base should return child, got %#v err=%v", got, err) } - _ = child.SetResolvedSpec(&FieldResolvedSpec{FieldName: "Status", Structural: FieldStructuralSpec{Name: "Status"}}) - if got, err := ResolveSelectionFieldConflict(base, child); err != nil || got != child { + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{FieldName: "Status", Structural: pkgmeta.FieldStructuralSpec{Name: "Status"}}) + if got, err := resolveSelectionFieldConflict(base, child); err != nil || got != child { t.Fatalf("child without selectionAdd should replace, got %#v err=%v", got, err) } child.ResolvedSpec = "{bad" - if _, err := ResolveSelectionFieldConflict(base, child); err == nil { + if _, err := resolveSelectionFieldConflict(base, child); err == nil { t.Fatal("expected child GetResolvedSpec error") } } func TestResolveSelectionFieldConflict_SelectionAddMerge(t *testing.T) { - baseSpec := &FieldResolvedSpec{ + baseSpec := &pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", String: "Status", SelectionKind: "static", - Selection: []FieldSelectionItem{ + Selection: []pkgmeta.FieldSelectionItem{ {Value: "draft", Label: "Draft"}, {Value: "done", Label: "Done"}, }, }, } - base := &Field{Name: "Status", FieldType: "selection", FieldString: "Status"} + base := &pkgmeta.Field{Name: "Status", FieldType: "selection", FieldString: "Status"} if err := base.SetResolvedSpec(baseSpec); err != nil { t.Fatalf("set base spec: %v", err) } applySelectionLegacyColumns(base, baseSpec) - stringText := NewTermReference("demo", "demo.fields", "Status", "literal") - helpText := NewTermReference("demo", "demo.fields", "Help", "literal") + stringText := pkgmeta.NewTermReference("demo", "demo.fields", "Status", "literal") + helpText := pkgmeta.NewTermReference("demo", "demo.fields", "Help", "literal") required := true indexed := true size := 64 @@ -184,9 +186,9 @@ func TestResolveSelectionFieldConflict_SelectionAddMerge(t *testing.T) { companyDependent := true copyFlag := false readonly := true - childSpec := &FieldResolvedSpec{ + childSpec := &pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", String: "Status Label", @@ -194,12 +196,12 @@ func TestResolveSelectionFieldConflict_SelectionAddMerge(t *testing.T) { Help: "Help text", HelpText: &helpText, HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{ + SelectionAdd: []pkgmeta.FieldSelectionItem{ {Value: "cancel", Label: "Cancel"}, {Value: "done", Label: "Finished"}, }, - Related: &FieldRelatedSpec{Path: "Parent.Status"}, - StorageHints: &FieldStructuralStorageHints{Required: &required, Indexed: &indexed, Size: &size, Precision: &precision, Scale: &scale}, + Related: &pkgmeta.FieldRelatedSpec{Path: "Parent.Status"}, + StorageHints: &pkgmeta.FieldStructuralStorageHints{Required: &required, Indexed: &indexed, Size: &size, Precision: &precision, Scale: &scale}, ColumnType: "varchar", CheckConstraint: "status <> ''", Translate: &translate, @@ -210,14 +212,14 @@ func TestResolveSelectionFieldConflict_SelectionAddMerge(t *testing.T) { MaxWidth: &width, MaxHeight: &height, }, - Behavior: FieldBehaviorSpec{ - Compute: &FieldBehaviorComputeSpec{Method: "ComputeStatus", Deps: []string{"Name"}, Store: true}, - SqlCompute: &FieldBehaviorSqlComputeSpec{Method: "SqlStatus", CtxType: "Ctx", ReturnType: "string"}, - Inverse: &FieldBehaviorMethodRef{Method: "InverseStatus"}, - Search: &FieldBehaviorMethodRef{Method: "SearchStatus"}, + Behavior: pkgmeta.FieldBehaviorSpec{ + Compute: &pkgmeta.FieldBehaviorComputeSpec{Method: "ComputeStatus", Deps: []string{"Name"}, Store: true}, + SqlCompute: &pkgmeta.FieldBehaviorSqlComputeSpec{Method: "SqlStatus", CtxType: "Ctx", ReturnType: "string"}, + Inverse: &pkgmeta.FieldBehaviorMethodRef{Method: "InverseStatus"}, + Search: &pkgmeta.FieldBehaviorMethodRef{Method: "SearchStatus"}, }, } - child := &Field{ + child := &pkgmeta.Field{ Name: "Status", FieldType: "selection", OriginModelPath: "/child", @@ -228,9 +230,9 @@ func TestResolveSelectionFieldConflict_SelectionAddMerge(t *testing.T) { t.Fatalf("set child spec: %v", err) } - merged, err := ResolveSelectionFieldConflict(base, child) + merged, err := resolveSelectionFieldConflict(base, child) if err != nil { - t.Fatalf("ResolveSelectionFieldConflict: %v", err) + t.Fatalf("resolveSelectionFieldConflict: %v", err) } spec, err := merged.GetResolvedSpec() if err != nil || spec == nil { @@ -269,25 +271,25 @@ func TestResolveSelectionFieldConflict_SelectionAddMerge(t *testing.T) { } func TestResolveSelectionFieldConflict_LegacyBaseWithoutResolvedSpec(t *testing.T) { - base := &Field{ + base := &pkgmeta.Field{ Name: "Status", FieldType: "selection", SelectionKind: "static", Selection: `[{"value":"a","label":"A"}]`, } - child := &Field{Name: "Status", FieldType: "selection", TsTypeAnnotation: "string"} - _ = child.SetResolvedSpec(&FieldResolvedSpec{ + child := &pkgmeta.Field{Name: "Status", FieldType: "selection", TsTypeAnnotation: "string"} + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "b", Label: "B"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "b", Label: "B"}}, }, }) - merged, err := ResolveSelectionFieldConflict(base, child) + merged, err := resolveSelectionFieldConflict(base, child) if err != nil { - t.Fatalf("ResolveSelectionFieldConflict: %v", err) + t.Fatalf("resolveSelectionFieldConflict: %v", err) } spec, err := merged.GetResolvedSpec() if err != nil || spec == nil || len(spec.Structural.Selection) != 2 { @@ -299,34 +301,34 @@ func TestResolveSelectionFieldConflict_OverlaysChildStorageHints(t *testing.T) { required := true indexed := true size := 64 - base := &Field{Name: "Status", FieldType: "selection", SelectionKind: "static", Size: 32, Indexed: true} - _ = base.SetResolvedSpec(&FieldResolvedSpec{ + base := &pkgmeta.Field{Name: "Status", FieldType: "selection", SelectionKind: "static", Size: 32, Indexed: true} + _ = base.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", SelectionKind: "static", - Selection: []FieldSelectionItem{{Value: "a", Label: "A"}}, - StorageHints: &FieldStructuralStorageHints{Size: intPtr(32), Indexed: &indexed, Precision: intPtr(10)}, + Selection: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, + StorageHints: &pkgmeta.FieldStructuralStorageHints{Size: intPtr(32), Indexed: &indexed, Precision: intPtr(10)}, }, }) - child := &Field{Name: "Status", FieldType: "selection"} - _ = child.SetResolvedSpec(&FieldResolvedSpec{ + child := &pkgmeta.Field{Name: "Status", FieldType: "selection"} + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "b", Label: "B"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "b", Label: "B"}}, // Partial overlay: only Required + Size; Indexed/Precision must stay from base. - StorageHints: &FieldStructuralStorageHints{Required: &required, Size: &size}, + StorageHints: &pkgmeta.FieldStructuralStorageHints{Required: &required, Size: &size}, Readonly: boolPtr(true), }, }) - merged, err := ResolveSelectionFieldConflict(base, child) + merged, err := resolveSelectionFieldConflict(base, child) if err != nil { - t.Fatalf("ResolveSelectionFieldConflict: %v", err) + t.Fatalf("resolveSelectionFieldConflict: %v", err) } spec, err := merged.GetResolvedSpec() if err != nil || spec == nil || spec.Structural.StorageHints == nil { @@ -348,14 +350,14 @@ func TestMergeStorageHints_PartialOverlayAndNilGuards(t *testing.T) { if got := mergeStorageHints(nil, nil); got != nil { t.Fatalf("expected nil, got %#v", got) } - base := &FieldStructuralStorageHints{Size: intPtr(16), Indexed: boolPtr(true)} + base := &pkgmeta.FieldStructuralStorageHints{Size: intPtr(16), Indexed: boolPtr(true)} if got := mergeStorageHints(base, nil); got != base { t.Fatalf("nil child should return base pointer, got %#v", got) } indexName := "idx_status" uniqueIndex := "uniq_status" def := "draft" - child := &FieldStructuralStorageHints{ + child := &pkgmeta.FieldStructuralStorageHints{ Required: boolPtr(true), Index: &indexName, PrimaryKey: boolPtr(false), @@ -390,28 +392,28 @@ func intPtr(v int) *int { return &v } func boolPtr(v bool) *bool { return &v } func TestResolveSelectionFieldConflict_FullSelectionReplaces(t *testing.T) { - base := &Field{Name: "Status", FieldType: "selection", SelectionKind: "static"} - _ = base.SetResolvedSpec(&FieldResolvedSpec{ + base := &pkgmeta.Field{Name: "Status", FieldType: "selection", SelectionKind: "static"} + _ = base.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", SelectionKind: "static", - Selection: []FieldSelectionItem{{Value: "a", Label: "A"}}, + Selection: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }, }) - child := &Field{Name: "Status", FieldType: "selection", SelectionKind: "static", TsTypeAnnotation: "fork"} - _ = child.SetResolvedSpec(&FieldResolvedSpec{ + child := &pkgmeta.Field{Name: "Status", FieldType: "selection", SelectionKind: "static", TsTypeAnnotation: "fork"} + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", SelectionKind: "static", - Selection: []FieldSelectionItem{{Value: "x", Label: "X"}}, + Selection: []pkgmeta.FieldSelectionItem{{Value: "x", Label: "X"}}, }, }) - got, err := ResolveSelectionFieldConflict(base, child) + got, err := resolveSelectionFieldConflict(base, child) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -421,125 +423,125 @@ func TestResolveSelectionFieldConflict_FullSelectionReplaces(t *testing.T) { } func TestResolveSelectionFieldConflict_DynamicBaseRejected(t *testing.T) { - base := &Field{Name: "Status", FieldType: "selection", SelectionKind: "dynamic", SelectionMethod: "Opts"} - _ = base.SetResolvedSpec(&FieldResolvedSpec{ + base := &pkgmeta.Field{Name: "Status", FieldType: "selection", SelectionKind: "dynamic", SelectionMethod: "Opts"} + _ = base.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", SelectionKind: "dynamic", SelectionMethod: "Opts", }, }) - child := &Field{Name: "Status", FieldType: "selection"} - _ = child.SetResolvedSpec(&FieldResolvedSpec{ + child := &pkgmeta.Field{Name: "Status", FieldType: "selection"} + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "a", Label: "A"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }, }) - _, err := ResolveSelectionFieldConflict(base, child) + _, err := resolveSelectionFieldConflict(base, child) if err == nil || !strings.Contains(err.Error(), "inherited static selection") { t.Fatalf("expected dynamic base rejection, got %v", err) } } func TestResolveSelectionFieldConflict_MissingStaticBaseRejected(t *testing.T) { - base := &Field{Name: "Status", FieldType: "varchar"} - _ = base.SetResolvedSpec(&FieldResolvedSpec{ + base := &pkgmeta.Field{Name: "Status", FieldType: "varchar"} + _ = base.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{Name: "Status", FieldType: "varchar"}, + Structural: pkgmeta.FieldStructuralSpec{Name: "Status", FieldType: "varchar"}, }) - child := &Field{Name: "Status", FieldType: "selection"} - _ = child.SetResolvedSpec(&FieldResolvedSpec{ + child := &pkgmeta.Field{Name: "Status", FieldType: "selection"} + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "a", Label: "A"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }, }) - _, err := ResolveSelectionFieldConflict(base, child) + _, err := resolveSelectionFieldConflict(base, child) if err == nil || !strings.Contains(err.Error(), "inherited static selection") { t.Fatalf("expected missing static base rejection, got %v", err) } } func TestResolveSelectionFieldConflict_BothSelectionAndAddRejected(t *testing.T) { - base := &Field{Name: "Status", FieldType: "selection", SelectionKind: "static"} - _ = base.SetResolvedSpec(&FieldResolvedSpec{ + base := &pkgmeta.Field{Name: "Status", FieldType: "selection", SelectionKind: "static"} + _ = base.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", SelectionKind: "static", - Selection: []FieldSelectionItem{{Value: "a", Label: "A"}}, + Selection: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }, }) - child := &Field{Name: "Status", FieldType: "selection"} - _ = child.SetResolvedSpec(&FieldResolvedSpec{ + child := &pkgmeta.Field{Name: "Status", FieldType: "selection"} + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", HasSelectionAdd: true, - Selection: []FieldSelectionItem{{Value: "x", Label: "X"}}, - SelectionAdd: []FieldSelectionItem{{Value: "y", Label: "Y"}}, + Selection: []pkgmeta.FieldSelectionItem{{Value: "x", Label: "X"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "y", Label: "Y"}}, }, }) - _, err := ResolveSelectionFieldConflict(base, child) + _, err := resolveSelectionFieldConflict(base, child) if err == nil || !strings.Contains(err.Error(), "cannot combine") { t.Fatalf("expected combine rejection, got %v", err) } } func TestResolveSelectionFieldConflict_BaseResolvedSpecError(t *testing.T) { - base := &Field{Name: "Status", ResolvedSpec: "{bad"} - child := &Field{Name: "Status"} - _ = child.SetResolvedSpec(&FieldResolvedSpec{ + base := &pkgmeta.Field{Name: "Status", ResolvedSpec: "{bad"} + child := &pkgmeta.Field{Name: "Status"} + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "a", Label: "A"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }, }) - if _, err := ResolveSelectionFieldConflict(base, child); err == nil { + if _, err := resolveSelectionFieldConflict(base, child); err == nil { t.Fatal("expected base GetResolvedSpec error") } } func TestOverlayAndApplySelectionLegacyColumnsNilGuards(t *testing.T) { - overlayStructuralSelectionAdd(nil, &FieldStructuralSpec{}) - overlayStructuralSelectionAdd(&FieldStructuralSpec{}, nil) - overlayBehaviorSelectionAdd(nil, &FieldBehaviorSpec{}) - overlayBehaviorSelectionAdd(&FieldBehaviorSpec{}, nil) - applySelectionLegacyColumns(nil, &FieldResolvedSpec{}) - applySelectionLegacyColumns(&Field{}, nil) - - field := &Field{} - applySelectionLegacyColumns(field, &FieldResolvedSpec{ - Structural: FieldStructuralSpec{SelectionKind: "static"}, + overlayStructuralSelectionAdd(nil, &pkgmeta.FieldStructuralSpec{}) + overlayStructuralSelectionAdd(&pkgmeta.FieldStructuralSpec{}, nil) + overlayBehaviorSelectionAdd(nil, &pkgmeta.FieldBehaviorSpec{}) + overlayBehaviorSelectionAdd(&pkgmeta.FieldBehaviorSpec{}, nil) + applySelectionLegacyColumns(nil, &pkgmeta.FieldResolvedSpec{}) + applySelectionLegacyColumns(&pkgmeta.Field{}, nil) + + field := &pkgmeta.Field{} + applySelectionLegacyColumns(field, &pkgmeta.FieldResolvedSpec{ + Structural: pkgmeta.FieldStructuralSpec{SelectionKind: "static"}, }) if field.Selection != "" || field.SelectionKind != "static" { t.Fatalf("expected empty selection cleared, got %#v", field) } zero := 0 - applySelectionLegacyColumns(field, &FieldResolvedSpec{ - Structural: FieldStructuralSpec{ - Selection: []FieldSelectionItem{{Value: "a", Label: "A"}}, + applySelectionLegacyColumns(field, &pkgmeta.FieldResolvedSpec{ + Structural: pkgmeta.FieldStructuralSpec{ + Selection: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, MaxUploadBytes: &zero, MaxWidth: &zero, MaxHeight: &zero, - StorageHints: &FieldStructuralStorageHints{}, + StorageHints: &pkgmeta.FieldStructuralStorageHints{}, }, - Behavior: FieldBehaviorSpec{ - SqlCompute: &FieldBehaviorSqlComputeSpec{Method: "SqlOnly"}, + Behavior: pkgmeta.FieldBehaviorSpec{ + SqlCompute: &pkgmeta.FieldBehaviorSqlComputeSpec{Method: "SqlOnly"}, }, }) if !field.IsReadonly { @@ -551,32 +553,32 @@ func TestOverlayAndApplySelectionLegacyColumnsNilGuards(t *testing.T) { } func TestResolveSelectionFieldConflict_DefensiveErrorSeams(t *testing.T) { - base := &Field{Name: "Status", FieldType: "selection", SelectionKind: "static"} - _ = base.SetResolvedSpec(&FieldResolvedSpec{ + base := &pkgmeta.Field{Name: "Status", FieldType: "selection", SelectionKind: "static"} + _ = base.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", SelectionKind: "static", - Selection: []FieldSelectionItem{{Value: "a", Label: "A"}}, + Selection: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }, }) - child := &Field{Name: "Status"} - _ = child.SetResolvedSpec(&FieldResolvedSpec{ + child := &pkgmeta.Field{Name: "Status"} + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "b", Label: "B"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "b", Label: "B"}}, }, }) origSet := selectionSetResolved - selectionSetResolved = func(field *Field, spec *FieldResolvedSpec) error { + selectionSetResolved = func(field *pkgmeta.Field, spec *pkgmeta.FieldResolvedSpec) error { return fmtError("set resolved failed") } defer func() { selectionSetResolved = origSet }() - if _, err := ResolveSelectionFieldConflict(base, child); err == nil || !strings.Contains(err.Error(), "set resolved failed") { + if _, err := resolveSelectionFieldConflict(base, child); err == nil || !strings.Contains(err.Error(), "set resolved failed") { t.Fatalf("expected setResolved seam error, got %v", err) } @@ -586,17 +588,17 @@ func TestResolveSelectionFieldConflict_DefensiveErrorSeams(t *testing.T) { return nil, fmtError("marshal failed") } defer func() { selectionJSONMarshal = origMarshal }() - merged, err := ResolveSelectionFieldConflict(base, child) + merged, err := resolveSelectionFieldConflict(base, child) if err != nil { t.Fatalf("marshal failures should be ignored for legacy columns, got %v", err) } if merged == nil { t.Fatal("expected merged field") } - ref := NewTermReference("demo", "demo.fields", "Status", "literal") - applySelectionLegacyColumns(merged, &FieldResolvedSpec{ - Structural: FieldStructuralSpec{ - Selection: []FieldSelectionItem{{Value: "a", Label: "A"}}, + ref := pkgmeta.NewTermReference("demo", "demo.fields", "Status", "literal") + applySelectionLegacyColumns(merged, &pkgmeta.FieldResolvedSpec{ + Structural: pkgmeta.FieldStructuralSpec{ + Selection: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, StringText: &ref, HelpText: &ref, }, @@ -608,25 +610,25 @@ type fmtError string func (e fmtError) Error() string { return string(e) } func TestResolveSelectionFieldConflict_EmptyStaticBaseAllowsAdd(t *testing.T) { - base := &Field{Name: "Status", FieldType: "selection", SelectionKind: "static"} - _ = base.SetResolvedSpec(&FieldResolvedSpec{ + base := &pkgmeta.Field{Name: "Status", FieldType: "selection", SelectionKind: "static"} + _ = base.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", FieldType: "selection", SelectionKind: "static", }, }) - child := &Field{Name: "Status", FieldType: ""} - _ = child.SetResolvedSpec(&FieldResolvedSpec{ + child := &pkgmeta.Field{Name: "Status", FieldType: ""} + _ = child.SetResolvedSpec(&pkgmeta.FieldResolvedSpec{ FieldName: "Status", - Structural: FieldStructuralSpec{ + Structural: pkgmeta.FieldStructuralSpec{ Name: "Status", HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "a", Label: "A"}}, + SelectionAdd: []pkgmeta.FieldSelectionItem{{Value: "a", Label: "A"}}, }, }) - merged, err := ResolveSelectionFieldConflict(base, child) + merged, err := resolveSelectionFieldConflict(base, child) if err != nil { t.Fatalf("empty static base should accept selectionAdd, got %v", err) } diff --git a/internal/module/metadata/model.go b/internal/module/metadata/model.go deleted file mode 100644 index 55f5595fb..000000000 --- a/internal/module/metadata/model.go +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package metadata - -// Entities returns module admin/ops metadata models. -func Entities() []any { - return []any{ - &ModuleIndex{}, - &ModelData{}, - &Setting{}, - &ModuleManagementLog{}, - &ModuleMigrationHistory{}, - } -} diff --git a/internal/module/metadata/model_data.go b/internal/module/metadata/model_data.go deleted file mode 100644 index af04d293a..000000000 --- a/internal/module/metadata/model_data.go +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package metadata - -import "github.com/choysum-dev/choysum/pkg/meta" - -type ModelData struct { - meta.BaseModel `gorm:"embedded"` - Application string `gorm:"type:varchar(255);not null;index"` - Module string `gorm:"type:varchar(255);not null;index:idx_model_data_module_name,unique"` - Name string `gorm:"type:varchar(255);not null;index:idx_model_data_module_name,unique"` - ModelId string `gorm:"column:model_id;type:char(20);not null;index"` - ModelName string `gorm:"column:model_name;type:varchar(255);not null;index"` - ResID string `gorm:"type:char(20);not null;index"` - NoUpdate bool `gorm:"not null;default:false"` -} - -func (md *ModelData) TableName() string { - return "meta_model_data" -} diff --git a/internal/module/metadata/model_test.go b/internal/module/metadata/model_test.go deleted file mode 100644 index 1b7b5b52c..000000000 --- a/internal/module/metadata/model_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package metadata - -import "testing" - -func TestEntitiesAndTableNames(t *testing.T) { - entities := Entities() - if len(entities) != 5 { - t.Fatalf("Entities() len = %d, want 5", len(entities)) - } - - wantTables := map[string]string{ - "ModuleIndex": (&ModuleIndex{}).TableName(), - "ModelData": (&ModelData{}).TableName(), - "Setting": (&Setting{}).TableName(), - "ModuleMigrationHistory": (ModuleMigrationHistory{}).TableName(), - } - for name, table := range wantTables { - if table == "" || table == "meta_ir_"+name { - t.Fatalf("%s.TableName() = %q, unexpected ir prefix or empty", name, table) - } - } - if got := (&ModuleIndex{}).TableName(); got != "meta_module_index" { - t.Fatalf("ModuleIndex.TableName() = %q", got) - } - if got := (&ModelData{}).TableName(); got != "meta_model_data" { - t.Fatalf("ModelData.TableName() = %q", got) - } - if got := (&Setting{}).TableName(); got != "meta_setting" { - t.Fatalf("Setting.TableName() = %q", got) - } - if got := (ModuleMigrationHistory{}).TableName(); got != "meta_module_migration_history" { - t.Fatalf("ModuleMigrationHistory.TableName() = %q", got) - } - if got := (ModuleManagementLog{}).TableName(); got != "meta_module_management_log" { - t.Fatalf("ModuleManagementLog.TableName() = %q", got) - } -} diff --git a/internal/state/lease/lease.go b/internal/state/lease/lease.go index 3a9a9b88f..36db5307c 100644 --- a/internal/state/lease/lease.go +++ b/internal/state/lease/lease.go @@ -9,7 +9,7 @@ import ( "strings" "time" - leasemodel "github.com/choysum-dev/choysum/internal/state/lease/model" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/scope" statepkg "github.com/choysum-dev/choysum/pkg/state" @@ -122,7 +122,7 @@ func acquireLease(db *gorm.DB, resource, ownerId string, now, expiresAt time.Tim } // Fast path: update existing expired/owned lease (also restores from soft delete). - res := newDB().Model(&leasemodel.LockLease{}). + res := newDB().Model(&modmeta.LockLease{}). Where("resource = ? AND (expires_at < ? OR owner_id = ?)", resource, now, ownerId). Updates(map[string]any{"owner_id": ownerId, "expires_at": expiresAt, "deleted_at": nil}) if res.Error != nil { @@ -133,21 +133,21 @@ func acquireLease(db *gorm.DB, resource, ownerId string, now, expiresAt time.Tim } // Try insert. - row := &leasemodel.LockLease{Resource: resource, OwnerId: ownerId, ExpiresAt: expiresAt} + row := &modmeta.LockLease{Resource: resource, OwnerId: ownerId, ExpiresAt: expiresAt} if err := newDB().Create(row).Error; err != nil { if !isUniqueViolation(err) { return fmt.Errorf("acquire lease insert: %w", err) } // Someone else inserted concurrently; re-check. - var existing leasemodel.LockLease + var existing modmeta.LockLease if err := newDB().Where("resource = ?", resource).Take(&existing).Error; err != nil { return fmt.Errorf("acquire lease reload: %w", err) } // Idempotent: treat as acquired, refresh. if existing.OwnerId == ownerId { - if err := newDB().Model(&leasemodel.LockLease{}). + if err := newDB().Model(&modmeta.LockLease{}). Where("resource = ? AND owner_id = ?", resource, ownerId). Updates(map[string]any{"expires_at": expiresAt, "deleted_at": nil}).Error; err != nil { return fmt.Errorf("acquire lease refresh: %w", err) @@ -157,7 +157,7 @@ func acquireLease(db *gorm.DB, resource, ownerId string, now, expiresAt time.Tim // Try take over if expired. if existing.ExpiresAt.Before(now) { - res2 := newDB().Model(&leasemodel.LockLease{}). + res2 := newDB().Model(&modmeta.LockLease{}). Where("resource = ? AND expires_at < ?", resource, now). Updates(map[string]any{"owner_id": ownerId, "expires_at": expiresAt, "deleted_at": nil}) if res2.Error != nil { @@ -229,7 +229,7 @@ func (l *Locker) Renew(ctx context.Context, resource, ownerId string, ttl time.D } func renewLeaseOnDB(db *gorm.DB, resource, ownerId string, expiresAt time.Time) error { - res := db.Session(&gorm.Session{}).Unscoped().Model(&leasemodel.LockLease{}). + res := db.Session(&gorm.Session{}).Unscoped().Model(&modmeta.LockLease{}). Where("resource = ? AND owner_id = ?", resource, ownerId). Updates(map[string]any{"expires_at": expiresAt, "deleted_at": nil}) if res.Error != nil { @@ -250,14 +250,14 @@ func (l *Locker) Release(ctx context.Context, resource, ownerId string) error { newDB := func() *gorm.DB { return db.Session(&gorm.Session{}).Unscoped() } - res := newDB().Where("resource = ? AND owner_id = ?", resource, ownerId).Delete(&leasemodel.LockLease{}) + res := newDB().Where("resource = ? AND owner_id = ?", resource, ownerId).Delete(&modmeta.LockLease{}) if res.Error != nil { return fmt.Errorf("release lease: %w", res.Error) } if res.RowsAffected == 1 { return nil } - var existing leasemodel.LockLease + var existing modmeta.LockLease err := newDB().Where("resource = ?", resource).Take(&existing).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -279,7 +279,7 @@ func (l *Locker) Release(ctx context.Context, resource, ownerId string) error { } // Hard delete to avoid unique-index conflicts with soft deletes. - res := newDB().Where("resource = ? AND owner_id = ?", resource, ownerId).Delete(&leasemodel.LockLease{}) + res := newDB().Where("resource = ? AND owner_id = ?", resource, ownerId).Delete(&modmeta.LockLease{}) if res.Error != nil { return fmt.Errorf("release lease: %w", res.Error) } @@ -288,7 +288,7 @@ func (l *Locker) Release(ctx context.Context, resource, ownerId string) error { } // Distinguish "doesn't exist" vs "not owner". - var existing leasemodel.LockLease + var existing modmeta.LockLease err := newDB().Where("resource = ?", resource).Take(&existing).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/state/lease/lease_test.go b/internal/state/lease/lease_test.go index 0c6788c14..de9c9d8f7 100644 --- a/internal/state/lease/lease_test.go +++ b/internal/state/lease/lease_test.go @@ -6,7 +6,7 @@ package lease import ( "context" "errors" - leasemodel "github.com/choysum-dev/choysum/internal/state/lease/model" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" "io" "log/slog" "path/filepath" @@ -155,7 +155,7 @@ func newSQLiteLocker(t *testing.T) (*Locker, scope.Scope) { slog.New(slog.NewTextHandler(io.Discard, nil)), ) - if err := runtimeScope.Session().AutoMigrate(&leasemodel.LockLease{}); err != nil { + if err := runtimeScope.Session().AutoMigrate(&modmeta.LockLease{}); err != nil { t.Fatalf("AutoMigrate: %v", err) } @@ -166,10 +166,10 @@ func newSQLiteLocker(t *testing.T) (*Locker, scope.Scope) { return New(runtimeScope), runtimeScope } -func fetchLease(t *testing.T, runtimeScope scope.Scope, resource string) (*leasemodel.LockLease, bool) { +func fetchLease(t *testing.T, runtimeScope scope.Scope, resource string) (*modmeta.LockLease, bool) { t.Helper() - var record leasemodel.LockLease + var record modmeta.LockLease err := runtimeScope.Session().Unscoped().Where("resource = ?", resource).Take(&record).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, false @@ -189,7 +189,7 @@ func registerLeaseInsertConflict(t *testing.T, db *gorm.DB, resource, ownerId st if inserted { return } - leaseRow, ok := tx.Statement.Dest.(*leasemodel.LockLease) + leaseRow, ok := tx.Statement.Dest.(*modmeta.LockLease) if !ok || leaseRow.Resource != resource { return } @@ -284,7 +284,7 @@ func TestAcquireCreateBusyTakeoverAndRestoreDeletedLease(t *testing.T) { t.Fatalf("Acquire busy error = %v, want %v", err, ErrLeaseBusy) } - expired := &leasemodel.LockLease{Resource: "resource-expired", OwnerId: "owner-old", ExpiresAt: time.Now().Add(-time.Minute)} + expired := &modmeta.LockLease{Resource: "resource-expired", OwnerId: "owner-old", ExpiresAt: time.Now().Add(-time.Minute)} if err := runtimeScope.Session().Create(expired).Error; err != nil { t.Fatalf("create expired lease: %v", err) } @@ -313,7 +313,7 @@ func TestAcquireWithinRunUsesSQLiteSessionFastPath(t *testing.T) { } var count int64 - if err := executionScope.Session().Model(&leasemodel.LockLease{}).Where("resource = ?", "resource-run").Count(&count).Error; err != nil { + if err := executionScope.Session().Model(&modmeta.LockLease{}).Where("resource = ?", "resource-run").Count(&count).Error; err != nil { return err } if count != 1 { diff --git a/internal/state/lease/model/model.go b/internal/state/lease/model/model.go deleted file mode 100644 index 31db99fa9..000000000 --- a/internal/state/lease/model/model.go +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package model - -// Entities returns lease metadata models. -func Entities() []any { - return []any{ - &LockLease{}, - } -} diff --git a/internal/state/lease/model/model_test.go b/internal/state/lease/model/model_test.go deleted file mode 100644 index 4697b72d5..000000000 --- a/internal/state/lease/model/model_test.go +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package model - -import "testing" - -func TestEntitiesAndLockLeaseTableName(t *testing.T) { - entities := Entities() - if len(entities) != 1 { - t.Fatalf("Entities() len = %d, want 1", len(entities)) - } - if _, ok := entities[0].(*LockLease); !ok { - t.Fatalf("Entities()[0] type = %T, want *LockLease", entities[0]) - } - if got := (&LockLease{}).TableName(); got != "meta_lock_lease" { - t.Fatalf("LockLease.TableName() = %q, want meta_lock_lease", got) - } -} diff --git a/internal/testing/e2e/runner.go b/internal/testing/e2e/runner.go index 1a70099a2..7b4fe3ff3 100644 --- a/internal/testing/e2e/runner.go +++ b/internal/testing/e2e/runner.go @@ -28,8 +28,6 @@ import ( tscore "github.com/buke/typescript-go-internal/pkg/core" tsparser "github.com/buke/typescript-go-internal/pkg/parser" - metadata "github.com/choysum-dev/choysum/internal/module/metadata" - "github.com/choysum-dev/choysum/internal/config/snapshot" _ "github.com/choysum-dev/choysum/internal/defaultengine" _ "github.com/choysum-dev/choysum/internal/defaultjsexecutor" @@ -37,6 +35,7 @@ import ( "github.com/choysum-dev/choysum/internal/logger" dataloader "github.com/choysum-dev/choysum/internal/module/evolution/data" "github.com/choysum-dev/choysum/internal/module/lifecycle" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" moddeps "github.com/choysum-dev/choysum/internal/testing/moddeps" noderuntime "github.com/choysum-dev/choysum/internal/testing/noderuntime" testsemantics "github.com/choysum-dev/choysum/internal/testing/semantics" @@ -683,8 +682,8 @@ func seedModuleIndexForE2E(ctx context.Context, configPath string, packages map[ if sess == nil || sess.DB == nil { return xfmt.Errorf("missing db session") } - if err := sess.DB.AutoMigrate(&metadata.ModuleIndex{}); err != nil { - return xfmt.Errorf("auto-migrate metadata.ModuleIndex: %w", err) + if err := sess.DB.AutoMigrate(&modmeta.ModuleIndex{}); err != nil { + return xfmt.Errorf("auto-migrate modmeta.ModuleIndex: %w", err) } now := time.Now() @@ -707,7 +706,7 @@ func seedModuleIndexForE2E(ctx context.Context, configPath string, packages map[ revision := fmt.Sprintf("%d:%d", info.ModTime().UnixNano(), info.Size()) localPath := filepath.Join(runtimeOptions.modulesPath, sm.DirName) - rec := metadata.ModuleIndex{ + rec := modmeta.ModuleIndex{ ModuleName: name, OriginType: "local", OriginRef: "local", diff --git a/internal/testing/e2e/runner_test.go b/internal/testing/e2e/runner_test.go index 7b26c4155..2c533de8c 100644 --- a/internal/testing/e2e/runner_test.go +++ b/internal/testing/e2e/runner_test.go @@ -1349,7 +1349,7 @@ func TestSeedModuleIndexForE2EAutoMigrateError(t *testing.T) { } err := seedModuleIndexForE2E(context.Background(), writeTempE2EConfig(t, t.TempDir()), map[string]*sourceModulePackage{}) - if err == nil || !strings.Contains(err.Error(), "auto-migrate metadata.ModuleIndex") { + if err == nil || !strings.Contains(err.Error(), "auto-migrate modmeta.ModuleIndex") { t.Fatalf("seedModuleIndexForE2E() error = %v, want auto-migrate failure", err) } } diff --git a/pkg/meta/acl_remap.go b/pkg/meta/acl_remap.go deleted file mode 100644 index d8e975e1d..000000000 --- a/pkg/meta/acl_remap.go +++ /dev/null @@ -1,278 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "errors" - "fmt" - "strings" - - "gorm.io/gorm" -) - -// Test hooks (production defaults). Override in *_test.go to force error paths. -var ( - aclLoadLiveModels = func(db *gorm.DB) ([]Model, error) { - var live []Model - err := db.Select("id", "application", "name", "module_id", "updated_at").Find(&live).Error - return live, err - } - aclLoadAllModels = func(db *gorm.DB) ([]Model, error) { - var all []Model - err := db.Unscoped().Select("id", "application", "name").Find(&all).Error - return all, err - } - aclExec = func(db *gorm.DB, sql string, values ...interface{}) error { - return db.Exec(sql, values...).Error - } - aclHasTable = func(db *gorm.DB, name string) bool { - return db.Migrator().HasTable(name) - } - aclLoadFieldRules = func(db *gorm.DB, dest interface{}) error { - return db.Table("auth_role_field_rule"). - Select("id", "meta_model_id", "meta_field_id"). - Where("meta_field_id IS NOT NULL AND meta_field_id <> ''"). - Find(dest).Error - } - aclTakeFieldUnscoped = func(db *gorm.DB, dest interface{}, fieldID string) error { - return db.Unscoped().Select("id", "name", "model_id").Where("id = ?", fieldID).Take(dest).Error - } - aclTakeFieldByModelName = func(db *gorm.DB, dest interface{}, modelID, name string) error { - return db.Where("model_id = ? AND name = ?", modelID, name).Take(dest).Error - } - aclLoadMethodAccess = func(db *gorm.DB, dest interface{}) error { - return db.Table("auth_role_method_access"). - Select("id", "meta_service_id"). - Where("meta_service_id IS NOT NULL AND meta_service_id <> ''"). - Find(dest).Error - } - aclTakeServiceUnscoped = func(db *gorm.DB, dest interface{}, svcID string) error { - return db.Unscoped().Select("id", "name", "model_id").Where("id = ?", svcID).Take(dest).Error - } - aclTakeServiceLive = func(db *gorm.DB, dest interface{}, svcID string) error { - return db.Where("id = ?", svcID).Take(dest).Error - } - aclTakeModelUnscoped = func(db *gorm.DB, dest interface{}, modelID string) error { - return db.Unscoped().Select("id", "application", "name").Where("id = ?", modelID).Take(dest).Error - } - aclLookupEffective = LookupEffectiveModel - aclTakeServiceByModelName = func(db *gorm.DB, dest interface{}, modelID, name string) error { - return db.Where("model_id = ? AND name = ?", modelID, name).Take(dest).Error - } -) - -// remapACLToEffectiveModelIDs rewrites auth ACL meta_model_id (and related field/service -// FKs) from historical/shell meta_model ids onto the live effective id per (application, name). -// -// Safe to run repeatedly. Missing auth_* tables are skipped (fresh DB before auth install). -// Intended for one-shot upgrades after dual-store migrate; wipe+reseed also works. -func remapACLToEffectiveModelIDs(db *gorm.DB) error { - if db == nil { - return fmt.Errorf("db is nil") - } - - live, err := aclLoadLiveModels(db) - if err != nil { - return fmt.Errorf("load live meta_model: %w", err) - } - effectiveByKey := map[string]Model{} - for _, row := range live { - key := strings.TrimSpace(row.Application) + "\x00" + strings.TrimSpace(row.Name) - if key == "\x00" || !row.Id.Valid { - continue - } - if existing, ok := effectiveByKey[key]; ok { - picked := pickEffectiveAmong([]Model{existing, row}) - effectiveByKey[key] = picked - continue - } - effectiveByKey[key] = row - } - if len(effectiveByKey) == 0 { - return nil - } - - effectiveIDSet := map[string]struct{}{} - for _, m := range effectiveByKey { - effectiveIDSet[m.Id.String] = struct{}{} - } - - allIncludingDeleted, err := aclLoadAllModels(db) - if err != nil { - return fmt.Errorf("load meta_model including deleted: %w", err) - } - oldToEffective := map[string]string{} - for _, row := range allIncludingDeleted { - if !row.Id.Valid { - continue - } - key := strings.TrimSpace(row.Application) + "\x00" + strings.TrimSpace(row.Name) - eff, ok := effectiveByKey[key] - if !ok { - continue - } - if row.Id.String == eff.Id.String { - continue - } - oldToEffective[row.Id.String] = eff.Id.String - } - - return db.Transaction(func(tx *gorm.DB) error { - tables := []string{ - "auth_role_method_access", - "auth_role_record_rule", - "auth_role_field_rule", - } - for _, table := range tables { - if !aclHasTable(tx, table) { - continue - } - for oldID, newID := range oldToEffective { - if err := aclExec(tx, - fmt.Sprintf("UPDATE %s SET meta_model_id = ? WHERE meta_model_id = ?", table), - newID, oldID, - ); err != nil { - return fmt.Errorf("remap %s meta_model_id %s→%s: %w", table, oldID, newID, err) - } - } - } - - // Always remap field FKs: meta_model_id may already be effective while - // meta_field_id still points at a replaced subtree after recompute. - if err := remapFieldRuleFieldIDs(tx); err != nil { - return err - } - return remapOrphanServices(tx, effectiveIDSet) - }) -} - -func remapFieldRuleFieldIDs(db *gorm.DB) error { - if !aclHasTable(db, "auth_role_field_rule") { - return nil - } - type fieldRuleRow struct { - ID string `gorm:"column:id"` - MetaModelID string `gorm:"column:meta_model_id"` - MetaFieldID string `gorm:"column:meta_field_id"` - } - var rules []fieldRuleRow - if err := aclLoadFieldRules(db, &rules); err != nil { - return fmt.Errorf("load field rules for remap: %w", err) - } - - for _, r := range rules { - fieldID := strings.TrimSpace(r.MetaFieldID) - modelID := strings.TrimSpace(r.MetaModelID) - if fieldID == "" || modelID == "" { - continue - } - var oldField Field - err := aclTakeFieldUnscoped(db, &oldField, fieldID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - return fmt.Errorf("lookup field rule %s field %s: %w", r.ID, fieldID, err) - } - name := strings.TrimSpace(oldField.Name) - if name == "" { - continue - } - var newField Field - if takeErr := aclTakeFieldByModelName(db, &newField, modelID, name); takeErr != nil { - if errors.Is(takeErr, gorm.ErrRecordNotFound) { - continue - } - return fmt.Errorf("lookup field rule %s replacement field %s/%s: %w", r.ID, modelID, name, takeErr) - } - if !newField.Id.Valid || newField.Id.String == fieldID { - continue - } - if err := aclExec(db, - "UPDATE auth_role_field_rule SET meta_field_id = ? WHERE id = ?", - newField.Id.String, r.ID, - ); err != nil { - return fmt.Errorf("remap field rule %s field_id: %w", r.ID, err) - } - } - return nil -} - -func remapOrphanServices(db *gorm.DB, effectiveIDSet map[string]struct{}) error { - if !aclHasTable(db, "auth_role_method_access") { - return nil - } - type maRow struct { - ID string `gorm:"column:id"` - MetaServiceID string `gorm:"column:meta_service_id"` - } - var rows []maRow - if err := aclLoadMethodAccess(db, &rows); err != nil { - return fmt.Errorf("load method access for service remap: %w", err) - } - for _, r := range rows { - svcID := strings.TrimSpace(r.MetaServiceID) - if svcID == "" { - continue - } - var svc Service - if err := aclTakeServiceUnscoped(db, &svc, svcID); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - return fmt.Errorf("lookup method access %s service %s: %w", r.ID, svcID, err) - } - if svc.ModelId.Valid { - if _, ok := effectiveIDSet[svc.ModelId.String]; ok { - var live Service - if err := aclTakeServiceLive(db, &live, svcID); err == nil { - continue - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("lookup live service %s: %w", svcID, err) - } - } - } - name := strings.TrimSpace(svc.Name) - if name == "" || !svc.ModelId.Valid { - continue - } - var hist Model - if err := aclTakeModelUnscoped(db, &hist, svc.ModelId.String); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - return fmt.Errorf("lookup historical model for service %s: %w", svcID, err) - } - if strings.TrimSpace(hist.Application) == "" || strings.TrimSpace(hist.Name) == "" { - continue - } - eff, err := aclLookupEffective(db, hist.Application, hist.Name) - if err != nil { - if IsEffectiveModelNotFound(err) { - continue - } - return fmt.Errorf("lookup effective model for service %s: %w", svcID, err) - } - if eff == nil || !eff.Id.Valid { - continue - } - var replacement Service - if err := aclTakeServiceByModelName(db, &replacement, eff.Id.String, name); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - return fmt.Errorf("lookup replacement service %s/%s: %w", eff.Id.String, name, err) - } - if !replacement.Id.Valid || replacement.Id.String == svcID { - continue - } - if err := aclExec(db, - "UPDATE auth_role_method_access SET meta_service_id = ? WHERE id = ?", - replacement.Id.String, r.ID, - ); err != nil { - return fmt.Errorf("remap method access %s service_id: %w", r.ID, err) - } - } - return nil -} diff --git a/pkg/meta/acl_remap_coverage_test.go b/pkg/meta/acl_remap_coverage_test.go deleted file mode 100644 index ad8bca838..000000000 --- a/pkg/meta/acl_remap_coverage_test.go +++ /dev/null @@ -1,562 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "errors" - "strings" - "testing" - "time" - - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func openACLRemapDB(t *testing.T, name string) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure dual store: %v", err) - } - return db -} - -func seedACLTables(t *testing.T, db *gorm.DB) { - t.Helper() - for _, ddl := range []string{ - `CREATE TABLE IF NOT EXISTS auth_role_record_rule ( - id TEXT PRIMARY KEY, meta_model_id TEXT, meta_application_id TEXT)`, - `CREATE TABLE IF NOT EXISTS auth_role_field_rule ( - id TEXT PRIMARY KEY, meta_model_id TEXT, meta_field_id TEXT, meta_application_id TEXT)`, - `CREATE TABLE IF NOT EXISTS auth_role_method_access ( - id TEXT PRIMARY KEY, meta_model_id TEXT, meta_service_id TEXT, meta_application_id TEXT)`, - } { - if err := db.Exec(ddl).Error; err != nil { - t.Fatalf("create acl table: %v", err) - } - } -} - -func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { - t.Run("empty_live_catalog", func(t *testing.T) { - db := openACLRemapDB(t, "acl-empty-live") - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("empty live: %v", err) - } - }) - - t.Run("skip_invalid_live_and_deleted_rows", func(t *testing.T) { - db := openACLRemapDB(t, "acl-skip-invalid") - seedACLTables(t, db) - ts := time.Now().UTC() - // Empty application+name → skipped from effectiveByKey. - blank := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "blank", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - } - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "User", - Application: "auth", - Path: "/eff", - } - other := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "other", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Role", - Application: "auth", - Path: "/other", - } - for _, m := range []*Model{blank, eff, other} { - if err := db.Create(m).Error; err != nil { - t.Fatalf("create: %v", err) - } - } - // Soft-deleted historical shell for same (app,name) as eff. - shell := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "shell", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "User", - Application: "auth", - Path: "/shell", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Create(shell).Error; err != nil { - t.Fatalf("create shell: %v", err) - } - if err := db.Delete(shell).Error; err != nil { - t.Fatalf("soft-delete shell: %v", err) - } - // Duplicate live keys → pickEffectiveAmong among live rows (empty ModuleId wins over newer shell). - eff2 := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff2", Valid: true}, CreatedAt: ts, UpdatedAt: ts.Add(time.Hour)}, - Name: "User", - Application: "auth", - Path: "/eff2", - ModuleId: sql.NullString{String: "mod2", Valid: true}, - } - if err := db.Create(eff2).Error; err != nil { - t.Fatalf("create eff2: %v", err) - } - if err := db.Exec( - `INSERT INTO auth_role_record_rule (id, meta_model_id) VALUES (?, ?)`, - "rr-shell", "shell", - ).Error; err != nil { - t.Fatalf("seed record rule: %v", err) - } - // Inject invalid-id row via load-all hook. - prevAll := aclLoadAllModels - t.Cleanup(func() { aclLoadAllModels = prevAll }) - aclLoadAllModels = func(db *gorm.DB) ([]Model, error) { - rows, err := prevAll(db) - if err != nil { - return nil, err - } - rows = append(rows, Model{Name: "Ghost", Application: "auth"}) // Id invalid - return rows, nil - } - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("remap: %v", err) - } - var mid string - if err := db.Raw(`SELECT meta_model_id FROM auth_role_record_rule WHERE id = ?`, "rr-shell").Scan(&mid).Error; err != nil { - t.Fatalf("read rr: %v", err) - } - if mid != "eff" { - t.Fatalf("expected remap onto empty-ModuleId eff, got %q", mid) - } - }) - - t.Run("field_and_service_skip_and_error_hooks", func(t *testing.T) { - db := openACLRemapDB(t, "acl-hooks") - seedACLTables(t, db) - ts := time.Now().UTC() - shell := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "shell-h", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "User", Application: "auth", Path: "/shell", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff-h", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "User", Application: "auth", Path: "/eff", - } - for _, m := range []*Model{shell, eff} { - if err := db.Create(m).Error; err != nil { - t.Fatalf("create model: %v", err) - } - } - oldField := &Field{BaseModel: BaseModel{Id: sql.NullString{String: "of", Valid: true}}, Name: "Login", ModelId: shell.Id} - newField := &Field{BaseModel: BaseModel{Id: sql.NullString{String: "nf", Valid: true}}, Name: "Login", ModelId: eff.Id} - blankNameField := &Field{BaseModel: BaseModel{Id: sql.NullString{String: "bnf", Valid: true}}, Name: " ", ModelId: shell.Id} - for _, f := range []*Field{oldField, newField, blankNameField} { - if err := db.Create(f).Error; err != nil { - t.Fatalf("create field: %v", err) - } - } - oldSvc := &Service{BaseModel: BaseModel{Id: sql.NullString{String: "os", Valid: true}}, Name: "Read", ModelId: shell.Id} - newSvc := &Service{BaseModel: BaseModel{Id: sql.NullString{String: "ns", Valid: true}}, Name: "Read", ModelId: eff.Id} - blankSvc := &Service{BaseModel: BaseModel{Id: sql.NullString{String: "bs", Valid: true}}, Name: "", ModelId: shell.Id} - liveUnderEff := &Service{BaseModel: BaseModel{Id: sql.NullString{String: "live", Valid: true}}, Name: "Browse", ModelId: eff.Id} - for _, s := range []*Service{oldSvc, newSvc, blankSvc, liveUnderEff} { - if err := db.Create(s).Error; err != nil { - t.Fatalf("create svc: %v", err) - } - } - // Soft-deleted under effective → live Take misses, then remap via TakeServiceByModelName hook. - orphanUnderEff := &Service{ - BaseModel: BaseModel{Id: sql.NullString{String: "orphan", Valid: true}}, - Name: "OrphanOld", ModelId: eff.Id, - } - if err := db.Create(orphanUnderEff).Error; err != nil { - t.Fatalf("create orphan: %v", err) - } - if err := db.Delete(orphanUnderEff).Error; err != nil { - t.Fatalf("soft-delete orphan: %v", err) - } - prevTakeByName := aclTakeServiceByModelName - t.Cleanup(func() { aclTakeServiceByModelName = prevTakeByName }) - aclTakeServiceByModelName = func(db *gorm.DB, dest interface{}, modelID, name string) error { - if name == "OrphanOld" { - if s, ok := dest.(*Service); ok { - *s = Service{BaseModel: BaseModel{Id: sql.NullString{String: "repl-o", Valid: true}}, Name: name, ModelId: sql.NullString{String: modelID, Valid: true}} - return nil - } - } - return prevTakeByName(db, dest, modelID, name) - } - - mustExecACL := func(sqlStmt string, args ...any) { - t.Helper() - if err := db.Exec(sqlStmt, args...).Error; err != nil { - t.Fatalf("exec: %v", err) - } - } - mustExecACL(`INSERT INTO auth_role_field_rule (id, meta_model_id, meta_field_id) VALUES (?,?,?)`, "fr-ok", "shell-h", "of") - mustExecACL(`INSERT INTO auth_role_field_rule (id, meta_model_id, meta_field_id) VALUES (?,?,?)`, "fr-blank-model", " ", "of") - mustExecACL(`INSERT INTO auth_role_field_rule (id, meta_model_id, meta_field_id) VALUES (?,?,?)`, "fr-missing-field", "eff-h", "gone-field") - mustExecACL(`INSERT INTO auth_role_field_rule (id, meta_model_id, meta_field_id) VALUES (?,?,?)`, "fr-blank-name", "eff-h", "bnf") - // Lonely exists only under the shell model, so no replacement resolves under effective. - lonely := &Field{BaseModel: BaseModel{Id: sql.NullString{String: "lonely", Valid: true}}, Name: "Lonely", ModelId: shell.Id} - if err := db.Create(lonely).Error; err != nil { - t.Fatalf("lonely: %v", err) - } - mustExecACL(`INSERT INTO auth_role_field_rule (id, meta_model_id, meta_field_id) VALUES (?,?,?)`, "fr-no-repl", "eff-h", "lonely") - mustExecACL(`INSERT INTO auth_role_field_rule (id, meta_model_id, meta_field_id) VALUES (?,?,?)`, "fr-same", "eff-h", "nf") - - mustExecACL(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-ok", "shell-h", "os") - mustExecACL(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-blank-svc", "shell-h", " ") - mustExecACL(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-missing-svc", "shell-h", "gone-svc") - mustExecACL(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-blank-name", "shell-h", "bs") - mustExecACL(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-live", "eff-h", "live") - mustExecACL(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-orphan", "eff-h", "orphan") - mustExecACL(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-no-hist", "shell-h", "os-nohist") - ghostSvc := &Service{BaseModel: BaseModel{Id: sql.NullString{String: "os-nohist", Valid: true}}, Name: "Ghost", ModelId: sql.NullString{String: "no-such-model", Valid: true}} - if err := db.Create(ghostSvc).Error; err != nil { - t.Fatalf("ghost svc: %v", err) - } - mustExecACL(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-no-eff", "shell-h", "os-noeff") - noEffModel := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "hist-only", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "OnlyHist", Application: "auth", Path: "/hist", ModuleId: sql.NullString{String: "m", Valid: true}, - } - if err := db.Create(noEffModel).Error; err != nil { - t.Fatalf("hist-only: %v", err) - } - if err := db.Delete(noEffModel).Error; err != nil { - t.Fatalf("delete hist-only: %v", err) - } - noEffSvc := &Service{BaseModel: BaseModel{Id: sql.NullString{String: "os-noeff", Valid: true}}, Name: "X", ModelId: noEffModel.Id} - if err := db.Create(noEffSvc).Error; err != nil { - t.Fatalf("noeff svc: %v", err) - } - mustExecACL(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-no-repl", "shell-h", "os-norepl") - noReplSvc := &Service{BaseModel: BaseModel{Id: sql.NullString{String: "os-norepl", Valid: true}}, Name: "UniqueSvc", ModelId: shell.Id} - if err := db.Create(noReplSvc).Error; err != nil { - t.Fatalf("norepl: %v", err) - } - - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("happy path branches: %v", err) - } - - // --- Error hooks --- - forceErr := errors.New("boom") - - t.Run("load_live_error", func(t *testing.T) { - prev := aclLoadLiveModels - t.Cleanup(func() { aclLoadLiveModels = prev }) - aclLoadLiveModels = func(*gorm.DB) ([]Model, error) { return nil, forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "load live") { - t.Fatalf("got %v", err) - } - }) - t.Run("load_all_error", func(t *testing.T) { - prev := aclLoadAllModels - t.Cleanup(func() { aclLoadAllModels = prev }) - aclLoadAllModels = func(*gorm.DB) ([]Model, error) { return nil, forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "including deleted") { - t.Fatalf("got %v", err) - } - }) - t.Run("exec_model_remap_error", func(t *testing.T) { - prev := aclExec - t.Cleanup(func() { aclExec = prev }) - aclExec = func(*gorm.DB, string, ...interface{}) error { return forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "remap") { - t.Fatalf("got %v", err) - } - }) - t.Run("load_field_rules_error", func(t *testing.T) { - prev := aclLoadFieldRules - t.Cleanup(func() { aclLoadFieldRules = prev }) - aclLoadFieldRules = func(*gorm.DB, interface{}) error { return forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "load field rules") { - t.Fatalf("got %v", err) - } - }) - t.Run("take_field_error", func(t *testing.T) { - prev := aclTakeFieldUnscoped - t.Cleanup(func() { aclTakeFieldUnscoped = prev }) - aclTakeFieldUnscoped = func(*gorm.DB, interface{}, string) error { return forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "lookup field rule") { - t.Fatalf("got %v", err) - } - }) - t.Run("take_replacement_field_error", func(t *testing.T) { - prev := aclTakeFieldByModelName - t.Cleanup(func() { aclTakeFieldByModelName = prev }) - aclTakeFieldByModelName = func(*gorm.DB, interface{}, string, string) error { return forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "replacement field") { - t.Fatalf("got %v", err) - } - }) - t.Run("exec_field_id_error", func(t *testing.T) { - // Need old→new field remap to reach exec; reset shell field rule. - mustExecACL(`INSERT OR REPLACE INTO auth_role_field_rule (id, meta_model_id, meta_field_id) VALUES (?,?,?)`, "fr-ok2", "eff-h", "of") - mustExecACL(`INSERT OR REPLACE INTO auth_role_record_rule (id, meta_model_id) VALUES (?,?)`, "rr-rollback", "shell-h") - n := 0 - prev := aclExec - t.Cleanup(func() { aclExec = prev }) - aclExec = func(db *gorm.DB, sql string, values ...interface{}) error { - if strings.Contains(sql, "meta_field_id") { - return forceErr - } - n++ - return prev(db, sql, values...) - } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "field_id") { - t.Fatalf("got %v (exec calls before fail=%d)", err, n) - } - var mid string - if err := db.Raw(`SELECT meta_model_id FROM auth_role_record_rule WHERE id = ?`, "rr-rollback").Scan(&mid).Error; err != nil { - t.Fatalf("read rollback probe: %v", err) - } - if mid != "shell-h" { - t.Fatalf("expected transaction rollback to keep shell-h, got %q", mid) - } - }) - t.Run("load_method_access_error", func(t *testing.T) { - prev := aclLoadMethodAccess - t.Cleanup(func() { aclLoadMethodAccess = prev }) - aclLoadMethodAccess = func(*gorm.DB, interface{}) error { return forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "load method access") { - t.Fatalf("got %v", err) - } - }) - t.Run("take_service_unscoped_error", func(t *testing.T) { - prev := aclTakeServiceUnscoped - t.Cleanup(func() { aclTakeServiceUnscoped = prev }) - aclTakeServiceUnscoped = func(*gorm.DB, interface{}, string) error { return forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "lookup method access") { - t.Fatalf("got %v", err) - } - }) - t.Run("take_live_service_error", func(t *testing.T) { - prev := aclTakeServiceLive - t.Cleanup(func() { aclTakeServiceLive = prev }) - aclTakeServiceLive = func(*gorm.DB, interface{}, string) error { return forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "lookup live service") { - t.Fatalf("got %v", err) - } - }) - t.Run("take_hist_model_error", func(t *testing.T) { - prev := aclTakeModelUnscoped - t.Cleanup(func() { aclTakeModelUnscoped = prev }) - aclTakeModelUnscoped = func(*gorm.DB, interface{}, string) error { return forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "historical model") { - t.Fatalf("got %v", err) - } - }) - t.Run("lookup_effective_error", func(t *testing.T) { - prev := aclLookupEffective - t.Cleanup(func() { aclLookupEffective = prev }) - aclLookupEffective = func(*gorm.DB, string, string) (*Model, error) { return nil, forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "lookup effective model") { - t.Fatalf("got %v", err) - } - }) - t.Run("lookup_effective_nil_id", func(t *testing.T) { - prev := aclLookupEffective - t.Cleanup(func() { aclLookupEffective = prev }) - aclLookupEffective = func(*gorm.DB, string, string) (*Model, error) { - return &Model{}, nil // Id invalid - } - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("nil id should continue: %v", err) - } - }) - t.Run("blank_hist_application_skipped", func(t *testing.T) { - blankHist := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "blank-hist", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - } - if err := db.Create(blankHist).Error; err != nil { - t.Fatalf("create blank hist: %v", err) - } - blankSvc := &Service{ - BaseModel: BaseModel{Id: sql.NullString{String: "blank-hist-svc", Valid: true}}, - Name: "Read", - ModelId: blankHist.Id, - } - if err := db.Create(blankSvc).Error; err != nil { - t.Fatalf("create blank hist svc: %v", err) - } - mustExecACL(`INSERT OR REPLACE INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, - "ma-blank-hist", "blank-hist", "blank-hist-svc") - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("blank hist application must not abort remap: %v", err) - } - }) - t.Run("take_replacement_service_error", func(t *testing.T) { - prev := aclTakeServiceByModelName - t.Cleanup(func() { aclTakeServiceByModelName = prev }) - aclTakeServiceByModelName = func(*gorm.DB, interface{}, string, string) error { return forceErr } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "replacement service") { - t.Fatalf("got %v", err) - } - }) - t.Run("exec_service_id_error", func(t *testing.T) { - mustExecACL(`INSERT OR REPLACE INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma-ok3", "shell-h", "os") - prev := aclExec - t.Cleanup(func() { aclExec = prev }) - aclExec = func(db *gorm.DB, sql string, values ...interface{}) error { - if strings.Contains(sql, "meta_service_id") { - return forceErr - } - return prev(db, sql, values...) - } - if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "service_id") { - t.Fatalf("got %v", err) - } - }) - }) - - t.Run("same_service_id_skip", func(t *testing.T) { - db := openACLRemapDB(t, "acl-same-svc") - seedACLTables(t, db) - ts := time.Now().UTC() - shell := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "sh", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "M", Application: "a", Path: "/s", ModuleId: sql.NullString{String: "m", Valid: true}, - } - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "ef", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "M", Application: "a", Path: "/e", - } - if err := db.Create(shell).Error; err != nil { - t.Fatalf("create shell: %v", err) - } - if err := db.Create(eff).Error; err != nil { - t.Fatalf("create eff: %v", err) - } - svc := &Service{BaseModel: BaseModel{Id: sql.NullString{String: "svc1", Valid: true}}, Name: "S", ModelId: shell.Id} - if err := db.Create(svc).Error; err != nil { - t.Fatalf("create svc: %v", err) - } - if err := db.Exec(`INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?,?,?)`, "ma1", "sh", "svc1").Error; err != nil { - t.Fatalf("seed method access: %v", err) - } - prev := aclTakeServiceByModelName - t.Cleanup(func() { aclTakeServiceByModelName = prev }) - aclTakeServiceByModelName = func(_ *gorm.DB, dest interface{}, _, _ string) error { - // Leave Id invalid - return nil - } - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("got %v", err) - } - }) - - t.Run("no_auth_tables", func(t *testing.T) { - db := openACLRemapDB(t, "acl-no-auth") - ts := time.Now().UTC() - if err := db.Create(&Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "e", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", Application: "a", Path: "/x", - }).Error; err != nil { - t.Fatalf("create model: %v", err) - } - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("no auth tables: %v", err) - } - }) - - t.Run("invalid_live_id_skipped", func(t *testing.T) { - prev := aclLoadLiveModels - t.Cleanup(func() { aclLoadLiveModels = prev }) - aclLoadLiveModels = func(*gorm.DB) ([]Model, error) { - return []Model{{Application: "a", Name: "X"}}, nil // invalid Id - } - db := openACLRemapDB(t, "acl-inv-live") - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("got %v", err) - } - }) -} - -func TestMigrateAndRecompute_RemapACLWiring(t *testing.T) { - t.Run("migrate_index_error", func(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Now().UTC() - src := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "m1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", Application: "a", Path: "/x.ts", ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Create(src).Error; err != nil { - t.Fatalf("create: %v", err) - } - prev := execDDL - t.Cleanup(func() { execDDL = prev }) - execDDL = func(*gorm.DB, string) error { return errors.New("index boom") } - if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "index boom") { - t.Fatalf("expected index error, got %v", err) - } - }) - - t.Run("migrate_remap_error", func(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Now().UTC() - src := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "m2", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Y", Application: "a", Path: "/y.ts", ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Create(src).Error; err != nil { - t.Fatalf("create: %v", err) - } - prev := remapACLToEffectiveModelIDsFn - t.Cleanup(func() { remapACLToEffectiveModelIDsFn = prev }) - remapACLToEffectiveModelIDsFn = func(*gorm.DB) error { return errors.New("remap boom") } - if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "remap boom") { - t.Fatalf("expected remap error, got %v", err) - } - }) - - t.Run("recompute_index_error", func(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Now().UTC() - raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Z", Application: "a", Path: "/z.ts", ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Create(raw).Error; err != nil { - t.Fatalf("create raw: %v", err) - } - prev := execDDL - t.Cleanup(func() { execDDL = prev }) - execDDL = func(*gorm.DB, string) error { return errors.New("recompute index boom") } - if err := recomputeAllEffectiveFromRaw(db); err == nil || !strings.Contains(err.Error(), "recompute index boom") { - t.Fatalf("expected index error, got %v", err) - } - }) - - t.Run("recompute_remap_error", func(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Now().UTC() - raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "r2", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "W", Application: "a", Path: "/w.ts", ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Create(raw).Error; err != nil { - t.Fatalf("create raw: %v", err) - } - prev := remapACLToEffectiveModelIDsFn - t.Cleanup(func() { remapACLToEffectiveModelIDsFn = prev }) - remapACLToEffectiveModelIDsFn = func(*gorm.DB) error { return errors.New("recompute remap boom") } - if err := recomputeAllEffectiveFromRaw(db); err == nil || !strings.Contains(err.Error(), "recompute remap boom") { - t.Fatalf("expected remap error, got %v", err) - } - }) -} diff --git a/pkg/meta/acl_remap_test.go b/pkg/meta/acl_remap_test.go deleted file mode 100644 index 53b452bd2..000000000 --- a/pkg/meta/acl_remap_test.go +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "testing" - "time" - - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func TestRemapACLToEffectiveModelIDs(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:acl-remap?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure dual store: %v", err) - } - - // Minimal auth ACL tables. - for _, ddl := range []string{ - `CREATE TABLE auth_role_record_rule ( - id TEXT PRIMARY KEY, - meta_model_id TEXT, - meta_application_id TEXT - )`, - `CREATE TABLE auth_role_field_rule ( - id TEXT PRIMARY KEY, - meta_model_id TEXT, - meta_field_id TEXT, - meta_application_id TEXT - )`, - `CREATE TABLE auth_role_method_access ( - id TEXT PRIMARY KEY, - meta_model_id TEXT, - meta_service_id TEXT, - meta_application_id TEXT - )`, - } { - if err := db.Exec(ddl).Error; err != nil { - t.Fatalf("create acl table: %v", err) - } - } - - ts := time.Now().UTC() - shell := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "shell-id", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "User", - Path: "/shell", - Application: "auth", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff-id", Valid: true}, CreatedAt: ts, UpdatedAt: ts.Add(time.Hour)}, - Name: "User", - Path: "/eff", - Application: "auth", - } - if err := db.Create(shell).Error; err != nil { - t.Fatalf("create shell: %v", err) - } - if err := db.Create(eff).Error; err != nil { - t.Fatalf("create eff: %v", err) - } - oldField := &Field{ - BaseModel: BaseModel{Id: sql.NullString{String: "old-field", Valid: true}}, - Name: "PasswordHash", - ModelId: shell.Id, - } - newField := &Field{ - BaseModel: BaseModel{Id: sql.NullString{String: "new-field", Valid: true}}, - Name: "PasswordHash", - ModelId: eff.Id, - } - if err := db.Create(oldField).Error; err != nil { - t.Fatalf("create old field: %v", err) - } - if err := db.Create(newField).Error; err != nil { - t.Fatalf("create new field: %v", err) - } - oldSvc := &Service{ - BaseModel: BaseModel{Id: sql.NullString{String: "old-svc", Valid: true}}, - Name: "Read", - ModelId: shell.Id, - } - newSvc := &Service{ - BaseModel: BaseModel{Id: sql.NullString{String: "new-svc", Valid: true}}, - Name: "Read", - ModelId: eff.Id, - } - if err := db.Create(oldSvc).Error; err != nil { - t.Fatalf("create old service: %v", err) - } - if err := db.Create(newSvc).Error; err != nil { - t.Fatalf("create new service: %v", err) - } - - if err := db.Exec( - `INSERT INTO auth_role_record_rule (id, meta_model_id) VALUES (?, ?)`, - "rr1", "shell-id", - ).Error; err != nil { - t.Fatalf("seed record rule: %v", err) - } - if err := db.Exec( - `INSERT INTO auth_role_field_rule (id, meta_model_id, meta_field_id) VALUES (?, ?, ?)`, - "fr1", "shell-id", "old-field", - ).Error; err != nil { - t.Fatalf("seed field rule: %v", err) - } - if err := db.Exec( - `INSERT INTO auth_role_method_access (id, meta_model_id, meta_service_id) VALUES (?, ?, ?)`, - "ma1", "shell-id", "old-svc", - ).Error; err != nil { - t.Fatalf("seed method access: %v", err) - } - - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("remapACLToEffectiveModelIDs: %v", err) - } - - var rrModelID, frModelID, frFieldID, maModelID, maServiceID string - if err := db.Raw(`SELECT meta_model_id FROM auth_role_record_rule WHERE id = ?`, "rr1").Scan(&rrModelID).Error; err != nil { - t.Fatalf("read rr: %v", err) - } - if err := db.Raw(`SELECT meta_model_id, meta_field_id FROM auth_role_field_rule WHERE id = ?`, "fr1").Row().Scan(&frModelID, &frFieldID); err != nil { - t.Fatalf("read fr: %v", err) - } - if err := db.Raw(`SELECT meta_model_id, meta_service_id FROM auth_role_method_access WHERE id = ?`, "ma1").Row().Scan(&maModelID, &maServiceID); err != nil { - t.Fatalf("read ma: %v", err) - } - if rrModelID != "eff-id" || maModelID != "eff-id" || frModelID != "eff-id" { - t.Fatalf("expected meta_model_id remapped to eff-id, got rr=%q fr=%q ma=%q", rrModelID, frModelID, maModelID) - } - if frFieldID != "new-field" { - t.Fatalf("expected meta_field_id remapped to new-field, got %q", frFieldID) - } - if maServiceID != "new-svc" { - t.Fatalf("expected meta_service_id remapped to new-svc, got %q", maServiceID) - } - - // Idempotent: values stay on effective ids. - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("second RemapACL: %v", err) - } - var rr2, fr2Model, fr2Field, ma2Model, ma2Svc string - if err := db.Raw(`SELECT meta_model_id FROM auth_role_record_rule WHERE id = ?`, "rr1").Scan(&rr2).Error; err != nil { - t.Fatalf("re-read rr: %v", err) - } - if err := db.Raw(`SELECT meta_model_id, meta_field_id FROM auth_role_field_rule WHERE id = ?`, "fr1").Row().Scan(&fr2Model, &fr2Field); err != nil { - t.Fatalf("re-read fr: %v", err) - } - if err := db.Raw(`SELECT meta_model_id, meta_service_id FROM auth_role_method_access WHERE id = ?`, "ma1").Row().Scan(&ma2Model, &ma2Svc); err != nil { - t.Fatalf("re-read ma: %v", err) - } - if rr2 != "eff-id" || fr2Model != "eff-id" || fr2Field != "new-field" || ma2Model != "eff-id" || ma2Svc != "new-svc" { - t.Fatalf("second run changed values: rr=%q frModel=%q frField=%q maModel=%q maSvc=%q", rr2, fr2Model, fr2Field, ma2Model, ma2Svc) - } - - if err := remapACLToEffectiveModelIDs(nil); err == nil { - t.Fatal("expected nil db error") - } -} - -func TestRemapACLToEffectiveModelIDs_FieldOnlyWhenModelAlreadyEffective(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:acl-remap-field-only?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure dual store: %v", err) - } - if err := db.Exec(`CREATE TABLE auth_role_field_rule ( - id TEXT PRIMARY KEY, - meta_model_id TEXT, - meta_field_id TEXT, - meta_application_id TEXT - )`).Error; err != nil { - t.Fatalf("create field rule table: %v", err) - } - - ts := time.Now().UTC() - eff := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff-only", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "User", - Path: "/eff", - Application: "auth", - } - if err := db.Create(eff).Error; err != nil { - t.Fatalf("create eff: %v", err) - } - staleField := &Field{ - BaseModel: BaseModel{Id: sql.NullString{String: "stale-field", Valid: true}}, - Name: "Login", - ModelId: sql.NullString{String: "gone-model", Valid: true}, - } - liveField := &Field{ - BaseModel: BaseModel{Id: sql.NullString{String: "live-field", Valid: true}}, - Name: "Login", - ModelId: eff.Id, - } - if err := db.Create(staleField).Error; err != nil { - t.Fatalf("create stale field: %v", err) - } - if err := db.Create(liveField).Error; err != nil { - t.Fatalf("create live field: %v", err) - } - // meta_model_id already effective; only field id is stale (no old→new model map). - if err := db.Exec( - `INSERT INTO auth_role_field_rule (id, meta_model_id, meta_field_id) VALUES (?, ?, ?)`, - "fr-stale", "eff-only", "stale-field", - ).Error; err != nil { - t.Fatalf("seed field rule: %v", err) - } - - if err := remapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("RemapACL: %v", err) - } - var modelID, fieldID string - if err := db.Raw(`SELECT meta_model_id, meta_field_id FROM auth_role_field_rule WHERE id = ?`, "fr-stale"). - Row().Scan(&modelID, &fieldID); err != nil { - t.Fatalf("read field rule: %v", err) - } - if modelID != "eff-only" || fieldID != "live-field" { - t.Fatalf("expected field-only remap, got model=%q field=%q", modelID, fieldID) - } -} diff --git a/pkg/meta/declaration.go b/pkg/meta/declaration.go deleted file mode 100644 index 5a4c7c6f7..000000000 --- a/pkg/meta/declaration.go +++ /dev/null @@ -1,304 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "errors" - "fmt" - "strings" - "time" - - "gorm.io/gorm" -) - -// DeclarationQuery filters declaration-layer (meta_raw_*) rows for ListDeclarations. -// Callers receive []*Model; Path / ModuleId / Id are preserved from raw. -type DeclarationQuery struct { - Application string - Name string - ModuleID string - Path string - Abstract *bool - // PreloadTree loads Fields / Services / Decorators trees (DDL, merge). - PreloadTree bool -} - -// ListDeclarations loads declaration rows matching q and returns them as Model trees. -func ListDeclarations(db *gorm.DB, q DeclarationQuery) ([]*Model, error) { - if db == nil { - return nil, fmt.Errorf("db is nil") - } - query := db.Model(&rawModel{}) - if app := strings.TrimSpace(q.Application); app != "" { - query = query.Where("application = ?", app) - } - if name := strings.TrimSpace(q.Name); name != "" { - query = query.Where("name = ?", name) - } - if moduleID := strings.TrimSpace(q.ModuleID); moduleID != "" { - query = query.Where("module_id = ?", moduleID) - } - if path := strings.TrimSpace(q.Path); path != "" { - query = query.Where("path = ?", path) - } - if q.Abstract != nil { - query = query.Where("abstract = ?", *q.Abstract) - } - if q.PreloadTree { - orderID := func(tx *gorm.DB) *gorm.DB { return tx.Order("id ASC") } - query = query. - Preload("Fields", orderID). - Preload("Fields.Decorators", orderID). - Preload("Fields.Decorators.Arguments", orderID). - Preload("Services", orderID). - Preload("Services.Parameters", orderID). - Preload("Services.TypeParameters", orderID). - Preload("Services.Decorators", orderID). - Preload("Services.Decorators.Arguments", orderID). - Preload("Decorators", orderID). - Preload("Decorators.Arguments", orderID) - } - - var raws []*rawModel - if err := query.Order("id DESC").Find(&raws).Error; err != nil { - return nil, fmt.Errorf("list declarations: %w", err) - } - return rawModelsAsModels(raws), nil -} - -// UpsertDeclaration ensures a declaration at (application, path) exists. -// Missing rows are created via PersistModelTreeAsRaw; existing rows update ModuleId -// when empty and create any Services listed on src that are not yet present by name. -func UpsertDeclaration(db *gorm.DB, src *Model) error { - if db == nil { - return fmt.Errorf("db is nil") - } - if src == nil { - return fmt.Errorf("declaration model is nil") - } - path := strings.TrimSpace(src.Path) - app := strings.TrimSpace(src.Application) - if path == "" || app == "" { - return fmt.Errorf("upsert declaration requires path and application") - } - - return db.Transaction(func(tx *gorm.DB) error { - var existing rawModel - err := tx.Where("path = ? AND application = ?", path, app). - Order("created_at DESC, id DESC"). - Take(&existing).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return PersistModelTreeAsRaw(tx, src) - } - if err != nil { - return fmt.Errorf("lookup declaration %s: %w", path, err) - } - - if src.ModuleId.Valid && strings.TrimSpace(src.ModuleId.String) != "" && - (!existing.ModuleId.Valid || strings.TrimSpace(existing.ModuleId.String) == "") { - if saveErr := tx.Model(&existing).Update("module_id", src.ModuleId.String).Error; saveErr != nil { - return fmt.Errorf("update declaration module: %w", saveErr) - } - existing.ModuleId = src.ModuleId - } - - for _, s := range src.Services { - if s == nil || strings.TrimSpace(s.Name) == "" { - continue - } - var svc rawService - takeErr := tx.Where("model_id = ? AND name = ?", existing.Id.String, s.Name).Take(&svc).Error - if takeErr == nil { - continue - } - if !errors.Is(takeErr, gorm.ErrRecordNotFound) { - return fmt.Errorf("lookup declaration service %s: %w", s.Name, takeErr) - } - ns := *s - if strings.TrimSpace(ns.OriginModelPath) == "" { - ns.OriginModelPath = existing.Path - } - if strings.TrimSpace(ns.AccessibilityModifier) == "" { - ns.AccessibilityModifier = "public" - } - rs := rawServiceFromService(&ns, existing.Id) - ensureBaseModelID(&rs.BaseModel) - if createErr := tx.Create(rs).Error; createErr != nil { - return fmt.Errorf("create declaration service %s: %w", s.Name, createErr) - } - } - return nil - }) -} - -// PreferDeclarationTip bumps created_at/updated_at on the declaration at path so -// pickTipRaw and E2 tip scalars prefer that row among same-name siblings. -func PreferDeclarationTip(db *gorm.DB, application, name, path string) error { - if db == nil { - return fmt.Errorf("db is nil") - } - application = strings.TrimSpace(application) - name = strings.TrimSpace(name) - path = strings.TrimSpace(path) - if application == "" || name == "" || path == "" { - return fmt.Errorf("prefer tip requires application, name, and path") - } - - var canonical rawModel - if err := db.Where("path = ? AND application = ?", path, application). - Order("created_at DESC, id DESC"). - Take(&canonical).Error; err != nil { - return fmt.Errorf("lookup tip declaration: %w", err) - } - if !canonical.Id.Valid { - return fmt.Errorf("tip declaration id is empty") - } - - var siblings []rawModel - if err := db.Select("id", "created_at", "updated_at"). - Where("application = ? AND name = ? AND id <> ?", application, name, canonical.Id.String). - Find(&siblings).Error; err != nil { - return fmt.Errorf("load tip siblings: %w", err) - } - tip := time.Now().UTC() - for _, s := range siblings { - if !tip.After(s.UpdatedAt) { - tip = s.UpdatedAt.Add(time.Millisecond) - } - if !tip.After(s.CreatedAt) { - tip = s.CreatedAt.Add(time.Millisecond) - } - } - if err := db.Model(&canonical).UpdateColumns(map[string]any{ - "created_at": tip, - "updated_at": tip, - }).Error; err != nil { - return fmt.Errorf("promote declaration tip: %w", err) - } - return nil -} - -// DeleteDeclarationTrees hard-deletes raw catalog trees for the given model ids -// (cascade: arguments → decorators → type/params → services → fields → models). -func DeleteDeclarationTrees(db *gorm.DB, modelIDs []string) error { - if db == nil { - return fmt.Errorf("db is nil") - } - ids := make([]string, 0, len(modelIDs)) - seen := map[string]struct{}{} - for _, id := range modelIDs { - id = strings.TrimSpace(id) - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - ids = append(ids, id) - } - if len(ids) == 0 { - return nil - } - - return db.Transaction(func(tx *gorm.DB) error { - fresh := func() *gorm.DB { return tx.Session(&gorm.Session{NewDB: true}).Unscoped() } - - var serviceIDs []string - if err := fresh().Model(&rawService{}).Where("model_id IN ?", ids).Pluck("id", &serviceIDs).Error; err != nil { - return fmt.Errorf("load declaration services: %w", err) - } - var fieldIDs []string - if err := fresh().Model(&rawField{}).Where("model_id IN ?", ids).Pluck("id", &fieldIDs).Error; err != nil { - return fmt.Errorf("load declaration fields: %w", err) - } - decoratorQ := fresh().Model(&rawDecorator{}).Where("model_id IN ?", ids) - if len(serviceIDs) > 0 { - decoratorQ = decoratorQ.Or("service_id IN ?", serviceIDs) - } - if len(fieldIDs) > 0 { - decoratorQ = decoratorQ.Or("field_id IN ?", fieldIDs) - } - var decoratorIDs []string - if err := decoratorQ.Pluck("id", &decoratorIDs).Error; err != nil { - return fmt.Errorf("load declaration decorators: %w", err) - } - - if len(decoratorIDs) > 0 { - if err := deleteWhereFn(fresh(), &rawArgument{}, "decorator_id IN ?", decoratorIDs); err != nil { - return fmt.Errorf("delete declaration arguments: %w", err) - } - if err := deleteWhereFn(fresh(), &rawDecorator{}, "id IN ?", decoratorIDs); err != nil { - return fmt.Errorf("delete declaration decorators: %w", err) - } - } - if len(serviceIDs) > 0 { - if err := deleteWhereFn(fresh(), &rawTypeParameter{}, "service_id IN ?", serviceIDs); err != nil { - return fmt.Errorf("delete declaration type parameters: %w", err) - } - if err := deleteWhereFn(fresh(), &rawParameter{}, "service_id IN ?", serviceIDs); err != nil { - return fmt.Errorf("delete declaration parameters: %w", err) - } - if err := deleteWhereFn(fresh(), &rawService{}, "id IN ?", serviceIDs); err != nil { - return fmt.Errorf("delete declaration services: %w", err) - } - } - if len(fieldIDs) > 0 { - if err := deleteWhereFn(fresh(), &rawField{}, "id IN ?", fieldIDs); err != nil { - return fmt.Errorf("delete declaration fields: %w", err) - } - } - if err := deleteWhereFn(fresh(), &rawModel{}, "id IN ?", ids); err != nil { - return fmt.Errorf("delete declaration models: %w", err) - } - return nil - }) -} - -// HasDeclarationCatalog reports whether meta_raw_model / meta_raw_service exist. -func HasDeclarationCatalog(db *gorm.DB) bool { - if db == nil { - return false - } - m := db.Migrator() - return m.HasTable((&rawModel{}).TableName()) && m.HasTable((&rawService{}).TableName()) -} - -// HasEffectiveCatalog reports whether meta_model / meta_service exist. -func HasEffectiveCatalog(db *gorm.DB) bool { - if db == nil { - return false - } - m := db.Migrator() - return m.HasTable((&Model{}).TableName()) && m.HasTable((&Service{}).TableName()) -} - -// NewAbstractReadonlyDeclaration builds a minimal declaration Model used by builtins (e.g. I18n). -func NewAbstractReadonlyDeclaration(name, path, application string, moduleID sql.NullString, serviceNames []string) *Model { - falseVal := false - m := &Model{ - Name: name, - Path: path, - Application: application, - ClassName: name, - Abstract: true, - Readonly: true, - AutoMigrate: &falseVal, - ModuleId: moduleID, - } - for _, methodName := range serviceNames { - methodName = strings.TrimSpace(methodName) - if methodName == "" { - continue - } - m.Services = append(m.Services, &Service{ - Name: methodName, - OriginModelPath: path, - AccessibilityModifier: "public", - IsStatic: true, - }) - } - return m -} diff --git a/pkg/meta/declaration_test.go b/pkg/meta/declaration_test.go deleted file mode 100644 index bd843db54..000000000 --- a/pkg/meta/declaration_test.go +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "strings" - "testing" - "time" - - "github.com/rs/xid" -) - -func TestListDeclarations_FiltersAndPreload(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - falseVal := false - mod := xid.New().String() - if err := PersistModelTreeAsRaw(db, &Model{ - Name: "AppSetting", Path: "/a/virt", Application: "crm", Abstract: false, AutoMigrate: &falseVal, - ModuleId: sql.NullString{String: mod, Valid: true}, - Fields: []*Field{{Name: "Key"}}, - }); err != nil { - t.Fatal(err) - } - if err := PersistModelTreeAsRaw(db, &Model{ - Name: "AppSetting", Path: "/a/hand", Application: "crm", Abstract: false, - ModuleId: sql.NullString{String: mod, Valid: true}, - }); err != nil { - t.Fatal(err) - } - if err := PersistModelTreeAsRaw(db, &Model{ - Name: "Other", Path: "/o", Application: "crm", Abstract: true, - }); err != nil { - t.Fatal(err) - } - - absFalse := false - got, err := ListDeclarations(db, DeclarationQuery{ - Application: "crm", Name: "AppSetting", Abstract: &absFalse, PreloadTree: true, - }) - if err != nil { - t.Fatal(err) - } - if len(got) != 2 { - t.Fatalf("got %d, want 2", len(got)) - } - var sawField bool - for _, m := range got { - if m.Path == "/a/virt" && len(m.Fields) == 1 && m.Fields[0].Name == "Key" { - sawField = true - } - } - if !sawField { - t.Fatalf("expected preloaded field on virt: %+v", got) - } - - byMod, err := ListDeclarations(db, DeclarationQuery{ModuleID: mod}) - if err != nil || len(byMod) != 2 { - t.Fatalf("by module: n=%d err=%v", len(byMod), err) - } -} - -func TestUpsertDeclaration_CreateThenIdempotentServices(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatal(err) - } - src := NewAbstractReadonlyDeclaration("I18n", "go://i18n/auth", "auth", sql.NullString{}, []string{"GetTranslations", "SearchTerms"}) - if err := UpsertDeclaration(db, src); err != nil { - t.Fatal(err) - } - src.Services = append(src.Services, &Service{Name: "UpdateTerm", IsStatic: true, AccessibilityModifier: "public"}) - mod := sql.NullString{String: xid.New().String(), Valid: true} - src.ModuleId = mod - if err := UpsertDeclaration(db, src); err != nil { - t.Fatal(err) - } - - listed, err := ListDeclarations(db, DeclarationQuery{ - Application: "auth", Name: "I18n", Path: "go://i18n/auth", PreloadTree: true, - }) - if err != nil || len(listed) != 1 { - t.Fatalf("list: n=%d err=%v", len(listed), err) - } - if !listed[0].ModuleId.Valid || listed[0].ModuleId.String != mod.String { - t.Fatalf("module_id not updated: %+v", listed[0].ModuleId) - } - if len(listed[0].Services) != 3 { - t.Fatalf("services=%d want 3", len(listed[0].Services)) - } -} - -func TestPreferDeclarationTip_PromotesCanonical(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatal(err) - } - older := time.Now().UTC().Add(-time.Hour) - newer := older.Add(time.Minute) - ext := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: newer, UpdatedAt: newer}, - Name: "I18n", - Path: "/ext", - Application: "auth", - Abstract: true, - } - canon := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: older, UpdatedAt: older}, - Name: "I18n", - Path: "go://i18n/auth", - Application: "auth", - Abstract: true, - } - for _, row := range []*rawModel{ext, canon} { - if err := db.Create(row).Error; err != nil { - t.Fatal(err) - } - } - if err := PreferDeclarationTip(db, "auth", "I18n", "go://i18n/auth"); err != nil { - t.Fatal(err) - } - var tip rawModel - if err := db.Where("path = ?", "go://i18n/auth").Take(&tip).Error; err != nil { - t.Fatal(err) - } - if !tip.CreatedAt.After(ext.CreatedAt) || !tip.UpdatedAt.After(ext.UpdatedAt) { - t.Fatalf("tip not promoted: tip=%v ext=%v", tip.CreatedAt, ext.CreatedAt) - } -} - -func TestDeleteDeclarationTrees_Cascade(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatal(err) - } - id := xid.New().String() - if err := PersistModelTreeAsRaw(db, &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: id, Valid: true}}, - Name: "X", Path: "/x", Application: "a", - Fields: []*Field{{Name: "F"}}, - Services: []*Service{{Name: "S", AccessibilityModifier: "public"}}, - }); err != nil { - t.Fatal(err) - } - if err := DeleteDeclarationTrees(db, []string{id, id, " ", ""}); err != nil { - t.Fatal(err) - } - var n int64 - if err := db.Unscoped().Model(&rawModel{}).Where("id = ?", id).Count(&n).Error; err != nil || n != 0 { - t.Fatalf("left=%d err=%v", n, err) - } - for name, dest := range map[string]any{"field": &rawField{}, "service": &rawService{}} { - var c int64 - if err := db.Unscoped().Model(dest).Where("model_id = ?", id).Count(&c).Error; err != nil || c != 0 { - t.Fatalf("%s rows left=%d err=%v", name, c, err) - } - } - if err := DeleteDeclarationTrees(nil, []string{"x"}); err == nil || !strings.Contains(err.Error(), "db is nil") { - t.Fatalf("nil db: %v", err) - } - if err := UpsertDeclaration(nil, &Model{}); err == nil || !strings.Contains(err.Error(), "db is nil") { - t.Fatalf("upsert nil: %v", err) - } - if !HasDeclarationCatalog(db) || !HasEffectiveCatalog(db) { - t.Fatal("catalog helpers") - } - if HasDeclarationCatalog(nil) || HasEffectiveCatalog(nil) { - t.Fatal("nil catalog") - } -} diff --git a/pkg/meta/dual_store_migrate_coverage_test.go b/pkg/meta/dual_store_migrate_coverage_test.go deleted file mode 100644 index 3778dda3c..000000000 --- a/pkg/meta/dual_store_migrate_coverage_test.go +++ /dev/null @@ -1,1157 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "errors" - "strings" - "testing" - "time" - - "gorm.io/gorm" -) - -type namedDialector struct { - gorm.Dialector - name string -} - -func (d namedDialector) Name() string { return d.name } - -func closeDualStoreDB(t *testing.T, db *gorm.DB) { - t.Helper() - sqlDB, err := db.DB() - if err != nil { - t.Fatalf("db.DB: %v", err) - } - if err := sqlDB.Close(); err != nil { - t.Fatalf("close: %v", err) - } -} - -func TestEnsureEffectiveAppNameUniqueIndex_NilDB(t *testing.T) { - if err := ensureEffectiveAppNameUniqueIndex(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { - t.Fatalf("expected nil db error, got %v", err) - } -} - -func TestEnsureBaseModelID_Nil(t *testing.T) { - ensureBaseModelID(nil) // no panic -} - -func TestEnsureDualStoreTables_NilAndClosed(t *testing.T) { - if err := EnsureDualStoreTables(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { - t.Fatalf("expected nil db error, got %v", err) - } - db := openDualStoreTestDB(t) - closeDualStoreDB(t, db) - if err := EnsureDualStoreTables(db); err == nil { - t.Fatal("expected AutoMigrate error on closed db") - } -} - -func TestMigrateIMDCatalogToDualStore_NilAndCountError(t *testing.T) { - if err := migrateIMDCatalogToDualStore(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { - t.Fatalf("expected nil db error, got %v", err) - } - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - closeDualStoreDB(t, db) - if err := migrateIMDCatalogToDualStore(db); err == nil { - t.Fatal("expected count/ensure error on closed db") - } -} - -func TestMigrateIMDCatalogToDualStore_DuplicateModulePath(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - for _, id := range []string{"a", "b"} { - m := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: id, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "app", - Path: "/same.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(m).Error; err != nil { - t.Fatalf("create %s: %v", id, err) - } - } - if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "duplicate live") { - t.Fatalf("expected duplicate path error, got %v", err) - } -} - -func TestMigrateIMDSources_NilEntriesAndWhitespaceModuleId(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - blank := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "blank", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "app", - Path: "/x.ts", - ModuleId: sql.NullString{String: " ", Valid: true}, - } - if err := migrateIMDSources(db, []*Model{nil, blank}); err == nil || !strings.Contains(err.Error(), "missing module_id") { - t.Fatalf("expected missing module_id, got %v", err) - } -} - -func TestMigrateIMDCatalogToDualStore_FullTreeCopy(t *testing.T) { - db := openDualStoreTestDB(t) - if err := db.AutoMigrate(append(DualStoreEffectiveEntities(), DualStoreRawEntities()...)...); err != nil { - t.Fatalf("migrate: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - src := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "m1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Application: "partner", - Path: "/partner.ts", - ClassName: "Partner", - ModelTable: "partner_partner", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - modelDec := &Decorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "md", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Model", - } - modelArg := &Argument{ - BaseModel: BaseModel{Id: sql.NullString{String: "ma", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Type: "string", - Value: `"Partner"`, - } - field := &Field{ - BaseModel: BaseModel{Id: sql.NullString{String: "f1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Name", - } - fieldDec := &Decorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "fd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Field", - } - fieldArg := &Argument{ - BaseModel: BaseModel{Id: sql.NullString{String: "fa", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Type: "object", - Value: `{}`, - } - svc := &Service{ - BaseModel: BaseModel{Id: sql.NullString{String: "s1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Create", - } - svcDec := &Decorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "sd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Rpc", - } - params := []*Parameter{ - {BaseModel: BaseModel{Id: sql.NullString{String: "p-this", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "this"}, - {BaseModel: BaseModel{Id: sql.NullString{String: "p-vals", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals"}, - } - tps := []*TypeParameter{ - {BaseModel: BaseModel{Id: sql.NullString{String: "tp1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T"}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(src).Error; err != nil { - t.Fatalf("create model: %v", err) - } - field.ModelId = src.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(field).Error; err != nil { - t.Fatalf("create field: %v", err) - } - fieldDec.FieldId = field.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(fieldDec).Error; err != nil { - t.Fatalf("create field decorator: %v", err) - } - fieldArg.DecoratorId = fieldDec.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(fieldArg).Error; err != nil { - t.Fatalf("create field arg: %v", err) - } - svc.ModelId = src.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svc).Error; err != nil { - t.Fatalf("create service: %v", err) - } - for _, p := range params { - p.ServiceId = svc.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(p).Error; err != nil { - t.Fatalf("create param: %v", err) - } - } - for _, tp := range tps { - tp.ServiceId = svc.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(tp).Error; err != nil { - t.Fatalf("create type param: %v", err) - } - } - svcDec.ServiceId = svc.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svcDec).Error; err != nil { - t.Fatalf("create service decorator: %v", err) - } - modelDec.ModelId = src.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(modelDec).Error; err != nil { - t.Fatalf("create model decorator: %v", err) - } - modelArg.DecoratorId = modelDec.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(modelArg).Error; err != nil { - t.Fatalf("create model arg: %v", err) - } - - // Also exercise nil skips in copyModelTreeToRaw via direct call after migrate would work; - // migrate path covers the live preload tree. - if err := migrateIMDCatalogToDualStore(db); err != nil { - t.Fatalf("migrate: %v", err) - } - - var eff []*Model - if err := db.Preload("Decorators.Arguments").Preload("Fields.Decorators").Preload("Services.Parameters").Preload("Services.TypeParameters").Preload("Services.Decorators").Find(&eff).Error; err != nil { - t.Fatalf("load effective: %v", err) - } - if len(eff) != 1 { - t.Fatalf("effective count=%d", len(eff)) - } - if len(eff[0].Decorators) != 1 || len(eff[0].Decorators[0].Arguments) != 1 { - t.Fatalf("model decorators %#v", eff[0].Decorators) - } - if len(eff[0].Fields) != 1 || eff[0].Fields[0].Name != "Name" { - t.Fatalf("fields %#v", eff[0].Fields) - } - if len(eff[0].Fields[0].Decorators) != 1 || eff[0].Fields[0].Decorators[0].Name != "Field" { - t.Fatalf("field decorators %#v", eff[0].Fields[0].Decorators) - } - if len(eff[0].Services) != 1 { - t.Fatalf("services %#v", eff[0].Services) - } - effSvc := eff[0].Services[0] - if len(effSvc.Parameters) != 1 || effSvc.Parameters[0].Name != "vals" { - t.Fatalf("params %#v", effSvc.Parameters) - } - if len(effSvc.TypeParameters) != 1 || effSvc.TypeParameters[0].Name != "T" { - t.Fatalf("type params %#v", effSvc.TypeParameters) - } - if len(effSvc.Decorators) != 1 { - t.Fatalf("svc decorators %#v", effSvc.Decorators) - } - - // Direct copy with nil children covers continue branches. - db2 := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db2); err != nil { - t.Fatalf("ensure2: %v", err) - } - srcNil := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "mn", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "NilKids", - Application: "a", - Path: "/nil.ts", - ModuleId: sql.NullString{String: "modn", Valid: true}, - Fields: []*Field{nil}, - Services: []*Service{nil, { - BaseModel: BaseModel{Id: sql.NullString{String: "sn", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "S", - Parameters: []*Parameter{nil}, - TypeParameters: []*TypeParameter{nil}, - }}, - Decorators: nil, - } - if err := copyModelTreeToRaw(db2, srcNil); err != nil { - t.Fatalf("copy with nils: %v", err) - } -} - -func TestRecomputeAllEffectiveFromRaw_NilAndErrors(t *testing.T) { - if err := recomputeAllEffectiveFromRaw(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { - t.Fatalf("expected nil db error, got %v", err) - } - - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "id-older", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { - t.Fatalf("create raw: %v", err) - } - newer := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "id-newer", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x2.ts", - ModuleId: sql.NullString{String: "mod2", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(newer).Error; err != nil { - t.Fatalf("create newer: %v", err) - } - olderTime := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "zzz", Valid: true}, CreatedAt: ts.Add(-time.Hour), UpdatedAt: ts}, - Name: "Y", - Application: "b", - Path: "/y.ts", - ModuleId: sql.NullString{String: "mody", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(olderTime).Error; err != nil { - t.Fatalf("create olderTime: %v", err) - } - younger := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "aaa", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Y", - Application: "b", - Path: "/y2.ts", - ModuleId: sql.NullString{String: "mody2", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(younger).Error; err != nil { - t.Fatalf("create younger: %v", err) - } - - if err := recomputeAllEffectiveFromRaw(db); err != nil { - t.Fatalf("recompute: %v", err) - } - - // Equal CreatedAt → lexicographically greater Id wins ("id-older" > "id-newer"). - var x Model - if err := db.Where("application = ? AND name = ?", "a", "X").First(&x).Error; err != nil { - t.Fatalf("load X: %v", err) - } - if x.Id.String != "id-older" { - t.Fatalf("X tip id=%q want id-older", x.Id.String) - } - var y Model - if err := db.Where("application = ? AND name = ?", "b", "Y").First(&y).Error; err != nil { - t.Fatalf("load Y: %v", err) - } - if y.Id.String != "aaa" { - t.Fatalf("Y tip id=%q want aaa (newer CreatedAt)", y.Id.String) - } -} - -func TestRecomputeAllEffectiveFromRaw_MergeError(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { - t.Fatalf("create raw: %v", err) - } - f := &rawField{ - BaseModel: BaseModel{Id: sql.NullString{String: "f1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Kind", - FieldType: "selection", - ModelId: raw.Id, - } - spec := &FieldResolvedSpec{ - FieldName: "Kind", - Structural: FieldStructuralSpec{ - Name: "Kind", - FieldType: "selection", - HasSelectionAdd: true, - SelectionAdd: []FieldSelectionItem{{Value: "vip", Label: "VIP"}}, - }, - } - if err := f.SetResolvedSpec(spec); err != nil { - t.Fatalf("spec: %v", err) - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(f).Error; err != nil { - t.Fatalf("create field: %v", err) - } - if err := recomputeAllEffectiveFromRaw(db); err == nil || !strings.Contains(err.Error(), "selectionAdd") { - t.Fatalf("expected merge error, got %v", err) - } -} - -func TestRecomputeAllEffectiveFromRaw_TxErrorOnClosed(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - closeDualStoreDB(t, db) - if err := recomputeAllEffectiveFromRaw(db); err == nil { - t.Fatal("expected recompute error on closed db") - } -} - -func TestFindRawByIDAndRawIsNewerTip(t *testing.T) { - if findRawByID([]*rawModel{nil}, "x") != nil { - t.Fatal("expected nil") - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - a := &rawModel{BaseModel: BaseModel{Id: sql.NullString{String: "a", Valid: true}, CreatedAt: ts}} - b := &rawModel{BaseModel: BaseModel{Id: sql.NullString{String: "b", Valid: true}, CreatedAt: ts}} - if findRawByID([]*rawModel{nil, a}, "a") != a { - t.Fatal("expected find a") - } - if !rawIsNewerTip(a, nil) { - t.Fatal("nil previous") - } - if !rawIsNewerTip(b, a) { // equal time, higher id - t.Fatal("expected b newer by id") - } - older := &rawModel{BaseModel: BaseModel{Id: sql.NullString{String: "z", Valid: true}, CreatedAt: ts.Add(-time.Hour)}} - if rawIsNewerTip(older, a) { - t.Fatal("older should not win") - } - newer := &rawModel{BaseModel: BaseModel{Id: sql.NullString{String: "c", Valid: true}, CreatedAt: ts.Add(time.Hour)}} - if !rawIsNewerTip(newer, a) { - t.Fatal("newer CreatedAt should win") - } -} - -func TestCopyModelTreeToRaw_CreateFailures(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - existing := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "existing", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(existing).Error; err != nil { - t.Fatalf("seed raw: %v", err) - } - src := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "dup", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := copyModelTreeToRaw(db, src); err == nil { - t.Fatal("expected unique violation creating raw model") - } -} - -func TestCopyModelTreeToRaw_ChildTableFailures(t *testing.T) { - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - cases := []struct { - name string - drop any - src *Model - }{ - { - name: "raw field", - drop: &rawField{}, - src: &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "m", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - Fields: []*Field{{ - BaseModel: BaseModel{Id: sql.NullString{String: "f", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Name", - }}, - }, - }, - { - name: "raw service", - drop: &rawService{}, - src: &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "m2", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Y", - Application: "a", - Path: "/y.ts", - ModuleId: sql.NullString{String: "mod2", Valid: true}, - Services: []*Service{{ - BaseModel: BaseModel{Id: sql.NullString{String: "s", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Create", - }}, - }, - }, - { - name: "raw parameter", - drop: &rawParameter{}, - src: &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "m3", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Z", - Application: "a", - Path: "/z.ts", - ModuleId: sql.NullString{String: "mod3", Valid: true}, - Services: []*Service{{ - BaseModel: BaseModel{Id: sql.NullString{String: "s3", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Create", - Parameters: []*Parameter{{BaseModel: BaseModel{Id: sql.NullString{String: "p", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals"}}, - }}, - }, - }, - { - name: "raw type parameter", - drop: &rawTypeParameter{}, - src: &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "m4", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "W", - Application: "a", - Path: "/w.ts", - ModuleId: sql.NullString{String: "mod4", Valid: true}, - Services: []*Service{{ - BaseModel: BaseModel{Id: sql.NullString{String: "s4", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Create", - TypeParameters: []*TypeParameter{{BaseModel: BaseModel{Id: sql.NullString{String: "tp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T"}}, - }}, - }, - }, - { - name: "raw decorator", - drop: &rawDecorator{}, - src: &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "m5", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "V", - Application: "a", - Path: "/v.ts", - ModuleId: sql.NullString{String: "mod5", Valid: true}, - Decorators: []*Decorator{{ - BaseModel: BaseModel{Id: sql.NullString{String: "d5", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Model", - }}, - }, - }, - { - name: "raw argument", - drop: &rawArgument{}, - src: &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "m6", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "U", - Application: "a", - Path: "/u.ts", - ModuleId: sql.NullString{String: "mod6", Valid: true}, - Decorators: []*Decorator{{ - BaseModel: BaseModel{Id: sql.NullString{String: "d6", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Model", - Arguments: []*Argument{{BaseModel: BaseModel{Id: sql.NullString{String: "a6", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Type: "string", Value: `"U"`}}, - }}, - }, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - if err := db.Migrator().DropTable(tc.drop); err != nil { - t.Fatalf("drop %s: %v", tc.name, err) - } - if err := copyModelTreeToRaw(db, tc.src); err == nil { - t.Fatalf("expected %s create failure", tc.name) - } - }) - } - - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure nil-decorator db: %v", err) - } - if err := copyDecoratorToRaw(db, nil, sql.NullString{}, sql.NullString{}, sql.NullString{}); err != nil { - t.Fatalf("nil decorator: %v", err) - } -} - -func TestClearEffectiveShapeTrees_Error(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - if err := db.Migrator().DropTable(&Argument{}); err != nil { - t.Fatalf("drop: %v", err) - } - if err := clearEffectiveShapeTrees(db); err == nil { - t.Fatal("expected clear error") - } -} - -func TestPersistEffectiveProjection_FailuresAndNils(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - merged := &Model{ - BaseModel: BaseModel{CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - Decorators: []*Decorator{nil}, - Fields: []*Field{nil, {Name: "Name", OriginModelPath: "kept"}}, - Services: []*Service{nil, { - Name: "Create", - OriginModelPath: "svc-origin", - Parameters: []*Parameter{nil, {Name: "this"}, {Name: "vals"}}, - TypeParameters: []*TypeParameter{nil, {Name: "T"}}, - Decorators: []*Decorator{{Name: "Rpc"}}, - }}, - } - if err := persistEffectiveProjection(db, merged, "eff1", nil); err != nil { - t.Fatalf("persist: %v", err) - } - - // model create failure - if err := persistEffectiveProjection(db, merged, "eff1", nil); err == nil { - t.Fatal("expected duplicate effective id failure") - } - - db2 := openDualStoreTestDB(t) - _ = EnsureDualStoreTables(db2) - _ = db2.Migrator().DropTable(&Field{}) - if err := persistEffectiveProjection(db2, &Model{ - BaseModel: BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", - Fields: []*Field{{Name: "Name"}}, - }, "e2", nil); err == nil { - t.Fatal("expected field create failure") - } - - db3 := openDualStoreTestDB(t) - _ = EnsureDualStoreTables(db3) - _ = db3.Migrator().DropTable(&Service{}) - if err := persistEffectiveProjection(db3, &Model{ - BaseModel: BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", - Services: []*Service{{Name: "Create"}}, - }, "e3", nil); err == nil { - t.Fatal("expected service create failure") - } - - db4 := openDualStoreTestDB(t) - _ = EnsureDualStoreTables(db4) - _ = db4.Migrator().DropTable(&Parameter{}) - if err := persistEffectiveProjection(db4, &Model{ - BaseModel: BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", - Services: []*Service{{Name: "Create", Parameters: []*Parameter{{Name: "vals"}}}}, - }, "e4", nil); err == nil { - t.Fatal("expected parameter create failure") - } - - db5 := openDualStoreTestDB(t) - _ = EnsureDualStoreTables(db5) - _ = db5.Migrator().DropTable(&TypeParameter{}) - if err := persistEffectiveProjection(db5, &Model{ - BaseModel: BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", - Services: []*Service{{Name: "Create", TypeParameters: []*TypeParameter{{Name: "T"}}}}, - }, "e5", nil); err == nil { - t.Fatal("expected type parameter create failure") - } - - db6 := openDualStoreTestDB(t) - _ = EnsureDualStoreTables(db6) - _ = db6.Migrator().DropTable(&Decorator{}) - if err := persistEffectiveProjection(db6, &Model{ - BaseModel: BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", - Decorators: []*Decorator{{Name: "Model"}}, - }, "e6", nil); err == nil { - t.Fatal("expected decorator create failure") - } - - db7 := openDualStoreTestDB(t) - _ = EnsureDualStoreTables(db7) - _ = db7.Migrator().DropTable(&Argument{}) - if err := persistDecoratorTree(db7, &Decorator{ - Name: "Model", Arguments: []*Argument{nil, {Type: "string", Value: `"X"`}}, - }, sql.NullString{String: "mid", Valid: true}, sql.NullString{}, sql.NullString{}); err == nil { - t.Fatal("expected argument create failure after dropping meta_argument") - } - - if err := persistDecoratorTree(db7, nil, sql.NullString{}, sql.NullString{}, sql.NullString{}); err != nil { - t.Fatalf("nil decorator: %v", err) - } -} - -func TestEnsureEffectiveAppNameUniqueIndex_Dialects(t *testing.T) { - // namedDialector only overrides Name() for branch selection; SQL still runs on SQLite. - // Each case opens a fresh DB so Dialector mutation does not leak across subtests. - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { - t.Fatalf("sqlite index: %v", err) - } - db.Dialector = namedDialector{Dialector: db.Dialector, name: "postgres"} - if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { - t.Fatalf("postgres index: %v", err) - } - - db2 := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db2); err != nil { - t.Fatalf("ensure2: %v", err) - } - db2.Dialector = namedDialector{Dialector: db2.Dialector, name: "mysql"} - if err := ensureEffectiveAppNameUniqueIndex(db2); err != nil { - t.Fatalf("mysql first: %v", err) - } - // second call: HasIndex true → DropIndex → Create - if err := ensureEffectiveAppNameUniqueIndex(db2); err != nil { - t.Fatalf("mysql second: %v", err) - } - - // CREATE failure when table is missing (first step creates temp index). - _ = db2.Migrator().DropTable(&Model{}) - if err := ensureEffectiveAppNameUniqueIndex(db2); err == nil { - t.Fatal("expected mysql create index failure") - } - - // sqlite CREATE failure - db3 := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db3); err != nil { - t.Fatalf("ensure3: %v", err) - } - _ = db3.Migrator().DropTable(&Model{}) - if err := ensureEffectiveAppNameUniqueIndex(db3); err == nil || !strings.Contains(err.Error(), "create unique index") { - t.Fatalf("expected sqlite create index failure, got %v", err) - } - - // sqlite/postgres DDL failure must surface (closed DB; first step is CREATE temp). - db4 := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db4); err != nil { - t.Fatalf("ensure4: %v", err) - } - closeDualStoreDB(t, db4) - if err := ensureEffectiveAppNameUniqueIndex(db4); err == nil || !strings.Contains(err.Error(), "create unique index") { - t.Fatalf("expected create unique index error on closed db, got %v", err) - } -} - -func TestEnsureEffectiveAppNameUniqueIndex_MySQLDropError(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - db.Dialector = namedDialector{Dialector: db.Dialector, name: "mysql"} - if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { - t.Fatalf("create: %v", err) - } - // Break migrator by swapping dialector to one that reports HasIndex but fails DropIndex. - // HasIndex(temp) is also true, so temp create is skipped; drop of final still fails. - db.Dialector = failingDropDialector{Dialector: db.Dialector, name: "mysql"} - if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "drop unique index") { - t.Fatalf("expected drop error, got %v", err) - } -} - -func TestEnsurePartialAliveAppNameUniqueIndex_PreservesUniquenessOnCreateFinalFailure(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { - t.Fatalf("initial index: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - soft := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "soft", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Application: "partner", - Path: "/p1.ts", - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(soft).Error; err != nil { - t.Fatalf("create soft: %v", err) - } - if err := db.Delete(soft).Error; err != nil { - t.Fatalf("soft-delete: %v", err) - } - - prev := execDDL - t.Cleanup(func() { execDDL = prev }) - execDDL = func(db *gorm.DB, sql string) error { - // Allow temp create + drop of final; fail recreate of the final name. - if strings.Contains(sql, "CREATE UNIQUE INDEX") && - strings.Contains(sql, effectiveAppNameUniqueIndex) && - !strings.Contains(sql, effectiveAppNameUniqueIndexTemp) { - return errors.New("create final boom") - } - return prev(db, sql) - } - if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "create final boom") { - t.Fatalf("expected create final failure, got %v", err) - } - // Temp partial index must still protect live uniqueness and allow soft-deleted reuse. - live := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "live", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Application: "partner", - Path: "/p2.ts", - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(live).Error; err != nil { - t.Fatalf("live row after soft-delete should succeed under temp partial index: %v", err) - } - dup := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "dup", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Application: "partner", - Path: "/p3.ts", - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(dup).Error; err == nil { - t.Fatal("expected live (application, name) uniqueness still enforced") - } -} - -func TestEnsurePartialAliveAppNameUniqueIndex_DropErrors(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - prev := execDDL - t.Cleanup(func() { execDDL = prev }) - - execDDL = func(db *gorm.DB, sql string) error { - if strings.HasPrefix(strings.TrimSpace(sql), "DROP INDEX") && strings.Contains(sql, effectiveAppNameUniqueIndex) && !strings.Contains(sql, "_new") { - return errors.New("drop final boom") - } - return prev(db, sql) - } - if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "drop final boom") { - t.Fatalf("expected drop final error, got %v", err) - } - - execDDL = func(db *gorm.DB, sql string) error { - if strings.HasPrefix(strings.TrimSpace(sql), "DROP INDEX") && strings.Contains(sql, effectiveAppNameUniqueIndexTemp) { - return errors.New("drop temp boom") - } - return prev(db, sql) - } - if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "drop temp boom") { - t.Fatalf("expected drop temp error, got %v", err) - } -} - -func TestEnsureFullAppNameUniqueIndex_CreateFinalAndDropTempErrors(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - db.Dialector = namedDialector{Dialector: db.Dialector, name: "mysql"} - if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { - t.Fatalf("initial: %v", err) - } - - prev := execDDL - t.Cleanup(func() { execDDL = prev }) - execDDL = func(db *gorm.DB, sql string) error { - if strings.Contains(sql, "CREATE UNIQUE INDEX") && - strings.Contains(sql, effectiveAppNameUniqueIndex) && - !strings.Contains(sql, effectiveAppNameUniqueIndexTemp) { - return errors.New("mysql create final boom") - } - return prev(db, sql) - } - if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "mysql create final boom") { - t.Fatalf("expected mysql create final error, got %v", err) - } - - execDDL = prev - // Leave temp in place from the failed run, then force DropIndex(temp) to fail. - db.Dialector = failingDropTempDialector{Dialector: db.Dialector, name: "mysql"} - if err := ensureEffectiveAppNameUniqueIndex(db); err == nil || !strings.Contains(err.Error(), "drop boom temp") { - t.Fatalf("expected drop temp error, got %v", err) - } -} - -type failingDropTempDialector struct { - gorm.Dialector - name string -} - -func (d failingDropTempDialector) Name() string { return d.name } - -func (d failingDropTempDialector) Migrator(db *gorm.DB) gorm.Migrator { - return failingDropTempMigrator{Migrator: d.Dialector.Migrator(db)} -} - -type failingDropTempMigrator struct { - gorm.Migrator -} - -func (m failingDropTempMigrator) HasIndex(dst interface{}, name string) bool { - return m.Migrator.HasIndex(dst, name) -} - -func (m failingDropTempMigrator) DropIndex(dst interface{}, name string) error { - if name == effectiveAppNameUniqueIndexTemp { - return errors.New("drop boom temp") - } - return m.Migrator.DropIndex(dst, name) -} - -type failingDropDialector struct { - gorm.Dialector - name string -} - -func (d failingDropDialector) Name() string { return d.name } - -func (d failingDropDialector) Migrator(db *gorm.DB) gorm.Migrator { - return failingDropMigrator{Migrator: d.Dialector.Migrator(db)} -} - -type failingDropMigrator struct { - gorm.Migrator -} - -func (m failingDropMigrator) HasIndex(dst interface{}, name string) bool { return true } -func (m failingDropMigrator) DropIndex(dst interface{}, name string) error { - return errors.New("drop boom") -} - -func TestMigrateIMDCatalogToDualStore_FindError(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - prev := loadIMDModelsForMigrate - t.Cleanup(func() { loadIMDModelsForMigrate = prev }) - loadIMDModelsForMigrate = func(*gorm.DB) ([]*Model, error) { - return nil, errors.New("find boom") - } - if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "load meta_model") { - t.Fatalf("expected load meta_model error, got %v", err) - } -} - -func TestMigrateIMDCatalogToDualStore_CountError(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - prev := countMetarawModels - t.Cleanup(func() { countMetarawModels = prev }) - countMetarawModels = func(*gorm.DB) (int64, error) { - return 0, errors.New("count boom") - } - if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "count meta_raw_model") { - t.Fatalf("expected count error, got %v", err) - } -} - -func TestRecomputeHooks_LoadClearPersistErrorsAndEmptyTip(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - - prevLoad, prevClear, prevPersist := loadrawModelsForRecompute, clearEffectiveShapeTreesFn, persistEffectiveProjectionFn - t.Cleanup(func() { - loadrawModelsForRecompute = prevLoad - clearEffectiveShapeTreesFn = prevClear - persistEffectiveProjectionFn = prevPersist - }) - - loadrawModelsForRecompute = func(*gorm.DB) ([]*rawModel, error) { - return nil, errors.New("raw find boom") - } - if err := recomputeAllEffectiveFromRaw(db); err == nil || !strings.Contains(err.Error(), "load meta_raw_model") { - t.Fatalf("expected load raw error, got %v", err) - } - - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - loadrawModelsForRecompute = func(*gorm.DB) ([]*rawModel, error) { - return []*rawModel{{ - BaseModel: BaseModel{Id: sql.NullString{String: "r", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - }}, nil - } - clearEffectiveShapeTreesFn = func(*gorm.DB) error { return errors.New("clear boom") } - if err := recomputeAllEffectiveFromRaw(db); err == nil || !strings.Contains(err.Error(), "clear boom") { - t.Fatalf("expected clear error, got %v", err) - } - - clearEffectiveShapeTreesFn = func(*gorm.DB) error { return nil } - persistEffectiveProjectionFn = func(*gorm.DB, *Model, string) error { return errors.New("persist boom") } - if err := recomputeAllEffectiveFromRaw(db); err == nil || !strings.Contains(err.Error(), "persist effective") { - t.Fatalf("expected persist error, got %v", err) - } - - // Empty tip id → xid path. - loadrawModelsForRecompute = func(*gorm.DB) ([]*rawModel, error) { - return []*rawModel{{ - BaseModel: BaseModel{Id: sql.NullString{String: "", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "EmptyTip", - Application: "a", - Path: "/empty.ts", - }}, nil - } - persistEffectiveProjectionFn = func(db *gorm.DB, merged *Model, effectiveID string) error { - return persistEffectiveProjection(db, merged, effectiveID, nil) - } - clearEffectiveShapeTreesFn = clearEffectiveShapeTrees - if err := recomputeAllEffectiveFromRaw(db); err != nil { - t.Fatalf("empty tip recompute: %v", err) - } - var count int64 - if err := db.Model(&Model{}).Where("name = ?", "EmptyTip").Count(&count).Error; err != nil { - t.Fatalf("count: %v", err) - } - if count != 1 { - t.Fatalf("expected EmptyTip row, got %d", count) - } -} - -func TestMigrateIMDSources_NilInCopyLoop(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - src := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "ok", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/ok.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := migrateIMDSources(db, []*Model{nil, src, nil}); err != nil { - t.Fatalf("migrateIMDSources: %v", err) - } -} - -func TestCopyDecoratorToRaw_NilArgument(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - d := &Decorator{ - BaseModel: BaseModel{Id: sql.NullString{String: "d", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Model", - Arguments: []*Argument{nil}, - } - if err := copyDecoratorToRaw(db, d, sql.NullString{String: "m", Valid: true}, sql.NullString{}, sql.NullString{}); err != nil { - t.Fatalf("copyDecoratorToRaw: %v", err) - } -} - -func TestCopyModelTreeToRaw_NestedDecoratorFailures(t *testing.T) { - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - src := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "mf", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/xf.ts", - ModuleId: sql.NullString{String: "modf", Valid: true}, - Fields: []*Field{{ - BaseModel: BaseModel{Id: sql.NullString{String: "ff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Name", - Decorators: []*Decorator{{BaseModel: BaseModel{Id: sql.NullString{String: "df", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Field"}}, - }}, - } - if err := db.Migrator().DropTable(&rawDecorator{}); err != nil { - t.Fatalf("drop: %v", err) - } - if err := copyModelTreeToRaw(db, src); err == nil { - t.Fatal("expected field decorator create failure") - } - - db2 := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db2); err != nil { - t.Fatalf("ensure2: %v", err) - } - src2 := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "ms", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Y", - Application: "a", - Path: "/ys.ts", - ModuleId: sql.NullString{String: "mods", Valid: true}, - Services: []*Service{{ - BaseModel: BaseModel{Id: sql.NullString{String: "ss", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Create", - Decorators: []*Decorator{{BaseModel: BaseModel{Id: sql.NullString{String: "ds", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Rpc"}}, - }}, - } - if err := db2.Migrator().DropTable(&rawDecorator{}); err != nil { - t.Fatalf("drop2: %v", err) - } - if err := copyModelTreeToRaw(db2, src2); err == nil { - t.Fatal("expected service decorator create failure") - } -} - -func TestPersistEffectiveProjection_NestedDecoratorFailures(t *testing.T) { - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - - db := openDualStoreTestDB(t) - _ = EnsureDualStoreTables(db) - _ = db.Migrator().DropTable(&Decorator{}) - if err := persistEffectiveProjection(db, &Model{ - BaseModel: BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", - Fields: []*Field{{Name: "Name", Decorators: []*Decorator{{Name: "Field"}}}}, - }, "ef", nil); err == nil { - t.Fatal("expected field decorator persist failure") - } - - db2 := openDualStoreTestDB(t) - _ = EnsureDualStoreTables(db2) - _ = db2.Migrator().DropTable(&Decorator{}) - if err := persistEffectiveProjection(db2, &Model{ - BaseModel: BaseModel{CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", Path: "/x.ts", - Services: []*Service{{Name: "Create", Decorators: []*Decorator{{Name: "Rpc"}}}}, - }, "es", nil); err == nil { - t.Fatal("expected service decorator persist failure") - } - - db3 := openDualStoreTestDB(t) - _ = EnsureDualStoreTables(db3) - if err := persistDecoratorTree(db3, &Decorator{ - Name: "Model", Arguments: []*Argument{nil, {Type: "string", Value: `"X"`}}, - }, sql.NullString{String: "no-model", Valid: true}, sql.NullString{}, sql.NullString{}); err != nil { - t.Fatalf("persistDecoratorTree with nil arg should succeed on sqlite: %v", err) - } - _ = db3.Migrator().DropTable(&Argument{}) - if err := persistDecoratorTree(db3, &Decorator{ - Name: "Model2", Arguments: []*Argument{{Type: "string", Value: `"Y"`}}, - }, sql.NullString{}, sql.NullString{}, sql.NullString{}); err == nil { - t.Fatal("expected argument create failure") - } -} - -func TestMigrateIMDSources_CopyErrorPropagates(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - existing := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "ex", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(existing).Error; err != nil { - t.Fatalf("seed: %v", err) - } - src := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "src", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := migrateIMDSources(db, []*Model{src}); err == nil { - t.Fatal("expected copy error") - } -} diff --git a/pkg/meta/dual_store_migrate_test.go b/pkg/meta/dual_store_migrate_test.go deleted file mode 100644 index 078a0895f..000000000 --- a/pkg/meta/dual_store_migrate_test.go +++ /dev/null @@ -1,375 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "fmt" - "path/filepath" - "strings" - "testing" - "time" - - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" -) - -func openDualStoreTestDB(t *testing.T) *gorm.DB { - t.Helper() - dsn := filepath.Join(t.TempDir(), "dual-store.sqlite") - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - return db -} - -func TestPersistModelTreeAsRaw_AssignsIDsWhenMissing(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - - // Parser-shaped trees often have empty primary keys; SkipHooks Create must still mint ids - // so Fields preload by model_id for schema migrator. - src := &Model{ - Name: "Address", - Application: "base", - Path: "/base/service/models/address.ts", - ModelTable: "base_address", - ModuleId: sql.NullString{String: "mod-base", Valid: true}, - Fields: []*Field{{ - Name: "Street", - ResolvedSpec: `{"fieldName":"Street","structural":{"name":"Street","fieldType":"varchar"},"migration":{"shouldCreateColumn":true,"resolvedColumnType":"varchar"}}`, - Decorators: []*Decorator{{ - Name: "Field", - Arguments: []*Argument{{ - Type: "object", - Value: `{"type":"varchar"}`, - }}, - }}, - }}, - Services: []*Service{{ - Name: "Search", - Parameters: []*Parameter{ - {Name: "this"}, - {Name: "domain"}, - }, - }}, - } - if err := PersistModelTreeAsRaw(db, src); err != nil { - t.Fatalf("PersistModelTreeAsRaw: %v", err) - } - - var raw rawModel - if err := db.Preload("Fields").Preload("Fields.Decorators").Preload("Fields.Decorators.Arguments"). - Preload("Services").Preload("Services.Parameters"). - Where("path = ?", src.Path).Take(&raw).Error; err != nil { - t.Fatalf("load raw: %v", err) - } - if strings.TrimSpace(raw.Id.String) == "" { - t.Fatal("expected non-empty raw model id") - } - if len(raw.Fields) != 1 || strings.TrimSpace(raw.Fields[0].Id.String) == "" { - t.Fatalf("expected linked field with id, got %#v", raw.Fields) - } - if raw.Fields[0].ModelId.String != raw.Id.String { - t.Fatalf("field.model_id=%q want %q", raw.Fields[0].ModelId.String, raw.Id.String) - } - if raw.Fields[0].ResolvedSpec == "" { - t.Fatal("expected ResolvedSpec preserved") - } - if len(raw.Fields[0].Decorators) != 1 || len(raw.Fields[0].Decorators[0].Arguments) != 1 { - t.Fatalf("expected decorator tree, got %#v", raw.Fields[0].Decorators) - } - if len(raw.Services) != 1 || len(raw.Services[0].Parameters) != 2 { - t.Fatalf("unexpected services: %#v", raw.Services) - } -} - -func TestMigrateIMDCatalogToDualStore_CollapsesSameNameToEffective(t *testing.T) { - db := openDualStoreTestDB(t) - if err := db.AutoMigrate(append(DualStoreEffectiveEntities(), &Module{})...); err != nil { - t.Fatalf("AutoMigrate: %v", err) - } - - basePath := "/partner/partner.ts" - bankPath := "/partner_bank/partner.ts" - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - - base := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "id-base", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Application: "partner", - Path: basePath, - ModelTable: "partner_partner", - ModuleId: sql.NullString{String: "mod-base", Valid: true}, - Fields: []*Field{{ - BaseModel: BaseModel{Id: sql.NullString{String: "f-name", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Name", - Decorators: []*Decorator{{ - BaseModel: BaseModel{Id: sql.NullString{String: "d-name", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Field", - Arguments: []*Argument{{ - BaseModel: BaseModel{Id: sql.NullString{String: "a-name", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Type: "object", - Value: `{"type":"varchar"}`, - }}, - }}, - }}, - } - bank := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "id-bank", Valid: true}, CreatedAt: ts.Add(time.Hour), UpdatedAt: ts.Add(time.Hour)}, - Name: "Partner", - Application: "partner", - Path: bankPath, - Extends: basePath, - ModelTable: "partner_partner", - ModuleId: sql.NullString{String: "mod-bank", Valid: true}, - Fields: []*Field{{BaseModel: BaseModel{Id: sql.NullString{String: "f-bank", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "BankAccounts"}}, - } - for _, m := range []*Model{base, bank} { - fields := m.Fields - m.Fields = nil - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(m).Error; err != nil { - t.Fatalf("create model: %v", err) - } - for _, f := range fields { - decs := f.Decorators - f.Decorators = nil - f.ModelId = m.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(f).Error; err != nil { - t.Fatalf("create field: %v", err) - } - for _, d := range decs { - args := d.Arguments - d.Arguments = nil - d.FieldId = f.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(d).Error; err != nil { - t.Fatalf("create decorator: %v", err) - } - for _, a := range args { - a.DecoratorId = d.Id - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(a).Error; err != nil { - t.Fatalf("create argument: %v", err) - } - } - } - } - } - - if err := migrateIMDCatalogToDualStore(db); err != nil { - t.Fatalf("migrateIMDCatalogToDualStore: %v", err) - } - - var rawCount int64 - if err := db.Model(&rawModel{}).Count(&rawCount).Error; err != nil { - t.Fatalf("count raw: %v", err) - } - if rawCount != 2 { - t.Fatalf("raw models = %d, want 2", rawCount) - } - - var eff []*Model - if err := db.Preload("Fields.Decorators.Arguments").Find(&eff).Error; err != nil { - t.Fatalf("load effective: %v", err) - } - if len(eff) != 1 { - t.Fatalf("effective models = %d, want 1", len(eff)) - } - if eff[0].Id.String != "id-bank" { - t.Fatalf("effective id = %q, want tip id-bank", eff[0].Id.String) - } - if eff[0].ModuleId.Valid { - t.Fatalf("effective ModuleId should be empty, got %#v", eff[0].ModuleId) - } - names := map[string]bool{} - for _, f := range eff[0].Fields { - if f != nil { - names[f.Name] = true - } - } - if !names["Name"] || !names["BankAccounts"] { - t.Fatalf("effective fields %#v", names) - } - - var nameField *Field - for _, f := range eff[0].Fields { - if f != nil && f.Name == "Name" { - nameField = f - break - } - } - if nameField == nil || len(nameField.Decorators) != 1 || nameField.Decorators[0].Name != "Field" { - t.Fatalf("expected Name field decorator preserved, got %#v", nameField) - } - if len(nameField.Decorators[0].Arguments) != 1 { - t.Fatalf("expected decorator argument preserved, got %#v", nameField.Decorators[0].Arguments) - } - arg := nameField.Decorators[0].Arguments[0] - if arg == nil || arg.Type != "object" || arg.Value != `{"type":"varchar"}` { - t.Fatalf("expected decorator argument payload preserved, got %#v", arg) - } - - // Soft-deleted effective tip must not block a new live row (partial unique index). - if err := db.Delete(eff[0]).Error; err != nil { - t.Fatalf("soft-delete effective: %v", err) - } - replacement := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "id-relive", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Application: "partner", - Path: "/partner/partner2.ts", - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(replacement).Error; err != nil { - t.Fatalf("create live row after soft-delete: %v", err) - } - - // Unique (application, name) still rejects a second live row. - dup := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "id-dup", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Application: "partner", - Path: "/other/partner.ts", - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(dup).Error; err == nil { - t.Fatal("expected unique (application, name) violation for second live row") - } -} - -func TestMigrateIMDCatalogToDualStore_RejectsMissingModuleId(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - src := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "id-nomod", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "X", - Application: "a", - Path: "/x.ts", - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(src).Error; err != nil { - t.Fatalf("create: %v", err) - } - if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "missing module_id") { - t.Fatalf("expected missing module_id error, got %v", err) - } -} - -func TestMigrateIMDCatalogToDualStore_RefusesNonEmptyRaw(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}}, - Name: "X", - Application: "a", - Path: "/x.ts", - ModuleId: sql.NullString{String: "mod-x", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { - t.Fatalf("create raw: %v", err) - } - if err := migrateIMDCatalogToDualStore(db); err == nil { - t.Fatal("expected refuse when raw non-empty") - } -} - -func TestMigrateIMDCatalogToDualStore_EmptySourcesIsNoOp(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - kept := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "id-soft", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Kept", - Application: "a", - Path: "/kept.ts", - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(kept).Error; err != nil { - t.Fatalf("create: %v", err) - } - if err := db.Delete(kept).Error; err != nil { - t.Fatalf("soft-delete: %v", err) - } - if err := migrateIMDCatalogToDualStore(db); err != nil { - t.Fatalf("migrateIMDCatalogToDualStore: %v", err) - } - var count int64 - if err := db.Unscoped().Model(&Model{}).Count(&count).Error; err != nil { - t.Fatalf("count: %v", err) - } - if count != 1 { - t.Fatalf("empty migrate wiped effective catalog, Unscoped count=%d", count) - } -} - -func TestRecomputeAllEffectiveFromRaw_OmitsThisParameter(t *testing.T) { - db := openDualStoreTestDB(t) - if err := EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &rawModel{ - BaseModel: BaseModel{Id: sql.NullString{String: "r-svc", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Application: "partner", - Path: "/partner.ts", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { - t.Fatalf("create raw: %v", err) - } - svc := &rawService{ - BaseModel: BaseModel{Id: sql.NullString{String: "s1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Create", - ModelId: raw.Id, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svc).Error; err != nil { - t.Fatalf("create service: %v", err) - } - for i, name := range []string{"this", "vals"} { - p := &rawParameter{ - BaseModel: BaseModel{Id: sql.NullString{String: fmt.Sprintf("p%d", i), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: name, - ServiceId: svc.Id, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(p).Error; err != nil { - t.Fatalf("create param %s: %v", name, err) - } - } - if err := recomputeAllEffectiveFromRaw(db); err != nil { - t.Fatalf("recomputeAllEffectiveFromRaw: %v", err) - } - var eff []*Model - if err := db.Preload("Services.Parameters").Find(&eff).Error; err != nil { - t.Fatalf("load effective: %v", err) - } - if len(eff) != 1 || len(eff[0].Services) != 1 { - t.Fatalf("expected one effective model/service, got %#v", eff) - } - params := eff[0].Services[0].Parameters - if len(params) != 1 || params[0].Name != "vals" { - t.Fatalf("expected only vals on effective service, got %#v", params) - } - var rawParams []*rawParameter - if err := db.Where("service_id = ?", svc.Id).Find(&rawParams).Error; err != nil { - t.Fatalf("load raw params: %v", err) - } - names := map[string]bool{} - for _, p := range rawParams { - if p != nil { - names[p.Name] = true - } - } - if len(rawParams) != 2 || !names["this"] || !names["vals"] { - t.Fatalf("expected raw service to retain this and vals, got %#v", rawParams) - } -} diff --git a/pkg/meta/facade.go b/pkg/meta/facade.go deleted file mode 100644 index 7a429dbc5..000000000 --- a/pkg/meta/facade.go +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "fmt" - "strings" - - "gorm.io/gorm" -) - -// AbstractModelSpec describes a Go-side abstract declaration (e.g. I18n) to ensure. -type AbstractModelSpec struct { - Name string - Path string - Application string - ModuleID sql.NullString - ServiceNames []string -} - -// EnsureAbstractModel upserts an abstract declaration and prefers its tip. -// Does not FlushEffective — callers flush at the install/persist boundary before -// reading effective rows (EDS-opt-2 / EDS-opt-3). -func EnsureAbstractModel(db *gorm.DB, spec AbstractModelSpec) error { - if db == nil { - return fmt.Errorf("db is nil") - } - name := strings.TrimSpace(spec.Name) - path := strings.TrimSpace(spec.Path) - app := strings.TrimSpace(spec.Application) - if name == "" || path == "" || app == "" { - return fmt.Errorf("EnsureAbstractModel requires name, path, and application") - } - decl := NewAbstractReadonlyDeclaration(name, path, app, spec.ModuleID, spec.ServiceNames) - if err := UpsertDeclaration(db, decl); err != nil { - return err - } - if err := PreferDeclarationTip(db, app, name, path); err != nil { - return err - } - return nil -} - -// ReplaceModuleDeclarations rewrites all declaration trees for moduleID from models -// and returns the logical keys that must be flushed afterward. -func ReplaceModuleDeclarations(db *gorm.DB, moduleID string, models []*Model) ([]LogicalKey, error) { - moduleID = strings.TrimSpace(moduleID) - if moduleID == "" { - return nil, nil - } - if db == nil { - return nil, fmt.Errorf("db is nil") - } - - orderedPaths := make([]string, 0, len(models)) - modelByPath := make(map[string]*Model, len(models)) - for _, m := range models { - if m == nil { - continue - } - path := strings.TrimSpace(m.Path) - if path == "" { - continue - } - if _, exists := modelByPath[path]; !exists { - orderedPaths = append(orderedPaths, path) - } - modelByPath[path] = m - } - - keys := make([]LogicalKey, 0) - appendKey := func(application, name string) { - k := LogicalKey{Application: application, Name: name}.Normalized() - if k.Valid() { - keys = append(keys, k) - } - } - - prevDecls, err := ListDeclarations(db, DeclarationQuery{ModuleID: moduleID}) - if err != nil { - return nil, fmt.Errorf("list previous raw models: %w", err) - } - for _, row := range prevDecls { - appendKey(row.Application, row.Name) - } - - var prevEff []Model - if err := db.Model(&Model{}). - Select("id, application, name"). - Where("module_id = ?", moduleID). - Find(&prevEff).Error; err != nil { - return nil, fmt.Errorf("list previous effective models: %w", err) - } - for _, row := range prevEff { - appendKey(row.Application, row.Name) - } - - for _, path := range orderedPaths { - m := modelByPath[path] - m.ModuleId = sql.NullString{String: moduleID, Valid: true} - appendKey(m.Application, m.Name) - } - - err = db.Transaction(func(tx *gorm.DB) error { - if err := DeleteRawModelsForModule(tx, moduleID); err != nil { - return fmt.Errorf("delete previous raw models: %w", err) - } - for _, path := range orderedPaths { - m := modelByPath[path] - if err := PersistModelTreeAsRaw(tx, m); err != nil { - return fmt.Errorf("persist raw model %s: %w", path, err) - } - } - return nil - }) - if err != nil { - return nil, err - } - return keys, nil -} diff --git a/pkg/meta/facade_coverage_test.go b/pkg/meta/facade_coverage_test.go deleted file mode 100644 index 9cab0a19c..000000000 --- a/pkg/meta/facade_coverage_test.go +++ /dev/null @@ -1,202 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "fmt" - "path/filepath" - "strings" - "testing" - - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" -) - -func openFacadeDB(t *testing.T, name string) *gorm.DB { - t.Helper() - dsn := filepath.Join(t.TempDir(), name+".sqlite") - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - if err != nil { - t.Fatal(err) - } - if err := EnsureDualStoreTables(db); err != nil { - t.Fatal(err) - } - return db -} - -func TestRawFacade_CountAndDrop(t *testing.T) { - if _, err := CountRawModelsByID(nil, "x", false); err == nil || !strings.Contains(err.Error(), "db is nil") { - t.Fatalf("nil db: %v", err) - } - if err := DropRawModelTable(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { - t.Fatalf("nil drop: %v", err) - } - - db := openFacadeDB(t, "raw-facade") - m := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-1", Valid: true}}, - Name: "X", - Path: "/x.ts", - Application: "app", - } - if err := PersistModelTreeAsRaw(db, m); err != nil { - t.Fatal(err) - } - n, err := CountRawModelsByID(db, "raw-1", false) - if err != nil || n != 1 { - t.Fatalf("count=%d err=%v", n, err) - } - n, err = CountRawModelsByID(db, " raw-1 ", true) - if err != nil || n != 1 { - t.Fatalf("unscoped count=%d err=%v", n, err) - } - if err := DropRawModelTable(db); err != nil { - t.Fatal(err) - } - if db.Migrator().HasTable(RawModelTable) { - t.Fatal("expected table dropped") - } -} - -func TestEnsureAbstractModel_UpsertErrorPath(t *testing.T) { - db := openFacadeDB(t, "ensure-upsert-err") - if err := DropRawModelTable(db); err != nil { - t.Fatal(err) - } - if err := EnsureAbstractModel(db, AbstractModelSpec{ - Name: "I18n", Path: "go://i18n", Application: "auth", - }); err == nil { - t.Fatal("expected upsert error after drop") - } -} - -func TestEnsureAbstractModel_PreferErrorPath(t *testing.T) { - db := openFacadeDB(t, "prefer-fail-ensure") - // PreferDeclarationTip finishes with UpdateColumns; UpsertDeclaration may update too. - // Seed via Upsert first, then enable the failing update callback and call Ensure again - // so Upsert takes the existing-row path and Prefer's tip bump hits the callback. - if err := EnsureAbstractModel(db, AbstractModelSpec{ - Name: "I18n", Path: "go://i18n/prefer-fail", Application: "auth", - }); err != nil { - t.Fatal(err) - } - if err := db.Callback().Update().Before("gorm:update").Register("test_fail_prefer_update", func(tx *gorm.DB) { - _ = tx.AddError(fmt.Errorf("forced prefer update failure")) - }); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = db.Callback().Update().Remove("test_fail_prefer_update") - }) - if err := EnsureAbstractModel(db, AbstractModelSpec{ - Name: "I18n", Path: "go://i18n/prefer-fail", Application: "auth", - }); err == nil { - t.Fatal("expected PreferDeclarationTip update error") - } -} - -func TestReplaceModuleDeclarations_GuardsAndErrors(t *testing.T) { - keys, err := ReplaceModuleDeclarations(nil, " ", nil) - if err != nil || keys != nil { - t.Fatalf("empty moduleID: %v %#v", err, keys) - } - if _, err := ReplaceModuleDeclarations(nil, "mod", nil); err == nil || !strings.Contains(err.Error(), "db is nil") { - t.Fatalf("nil db: %v", err) - } - - db := openFacadeDB(t, "replace-guards") - keys, err = ReplaceModuleDeclarations(db, "mod-1", []*Model{ - nil, - {Name: "NoPath", Path: " ", Application: "a"}, - {Name: "A", Path: "/a.ts", Application: "a"}, - {Name: "A2", Path: "/a.ts", Application: "a"}, - }) - if err != nil { - t.Fatal(err) - } - if len(keys) == 0 { - t.Fatal("expected keys") - } - - if err := DropRawModelTable(db); err != nil { - t.Fatal(err) - } - if _, err := ReplaceModuleDeclarations(db, "mod-1", nil); err == nil { - t.Fatal("expected list previous raw error") - } - - db2 := openFacadeDB(t, "replace-eff-err") - if err := db2.Migrator().DropTable(&Model{}); err != nil { - t.Fatal(err) - } - if _, err := ReplaceModuleDeclarations(db2, "mod-2", nil); err == nil { - t.Fatal("expected list previous effective error") - } - - db3 := openFacadeDB(t, "replace-del-err") - if err := PersistModelTreeAsRaw(db3, &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "p1", Valid: true}}, - Name: "P", - Path: "/p.ts", - Application: "a", - ModuleId: sql.NullString{String: "mod-3", Valid: true}, - }); err != nil { - t.Fatal(err) - } - if err := db3.Migrator().DropTable(RawServiceTable); err != nil { - t.Fatal(err) - } - if _, err := ReplaceModuleDeclarations(db3, "mod-3", []*Model{ - {Name: "N", Path: "/n.ts", Application: "a"}, - }); err == nil { - t.Fatal("expected delete previous raw error") - } -} - -func TestReplaceModuleDeclarations_PrevEffectiveKeys(t *testing.T) { - db := openFacadeDB(t, "replace-eff-keys") - modID := "mod-eff" - eff := Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "eff-1", Valid: true}}, - Name: "OldEff", - Path: "/old.ts", - Application: "partner", - ModuleId: sql.NullString{String: modID, Valid: true}, - } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&eff).Error; err != nil { - t.Fatal(err) - } - keys, err := ReplaceModuleDeclarations(db, modID, []*Model{ - {Name: "New", Path: "/new.ts", Application: "partner"}, - }) - if err != nil { - t.Fatal(err) - } - found := false - for _, k := range keys { - if k.Name == "OldEff" && k.Application == "partner" { - found = true - } - } - if !found { - t.Fatalf("expected previous effective key, got %#v", keys) - } -} - -func TestReplaceModuleDeclarations_NilPrevDeclRow(t *testing.T) { - db := openFacadeDB(t, "replace-nil-row") - // Force ListDeclarations to return a nil element via soft path: not possible. - // Cover appendKey invalid key (empty application/name) from prev effective zero Model. - modID := "mod-zero" - eff := Model{ModuleId: sql.NullString{String: modID, Valid: true}} - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&eff).Error; err != nil { - t.Fatal(err) - } - if _, err := ReplaceModuleDeclarations(db, modID, nil); err != nil { - t.Fatal(err) - } -} diff --git a/pkg/meta/facade_test.go b/pkg/meta/facade_test.go deleted file mode 100644 index 8e93d2aa8..000000000 --- a/pkg/meta/facade_test.go +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "path/filepath" - "testing" - - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" -) - -func TestEnsureAbstractModelAndReplaceModuleDeclarations(t *testing.T) { - dsn := filepath.Join(t.TempDir(), "facade.sqlite") - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - if err != nil { - t.Fatal(err) - } - if err := EnsureDualStoreTables(db); err != nil { - t.Fatal(err) - } - - if err := EnsureAbstractModel(nil, AbstractModelSpec{}); err == nil { - t.Fatal("expected nil db error") - } - if err := EnsureAbstractModel(db, AbstractModelSpec{Name: "I18n"}); err == nil { - t.Fatal("expected missing fields error") - } - - if err := EnsureAbstractModel(db, AbstractModelSpec{ - Name: "I18n", - Path: "go://i18n/auth", - Application: "auth", - ServiceNames: []string{"GetTranslations"}, - }); err != nil { - t.Fatalf("EnsureAbstractModel: %v", err) - } - if err := FlushEffective(db, []LogicalKey{{Application: "auth", Name: "I18n"}}); err != nil { - t.Fatalf("FlushEffective: %v", err) - } - var n int64 - if err := db.Model(&Model{}).Where("application = ? AND name = ?", "auth", "I18n").Count(&n).Error; err != nil || n != 1 { - t.Fatalf("effective I18n count=%d err=%v", n, err) - } - - modID := "mod-1" - tree := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-partner", Valid: true}}, - Name: "Partner", - Path: "/m/partner/service/models/partner.ts", - Application: "partner", - ModuleId: sql.NullString{String: modID, Valid: true}, - } - keys, err := ReplaceModuleDeclarations(db, modID, []*Model{tree}) - if err != nil { - t.Fatalf("ReplaceModuleDeclarations: %v", err) - } - if len(keys) == 0 { - t.Fatal("expected keys") - } - if err := FlushEffective(db, keys); err != nil { - t.Fatalf("flush partner: %v", err) - } - if err := db.Model(&Model{}).Where("application = ? AND name = ?", "partner", "Partner").Count(&n).Error; err != nil || n != 1 { - t.Fatalf("effective Partner count=%d err=%v", n, err) - } -} - -func TestReplaceModuleDeclarations_RollsBackOnPersistFailure(t *testing.T) { - dsn := filepath.Join(t.TempDir(), "facade-rollback.sqlite") - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - if err != nil { - t.Fatal(err) - } - if err := EnsureDualStoreTables(db); err != nil { - t.Fatal(err) - } - - modID := "mod-rollback" - prevPath := "/m/partner/service/models/old.ts" - prev := &Model{ - BaseModel: BaseModel{Id: sql.NullString{String: "raw-old", Valid: true}}, - Name: "OldPartner", - Path: prevPath, - Application: "partner", - ModuleId: sql.NullString{String: modID, Valid: true}, - } - if err := PersistModelTreeAsRaw(db, prev); err != nil { - t.Fatalf("seed previous declaration: %v", err) - } - - _, err = ReplaceModuleDeclarations(db, modID, []*Model{ - { - BaseModel: BaseModel{Id: sql.NullString{String: "dup-id", Valid: true}}, - Name: "First", - Path: "/m/partner/service/models/first.ts", - Application: "partner", - }, - { - BaseModel: BaseModel{Id: sql.NullString{String: "dup-id", Valid: true}}, - Name: "Second", - Path: "/m/partner/service/models/second.ts", - Application: "partner", - }, - }) - if err == nil { - t.Fatal("expected persist failure for duplicate id") - } - - decls, listErr := ListDeclarations(db, DeclarationQuery{ModuleID: modID}) - if listErr != nil { - t.Fatalf("list after failed replace: %v", listErr) - } - if len(decls) != 1 || decls[0] == nil || decls[0].Path != prevPath || decls[0].Name != "OldPartner" { - t.Fatalf("expected previous declaration retained, got %#v", decls) - } -} diff --git a/pkg/meta/meta_field.go b/pkg/meta/meta_field.go index 98265f184..80cc681ea 100644 --- a/pkg/meta/meta_field.go +++ b/pkg/meta/meta_field.go @@ -44,7 +44,7 @@ type TermReference struct { Kind string `json:"kind"` } -const TermReferenceNamespace = "__terms" +const termReferenceNamespace = "__terms" func TermReferenceKey(module, scope, src, kind string) string { values := []string{module, scope, src, kind} @@ -53,7 +53,7 @@ func TermReferenceKey(module, scope, src, kind string) string { identity.WriteString(fmt.Sprintf("%d:", len([]byte(value)))) identity.WriteString(value) } - return TermReferenceNamespace + "." + hex.EncodeToString([]byte(identity.String())) + return termReferenceNamespace + "." + hex.EncodeToString([]byte(identity.String())) } func NewTermReference(module, scope, src, kind string) TermReference { diff --git a/pkg/meta/meta_field_test.go b/pkg/meta/meta_field_test.go index ce1325c81..84c37fcad 100644 --- a/pkg/meta/meta_field_test.go +++ b/pkg/meta/meta_field_test.go @@ -19,8 +19,8 @@ func TestTermReferenceKeyStabilityAndJSONRoundTrip(t *testing.T) { if key != TermReferenceKey(identity[0], identity[1], identity[2], identity[3]) { t.Fatal("key is not deterministic") } - if !strings.HasPrefix(key, TermReferenceNamespace+".") || - strings.Contains(strings.TrimPrefix(key, TermReferenceNamespace+"."), ".") { + if !strings.HasPrefix(key, termReferenceNamespace+".") || + strings.Contains(strings.TrimPrefix(key, termReferenceNamespace+"."), ".") { t.Fatalf("key is not vue-i18n path safe: %q", key) } for index := range identity { diff --git a/pkg/meta/meta_module.go b/pkg/meta/meta_module.go index 3ec22a4d1..0abac0a1c 100644 --- a/pkg/meta/meta_module.go +++ b/pkg/meta/meta_module.go @@ -12,12 +12,10 @@ import ( type Status string const ( - Uninstallable Status = "uninstallable" - Uninstalled Status = "uninstalled" - Installed Status = "installed" - ToUpgrade Status = "to upgrade" - ToRemove Status = "to remove" - ToInstall Status = "to install" + Uninstalled Status = "uninstalled" + Installed Status = "installed" + ToUpgrade Status = "to upgrade" + ToInstall Status = "to install" ) type Module struct { diff --git a/pkg/meta/model.go b/pkg/meta/model.go index c534a89bd..347a07323 100644 --- a/pkg/meta/model.go +++ b/pkg/meta/model.go @@ -30,7 +30,7 @@ func (m *BaseModel) BeforeCreate(tx *gorm.DB) error { // Entities returns AutoMigrate models for non-declaration catalog tables plus the // effective dual-store projection. Declaration-layer tables are registered via -// DualStoreRawEntities (callers that need both should append both slices). +// internal/module/meta.DualStoreRawEntities (callers that need both should append both slices). func Entities() []any { out := []any{ &Application{}, @@ -40,31 +40,11 @@ func Entities() []any { &UiResourceMenuRoute{}, &UiResourceRouteAction{}, } - return append(out, DualStoreEffectiveEntities()...) + return append(out, dualStoreEffectiveEntities()...) } -// CatalogEntities returns Entities plus DualStoreRawEntities for AutoMigrate of the -// full dual-store catalog (effective projection + declaration tables). -func CatalogEntities() []any { - out := append([]any{}, Entities()...) - return append(out, DualStoreRawEntities()...) -} - -// DualStoreRawEntities returns declaration-layer tables only. -func DualStoreRawEntities() []any { - return []any{ - &rawModel{}, - &rawField{}, - &rawService{}, - &rawTypeParameter{}, - &rawParameter{}, - &rawDecorator{}, - &rawArgument{}, - } -} - -// DualStoreEffectiveEntities returns effective projection tables (current meta_* names). -func DualStoreEffectiveEntities() []any { +// dualStoreEffectiveEntities returns effective projection tables (current meta_* names). +func dualStoreEffectiveEntities() []any { return []any{ &Model{}, &Field{}, diff --git a/pkg/meta/model_test.go b/pkg/meta/model_test.go index c8039d2f1..4f8f2970e 100644 --- a/pkg/meta/model_test.go +++ b/pkg/meta/model_test.go @@ -57,29 +57,6 @@ func TestEntitiesAndTableNames(t *testing.T) { } } - rawExpected := []reflect.Type{ - reflect.TypeOf(&rawModel{}), - reflect.TypeOf(&rawField{}), - reflect.TypeOf(&rawService{}), - reflect.TypeOf(&rawTypeParameter{}), - reflect.TypeOf(&rawParameter{}), - reflect.TypeOf(&rawDecorator{}), - reflect.TypeOf(&rawArgument{}), - } - rawEntities := DualStoreRawEntities() - if len(rawEntities) != len(rawExpected) { - t.Fatalf("DualStoreRawEntities() len = %d, want %d", len(rawEntities), len(rawExpected)) - } - for index, entity := range rawEntities { - if reflect.TypeOf(entity) != rawExpected[index] { - t.Fatalf("DualStoreRawEntities()[%d] type = %v, want %v", index, reflect.TypeOf(entity), rawExpected[index]) - } - } - catalog := CatalogEntities() - if len(catalog) != len(entities)+len(rawEntities) { - t.Fatalf("CatalogEntities() len = %d, want %d", len(catalog), len(entities)+len(rawEntities)) - } - tableNames := []struct { name string got string @@ -98,13 +75,6 @@ func TestEntitiesAndTableNames(t *testing.T) { {name: "UiResource", got: (&UiResource{}).TableName(), want: "meta_ui_resource"}, {name: "UiResourceMenuRoute", got: (&UiResourceMenuRoute{}).TableName(), want: "meta_ui_resource_menu_route"}, {name: "UiResourceRouteAction", got: (&UiResourceRouteAction{}).TableName(), want: "meta_ui_resource_route_action"}, - {name: "rawModel", got: (&rawModel{}).TableName(), want: "meta_raw_model"}, - {name: "rawField", got: (&rawField{}).TableName(), want: "meta_raw_field"}, - {name: "rawService", got: (&rawService{}).TableName(), want: "meta_raw_service"}, - {name: "rawTypeParameter", got: (&rawTypeParameter{}).TableName(), want: "meta_raw_type_parameter"}, - {name: "rawParameter", got: (&rawParameter{}).TableName(), want: "meta_raw_parameter"}, - {name: "rawDecorator", got: (&rawDecorator{}).TableName(), want: "meta_raw_decorator"}, - {name: "rawArgument", got: (&rawArgument{}).TableName(), want: "meta_raw_argument"}, } for _, check := range tableNames { diff --git a/pkg/meta/raw_facade.go b/pkg/meta/raw_facade.go deleted file mode 100644 index fc969f1be..000000000 --- a/pkg/meta/raw_facade.go +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "fmt" - "strings" - - "gorm.io/gorm" -) - -// Raw model / child table names (declaration layer). Prefer these over exporting Raw* types. -const ( - RawModelTable = "meta_raw_model" - RawFieldTable = "meta_raw_field" - RawServiceTable = "meta_raw_service" - RawDecoratorTable = "meta_raw_decorator" - RawArgumentTable = "meta_raw_argument" - RawParameterTable = "meta_raw_parameter" - RawTypeParameterTable = "meta_raw_type_parameter" -) - -// CountRawModelsByID counts declaration rows with the given id (including soft-deleted when unscoped). -func CountRawModelsByID(db *gorm.DB, id string, unscoped bool) (int64, error) { - if db == nil { - return 0, fmt.Errorf("db is nil") - } - id = strings.TrimSpace(id) - q := db - if unscoped { - q = db.Unscoped() - } - var n int64 - err := q.Table(RawModelTable).Where("id = ?", id).Count(&n).Error - return n, err -} - -// DropRawModelTable drops meta_raw_model (tests that force list/load failures). -func DropRawModelTable(db *gorm.DB) error { - if db == nil { - return fmt.Errorf("db is nil") - } - return db.Migrator().DropTable(RawModelTable) -} diff --git a/pkg/meta/utils.go b/pkg/meta/utils.go index 35879151d..b49c045fd 100644 --- a/pkg/meta/utils.go +++ b/pkg/meta/utils.go @@ -12,9 +12,9 @@ import ( "github.com/choysum-dev/choysum/pkg/scope" ) -// IsConventionalServiceName returns true when a service method name starts with +// isConventionalServiceName returns true when a service method name starts with // an upper-case rune. -func IsConventionalServiceName(name string) bool { +func isConventionalServiceName(name string) bool { name = strings.TrimSpace(name) if name == "" { return false @@ -35,7 +35,7 @@ func IsConventionalModelService(accessibilityModifier string, isStatic bool, ser return false } - return IsConventionalServiceName(serviceName) + return isConventionalServiceName(serviceName) } func IsCoreModule(moduleName string) bool {