diff --git a/internal/i18n/models/i18n_meta.go b/internal/i18n/models/i18n_meta.go index 9a7e5087..f145d004 100644 --- a/internal/i18n/models/i18n_meta.go +++ b/internal/i18n/models/i18n_meta.go @@ -29,8 +29,8 @@ var i18nServiceMethods = []string{ } // EnsureI18nMeta registers declaration-layer I18n + Service methods via the meta -// declaration facade, recomputes the effective projection, and seeds Terminology -// Editor ACL rows against effective service ids. Does not register TranslationTerm. +// declaration facade, flushes the effective projection for ACL seeding, and seeds +// Terminology Editor ACL rows against effective service ids. Does not register TranslationTerm. func EnsureI18nMeta(runtimeScope scope.Scope, application string, moduleID sql.NullString) error { application = strings.TrimSpace(application) if application == "" || application == coreApplication { @@ -45,18 +45,18 @@ func EnsureI18nMeta(runtimeScope scope.Scope, application string, moduleID sql.N } path := fmt.Sprintf("go://i18n/%s", application) - decl := meta.NewAbstractReadonlyDeclaration(i18nModelName, path, application, moduleID, i18nServiceMethods) - if err := meta.UpsertDeclaration(db, decl); err != nil { + if err := meta.EnsureAbstractModel(db, meta.AbstractModelSpec{ + Name: i18nModelName, + Path: path, + Application: application, + ModuleID: moduleID, + ServiceNames: i18nServiceMethods, + }); err != nil { return err } - // E2 tip scalars (Path, Abstract, …) come from the last-ranked same-name raw, and the - // first effective id is minted from pickTipRaw (created_at). Prefer the canonical path - // so Path and id stay on go://i18n/. - if err := meta.PreferDeclarationTip(db, application, i18nModelName, path); err != nil { - return err - } - if err := meta.RecomputeEffective(db, application, i18nModelName); err != nil { - return fmt.Errorf("recompute I18n effective: %w", err) + // Flush before ACL seeding reads effective service ids (install boundary for I18n). + if err := meta.FlushEffective(db, []meta.LogicalKey{{Application: application, Name: i18nModelName}}); err != nil { + return fmt.Errorf("flush I18n effective: %w", err) } serviceIDs, err := loadEffectiveI18nServiceIDs(db, application) if err != nil { diff --git a/internal/i18n/models/i18n_meta_test.go b/internal/i18n/models/i18n_meta_test.go index a39539fd..0315ecfb 100644 --- a/internal/i18n/models/i18n_meta_test.go +++ b/internal/i18n/models/i18n_meta_test.go @@ -382,8 +382,8 @@ func TestEnsureI18nMetaSaveModuleAndServiceLookupErrors(t *testing.T) { if err := db.Exec("ALTER TABLE meta_model RENAME COLUMN name TO name_broken").Error; err != nil { t.Fatal(err) } - if err := EnsureI18nMeta(rs, "auth", sql.NullString{}); err == nil || !strings.Contains(err.Error(), "recompute I18n effective") { - t.Fatalf("expected recompute effective error, got %v", err) + if err := EnsureI18nMeta(rs, "auth", sql.NullString{}); err == nil || !strings.Contains(err.Error(), "flush I18n effective") { + t.Fatalf("expected flush effective error, got %v", err) } } diff --git a/internal/module/artifact/build/backend/app_setting.go b/internal/module/artifact/build/backend/app_setting.go deleted file mode 100644 index 80764aba..00000000 --- a/internal/module/artifact/build/backend/app_setting.go +++ /dev/null @@ -1,424 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package backendbuilder - -import ( - "fmt" - "path/filepath" - "strconv" - "strings" - "sync" - - module "github.com/choysum-dev/choysum/internal/module/artifact/result" - "github.com/choysum-dev/choysum/internal/parser" - "github.com/choysum-dev/choysum/pkg/meta" - xfmt "golang.org/x/exp/errors/fmt" -) - -// AppSettingPlan is the Decide output for C2 virtual AppSetting inject. -type AppSettingPlan struct { - NeedInject bool - SupersedeVirtual bool - // scheduledApp is set when this builder claimed the process-wide NeedInject slot. - // Cleared via releaseAppSettingSchedule on failure or after Persist/Bundle. - scheduledApp string -} - -const ( - appSettingGeneratedRelPath = "service/models/__generated__/app_setting.ts" - appSettingModelName = "AppSetting" - appSettingDuplicateCode = "APP_SETTING_DUPLICATE" -) - -// appSettingScheduledApps dedupes NeedInject across modules sharing one Application -// within a single process (install / upgrade). Entries must be released when the -// claiming build/persist does not complete successfully. -var appSettingScheduledApps sync.Map - -// virtualAppSettingSource builds C2 thin-class source with absolute imports so -// esbuild can resolve them even when the pseudo __generated__ directory is not on disk. -// -// softDelete: false is required so Set(key, null) hard-deletes and unique keys can be reused. -// application must be baked into @Model options (same reason as FieldDefault). -func virtualAppSettingSource(modulesPath string, application string) string { - modulesPath = filepath.ToSlash(filepath.Clean(strings.TrimSpace(modulesPath))) - application = strings.TrimSpace(application) - if application == "" { - application = "application" - } - coreService := filepath.ToSlash(filepath.Join(modulesPath, "core/service/index.ts")) - baseModel := filepath.ToSlash(filepath.Join(modulesPath, "core/service/orm/model/app_setting_base_model.ts")) - return fmt.Sprintf(`import { Model } from %s -import AppSettingBaseModel from %s - -@Model('AppSetting', { application: %s, softDelete: false }) -export default class AppSetting extends AppSettingBaseModel {} -`, strconv.Quote(coreService), strconv.Quote(baseModel), strconv.Quote(application)) -} - -func isGeneratedAppSettingPath(path string) bool { - trimmed := strings.TrimSpace(path) - if trimmed == "" { - return false - } - normalized := filepath.ToSlash(filepath.Clean(trimmed)) - return strings.HasSuffix(normalized, "/"+appSettingGeneratedRelPath) || - normalized == appSettingGeneratedRelPath -} - -func appSettingGeneratedPath(modulePath string) string { - return filepath.ToSlash(filepath.Clean(filepath.Join(strings.TrimSpace(modulePath), appSettingGeneratedRelPath))) -} - -func appSettingsIn(results []*parser.ParserResult, modulePath string) []*meta.Model { - out := make([]*meta.Model, 0) - for _, result := range results { - if result == nil || result.Model == nil { - continue - } - m := result.Model - if m.Name != appSettingModelName || m.Abstract { - continue - } - if modulePath != "" && !pathWithinModuleRoot(result.Path, modulePath) && !pathWithinModuleRoot(m.Path, modulePath) { - continue - } - out = append(out, m) - } - return out -} - -func handwrittenAppSettings(models []*meta.Model) []*meta.Model { - out := make([]*meta.Model, 0, len(models)) - for _, m := range models { - if m == nil || isGeneratedAppSettingPath(m.Path) { - continue - } - out = append(out, m) - } - return out -} - -func generatedAppSettings(models []*meta.Model) []*meta.Model { - out := make([]*meta.Model, 0, len(models)) - for _, m := range models { - if m == nil || !isGeneratedAppSettingPath(m.Path) { - continue - } - out = append(out, m) - } - return out -} - -func appSettingSameModule(existing []*meta.Model, mod *meta.Module) bool { - if mod == nil { - return false - } - modPath := strings.TrimSpace(mod.Path) - modID := "" - if mod.Id.Valid { - modID = strings.TrimSpace(mod.Id.String) - } - for _, m := range existing { - if m == nil { - continue - } - if modID != "" && m.ModuleId.Valid && strings.TrimSpace(m.ModuleId.String) == modID { - return true - } - if modPath != "" && pathWithinModuleRoot(m.Path, modPath) { - return true - } - } - return false -} - -func (b *ModuleBuilder) dbLoadAppSettings(app string) ([]*meta.Model, error) { - app = strings.TrimSpace(app) - if app == "" || b == nil || b.runtimeScope == nil || b.runtimeScope.Session() == nil { - return nil, nil - } - absFalse := false - return meta.ListDeclarations(b.runtimeScope.Session().DB, meta.DeclarationQuery{ - Application: app, - Name: appSettingModelName, - Abstract: &absFalse, - }) -} - -func (b *ModuleBuilder) releaseAppSettingSchedule() { - if b == nil { - return - } - app := strings.TrimSpace(b.appSettingPlan.scheduledApp) - if app == "" { - return - } - appSettingScheduledApps.Delete(app) - b.appSettingPlan.scheduledApp = "" -} - -func (b *ModuleBuilder) decideAppSettingPlan(prebuildResults []*parser.ParserResult) (AppSettingPlan, error) { - plan := AppSettingPlan{} - if b == nil || b.module == nil { - return plan, nil - } - mod := b.module - if strings.TrimSpace(mod.ServiceEntryPoint) == "" || - strings.TrimSpace(mod.ApplicationStr) == "" || - strings.TrimSpace(mod.ApplicationStr) == "core" { - return plan, nil - } - - app := strings.TrimSpace(mod.ApplicationStr) - local := appSettingsIn(prebuildResults, mod.Path) - localHand := handwrittenAppSettings(local) - if len(localHand) > 1 { - return plan, xfmt.Errorf("%s: application %q has multiple handwritten AppSetting models in module %q", appSettingDuplicateCode, app, mod.Name) - } - - existing, err := b.dbLoadAppSettings(app) - if err != nil { - return plan, xfmt.Errorf("load AppSetting models for application %q: %w", app, err) - } - existingVirt := generatedAppSettings(existing) - existingHand := handwrittenAppSettings(existing) - - if len(localHand) > 0 { - if len(existingHand) > 0 && !appSettingSameModule(existingHand, mod) { - return plan, xfmt.Errorf("%s: application %q already has a handwritten AppSetting outside module %q", appSettingDuplicateCode, app, mod.Name) - } - if len(existingVirt) > 0 { - return AppSettingPlan{SupersedeVirtual: true}, nil - } - return plan, nil - } - - // No local handwritten AppSetting — consider C2 inject. - if len(existingHand) > 0 { - return plan, nil - } - if len(local) > 0 { - return plan, nil - } - if len(existingVirt) > 0 { - // Virtual rows in DB are metadata only — sources are not on disk (D12). - // Re-inject when this module owns the virtual path (rebuild / hot-reload / bundle). - // Other modules sharing the application must not inject a second store path. - if appSettingSameModule(existingVirt, mod) { - owner, loaded := appSettingScheduledApps.LoadOrStore(app, mod.Name) - if loaded { - if ownerName, ok := owner.(string); ok && ownerName != mod.Name { - // Another in-process builder still holds the claim; re-inject - // without adopting release ownership (avoid deleting their key). - return AppSettingPlan{NeedInject: true}, nil - } - } - return AppSettingPlan{NeedInject: true, scheduledApp: app}, nil - } - return plan, nil - } - owner, loaded := appSettingScheduledApps.LoadOrStore(app, mod.Name) - if loaded { - if ownerName, ok := owner.(string); ok && ownerName == mod.Name { - return AppSettingPlan{NeedInject: true, scheduledApp: app}, nil - } - return plan, nil - } - return AppSettingPlan{NeedInject: true, scheduledApp: app}, nil -} - -func (b *ModuleBuilder) rememberAppSettingInjectPath(path string) { - path = strings.TrimSpace(path) - if b == nil || path == "" { - return - } - b.appSettingInjectPath = path - for _, existing := range b.appSettingInjectPaths { - if existing == path { - return - } - } - b.appSettingInjectPaths = append(b.appSettingInjectPaths, path) -} - -func (b *ModuleBuilder) applyAppSettingInject(plan AppSettingPlan) error { - if b == nil || !plan.NeedInject || b.module == nil { - return nil - } - if strings.TrimSpace(b.module.Path) == "" { - return xfmt.Errorf("AppSetting inject requires a non-empty module path") - } - path := appSettingGeneratedPath(b.module.Path) - b.rememberAppSettingInjectPath(path) - - imports := append(b.entryPointImports(), b.appSettingInjectPaths...) - if setter, ok := b.buildPlugin.(interface{ SetEntryPointImports([]string) }); ok { - setter.SetEntryPointImports(imports) - } - if setter, ok := b.prebuildPlugin.(interface{ SetEntryPointImports([]string) }); ok { - setter.SetEntryPointImports(imports) - } - modulesPath := strings.TrimSpace(b.resolvedRuntimeOptions().modulesPath) - if modulesPath == "" { - modulesPath = filepath.Dir(b.module.Path) - } - source := virtualAppSettingSource(modulesPath, b.module.ApplicationStr) - if registrar, ok := b.buildPlugin.(virtualSourceRegistrar); ok { - registrar.RegisterVirtualSource(path, source) - } - if registrar, ok := b.prebuildPlugin.(virtualSourceRegistrar); ok { - registrar.RegisterVirtualSource(path, source) - } - return nil -} - -// EnsureAppSettingVirtualImports registers C2 AppSetting virtual sources for each -// distinct non-core application represented by modules and records those paths so -// buildOptions merges them into WithEntryPointImports. Used by the multi-app -// dist/bundles builder (parallel to EnsureFieldDefaultVirtualImports). -func (b *ModuleBuilder) EnsureAppSettingVirtualImports(modules []*meta.Module) error { - if b == nil { - return nil - } - modulesPathCfg := strings.TrimSpace(b.resolvedRuntimeOptions().modulesPath) - seenApp := make(map[string]struct{}) - for _, mod := range modules { - if mod == nil { - continue - } - app := strings.TrimSpace(mod.ApplicationStr) - if app == "" || app == "core" || strings.TrimSpace(mod.ServiceEntryPoint) == "" { - continue - } - if _, ok := seenApp[app]; ok { - continue - } - if strings.TrimSpace(mod.Path) == "" { - continue - } - seenApp[app] = struct{}{} - - existing, err := b.dbLoadAppSettings(app) - if err != nil { - return xfmt.Errorf("load AppSetting models for application %q: %w", app, err) - } - if len(handwrittenAppSettings(existing)) > 0 { - continue - } - - path := appSettingGeneratedPath(mod.Path) - if virt := generatedAppSettings(existing); len(virt) > 0 { - if p := strings.TrimSpace(virt[0].Path); p != "" { - path = filepath.ToSlash(filepath.Clean(p)) - } - } - b.rememberAppSettingInjectPath(path) - modulesPath := modulesPathCfg - if modulesPath == "" { - modulesPath = filepath.Dir(mod.Path) - } - source := virtualAppSettingSource(modulesPath, app) - if registrar, ok := b.buildPlugin.(virtualSourceRegistrar); ok { - registrar.RegisterVirtualSource(path, source) - } - if registrar, ok := b.prebuildPlugin.(virtualSourceRegistrar); ok { - registrar.RegisterVirtualSource(path, source) - } - } - if len(b.appSettingInjectPaths) == 0 { - return nil - } - imports := append(b.entryPointImports(), b.appSettingInjectPaths...) - if setter, ok := b.buildPlugin.(interface{ SetEntryPointImports([]string) }); ok { - setter.SetEntryPointImports(imports) - } - if setter, ok := b.prebuildPlugin.(interface{ SetEntryPointImports([]string) }); ok { - setter.SetEntryPointImports(imports) - } - return nil -} - -func (b *ModuleBuilder) planAndInjectAppSetting(prebuildResult *module.BuildResult) error { - plan, err := b.decideAppSettingPlan(module.ParserResults(prebuildResult)) - if err != nil { - return err - } - b.appSettingPlan = plan - if err := b.applyAppSettingInject(plan); err != nil { - b.releaseAppSettingSchedule() - return xfmt.Errorf("error injecting AppSetting: %w", err) - } - return nil -} - -func (b *ModuleBuilder) validateAppSetting(buildResult *module.BuildResult) error { - if buildResult == nil || b == nil || b.module == nil { - return nil - } - app := strings.TrimSpace(b.module.ApplicationStr) - if app == "" || app == "core" { - return nil - } - - models := appSettingsIn(module.ParserResults(buildResult), b.module.Path) - if len(models) <= 1 { - return nil - } - return xfmt.Errorf("%s: application %q build produced multiple AppSetting models", appSettingDuplicateCode, app) -} - -func (b *ModuleBuilder) supersedeVirtualAppSettings() error { - if b == nil || !b.appSettingPlan.SupersedeVirtual || b.module == nil { - return nil - } - if b.runtimeScope == nil || b.runtimeScope.Session() == nil || b.runtimeScope.Session().DB == nil { - return nil - } - app := strings.TrimSpace(b.module.ApplicationStr) - if app == "" { - return nil - } - - absFalse := false - existing, err := meta.ListDeclarations(b.runtimeScope.Session().DB, meta.DeclarationQuery{ - Application: app, - Name: appSettingModelName, - Abstract: &absFalse, - }) - if err != nil { - return xfmt.Errorf("load AppSetting rows for supersede: %w", err) - } - - ids := make([]string, 0) - for _, m := range existing { - if m == nil || !isGeneratedAppSettingPath(m.Path) { - continue - } - if !m.Id.Valid || strings.TrimSpace(m.Id.String) == "" { - continue - } - ids = append(ids, m.Id.String) - } - if len(ids) == 0 { - return nil - } - - root := b.runtimeScope.Session().DB - if err := meta.DeleteDeclarationTrees(root, ids); err != nil { - return xfmt.Errorf("delete superseded virtual AppSetting rows: %w", err) - } - if err := meta.RecomputeKeys(root, []meta.LogicalKey{{Application: app, Name: appSettingModelName}}); err != nil { - return xfmt.Errorf("recompute AppSetting after supersede: %w", err) - } - return nil -} - -// resetAppSettingScheduledAppsForTest clears the process-wide inject dedup map (tests only). -func resetAppSettingScheduledAppsForTest() { - appSettingScheduledApps.Range(func(key, _ any) bool { - appSettingScheduledApps.Delete(key) - return true - }) -} diff --git a/internal/module/artifact/build/backend/app_setting_coverage_test.go b/internal/module/artifact/build/backend/app_setting_coverage_test.go deleted file mode 100644 index 4b28b89e..00000000 --- a/internal/module/artifact/build/backend/app_setting_coverage_test.go +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package backendbuilder - -import ( - "database/sql" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/choysum-dev/choysum/internal/esbplugins" - module "github.com/choysum-dev/choysum/internal/module/artifact/result" - "github.com/choysum-dev/choysum/internal/parser" - "github.com/choysum-dev/choysum/pkg/jsexecutor" - "github.com/choysum-dev/choysum/pkg/meta" - "github.com/choysum-dev/choysum/pkg/scope" - "github.com/evanw/esbuild/pkg/api" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -// bareAppSettingEsbPlugin implements EsbPlugin without entry-import / virtual-source helpers. -type bareAppSettingEsbPlugin struct{ name string } - -func (p bareAppSettingEsbPlugin) DefinePlugins(_ scope.Scope, _ jsexecutor.ScriptExecutor, _ *meta.Module, _ ...esbplugins.EsbPluginOptions) []api.Plugin { - return []api.Plugin{{Name: p.name}} -} -func (p bareAppSettingEsbPlugin) GetParserResults() ([]*parser.ParserResult, error) { return nil, nil } -func (p bareAppSettingEsbPlugin) SetParserResults([]*parser.ParserResult) error { return nil } - -// stickyAppSettingParserPlugin ignores SetParserResults so build can return fixed duplicates for validate. -type stickyAppSettingParserPlugin struct { - results []*parser.ParserResult -} - -func (p *stickyAppSettingParserPlugin) DefinePlugins(_ scope.Scope, _ jsexecutor.ScriptExecutor, _ *meta.Module, options ...esbplugins.EsbPluginOptions) []api.Plugin { - for _, opt := range options { - if opt != nil { - opt(p) - } - } - return []api.Plugin{{Name: "sticky", Setup: func(api.PluginBuild) {}}} -} -func (p *stickyAppSettingParserPlugin) GetParserResults() ([]*parser.ParserResult, error) { - return p.results, nil -} -func (p *stickyAppSettingParserPlugin) SetParserResults([]*parser.ParserResult) error { return nil } -func (p *stickyAppSettingParserPlugin) SetEntryPointImports([]string) {} -func (p *stickyAppSettingParserPlugin) RegisterVirtualSource(string, string) {} - -func TestAppSettingHelpers_EdgeCases(t *testing.T) { - if isGeneratedAppSettingPath("") || isGeneratedAppSettingPath(" ") { - t.Fatal("empty path should not be generated") - } - if !isGeneratedAppSettingPath(appSettingGeneratedRelPath) { - t.Fatal("relative generated path should match") - } - - out := appSettingsIn([]*parser.ParserResult{ - nil, - {Path: "/x", Model: nil}, - {Path: "/other/mod/service/models/app_setting.ts", Model: &meta.Model{Name: "AppSetting", Path: "/other/mod/service/models/app_setting.ts"}}, - {Path: "/m/service/models/app_setting.ts", Model: &meta.Model{Name: "AppSetting", Path: "/m/service/models/app_setting.ts", Abstract: true}}, - {Path: "/m/service/models/app_setting.ts", Model: &meta.Model{Name: "Partner", Path: "/m/service/models/partner.ts"}}, - {Path: "/m/service/models/app_setting.ts", Model: &meta.Model{Name: "AppSetting", Path: "/m/service/models/app_setting.ts"}}, - }, "/m") - if len(out) != 1 || out[0].Name != "AppSetting" { - t.Fatalf("appSettingsIn filter = %#v", out) - } - - if appSettingSameModule(nil, nil) { - t.Fatal("nil module") - } - mod := &meta.Module{Path: "/m", BaseModel: meta.BaseModel{Id: sql.NullString{String: "mid", Valid: true}}} - if !appSettingSameModule([]*meta.Model{ - nil, - {ModuleId: sql.NullString{String: "mid", Valid: true}}, - }, mod) { - t.Fatal("expected module id match") - } - if !appSettingSameModule([]*meta.Model{ - {Path: "/m/service/models/app_setting.ts"}, - }, &meta.Module{Path: "/m"}) { - t.Fatal("expected path match") - } - if appSettingSameModule([]*meta.Model{{Path: "/other/x.ts"}}, &meta.Module{Path: "/m"}) { - t.Fatal("expected path mismatch") - } - - hand := handwrittenAppSettings([]*meta.Model{nil, {Path: appSettingGeneratedPath("/m")}}) - if len(hand) != 0 { - t.Fatalf("handwritten filter: %#v", hand) - } - gen := generatedAppSettings([]*meta.Model{nil, {Path: "/m/service/models/app_setting.ts"}}) - if len(gen) != 0 { - t.Fatalf("generated filter: %#v", gen) - } -} - -func TestDbLoadAppSettings_GuardsAndError(t *testing.T) { - if models, err := (*ModuleBuilder)(nil).dbLoadAppSettings("partner"); models != nil || err != nil { - t.Fatalf("nil builder: %#v %v", models, err) - } - builder, db := newAppSettingTestBuilder(t, &meta.Module{Name: "p", Path: "/m", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}) - if models, err := builder.dbLoadAppSettings(""); models != nil || err != nil { - t.Fatalf("empty app: %#v %v", models, err) - } - builder.runtimeScope = nil - if models, err := builder.dbLoadAppSettings("partner"); models != nil || err != nil { - t.Fatalf("nil scope: %#v %v", models, err) - } - - builder, db = newAppSettingTestBuilder(t, &meta.Module{Name: "p", Path: "/m", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}) - sqlDB, err := db.DB() - if err != nil { - t.Fatal(err) - } - _ = sqlDB.Close() - if _, err := builder.dbLoadAppSettings("partner"); err == nil { - t.Fatal("expected find error on closed db") - } - if _, err := builder.decideAppSettingPlan(nil); err == nil || !strings.Contains(err.Error(), "load AppSetting") { - t.Fatalf("expected decide load error, got %v", err) - } -} - -func TestReleaseAppSettingSchedule_NilBuilder(t *testing.T) { - (*ModuleBuilder)(nil).releaseAppSettingSchedule() -} - -func TestDecideAppSettingPlan_MoreBranches(t *testing.T) { - if plan, err := (*ModuleBuilder)(nil).decideAppSettingPlan(nil); err != nil || plan.NeedInject { - t.Fatalf("nil builder: %+v %v", plan, err) - } - builder, _ := newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - handA := "/virtual/modules/partner/service/models/fd_a.ts" - handB := "/virtual/modules/partner/service/models/fd_b.ts" - _, err := builder.decideAppSettingPlan([]*parser.ParserResult{ - {Path: handA, Model: &meta.Model{Name: "AppSetting", Path: handA}}, - {Path: handB, Model: &meta.Model{Name: "AppSetting", Path: handB}}, - }) - if err == nil || !strings.Contains(err.Error(), appSettingDuplicateCode) { - t.Fatalf("expected local duplicate, got %v", err) - } - - // Local handwritten, no DB virtual → no-op plan. - plan, err := builder.decideAppSettingPlan([]*parser.ParserResult{ - {Path: handA, Model: &meta.Model{Name: "AppSetting", Path: handA}}, - }) - if err != nil || plan.NeedInject || plan.SupersedeVirtual { - t.Fatalf("expected no-op for local hand only, got %+v %v", plan, err) - } - - // Local generated (no handwritten) → skip inject. - virt := appSettingGeneratedPath(builder.module.Path) - plan, err = builder.decideAppSettingPlan([]*parser.ParserResult{ - {Path: virt, Model: &meta.Model{Name: "AppSetting", Path: virt}}, - }) - if err != nil || plan.NeedInject { - t.Fatalf("local generated should skip inject, got %+v %v", plan, err) - } - - // Same-module reclaim. - resetAppSettingScheduledAppsForTest() - plan, err = builder.decideAppSettingPlan(nil) - if err != nil || !plan.NeedInject { - t.Fatalf("first inject: %+v %v", plan, err) - } - plan2, err := builder.decideAppSettingPlan(nil) - if err != nil || !plan2.NeedInject || plan2.scheduledApp != "partner" { - t.Fatalf("reclaim: %+v %v", plan2, err) - } -} - -func TestApplyAndPlanAppSettingInject_ErrorPaths(t *testing.T) { - if err := (*ModuleBuilder)(nil).applyAppSettingInject(AppSettingPlan{NeedInject: true}); err != nil { - t.Fatal(err) - } - builder, _ := newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - if err := builder.applyAppSettingInject(AppSettingPlan{NeedInject: true}); err == nil || !strings.Contains(err.Error(), "module path") { - t.Fatalf("expected path error, got %v", err) - } - - // Empty modulesPath in config → derive from module.Path; still register virtual source. - builder.module.Path = "/virtual/modules/partner" - builder.runtimeScope.(*builderTestScope).cfg.ModulesPath = "" - if err := builder.applyAppSettingInject(AppSettingPlan{NeedInject: true}); err != nil { - t.Fatalf("inject with empty modulesPath: %v", err) - } - stub := builder.buildPlugin.(*stubEsbPlugin) - if len(stub.virtualSources) == 0 { - t.Fatal("expected virtual source registration") - } - - // buildPlugin without SetEntryPointImports / RegisterVirtualSource still succeeds. - builder.buildPlugin = bareAppSettingEsbPlugin{name: "bare"} - if err := builder.applyAppSettingInject(AppSettingPlan{NeedInject: true}); err != nil { - t.Fatalf("bare plugin: %v", err) - } - - builder, _ = newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - err := builder.planAndInjectAppSetting(module.WithParserResults(&module.BuildResult{Module: builder.module}, []*parser.ParserResult{ - {Path: "/virtual/modules/partner/a.ts", Model: &meta.Model{Name: "AppSetting", Path: "/virtual/modules/partner/a.ts"}}, - {Path: "/virtual/modules/partner/b.ts", Model: &meta.Model{Name: "AppSetting", Path: "/virtual/modules/partner/b.ts"}}, - })) - if err == nil || !strings.Contains(err.Error(), appSettingDuplicateCode) { - t.Fatalf("planAndInject decide error: %v", err) - } - - builder.appSettingPlan = AppSettingPlan{} - resetAppSettingScheduledAppsForTest() - builder.module.Path = "" - builder.module.ApplicationStr = "partner" - builder.module.ServiceEntryPoint = "service/index.ts" - builder.module.Name = "partner" - err = builder.planAndInjectAppSetting(module.WithParserResults(&module.BuildResult{Module: builder.module}, nil)) - if err == nil || !strings.Contains(err.Error(), "injecting AppSetting") { - t.Fatalf("planAndInject inject error: %v", err) - } - if _, loaded := appSettingScheduledApps.Load("partner"); loaded { - t.Fatal("schedule should be released after inject failure") - } -} - -func TestValidateAppSetting_Guards(t *testing.T) { - if err := (*ModuleBuilder)(nil).validateAppSetting(nil); err != nil { - t.Fatal(err) - } - builder, _ := newAppSettingTestBuilder(t, &meta.Module{Name: "core", Path: "/m", ApplicationStr: "core"}) - if err := builder.validateAppSetting(&module.BuildResult{Module: builder.module}); err != nil { - t.Fatal(err) - } - builder.module.ApplicationStr = "" - if err := builder.validateAppSetting(&module.BuildResult{Module: builder.module}); err != nil { - t.Fatal(err) - } - builder.module.ApplicationStr = "partner" - if err := builder.validateAppSetting(module.WithParserResults(&module.BuildResult{Module: builder.module}, nil)); err != nil { - t.Fatal(err) - } -} - -func TestSupersedeVirtualAppSettings_GuardsAndDependents(t *testing.T) { - if err := (*ModuleBuilder)(nil).supersedeVirtualAppSettings(); err != nil { - t.Fatal(err) - } - builder, db := newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - if err := builder.supersedeVirtualAppSettings(); err != nil { - t.Fatal(err) - } - builder.appSettingPlan = AppSettingPlan{SupersedeVirtual: true} - builder.runtimeScope = nil - if err := builder.supersedeVirtualAppSettings(); err != nil { - t.Fatal(err) - } - builder, db = newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.appSettingPlan = AppSettingPlan{SupersedeVirtual: true} - builder.module.ApplicationStr = "" - if err := builder.supersedeVirtualAppSettings(); err != nil { - t.Fatal(err) - } - builder.module.ApplicationStr = "partner" - - // Generated row with empty id → filtered out (no delete). - if err := db.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: " ", Valid: true}}, - Name: "AppSetting", - Path: "/virtual/modules/partner/service/models/__generated__/app_setting.ts", - Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - if err := builder.supersedeVirtualAppSettings(); err != nil { - t.Fatal(err) - } - - // Session with nil DB handle (guard before Find). - builder.runtimeScope = &builderTestScope{session: &scope.Session{DB: nil}} - builder.appSettingPlan = AppSettingPlan{SupersedeVirtual: true} - builder.module.ApplicationStr = "partner" - if err := builder.supersedeVirtualAppSettings(); err != nil { - t.Fatal(err) - } - - // Full dependent tree delete. - builder, db = newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.appSettingPlan = AppSettingPlan{SupersedeVirtual: true} - virtID := "virt-dep" - if err := db.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: virtID, Valid: true}}, - Name: "AppSetting", Path: "/virtual/modules/partner/service/models/__generated__/app_setting.ts", Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - fieldID := "fld1" - svcID := "svc1" - decModel := "dec-m" - decField := "dec-f" - decSvc := "dec-s" - if err := db.Create(&meta.RawField{BaseModel: meta.BaseModel{Id: sql.NullString{String: fieldID, Valid: true}}, Name: "Model", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawService{BaseModel: meta.BaseModel{Id: sql.NullString{String: svcID, Valid: true}}, Name: "Get", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: decModel, Valid: true}}, Name: "Model", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: decField, Valid: true}}, Name: "Field", FieldId: sql.NullString{String: fieldID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: decSvc, Valid: true}}, Name: "Service", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawArgument{BaseModel: meta.BaseModel{Id: sql.NullString{String: "arg1", Valid: true}}, DecoratorId: sql.NullString{String: decModel, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawTypeParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "tp1", Valid: true}}, Name: "T", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "p1", Valid: true}}, Name: "x", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := builder.supersedeVirtualAppSettings(); err != nil { - t.Fatalf("supersede dependents: %v", err) - } - var left int64 - if err := db.Unscoped().Model(&meta.RawModel{}).Where("id = ?", virtID).Count(&left).Error; err != nil || left != 0 { - t.Fatalf("virt model left=%d err=%v", left, err) - } -} - -func TestSupersedeVirtualAppSettings_RecomputeError(t *testing.T) { - builder, db := newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.appSettingPlan = AppSettingPlan{SupersedeVirtual: true} - if err := db.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "virt-recompute", Valid: true}}, - Name: "AppSetting", Path: "/virtual/modules/partner/service/models/__generated__/app_setting.ts", Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - if err := db.Migrator().DropTable(&meta.Model{}); err != nil { - t.Fatal(err) - } - if err := builder.supersedeVirtualAppSettings(); err == nil || !strings.Contains(err.Error(), "recompute AppSetting after supersede") { - t.Fatalf("expected recompute error, got %v", err) - } -} - -func TestSupersedeVirtualAppSettings_ErrorBranches(t *testing.T) { - builder, db := newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.appSettingPlan = AppSettingPlan{SupersedeVirtual: true} - if err := db.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "virt", Valid: true}}, - Name: "AppSetting", Path: "/virtual/modules/partner/service/models/__generated__/app_setting.ts", Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - - // Find error - sqlDB, err := db.DB() - if err != nil { - t.Fatal(err) - } - _ = sqlDB.Close() - if err := builder.supersedeVirtualAppSettings(); err == nil || !strings.Contains(err.Error(), "load AppSetting rows") { - t.Fatalf("expected load error, got %v", err) - } - - // Pluck / delete errors via callbacks on a fresh DB. - cases := []struct { - name string - table string - phase string // query|delete - substr string - }{ - {name: "service pluck", table: "meta_raw_service", phase: "query", substr: "load declaration services"}, - {name: "field pluck", table: "meta_raw_field", phase: "query", substr: "load declaration fields"}, - {name: "decorator pluck", table: "meta_raw_decorator", phase: "query", substr: "load declaration decorators"}, - {name: "argument delete", table: "meta_raw_argument", phase: "delete", substr: "delete declaration arguments"}, - {name: "decorator delete", table: "meta_raw_decorator", phase: "delete", substr: "delete declaration decorators"}, - {name: "type param delete", table: "meta_raw_type_parameter", phase: "delete", substr: "delete declaration type parameters"}, - {name: "parameter delete", table: "meta_raw_parameter", phase: "delete", substr: "delete declaration parameters"}, - {name: "service delete", table: "meta_raw_service", phase: "delete", substr: "delete declaration services"}, - {name: "field delete", table: "meta_raw_field", phase: "delete", substr: "delete declaration fields"}, - {name: "model delete", table: "meta_raw_model", phase: "delete", substr: "delete declaration models"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - b, gdb := newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - b.appSettingPlan = AppSettingPlan{SupersedeVirtual: true} - virtID := "v-" + tc.name - if err := gdb.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: virtID, Valid: true}}, - Name: "AppSetting", Path: "/virtual/modules/partner/service/models/__generated__/app_setting.ts", Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - fieldID := "f-" + tc.name - svcID := "s-" + tc.name - decID := "d-" + tc.name - if err := gdb.Create(&meta.RawField{BaseModel: meta.BaseModel{Id: sql.NullString{String: fieldID, Valid: true}}, Name: "Model", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawService{BaseModel: meta.BaseModel{Id: sql.NullString{String: svcID, Valid: true}}, Name: "Get", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: decID, Valid: true}}, Name: "Model", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawArgument{BaseModel: meta.BaseModel{Id: sql.NullString{String: "a-" + tc.name, Valid: true}}, DecoratorId: sql.NullString{String: decID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawTypeParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "t-" + tc.name, Valid: true}}, Name: "T", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "p-" + tc.name, Valid: true}}, Name: "x", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - - boom := errors.New("forced-" + tc.name) - switch tc.phase { - case "query": - if err := gdb.Callback().Query().Before("gorm:query").Register("force-"+tc.name, func(tx *gorm.DB) { - if tx.Statement != nil && tx.Statement.Table == tc.table { - _ = tx.AddError(boom) - } - }); err != nil { - t.Fatal(err) - } - case "delete": - if err := gdb.Callback().Delete().Before("gorm:delete").Register("force-"+tc.name, func(tx *gorm.DB) { - if tx.Statement != nil && tx.Statement.Table == tc.table { - _ = tx.AddError(boom) - } - }); err != nil { - t.Fatal(err) - } - } - err := b.supersedeVirtualAppSettings() - if err == nil || !strings.Contains(err.Error(), tc.substr) { - t.Fatalf("expected %q in error, got %v", tc.substr, err) - } - }) - } -} - -func TestBuildLifecycle_AppSettingInjectAndRelease(t *testing.T) { - resetAppSettingScheduledAppsForTest() - db, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-lifecycle")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate( - &meta.Application{}, &meta.Module{}, - &meta.RawModel{}, &meta.RawField{}, &meta.RawService{}, &meta.RawDecorator{}, &meta.RawArgument{}, - &meta.RawParameter{}, &meta.RawTypeParameter{}, - &meta.Model{}, &meta.Field{}, &meta.Service{}, &meta.Decorator{}, &meta.Argument{}, - &meta.Parameter{}, &meta.TypeParameter{}, - ); err != nil { - t.Fatal(err) - } - testScope := newBuilderTestScope() - testScope.session = &scope.Session{DB: db} - mod := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - buildPlugin := &stubEsbPlugin{name: "build"} - prebuildPlugin := &stubEsbPlugin{name: "prebuild"} - builder := &ModuleBuilder{ - runtimeScope: testScope, - module: mod, - buildPlugin: buildPlugin, - prebuildPlugin: prebuildPlugin, - entryPoint: "", - } - - result, err := builder.BuildWithoutPersist() - if err != nil { - t.Fatalf("BuildWithoutPersist: %v", err) - } - if builder.appSettingInjectPath == "" || !builder.appSettingPlan.NeedInject { - t.Fatalf("expected inject plan, path=%q plan=%+v", builder.appSettingInjectPath, builder.appSettingPlan) - } - if _, ok := buildPlugin.virtualSources[builder.appSettingInjectPath]; !ok { - t.Fatal("expected virtual source on build plugin") - } - // buildOptions(false) should include inject path via SetEntryPointImports already called; - // also exercise append branch by calling buildOptions directly. - _ = builder.buildOptions(false) - - if err := builder.Persist(result); err != nil { - t.Fatalf("Persist: %v", err) - } - if builder.appSettingPlan.scheduledApp != "" { - t.Fatal("expected schedule cleared after Persist") - } - - // Bundle releases schedule via defer. - resetAppSettingScheduledAppsForTest() - builder2 := &ModuleBuilder{ - runtimeScope: testScope, - module: &meta.Module{Name: "partner_bank", Path: "/virtual/modules/partner_bank", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, - prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - } - if _, err := builder2.Bundle(); err != nil { - t.Fatalf("Bundle: %v", err) - } - if builder2.appSettingPlan.scheduledApp != "" { - t.Fatal("bundle should clear scheduledApp") - } -} - -func TestBuildWithoutPersist_ReleasesAppSettingOnFailures(t *testing.T) { - resetAppSettingScheduledAppsForTest() - db, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&meta.Application{}, &meta.Module{}, &meta.RawModel{}, &meta.Model{}); err != nil { - t.Fatal(err) - } - testScope := newBuilderTestScope() - testScope.session = &scope.Session{DB: db} - - // prebuild failure - builder := &ModuleBuilder{ - runtimeScope: testScope, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "/no/such/prebuild-entry.ts", - } - if _, err := builder.BuildWithoutPersist(); err == nil { - t.Fatal("expected prebuild failure") - } - - // planAndInjectAppSetting failure after FieldDefault skips (handwritten FD owns app). - // Empty Path makes AppSetting NeedInject fail in apply; FieldDefault Decide skips. - mod := &meta.Module{Name: "partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"} - if err := db.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "as-hand", Valid: true}}, - Name: "FieldDefault", - Path: "/virtual/modules/partner/service/models/field_default.ts", - Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - builder = &ModuleBuilder{ - runtimeScope: testScope, module: mod, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - } - if _, err := builder.BuildWithoutPersist(); err == nil { - t.Fatal("expected AppSetting inject path error") - } - if _, loaded := appSettingScheduledApps.Load("partner"); loaded { - t.Fatal("claim should be released") - } - - // updatePrebuildResult failure → release (NeedInject claims, then extends refresh fails). - resetAppSettingScheduledAppsForTest() - dbUpd, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-upd-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := dbUpd.AutoMigrate(&meta.Model{}); err != nil { - t.Fatal(err) - } - parentPath := "/virtual/modules/partner/service/models/parent.ts" - latestPath := "/virtual/modules/partner/service/models/latest.ts" - childPath := "/virtual/modules/partner/service/models/child.ts" - if err := dbUpd.Create(&meta.Model{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "aaa", Valid: true}}, - Name: "X", Path: parentPath, Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - if err := dbUpd.Create(&meta.Model{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "zzz", Valid: true}}, - Name: "X", Path: latestPath, Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - scopeUpd := newBuilderTestScope() - scopeUpd.session = &scope.Session{DB: dbUpd} - pre := &stubEsbPlugin{name: "prebuild", parserResults: []*parser.ParserResult{{ - Path: childPath, - RawContent: "export default class X {}", - Model: &meta.Model{Name: "X", Path: childPath, Extends: parentPath}, - }}} - builder = &ModuleBuilder{ - runtimeScope: scopeUpd, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: pre, - entryPoint: "", - tsParser: fixedParser{err: errors.New("reparse boom")}, - tsPathAlias: map[string]string{}, - } - if _, err := builder.BuildWithoutPersist(); err == nil { - t.Fatal("expected update/prebuild downstream error") - } - if _, loaded := appSettingScheduledApps.Load("partner"); loaded { - t.Fatal("claim should be released after update failure") - } - - // Persist failure releases. - resetAppSettingScheduledAppsForTest() - db2, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-persist-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - testScope2 := newBuilderTestScope() - testScope2.session = &scope.Session{DB: db2} - builder = &ModuleBuilder{ - runtimeScope: testScope2, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - appSettingPlan: AppSettingPlan{NeedInject: true, scheduledApp: "partner"}, - } - appSettingScheduledApps.Store("partner", "partner") - if err := builder.Persist(&module.BuildResult{Module: builder.module}); err == nil { - t.Fatal("expected persist failure without migrated tables") - } - if _, loaded := appSettingScheduledApps.Load("partner"); loaded { - t.Fatal("persist failure should release schedule") - } - - // Persist fails inside supersedeVirtualAppSettings (closed DB) and still releases. - resetAppSettingScheduledAppsForTest() - dbSupersede, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-persist-supersede-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := dbSupersede.AutoMigrate(&meta.RawModel{}, &meta.Model{}); err != nil { - t.Fatal(err) - } - sqlDBSupersede, err := dbSupersede.DB() - if err != nil { - t.Fatal(err) - } - _ = sqlDBSupersede.Close() - scopeSupersede := newBuilderTestScope() - scopeSupersede.session = &scope.Session{DB: dbSupersede} - builder = &ModuleBuilder{ - runtimeScope: scopeSupersede, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - appSettingPlan: AppSettingPlan{SupersedeVirtual: true, scheduledApp: "partner"}, - } - appSettingScheduledApps.Store("partner", "partner") - if err := builder.Persist(&module.BuildResult{Module: builder.module}); err == nil { - t.Fatal("expected persist failure from supersede") - } - if _, loaded := appSettingScheduledApps.Load("partner"); loaded { - t.Fatal("supersede persist failure should release schedule") - } - - // validate failure → release: claim NeedInject, then swap parser results to duplicates before validate - // by using a build plugin that returns two AppSettings while prebuild stayed empty. - resetAppSettingScheduledAppsForTest() - db3, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-validate-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db3.AutoMigrate(&meta.Application{}, &meta.Module{}, &meta.RawModel{}, &meta.Model{}); err != nil { - t.Fatal(err) - } - testScope3 := newBuilderTestScope() - testScope3.session = &scope.Session{DB: db3} - tmpVal := t.TempDir() - entryVal := filepath.Join(tmpVal, "entry.ts") - if err := os.WriteFile(entryVal, []byte("export const ok = 1;\n"), 0o644); err != nil { - t.Fatal(err) - } - modulesVal := filepath.Join(tmpVal, "modules") - if err := os.MkdirAll(modulesVal, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(modulesVal, "tsconfig.json"), []byte(`{"compilerOptions":{"baseUrl":"."}}`), 0o644); err != nil { - t.Fatal(err) - } - testScope3.cfg.ModulesPath = modulesVal - testScope3.cfg.DistPath = filepath.Join(tmpVal, "dist") - hand := filepath.Join(modulesVal, "partner/service/models/app_setting.ts") - virt := filepath.Join(modulesVal, "partner/service/models/__generated__/app_setting.ts") - dupes := []*parser.ParserResult{ - {Path: hand, Model: &meta.Model{Name: "AppSetting", Path: hand, Application: "partner"}}, - {Path: virt, Model: &meta.Model{Name: "AppSetting", Path: virt, Application: "partner"}}, - } - buildPlugVal := &stickyAppSettingParserPlugin{results: dupes} - builder = &ModuleBuilder{ - runtimeScope: testScope3, - module: &meta.Module{ - Name: "partner", Path: filepath.Join(modulesVal, "partner"), - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }, - buildPlugin: buildPlugVal, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: entryVal, outFileName: "index.js", - } - if _, err := builder.BuildWithoutPersist(); err == nil || !strings.Contains(err.Error(), appSettingDuplicateCode) { - t.Fatalf("expected validate duplicate, got %v", err) - } - if _, loaded := appSettingScheduledApps.Load("partner"); loaded { - t.Fatal("validate failure should release") - } - - // build failure → release (non-empty entry that esbuild rejects after inject). - resetAppSettingScheduledAppsForTest() - db4, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-build-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db4.AutoMigrate(&meta.Application{}, &meta.Module{}, &meta.RawModel{}, &meta.Model{}); err != nil { - t.Fatal(err) - } - testScope4 := newBuilderTestScope() - testScope4.session = &scope.Session{DB: db4} - tmp := t.TempDir() - entry := filepath.Join(tmp, "entry.ts") - if err := os.WriteFile(entry, []byte("export const ok = 1;\n"), 0o644); err != nil { - t.Fatal(err) - } - modulesDir := filepath.Join(tmp, "modules") - if err := os.MkdirAll(modulesDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(modulesDir, "tsconfig.json"), []byte(`{"compilerOptions":{"baseUrl":"."}}`), 0o644); err != nil { - t.Fatal(err) - } - testScope4.cfg.ModulesPath = modulesDir - testScope4.cfg.DistPath = filepath.Join(tmp, "dist") - buildPlug := &stubEsbPlugin{name: "build", getParserResultsErr: errors.New("parser boom")} - builder = &ModuleBuilder{ - runtimeScope: testScope4, - module: &meta.Module{ - Name: "partner", Path: filepath.Join(modulesDir, "partner"), - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }, - buildPlugin: buildPlug, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: entry, outFileName: "index.js", - } - if _, err := builder.BuildWithoutPersist(); err == nil { - t.Fatal("expected build parser error") - } - if _, loaded := appSettingScheduledApps.Load("partner"); loaded { - t.Fatal("build failure should release") - } -} - -func TestBundle_AppSettingFailurePaths(t *testing.T) { - resetAppSettingScheduledAppsForTest() - testScope := newBuilderTestScope() - // prebuild failure (bad entry) - builder := &ModuleBuilder{ - runtimeScope: testScope, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "/no/such/entry.ts", - } - if _, err := builder.Bundle(); err == nil { - t.Fatal("expected prebuild failure") - } - - resetAppSettingScheduledAppsForTest() - dbFD, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-bundle-inject")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := dbFD.AutoMigrate(&meta.RawModel{}); err != nil { - t.Fatal(err) - } - if err := dbFD.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "as-hand", Valid: true}}, - Name: "FieldDefault", - Path: "/virtual/modules/partner/service/models/field_default.ts", - Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - scopeFD := newBuilderTestScope() - scopeFD.session = &scope.Session{DB: dbFD} - builder = &ModuleBuilder{ - runtimeScope: scopeFD, - module: &meta.Module{Name: "partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - } - _, err = builder.Bundle() - if err == nil { - t.Fatal("expected AppSetting inject failure after FieldDefault skip") - } - if !strings.Contains(err.Error(), "AppSetting") { - t.Fatalf("expected AppSetting inject error, got %v", err) - } - - db, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-bundle-upd")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&meta.Model{}); err != nil { - t.Fatal(err) - } - parentPath := "/virtual/modules/partner/service/models/parent.ts" - latestPath := "/virtual/modules/partner/service/models/latest.ts" - childPath := "/virtual/modules/partner/service/models/child.ts" - if err := db.Create(&meta.Model{BaseModel: meta.BaseModel{Id: sql.NullString{String: "aaa", Valid: true}}, Name: "X", Path: parentPath}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.Model{BaseModel: meta.BaseModel{Id: sql.NullString{String: "zzz", Valid: true}}, Name: "X", Path: latestPath}).Error; err != nil { - t.Fatal(err) - } - scopeUpd := newBuilderTestScope() - scopeUpd.session = &scope.Session{DB: db} - pre := &stubEsbPlugin{name: "prebuild", parserResults: []*parser.ParserResult{{ - Path: childPath, RawContent: "export default class X {}", - Model: &meta.Model{Name: "X", Path: childPath, Extends: parentPath}, - }}} - builder = &ModuleBuilder{ - runtimeScope: scopeUpd, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: pre, - entryPoint: "", tsParser: fixedParser{err: errors.New("reparse boom")}, tsPathAlias: map[string]string{}, - } - if _, err := builder.Bundle(); err == nil { - t.Fatal("expected update failure in Bundle") - } - - // build failure in Bundle - resetAppSettingScheduledAppsForTest() - db2, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "as-bundle-build")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db2.AutoMigrate(&meta.Model{}); err != nil { - t.Fatal(err) - } - tmp := t.TempDir() - entry := filepath.Join(tmp, "entry.ts") - if err := os.WriteFile(entry, []byte("export const ok = 1;\n"), 0o644); err != nil { - t.Fatal(err) - } - modulesDir := filepath.Join(tmp, "modules") - if err := os.MkdirAll(modulesDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(modulesDir, "tsconfig.json"), []byte(`{"compilerOptions":{"baseUrl":"."}}`), 0o644); err != nil { - t.Fatal(err) - } - scopeBuild := newBuilderTestScope() - scopeBuild.session = &scope.Session{DB: db2} - scopeBuild.cfg.ModulesPath = modulesDir - scopeBuild.cfg.DistPath = filepath.Join(tmp, "dist") - builder = &ModuleBuilder{ - runtimeScope: scopeBuild, - module: &meta.Module{ - Name: "partner", Path: filepath.Join(modulesDir, "partner"), - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }, - buildPlugin: &stubEsbPlugin{name: "build", getParserResultsErr: errors.New("parser boom")}, - prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: entry, outFileName: "index.js", - } - if _, err := builder.Bundle(); err == nil { - t.Fatal("expected build failure in Bundle") - } -} diff --git a/internal/module/artifact/build/backend/app_setting_test.go b/internal/module/artifact/build/backend/app_setting_test.go deleted file mode 100644 index b81d6c6a..00000000 --- a/internal/module/artifact/build/backend/app_setting_test.go +++ /dev/null @@ -1,612 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package backendbuilder - -import ( - "database/sql" - "fmt" - "path/filepath" - "strings" - "sync/atomic" - "testing" - - module "github.com/choysum-dev/choysum/internal/module/artifact/result" - "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" -) - -// appSettingTestDBSeq keeps shared-cache in-memory DSNs unique across -count=N reruns. -var appSettingTestDBSeq atomic.Uint64 - -func appSettingMemoryDSN(t *testing.T, prefix string) string { - t.Helper() - name := strings.ReplaceAll(t.Name(), "/", "_") - return fmt.Sprintf("file:%s-%s-%d?mode=memory&cache=shared", prefix, name, appSettingTestDBSeq.Add(1)) -} - -func newAppSettingTestBuilder(t *testing.T, mod *meta.Module) (*ModuleBuilder, *gorm.DB) { - t.Helper() - resetAppSettingScheduledAppsForTest() - db, err := gorm.Open(sqlite.Open(appSettingMemoryDSN(t, "app-setting")), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := meta.EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure dual store: %v", err) - } - if err := db.AutoMigrate(&meta.Application{}, &meta.Module{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - testScope := newBuilderTestScope() - testScope.session = &scope.Session{DB: db} - buildPlugin := &stubEsbPlugin{name: "build"} - return &ModuleBuilder{ - runtimeScope: testScope, - module: mod, - buildPlugin: buildPlugin, - entryPoint: "/virtual/entry.ts", - }, db -} - -func seedAppSettingDeclaration(t *testing.T, db *gorm.DB, id, path, application, moduleID string) { - t.Helper() - m := &meta.Model{ - Name: "AppSetting", - Path: path, - Application: application, - } - if id != "" { - m.Id = sql.NullString{String: id, Valid: true} - } - if moduleID != "" { - m.ModuleId = sql.NullString{String: moduleID, Valid: true} - } - if err := meta.PersistModelTreeAsRaw(db, m); err != nil { - t.Fatalf("seed AppSetting declaration: %v", err) - } -} - -func TestIsGeneratedAppSettingPath(t *testing.T) { - if !isGeneratedAppSettingPath("/mods/partner/service/models/__generated__/app_setting.ts") { - t.Fatal("expected generated path match") - } - if isGeneratedAppSettingPath("/mods/partner/service/models/app_setting.ts") { - t.Fatal("expected handwritten path mismatch") - } -} - -func TestDecideAppSettingPlan_NeedInject(t *testing.T) { - mod := &meta.Module{ - Name: "partner", - Path: "/virtual/modules/partner", - ApplicationStr: "partner", - ServiceEntryPoint: "service/index.ts", - } - builder, _ := newAppSettingTestBuilder(t, mod) - plan, err := builder.decideAppSettingPlan(nil) - if err != nil { - t.Fatalf("decide: %v", err) - } - if !plan.NeedInject || plan.SupersedeVirtual { - t.Fatalf("expected NeedInject only, got %+v", plan) - } -} - -func TestDecideAppSettingPlan_SkipsCoreAndEmptyService(t *testing.T) { - builder, _ := newAppSettingTestBuilder(t, &meta.Module{ - Name: "core", Path: "/virtual/modules/core", ApplicationStr: "core", ServiceEntryPoint: "service/index.ts", - }) - plan, err := builder.decideAppSettingPlan(nil) - if err != nil || plan.NeedInject { - t.Fatalf("core should skip, got plan=%+v err=%v", plan, err) - } - - builder.module = &meta.Module{Name: "web", Path: "/virtual/modules/web", ApplicationStr: "web"} - plan, err = builder.decideAppSettingPlan(nil) - if err != nil || plan.NeedInject { - t.Fatalf("empty ServiceEntryPoint should skip, got plan=%+v err=%v", plan, err) - } -} - -func TestDecideAppSettingPlan_DBVirtualReinjectsForOwner(t *testing.T) { - owner := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newAppSettingTestBuilder(t, owner) - seedAppSettingDeclaration(t, db, "fd1", "/virtual/modules/partner/service/models/__generated__/app_setting.ts", "partner", "") - plan, err := builder.decideAppSettingPlan(nil) - if err != nil { - t.Fatalf("decide: %v", err) - } - if !plan.NeedInject || plan.SupersedeVirtual || plan.scheduledApp != "partner" { - t.Fatalf("expected NeedInject reclaim with scheduledApp, got %+v", plan) - } - if owner, ok := appSettingScheduledApps.Load("partner"); !ok || owner != "partner" { - t.Fatalf("expected process claim for owning module, got %#v ok=%v", owner, ok) - } - - // Another in-process builder still holding the claim must not be released by reclaim. - resetAppSettingScheduledAppsForTest() - appSettingScheduledApps.Store("partner", "other_builder") - plan, err = builder.decideAppSettingPlan(nil) - if err != nil { - t.Fatalf("decide with foreign claim: %v", err) - } - if !plan.NeedInject || plan.scheduledApp != "" { - t.Fatalf("expected NeedInject without adopting foreign claim, got %+v", plan) - } - if owner, ok := appSettingScheduledApps.Load("partner"); !ok || owner != "other_builder" { - t.Fatalf("foreign claim must remain, got %#v ok=%v", owner, ok) - } - - // Sibling module sharing the application must not inject a second store path. - resetAppSettingScheduledAppsForTest() - builder.module = &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - plan, err = builder.decideAppSettingPlan(nil) - if err != nil { - t.Fatalf("decide sibling: %v", err) - } - if plan.NeedInject || plan.SupersedeVirtual { - t.Fatalf("expected sibling skip when owner virtual exists, got %+v", plan) - } -} - -func TestDecideAppSettingPlan_DBHandwrittenSkipsInject(t *testing.T) { - mod := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newAppSettingTestBuilder(t, mod) - seedAppSettingDeclaration(t, db, "hand", "/virtual/modules/partner/service/models/app_setting.ts", "partner", "") - plan, err := builder.decideAppSettingPlan(nil) - if err != nil { - t.Fatalf("decide: %v", err) - } - if plan.NeedInject || plan.SupersedeVirtual { - t.Fatalf("expected skip when DB has handwritten AppSetting, got %+v", plan) - } -} - -func TestDecideAppSettingPlan_SupersedeVirtual(t *testing.T) { - mod := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newAppSettingTestBuilder(t, mod) - seedAppSettingDeclaration(t, db, "virt", "/virtual/modules/partner/service/models/__generated__/app_setting.ts", "partner", "mod-partner") - handPath := filepath.Join(mod.Path, "service/models/app_setting.ts") - prebuild := []*parser.ParserResult{{ - Path: handPath, - Model: &meta.Model{Name: "AppSetting", Path: handPath, Application: "partner"}, - }} - plan, err := builder.decideAppSettingPlan(prebuild) - if err != nil { - t.Fatalf("decide: %v", err) - } - if !plan.SupersedeVirtual || plan.NeedInject { - t.Fatalf("expected SupersedeVirtual, got %+v", plan) - } -} - -func TestDecideAppSettingPlan_DuplicateHandwritten(t *testing.T) { - mod := &meta.Module{ - Name: "partner_commercial", Path: "/virtual/modules/partner_commercial", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newAppSettingTestBuilder(t, mod) - seedAppSettingDeclaration(t, db, "hand", "/virtual/modules/partner_bank/service/models/app_setting.ts", "partner", "mod-bank") - handPath := filepath.Join(mod.Path, "service/models/app_setting.ts") - prebuild := []*parser.ParserResult{{ - Path: handPath, - Model: &meta.Model{Name: "AppSetting", Path: handPath, Application: "partner"}, - }} - _, err := builder.decideAppSettingPlan(prebuild) - if err == nil || !strings.Contains(err.Error(), appSettingDuplicateCode) { - t.Fatalf("expected APP_SETTING_DUPLICATE, got %v", err) - } -} - -func TestDecideAppSettingPlan_ProcessDedupOneInject(t *testing.T) { - resetAppSettingScheduledAppsForTest() - modA := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - modB := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builderA, _ := newAppSettingTestBuilder(t, modA) - planA, err := builderA.decideAppSettingPlan(nil) - if err != nil || !planA.NeedInject { - t.Fatalf("first module should inject, plan=%+v err=%v", planA, err) - } - builderB := &ModuleBuilder{runtimeScope: builderA.runtimeScope, module: modB, buildPlugin: &stubEsbPlugin{name: "build"}} - planB, err := builderB.decideAppSettingPlan(nil) - if err != nil { - t.Fatalf("second decide: %v", err) - } - if planB.NeedInject { - t.Fatalf("second module same app must not inject again, got %+v", planB) - } -} - -func TestApplyAppSettingInject_SetsEntryImportAndPath(t *testing.T) { - mod := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, _ := newAppSettingTestBuilder(t, mod) - stub := builder.buildPlugin.(*stubEsbPlugin) - if err := builder.applyAppSettingInject(AppSettingPlan{NeedInject: true}); err != nil { - t.Fatalf("inject: %v", err) - } - want := appSettingGeneratedPath(mod.Path) - if builder.appSettingInjectPath != want { - t.Fatalf("inject path = %q, want %q", builder.appSettingInjectPath, want) - } - found := false - for _, imp := range stub.entryImports { - if imp == want { - found = true - break - } - } - if !found { - t.Fatalf("expected entry import %q in %#v", want, stub.entryImports) - } - src, ok := stub.virtualSources[want] - if !ok || src == "" { - t.Fatalf("expected virtual source registered for %q, got %#v", want, stub.virtualSources) - } - if !strings.Contains(src, `@Model('AppSetting', { application: "partner", softDelete: false })`) { - t.Fatalf("unexpected virtual source: %s", src) - } - if !strings.Contains(src, "AppSettingBaseModel") { - t.Fatalf("expected AppSettingBaseModel import, got: %s", src) - } -} - -func TestVirtualAppSettingSource_QuotesLiterals(t *testing.T) { - src := virtualAppSettingSource(`/tmp/mod"quote`, "app'name\\x") - if !strings.Contains(src, `from "/tmp/mod\"quote/core/service/index.ts"`) { - t.Fatalf("expected quoted core import, got:\n%s", src) - } - if !strings.Contains(src, `application: "app'name\\x"`) { - t.Fatalf("expected quoted application literal, got:\n%s", src) - } - if !strings.Contains(src, "softDelete: false") { - t.Fatalf("expected softDelete: false in C2 template, got:\n%s", src) - } -} - -func TestEnsureAppSettingVirtualImports_SkipsHandwritten(t *testing.T) { - owner := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newAppSettingTestBuilder(t, owner) - seedAppSettingDeclaration(t, db, "hand", "/virtual/modules/partner/service/models/app_setting.ts", "partner", "") - base := &meta.Module{ - Name: "base", Path: "/virtual/modules/base", - ApplicationStr: "base", ServiceEntryPoint: "service/index.ts", - } - if err := builder.EnsureAppSettingVirtualImports([]*meta.Module{owner, base}); err != nil { - t.Fatalf("ensure: %v", err) - } - wantBase := appSettingGeneratedPath(base.Path) - if len(builder.appSettingInjectPaths) != 1 || builder.appSettingInjectPaths[0] != wantBase { - t.Fatalf("expected only base C2 path, got %#v", builder.appSettingInjectPaths) - } - stub := builder.buildPlugin.(*stubEsbPlugin) - if _, ok := stub.virtualSources[appSettingGeneratedPath(owner.Path)]; ok { - t.Fatal("handwritten app must not register C2 virtual source") - } - if _, ok := stub.virtualSources[wantBase]; !ok { - t.Fatalf("expected base virtual source, got %#v", stub.virtualSources) - } -} - -func TestEnsureAppSettingVirtualImports_PrefersMetaVirtualPath(t *testing.T) { - // Owner candidate is a sibling path; meta already points at the primary module. - sibling := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newAppSettingTestBuilder(t, sibling) - metaPath := "/virtual/modules/partner/service/models/__generated__/app_setting.ts" - seedAppSettingDeclaration(t, db, "virt", metaPath, "partner", "") - if err := builder.EnsureAppSettingVirtualImports([]*meta.Module{sibling}); err != nil { - t.Fatalf("ensure: %v", err) - } - if len(builder.appSettingInjectPaths) != 1 || builder.appSettingInjectPaths[0] != metaPath { - t.Fatalf("expected meta virt path, got %#v", builder.appSettingInjectPaths) - } -} - -func TestAppSettingPatchCoverage_Branches(t *testing.T) { - if src := virtualAppSettingSource("/virtual/modules", " "); !strings.Contains(src, `application: "application"`) { - t.Fatalf("empty application should default, got:\n%s", src) - } - - (*ModuleBuilder)(nil).rememberAppSettingInjectPath("/x") - builder, _ := newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.rememberAppSettingInjectPath(" ") - if builder.appSettingInjectPath != "" || len(builder.appSettingInjectPaths) != 0 { - t.Fatalf("empty path must not remember, got path=%q paths=%#v", builder.appSettingInjectPath, builder.appSettingInjectPaths) - } - builder.rememberAppSettingInjectPath("/a") - builder.rememberAppSettingInjectPath("/a") - if len(builder.appSettingInjectPaths) != 1 { - t.Fatalf("dedupe remember: %#v", builder.appSettingInjectPaths) - } - - // Only appSettingInjectPath (no slice) must still merge into buildOptions. - builder.appSettingInjectPaths = nil - builder.appSettingInjectPath = "/only-single" - stub := builder.buildPlugin.(*stubEsbPlugin) - stub.SetEntryPointImports(nil) - _ = builder.buildOptions(false) - found := false - for _, imp := range stub.entryImports { - if imp == "/only-single" { - found = true - break - } - } - if !found { - t.Fatalf("expected single inject path in entry imports, got %#v", stub.entryImports) - } - - if err := (*ModuleBuilder)(nil).EnsureAppSettingVirtualImports(nil); err != nil { - t.Fatalf("nil ensure: %v", err) - } - if err := builder.EnsureAppSettingVirtualImports([]*meta.Module{ - nil, - {Name: "core", Path: "/virtual/modules/core", ApplicationStr: "core", ServiceEntryPoint: "service/index.ts"}, - {Name: "noentry", Path: "/virtual/modules/x", ApplicationStr: "x", ServiceEntryPoint: ""}, - {Name: "nopath", Path: "", ApplicationStr: "y", ServiceEntryPoint: "service/index.ts"}, - {Name: "emptyapp", Path: "/virtual/modules/z", ApplicationStr: " ", ServiceEntryPoint: "service/index.ts"}, - }); err != nil { - t.Fatalf("skip-only ensure: %v", err) - } - // Clear paths then ensure only skipped apps → early return at empty inject paths. - builder.appSettingInjectPaths = nil - builder.appSettingInjectPath = "" - if err := builder.EnsureAppSettingVirtualImports([]*meta.Module{ - {Name: "core", Path: "/m/core", ApplicationStr: "core", ServiceEntryPoint: "service/index.ts"}, - }); err != nil { - t.Fatalf("empty ensure: %v", err) - } - if len(builder.appSettingInjectPaths) != 0 { - t.Fatalf("expected no inject paths, got %#v", builder.appSettingInjectPaths) - } - - // Handwritten-only ensure also returns with empty inject paths. - builder, db := newAppSettingTestBuilder(t, &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - seedAppSettingDeclaration(t, db, "hand2", "/virtual/modules/partner/service/models/app_setting.ts", "partner", "") - if err := builder.EnsureAppSettingVirtualImports([]*meta.Module{builder.module}); err != nil { - t.Fatalf("handwritten-only ensure: %v", err) - } - if len(builder.appSettingInjectPaths) != 0 { - t.Fatalf("handwritten-only should skip C2, got %#v", builder.appSettingInjectPaths) - } - - // dbLoad error from Ensure. - builder, db = newAppSettingTestBuilder(t, &meta.Module{ - Name: "base", Path: "/virtual/modules/base", - ApplicationStr: "base", ServiceEntryPoint: "service/index.ts", - }) - sqlDB, err := db.DB() - if err != nil { - t.Fatal(err) - } - _ = sqlDB.Close() - if err := builder.EnsureAppSettingVirtualImports([]*meta.Module{builder.module}); err == nil || !strings.Contains(err.Error(), "load AppSetting") { - t.Fatalf("expected ensure load error, got %v", err) - } - - // Empty modulesPath + prebuild plugin registration / entry imports. - builder, _ = newAppSettingTestBuilder(t, &meta.Module{ - Name: "base", Path: "/virtual/modules/base", - ApplicationStr: "base", ServiceEntryPoint: "service/index.ts", - }) - pre := &stubEsbPlugin{name: "prebuild"} - builder.prebuildPlugin = pre - builder.runtimeScope.(*builderTestScope).cfg.ModulesPath = "" - if err := builder.EnsureAppSettingVirtualImports([]*meta.Module{ - builder.module, - {Name: "auth", Path: "/other-root/auth", ApplicationStr: "auth", ServiceEntryPoint: "service/index.ts"}, - }); err != nil { - t.Fatalf("ensure empty modulesPath: %v", err) - } - want := appSettingGeneratedPath(builder.module.Path) - srcBase, ok := pre.virtualSources[want] - if !ok { - t.Fatalf("expected prebuild virtual source, got %#v", pre.virtualSources) - } - if !strings.Contains(srcBase, `from "/virtual/modules/core/service/index.ts"`) { - t.Fatalf("base AppSetting should resolve modulesPath from its own parent, got:\n%s", srcBase) - } - wantAuth := appSettingGeneratedPath("/other-root/auth") - srcAuth, ok := pre.virtualSources[wantAuth] - if !ok { - t.Fatalf("expected auth virtual source, got %#v", pre.virtualSources) - } - if !strings.Contains(srcAuth, `from "/other-root/core/service/index.ts"`) { - t.Fatalf("auth AppSetting must not reuse base modulesPath fallback, got:\n%s", srcAuth) - } - if len(pre.entryImports) == 0 { - t.Fatal("expected prebuild entry imports") - } - - // applyAppSettingInject also wires prebuild plugin. - builder, _ = newAppSettingTestBuilder(t, &meta.Module{ - Name: "meta", Path: "/virtual/modules/meta", - ApplicationStr: "meta", ServiceEntryPoint: "service/index.ts", - }) - pre = &stubEsbPlugin{name: "prebuild"} - builder.prebuildPlugin = pre - if err := builder.applyAppSettingInject(AppSettingPlan{NeedInject: true}); err != nil { - t.Fatalf("apply with prebuild: %v", err) - } - wantMeta := appSettingGeneratedPath(builder.module.Path) - if _, ok := pre.virtualSources[wantMeta]; !ok { - t.Fatalf("apply should register on prebuild plugin: %#v", pre.virtualSources) - } - if len(pre.entryImports) == 0 { - t.Fatal("apply should set prebuild entry imports") - } -} - -func TestEnsureAppSettingVirtualImports_SurvivesBuildOptionsReplace(t *testing.T) { - // Multi-app bundles Decide/Inject against a core representative; Ensure must - // still get every app's AppSetting into WithEntryPointImports. - core := &meta.Module{ - Name: "core", Path: "/virtual/modules/core", - ApplicationStr: "core", ServiceEntryPoint: "service/index.ts", - } - builder, _ := newAppSettingTestBuilder(t, core) - owners := []*meta.Module{ - {Name: "base", Path: "/virtual/modules/base", ApplicationStr: "base", ServiceEntryPoint: "service/index.ts"}, - {Name: "task", Path: "/virtual/modules/task", ApplicationStr: "task", ServiceEntryPoint: "service/index.ts"}, - {Name: "base_dup", Path: "/virtual/modules/base2", ApplicationStr: "base", ServiceEntryPoint: "service/index.ts"}, - } - if err := builder.EnsureAppSettingVirtualImports(owners); err != nil { - t.Fatalf("ensure: %v", err) - } - wantBase := appSettingGeneratedPath(owners[0].Path) - wantTask := appSettingGeneratedPath(owners[1].Path) - if len(builder.appSettingInjectPaths) != 2 { - t.Fatalf("expected 2 inject paths (dedupe app), got %#v", builder.appSettingInjectPaths) - } - - stub := builder.buildPlugin.(*stubEsbPlugin) - // Simulate a wipe of whatever Ensure put on the plugin; buildOptions must restore. - stub.SetEntryPointImports([]string{"/wiped"}) - _ = builder.buildOptions(false) - - foundBase, foundTask := false, false - for _, imp := range stub.entryImports { - if imp == wantBase { - foundBase = true - } - if imp == wantTask { - foundTask = true - } - } - if !foundBase || !foundTask { - t.Fatalf("expected inject paths in entry imports after buildOptions, got %#v", stub.entryImports) - } - if _, ok := stub.virtualSources[wantBase]; !ok { - t.Fatalf("missing virtual source for base: %#v", stub.virtualSources) - } - if _, ok := stub.virtualSources[wantTask]; !ok { - t.Fatalf("missing virtual source for task: %#v", stub.virtualSources) - } -} - -func TestReleaseAppSettingSchedule_AllowsRetryAfterFailure(t *testing.T) { - mod := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, _ := newAppSettingTestBuilder(t, mod) - plan, err := builder.decideAppSettingPlan(nil) - if err != nil || !plan.NeedInject || plan.scheduledApp != "partner" { - t.Fatalf("expected NeedInject claim, got %+v err=%v", plan, err) - } - builder.appSettingPlan = plan - builder.releaseAppSettingSchedule() - - plan2, err := builder.decideAppSettingPlan(nil) - if err != nil || !plan2.NeedInject { - t.Fatalf("expected retry inject after release, got %+v err=%v", plan2, err) - } -} - -func TestValidateAppSetting_DuplicateHandAndVirtual(t *testing.T) { - mod := &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner"} - builder, _ := newAppSettingTestBuilder(t, mod) - hand := filepath.Join(mod.Path, "service/models/app_setting.ts") - virt := appSettingGeneratedPath(mod.Path) - buildResult := module.WithParserResults(&module.BuildResult{Module: mod}, []*parser.ParserResult{ - {Path: hand, Model: &meta.Model{Name: "AppSetting", Path: hand}}, - {Path: virt, Model: &meta.Model{Name: "AppSetting", Path: virt}}, - }) - err := builder.validateAppSetting(buildResult) - if err == nil || !strings.Contains(err.Error(), appSettingDuplicateCode) { - t.Fatalf("expected duplicate error, got %v", err) - } -} - -func TestSupersedeVirtualAppSettings_DeletesGeneratedRows(t *testing.T) { - mod := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newAppSettingTestBuilder(t, mod) - builder.appSettingPlan = AppSettingPlan{SupersedeVirtual: true} - seedAppSettingDeclaration(t, db, "virt", "/virtual/modules/partner/service/models/__generated__/app_setting.ts", "partner", "") - seedAppSettingDeclaration(t, db, "hand", "/virtual/modules/partner_bank/service/models/app_setting.ts", "partner", "") - if err := db.Create(&meta.Model{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "other", Valid: true}}, - Name: "Partner", - Path: "/virtual/modules/partner/service/models/partner.ts", - Application: "partner", - }).Error; err != nil { - t.Fatalf("seed other: %v", err) - } - if err := builder.supersedeVirtualAppSettings(); err != nil { - t.Fatalf("supersede: %v", err) - } - var virtRawLeft int64 - virtDecls, err := meta.ListDeclarations(db, meta.DeclarationQuery{ - Application: "partner", Name: "AppSetting", - Path: "/virtual/modules/partner/service/models/__generated__/app_setting.ts", - }) - if err != nil { - t.Fatalf("list virt: %v", err) - } - virtRawLeft = int64(len(virtDecls)) - if virtRawLeft != 0 { - t.Fatalf("expected virtual raw AppSetting deleted, count=%d", virtRawLeft) - } - handDecls, err := meta.ListDeclarations(db, meta.DeclarationQuery{ - Application: "partner", Name: "AppSetting", - Path: "/virtual/modules/partner_bank/service/models/app_setting.ts", - }) - if err != nil { - t.Fatalf("list hand: %v", err) - } - if len(handDecls) != 1 { - t.Fatalf("expected handwritten raw AppSetting kept, count=%d", len(handDecls)) - } - var count int64 - if err := db.Model(&meta.Model{}).Where("name = ?", "AppSetting").Count(&count).Error; err != nil { - t.Fatalf("count effective: %v", err) - } - if count != 1 { - t.Fatalf("expected handwritten effective AppSetting after recompute, count=%d", count) - } - if err := db.Model(&meta.Model{}).Where("name = ?", "Partner").Count(&count).Error; err != nil { - t.Fatalf("count partner: %v", err) - } - if count != 1 { - t.Fatalf("expected unrelated model kept, count=%d", count) - } -} diff --git a/internal/module/artifact/build/backend/builder.go b/internal/module/artifact/build/backend/builder.go index ecb04517..6a14b571 100644 --- a/internal/module/artifact/build/backend/builder.go +++ b/internal/module/artifact/build/backend/builder.go @@ -6,7 +6,6 @@ package backendbuilder import ( "context" "crypto/sha1" - "database/sql" "encoding/hex" "encoding/json" "errors" @@ -19,6 +18,7 @@ import ( "github.com/choysum-dev/choysum/internal/esbplugins" internalbackendplugin "github.com/choysum-dev/choysum/internal/esbplugins/backendplugin" "github.com/choysum-dev/choysum/internal/esmresolver" + "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" "github.com/choysum-dev/choysum/internal/module/artifact/staging" @@ -60,16 +60,10 @@ type ModuleBuilder struct { entryPointImportsCacheValid bool entryPointImportsCache []string - // FieldDefault C2 plan from Decide; Inject path(s) are applied on the build pass only. - // Multi-app bundles may inject one virtual store per application. - fieldDefaultPlan FieldDefaultPlan - fieldDefaultInjectPath string // last injected path (single-module / tests) - fieldDefaultInjectPaths []string // all inject paths merged into build entry imports - - // AppSetting C2 plan / inject paths (parallel to FieldDefault). - appSettingPlan AppSettingPlan - appSettingInjectPath string - appSettingInjectPaths []string + // injectRegistry is the shared Spec/claim catalog (nil ⇒ DefaultRegistry). + injectRegistry *injectappmodel.Registry + // injectSession holds per-build Decide plans and inject paths. + injectSession *injectappmodel.Session } func pathWithinModuleRoot(path string, root string) bool { @@ -194,18 +188,12 @@ func (b *ModuleBuilder) buildOptions(prebuild bool) *api.BuildOptions { imports := b.entryPointImports() // WithEntryPointImports replaces plugin EntryPointImports — always pass the - // full set here. Include FieldDefault / AppSetting on prebuild too so - // injectModelApplication can rewrite Content before the build pass that emits dist JS. - extra := append([]string(nil), b.fieldDefaultInjectPaths...) - if len(b.fieldDefaultInjectPaths) == 0 && strings.TrimSpace(b.fieldDefaultInjectPath) != "" { - extra = append(extra, b.fieldDefaultInjectPath) - } - extra = append(extra, b.appSettingInjectPaths...) - if len(b.appSettingInjectPaths) == 0 && strings.TrimSpace(b.appSettingInjectPath) != "" { - extra = append(extra, b.appSettingInjectPath) - } - if len(extra) > 0 { - imports = append(append([]string(nil), imports...), extra...) + // full set here. Include inject paths on prebuild too so injectModelApplication + // can rewrite Content before the build pass that emits dist JS. + if sess := b.injectSession; sess != nil { + if extra := sess.AllInjectPaths(); len(extra) > 0 { + imports = append(append([]string(nil), imports...), extra...) + } } esbOpts := []esbplugins.EsbPluginOptions{ esbplugins.WithEntryPointImports(imports), @@ -623,6 +611,11 @@ func (b *ModuleBuilder) prebuild() (*module.BuildResult, error) { func (b *ModuleBuilder) build(prebuildResult *module.BuildResult) (*module.BuildResult, error) { if b.entryPoint == "" { + if b.buildPlugin != nil { + if _, err := b.buildPlugin.GetParserResults(); err != nil { + return nil, xfmt.Errorf("Error getting parser results: %w", err) + } + } return prebuildResult, nil } @@ -815,10 +808,7 @@ func (b *ModuleBuilder) validate(buildResult *module.BuildResult) error { } } - if err := b.validateFieldDefault(buildResult); err != nil { - return err - } - if err := b.validateAppSetting(buildResult); err != nil { + if err := b.validateInjectAppModels(buildResult); err != nil { return err } @@ -828,10 +818,7 @@ func (b *ModuleBuilder) validate(buildResult *module.BuildResult) error { func (b *ModuleBuilder) persist(buildResult *module.BuildResult) error { mod := buildResult.Module - if err := b.supersedeVirtualFieldDefaults(); err != nil { - return err - } - if err := b.supersedeVirtualAppSettings(); err != nil { + if err := b.supersedeInjectAppModels(); err != nil { return err } @@ -888,74 +875,26 @@ func (b *ModuleBuilder) persistModuleModels(moduleID string, models []*meta.Mode } db := b.runtimeScope.Session() - orderedPaths := make([]string, 0, len(models)) - modelByPath := make(map[string]*meta.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([]meta.LogicalKey, 0) - appendKey := func(application, name string) { - k := meta.LogicalKey{Application: application, Name: name}.Normalized() - if k.Valid() { - keys = append(keys, k) - } - } - - // Collect logical names owned by this module before rewrite (removed declarations). - prevDecls, err := meta.ListDeclarations(db.DB, meta.DeclarationQuery{ModuleID: moduleID}) + keys, err := meta.ReplaceModuleDeclarations(db.DB, moduleID, models) if err != nil { - return xfmt.Errorf("list previous raw models: %w", err) - } - for _, row := range prevDecls { - if row == nil { - continue - } - appendKey(row.Application, row.Name) - } - - // Legacy IMD leftovers still carrying module_id on effective rows: include their - // logical names in recompute so EDS5 can keep the tip id. Do not delete here — - // RecomputeEffective deletes all existing trees after capturing the tip. - var prevEff []meta.Model - if err := db.Model(&meta.Model{}). - Select("id, application, name"). - Where("module_id = ?", moduleID). - Find(&prevEff).Error; err != nil { - return xfmt.Errorf("list previous effective models: %w", err) - } - for _, row := range prevEff { - appendKey(row.Application, row.Name) - } - - if err := meta.DeleteRawModelsForModule(db.DB, moduleID); err != nil { - return xfmt.Errorf("delete previous raw models: %w", err) + return err } - for _, path := range orderedPaths { - m := modelByPath[path] - if m == nil { - continue - } - m.ModuleId = sql.NullString{String: moduleID, Valid: true} - appendKey(m.Application, m.Name) - if err := meta.PersistModelTreeAsRaw(db.DB, m); err != nil { - return xfmt.Errorf("persist raw model %s: %w", path, err) + // Supersede may have deleted cross-module generated decls; include those keys in the + // single FlushEffective at this persist boundary (EDS-opt-2). + if b.injectSession != nil && b.module != nil { + app := strings.TrimSpace(b.module.ApplicationStr) + 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}) + } + } } } - if err := meta.RecomputeKeys(db.DB, keys); err != nil { - return xfmt.Errorf("recompute effective models: %w", err) + if err := meta.FlushEffective(db.DB, keys); err != nil { + return xfmt.Errorf("flush effective models: %w", err) } return nil } @@ -981,33 +920,26 @@ func (b *ModuleBuilder) BuildWithoutPersist() (*module.BuildResult, error) { } // 1b. FieldDefault / AppSetting C2 Decide / Inject (before extends rewrite + build) - if err := b.planAndInjectFieldDefault(prebuildResult); err != nil { - return nil, err - } - if err := b.planAndInjectAppSetting(prebuildResult); err != nil { - b.releaseFieldDefaultSchedule() + if err := b.injectAppModels(prebuildResult); err != nil { return nil, err } // 2. recompute and modify file content for model extends if err := b.updatePrebuildResult(prebuildResult); err != nil { - b.releaseFieldDefaultSchedule() - b.releaseAppSettingSchedule() + b.releaseInjectSchedules() return nil, xfmt.Errorf("error generating content: %w", err) } // 3. build for output buildResult, err := b.build(prebuildResult) if err != nil { - b.releaseFieldDefaultSchedule() - b.releaseAppSettingSchedule() + b.releaseInjectSchedules() return nil, xfmt.Errorf("error building: %w", err) } // 4. validate models if err := b.validate(buildResult); err != nil { - b.releaseFieldDefaultSchedule() - b.releaseAppSettingSchedule() + b.releaseInjectSchedules() return nil, xfmt.Errorf("error validating: %w", err) } @@ -1018,13 +950,11 @@ func (b *ModuleBuilder) BuildWithoutPersist() (*module.BuildResult, error) { // builder's ambient session. Intended for a short Required commit window. func (b *ModuleBuilder) Persist(buildResult *module.BuildResult) error { if err := b.persist(buildResult); err != nil { - b.releaseFieldDefaultSchedule() - b.releaseAppSettingSchedule() + b.releaseInjectSchedules() return xfmt.Errorf("error persisting build result: %w", err) } // DB now holds FieldDefault / AppSetting (or none was claimed); drop process-local claims. - b.releaseFieldDefaultSchedule() - b.releaseAppSettingSchedule() + b.releaseInjectSchedules() return nil } @@ -1032,18 +962,14 @@ func (b *ModuleBuilder) Persist(buildResult *module.BuildResult) error { // This is intended for application-stage bundling where DB/IR is already correct. func (b *ModuleBuilder) Bundle() (*module.BuildResult, error) { // Bundle never persists meta; always release any NeedInject process claim. - defer b.releaseFieldDefaultSchedule() - defer b.releaseAppSettingSchedule() + defer b.releaseInjectSchedules() prebuildResult, err := b.prebuild() if err != nil { return nil, xfmt.Errorf("error prebuilding: %w", err) } - if err := b.planAndInjectFieldDefault(prebuildResult); err != nil { - return nil, err - } - if err := b.planAndInjectAppSetting(prebuildResult); err != nil { + if err := b.injectAppModels(prebuildResult); err != nil { return nil, err } @@ -1138,3 +1064,11 @@ func WithGlobalName(name string) func(*ModuleBuilder) { b.globalName = name } } + +// WithInjectRegistry sets the inject Spec/claim registry for this builder. +// If omitted, ensureInjectSession uses injectappmodel.DefaultRegistry(). +func WithInjectRegistry(reg *injectappmodel.Registry) func(*ModuleBuilder) { + return func(b *ModuleBuilder) { + b.injectRegistry = reg + } +} diff --git a/internal/module/artifact/build/backend/builder_test.go b/internal/module/artifact/build/backend/builder_test.go index 83b88c39..be214147 100644 --- a/internal/module/artifact/build/backend/builder_test.go +++ b/internal/module/artifact/build/backend/builder_test.go @@ -13,10 +13,12 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "testing" "github.com/choysum-dev/choysum/internal/esbplugins" + "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" "github.com/choysum-dev/choysum/internal/parser" @@ -450,14 +452,16 @@ func TestGetNewExtendsAndUpdatePrebuildResult(t *testing.T) { if err := meta.EnsureDualStoreTables(db); err != nil { t.Fatalf("ensure dual store: %v", err) } - rows := []*meta.RawModel{ + 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}}}, // 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}}}, } - if err := db.Create(&rows).Error; err != nil { - t.Fatalf("seed models: %v", err) + for _, row := range rows { + if err := meta.PersistModelTreeAsRaw(db, row); err != nil { + t.Fatalf("seed models: %v", err) + } } testRuntimeScope := newBuilderTestScope() @@ -556,13 +560,13 @@ func TestPersistHelpersAndBuild(t *testing.T) { if err := db.Create(seedModel).Error; err != nil { t.Fatalf("seed stale model: %v", err) } - if err := db.Create(&meta.RawModel{ + if err := meta.PersistModelTreeAsRaw(db, &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}, - }).Error; err != nil { + }); err != nil { t.Fatalf("seed previous raw model: %v", err) } models := []*meta.Model{ @@ -574,10 +578,11 @@ func TestPersistHelpersAndBuild(t *testing.T) { if err := builder.persistModuleModels("module-1", models); err != nil { t.Fatalf("persistModuleModels() error = %v", err) } - var persisted []*meta.RawModel - if err := db.Where("module_id = ?", "module-1").Order("path ASC").Find(&persisted).Error; err != nil { + persisted, err := meta.ListDeclarations(db, meta.DeclarationQuery{ModuleID: "module-1"}) + if err != nil { t.Fatalf("query persisted raw models: %v", err) } + sort.Slice(persisted, func(i, j int) bool { return persisted[i].Path < persisted[j].Path }) if len(persisted) != 2 || persisted[0].Path != "/models/order" || persisted[1].Name != "Partner" { t.Fatalf("unexpected persisted raw models: %#v", persisted) } @@ -589,45 +594,54 @@ func TestPersistHelpersAndBuild(t *testing.T) { t.Fatalf("unexpected effective models: %#v", effective) } - older := &meta.RawModel{BaseModel: meta.BaseModel{Id: sql.NullString{String: "aaa", Valid: true}}, Name: "Partner", Path: "/models/history"} - latest := &meta.RawModel{BaseModel: meta.BaseModel{Id: sql.NullString{String: "zzz", Valid: true}}, Name: "Partner", Path: "/models/history"} - if err := db.Create(older).Error; err != nil { + if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: "aaa", Valid: true}}, + Name: "Partner", + Path: "/models/history", + }); err != nil { t.Fatalf("seed older history model: %v", err) } - if err := db.Create(latest).Error; err != nil { + if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: "zzz", Valid: true}}, + Name: "Partner", + Path: "/models/history", + Fields: []*meta.Field{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: "field1", Valid: true}}, + Name: "Name", + Decorators: []*meta.Decorator{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: "fielddec", Valid: true}}, + Name: "Field", + Arguments: []*meta.Argument{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: "arg1", Valid: true}}, + Type: "Literal", + Value: "'name'", + }}, + }}, + }}, + Services: []*meta.Service{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: "svc1", Valid: true}}, + Name: "List", + Decorators: []*meta.Decorator{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: "svcdec", Valid: true}}, + Name: "Service", + Arguments: []*meta.Argument{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: "arg2", Valid: true}}, + Type: "Literal", + Value: "'list'", + }}, + }}, + TypeParameters: []*meta.TypeParameter{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: "tp1", Valid: true}}, + Name: "T", + }}, + Parameters: []*meta.Parameter{ + {BaseModel: meta.BaseModel{Id: sql.NullString{String: "param1", Valid: true}}, Name: "this"}, + {BaseModel: meta.BaseModel{Id: sql.NullString{String: "param2", Valid: true}}, Name: "query"}, + }, + }}, + }); err != nil { t.Fatalf("seed latest history model: %v", err) } - field := &meta.RawField{BaseModel: meta.BaseModel{Id: sql.NullString{String: "field1", Valid: true}}, Name: "Name", ModelId: latest.Id} - if err := db.Create(field).Error; err != nil { - t.Fatalf("seed field: %v", err) - } - fieldDec := &meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: "fielddec", Valid: true}}, Name: "Field", FieldId: field.Id} - if err := db.Create(fieldDec).Error; err != nil { - t.Fatalf("seed field decorator: %v", err) - } - if err := db.Create(&meta.RawArgument{BaseModel: meta.BaseModel{Id: sql.NullString{String: "arg1", Valid: true}}, Type: "Literal", Value: "'name'", DecoratorId: fieldDec.Id}).Error; err != nil { - t.Fatalf("seed field argument: %v", err) - } - service := &meta.RawService{BaseModel: meta.BaseModel{Id: sql.NullString{String: "svc1", Valid: true}}, Name: "List", ModelId: latest.Id} - if err := db.Create(service).Error; err != nil { - t.Fatalf("seed service: %v", err) - } - serviceDec := &meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: "svcdec", Valid: true}}, Name: "Service", ServiceId: service.Id} - if err := db.Create(serviceDec).Error; err != nil { - t.Fatalf("seed service decorator: %v", err) - } - if err := db.Create(&meta.RawArgument{BaseModel: meta.BaseModel{Id: sql.NullString{String: "arg2", Valid: true}}, Type: "Literal", Value: "'list'", DecoratorId: serviceDec.Id}).Error; err != nil { - t.Fatalf("seed service argument: %v", err) - } - if err := db.Create(&meta.RawTypeParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "tp1", Valid: true}}, Name: "T", ServiceId: service.Id}).Error; err != nil { - t.Fatalf("seed type parameter: %v", err) - } - if err := db.Create(&meta.RawParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "param1", Valid: true}}, Name: "this", ServiceId: service.Id}).Error; err != nil { - t.Fatalf("seed this parameter: %v", err) - } - if err := db.Create(&meta.RawParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "param2", Valid: true}}, Name: "query", ServiceId: service.Id}).Error; err != nil { - t.Fatalf("seed query parameter: %v", err) - } decls, err := meta.ListDeclarations(db, meta.DeclarationQuery{Path: "/models/history", PreloadTree: true}) if err != nil { @@ -651,7 +665,7 @@ func TestPersistHelpersAndBuild(t *testing.T) { if len(missing) != 0 { t.Fatalf("expected empty for missing path, got %#v", missing) } - if err := db.Migrator().DropTable(&meta.RawModel{}); err != nil { + if err := meta.DropRawModelTable(db); 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") { @@ -732,6 +746,7 @@ func TestNewModuleBuilderOptionsAndBundleToDirCtx(t *testing.T) { WithPublishDist(false), WithOutFileName("bundle.js"), WithGlobalName("AuthApp"), + WithInjectRegistry(injectappmodel.NewRegistryWithDefaults()), ).(*ModuleBuilder) if !ok { t.Fatalf("expected *ModuleBuilder, got %T", configured) @@ -744,7 +759,7 @@ func TestNewModuleBuilderOptionsAndBundleToDirCtx(t *testing.T) { if !ok { t.Fatalf("expected configured build plugin concrete type, got %T", configured.buildPlugin) } - if configuredPrebuild != prebuildPlugin || configuredBuild != buildPlugin || configured.publishDist || configured.outFileName != "bundle.js" || configured.globalName != "AuthApp" { + if configuredPrebuild != prebuildPlugin || configuredBuild != buildPlugin || configured.publishDist || configured.outFileName != "bundle.js" || configured.globalName != "AuthApp" || configured.injectRegistry == nil { t.Fatalf("unexpected configured builder: %#v", configured) } @@ -1264,7 +1279,7 @@ func TestPersistApplicationLookupAndModelDeleteErrors(t *testing.T) { t.Fatalf("ensure dual store: %v", err) } testRuntimeScope.session = &scope.Session{DB: db} - if err := db.Migrator().DropTable(&meta.RawModel{}); err != nil { + if err := meta.DropRawModelTable(db); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } err = builder.persistModuleModels("module-1", []*meta.Model{{Name: "Partner", Path: "/models/partner"}}) @@ -1307,7 +1322,7 @@ func TestGetNewExtends_FindError(t *testing.T) { runtimeScope: testRuntimeScope, module: &meta.Module{ApplicationStr: "auth"}, } - if err := db.Migrator().DropTable(&meta.RawModel{}); err != nil { + if err := meta.DropRawModelTable(db); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } current := &meta.Model{Name: "Partner", Path: "/models/current", Extends: "/models/base"} @@ -1334,7 +1349,7 @@ func TestPersistModuleModels_ErrorBranches(t *testing.T) { t.Run("list previous raw models", func(t *testing.T) { db, builder := openPersistDB("prev-raw") - if err := db.Migrator().DropTable(&meta.RawModel{}); err != nil { + if err := meta.DropRawModelTable(db); err != nil { t.Fatal(err) } err := builder.persistModuleModels("module-1", models) @@ -1356,13 +1371,13 @@ func TestPersistModuleModels_ErrorBranches(t *testing.T) { t.Run("delete previous raw models", func(t *testing.T) { db, builder := openPersistDB("delete-raw") - if err := db.Create(&meta.RawModel{ + if err := meta.PersistModelTreeAsRaw(db, &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}, - }).Error; err != nil { + }); err != nil { t.Fatal(err) } boom := errors.New("forced raw delete") @@ -1399,8 +1414,8 @@ func TestPersistModuleModels_ErrorBranches(t *testing.T) { } }) - t.Run("recompute effective models", func(t *testing.T) { - db, builder := openPersistDB("recompute") + t.Run("flush effective models", func(t *testing.T) { + db, builder := openPersistDB("flush") const cbTag = "drop-effective-after-raw-create" if err := db.Callback().Create().After("gorm:after_create").Register(cbTag, func(tx *gorm.DB) { if tx.Statement != nil && tx.Statement.Table == "meta_raw_model" { @@ -1411,8 +1426,8 @@ func TestPersistModuleModels_ErrorBranches(t *testing.T) { } t.Cleanup(func() { _ = db.Callback().Create().Remove(cbTag) }) err := builder.persistModuleModels("module-1", models) - if err == nil || !strings.Contains(err.Error(), "recompute effective models") { - t.Fatalf("expected recompute error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "flush effective models") { + t.Fatalf("expected flush error, got %v", err) } }) } diff --git a/internal/module/artifact/build/backend/field_default.go b/internal/module/artifact/build/backend/field_default.go deleted file mode 100644 index d824b167..00000000 --- a/internal/module/artifact/build/backend/field_default.go +++ /dev/null @@ -1,432 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package backendbuilder - -import ( - "fmt" - "path/filepath" - "strconv" - "strings" - "sync" - - module "github.com/choysum-dev/choysum/internal/module/artifact/result" - "github.com/choysum-dev/choysum/internal/parser" - "github.com/choysum-dev/choysum/pkg/meta" - xfmt "golang.org/x/exp/errors/fmt" -) - -// FieldDefaultPlan is the Decide output for C2 virtual FieldDefault inject. -type FieldDefaultPlan struct { - NeedInject bool - SupersedeVirtual bool - // scheduledApp is set when this builder claimed the process-wide NeedInject slot. - // Cleared via releaseFieldDefaultSchedule on failure or after Persist/Bundle. - scheduledApp string -} - -const ( - fieldDefaultGeneratedRelPath = "service/models/__generated__/field_default.ts" - fieldDefaultModelName = "FieldDefault" - fieldDefaultDuplicateCode = "FIELD_DEFAULT_DUPLICATE" -) - -// fieldDefaultScheduledApps dedupes NeedInject across modules sharing one Application -// within a single process (install / upgrade). Entries must be released when the -// claiming build/persist does not complete successfully. -var fieldDefaultScheduledApps sync.Map - -type virtualSourceRegistrar interface { - RegisterVirtualSource(path string, contents string) -} - -// virtualFieldDefaultSource builds C2 thin-class source with absolute imports so -// esbuild can resolve them even when the pseudo __generated__ directory is not on disk -// (path aliases like @/core/service fail for virtual OnLoad paths). -// -// application must be baked into @Model options: FieldDefault is often first loaded on -// the build pass (after prebuild), so injectModelApplication runs too late to rewrite -// the JS that actually lands in dist/bundles/index.js. Without application, the -// runtime pool key becomes "application.FieldDefault" and lookup `{app}.FieldDefault` misses. -func virtualFieldDefaultSource(modulesPath string, application string) string { - modulesPath = filepath.ToSlash(filepath.Clean(strings.TrimSpace(modulesPath))) - application = strings.TrimSpace(application) - if application == "" { - application = "application" - } - coreService := filepath.ToSlash(filepath.Join(modulesPath, "core/service/index.ts")) - baseModel := filepath.ToSlash(filepath.Join(modulesPath, "core/service/orm/model/field_default_base_model.ts")) - // strconv.Quote produces valid TS/JS string literals (backslash, quotes, newlines). - return fmt.Sprintf(`import { Model } from %s -import FieldDefaultBaseModel from %s - -@Model('FieldDefault', { application: %s }) -export default class FieldDefault extends FieldDefaultBaseModel {} -`, strconv.Quote(coreService), strconv.Quote(baseModel), strconv.Quote(application)) -} - -func isGeneratedFieldDefaultPath(path string) bool { - trimmed := strings.TrimSpace(path) - if trimmed == "" { - return false - } - normalized := filepath.ToSlash(filepath.Clean(trimmed)) - return strings.HasSuffix(normalized, "/"+fieldDefaultGeneratedRelPath) || - normalized == fieldDefaultGeneratedRelPath -} - -func fieldDefaultGeneratedPath(modulePath string) string { - return filepath.ToSlash(filepath.Clean(filepath.Join(strings.TrimSpace(modulePath), fieldDefaultGeneratedRelPath))) -} - -func fieldDefaultsIn(results []*parser.ParserResult, modulePath string) []*meta.Model { - out := make([]*meta.Model, 0) - for _, result := range results { - if result == nil || result.Model == nil { - continue - } - m := result.Model - if m.Name != fieldDefaultModelName || m.Abstract { - continue - } - if modulePath != "" && !pathWithinModuleRoot(result.Path, modulePath) && !pathWithinModuleRoot(m.Path, modulePath) { - continue - } - out = append(out, m) - } - return out -} - -func handwrittenFieldDefaults(models []*meta.Model) []*meta.Model { - out := make([]*meta.Model, 0, len(models)) - for _, m := range models { - if m == nil || isGeneratedFieldDefaultPath(m.Path) { - continue - } - out = append(out, m) - } - return out -} - -func generatedFieldDefaults(models []*meta.Model) []*meta.Model { - out := make([]*meta.Model, 0, len(models)) - for _, m := range models { - if m == nil || !isGeneratedFieldDefaultPath(m.Path) { - continue - } - out = append(out, m) - } - return out -} - -func fieldDefaultSameModule(existing []*meta.Model, mod *meta.Module) bool { - if mod == nil { - return false - } - modPath := strings.TrimSpace(mod.Path) - modID := "" - if mod.Id.Valid { - modID = strings.TrimSpace(mod.Id.String) - } - for _, m := range existing { - if m == nil { - continue - } - if modID != "" && m.ModuleId.Valid && strings.TrimSpace(m.ModuleId.String) == modID { - return true - } - if modPath != "" && pathWithinModuleRoot(m.Path, modPath) { - return true - } - } - return false -} - -func (b *ModuleBuilder) dbLoadFieldDefaults(app string) ([]*meta.Model, error) { - app = strings.TrimSpace(app) - if app == "" || b == nil || b.runtimeScope == nil || b.runtimeScope.Session() == nil { - return nil, nil - } - absFalse := false - return meta.ListDeclarations(b.runtimeScope.Session().DB, meta.DeclarationQuery{ - Application: app, - Name: fieldDefaultModelName, - Abstract: &absFalse, - }) -} - -func (b *ModuleBuilder) releaseFieldDefaultSchedule() { - if b == nil { - return - } - app := strings.TrimSpace(b.fieldDefaultPlan.scheduledApp) - if app == "" { - return - } - fieldDefaultScheduledApps.Delete(app) - b.fieldDefaultPlan.scheduledApp = "" -} - -func (b *ModuleBuilder) decideFieldDefaultPlan(prebuildResults []*parser.ParserResult) (FieldDefaultPlan, error) { - plan := FieldDefaultPlan{} - if b == nil || b.module == nil { - return plan, nil - } - mod := b.module - if strings.TrimSpace(mod.ServiceEntryPoint) == "" || - strings.TrimSpace(mod.ApplicationStr) == "" || - strings.TrimSpace(mod.ApplicationStr) == "core" { - return plan, nil - } - - app := strings.TrimSpace(mod.ApplicationStr) - local := fieldDefaultsIn(prebuildResults, mod.Path) - localHand := handwrittenFieldDefaults(local) - if len(localHand) > 1 { - return plan, xfmt.Errorf("%s: application %q has multiple handwritten FieldDefault models in module %q", fieldDefaultDuplicateCode, app, mod.Name) - } - - existing, err := b.dbLoadFieldDefaults(app) - if err != nil { - return plan, xfmt.Errorf("load FieldDefault models for application %q: %w", app, err) - } - existingVirt := generatedFieldDefaults(existing) - existingHand := handwrittenFieldDefaults(existing) - - if len(localHand) > 0 { - if len(existingHand) > 0 && !fieldDefaultSameModule(existingHand, mod) { - return plan, xfmt.Errorf("%s: application %q already has a handwritten FieldDefault outside module %q", fieldDefaultDuplicateCode, app, mod.Name) - } - if len(existingVirt) > 0 { - return FieldDefaultPlan{SupersedeVirtual: true}, nil - } - return plan, nil - } - - // No local handwritten FieldDefault — consider C2 inject. - if len(existingHand) > 0 { - // Handwritten store already owns this application. - return plan, nil - } - if len(local) > 0 { - // Already present in this prebuild graph (handwritten or virtual). - return plan, nil - } - if len(existingVirt) > 0 { - // Virtual rows in DB are metadata only — sources are not on disk (D12). - // Re-inject when this module owns the virtual path (rebuild / hot-reload / bundle). - // Other modules sharing the application must not inject a second store path. - if fieldDefaultSameModule(existingVirt, mod) { - return FieldDefaultPlan{NeedInject: true, scheduledApp: app}, nil - } - return plan, nil - } - owner, loaded := fieldDefaultScheduledApps.LoadOrStore(app, mod.Name) - if loaded { - // Same module reclaim (rebuild/hot-reload): allow re-inject. - if ownerName, ok := owner.(string); ok && ownerName == mod.Name { - return FieldDefaultPlan{NeedInject: true, scheduledApp: app}, nil - } - return plan, nil - } - return FieldDefaultPlan{NeedInject: true, scheduledApp: app}, nil -} - -func (b *ModuleBuilder) rememberFieldDefaultInjectPath(path string) { - path = strings.TrimSpace(path) - if b == nil || path == "" { - return - } - b.fieldDefaultInjectPath = path - for _, existing := range b.fieldDefaultInjectPaths { - if existing == path { - return - } - } - b.fieldDefaultInjectPaths = append(b.fieldDefaultInjectPaths, path) -} - -func (b *ModuleBuilder) applyFieldDefaultInject(plan FieldDefaultPlan) error { - if b == nil || !plan.NeedInject || b.module == nil { - return nil - } - if strings.TrimSpace(b.module.Path) == "" { - return xfmt.Errorf("FieldDefault inject requires a non-empty module path") - } - path := fieldDefaultGeneratedPath(b.module.Path) - b.rememberFieldDefaultInjectPath(path) - - imports := append(b.entryPointImports(), b.fieldDefaultInjectPaths...) - if setter, ok := b.buildPlugin.(interface{ SetEntryPointImports([]string) }); ok { - setter.SetEntryPointImports(imports) - } - if setter, ok := b.prebuildPlugin.(interface{ SetEntryPointImports([]string) }); ok { - setter.SetEntryPointImports(imports) - } - modulesPath := strings.TrimSpace(b.resolvedRuntimeOptions().modulesPath) - if modulesPath == "" { - modulesPath = filepath.Dir(b.module.Path) - } - source := virtualFieldDefaultSource(modulesPath, b.module.ApplicationStr) - if registrar, ok := b.buildPlugin.(virtualSourceRegistrar); ok { - registrar.RegisterVirtualSource(path, source) - } - if registrar, ok := b.prebuildPlugin.(virtualSourceRegistrar); ok { - registrar.RegisterVirtualSource(path, source) - } - return nil -} - -// EnsureFieldDefaultVirtualImports registers C2 FieldDefault virtual sources for each -// distinct non-core application represented by modules and records those paths so -// buildOptions merges them into WithEntryPointImports (which replaces any prior -// plugin SetEntryPointImports). Used by the multi-app dist/bundles builder, which -// otherwise only Decide/Injects against a single representative module (often core). -// -// Handwritten FieldDefault ownership (same precedence as decideFieldDefaultPlan) skips -// C2 registration so the bundle does not load two stores for one application. -func (b *ModuleBuilder) EnsureFieldDefaultVirtualImports(modules []*meta.Module) error { - if b == nil { - return nil - } - modulesPath := strings.TrimSpace(b.resolvedRuntimeOptions().modulesPath) - seenApp := make(map[string]struct{}) - for _, mod := range modules { - if mod == nil { - continue - } - app := strings.TrimSpace(mod.ApplicationStr) - if app == "" || app == "core" || strings.TrimSpace(mod.ServiceEntryPoint) == "" { - continue - } - if _, ok := seenApp[app]; ok { - continue - } - if strings.TrimSpace(mod.Path) == "" { - continue - } - seenApp[app] = struct{}{} - - existing, err := b.dbLoadFieldDefaults(app) - if err != nil { - return xfmt.Errorf("load FieldDefault models for application %q: %w", app, err) - } - if len(handwrittenFieldDefaults(existing)) > 0 { - // Handwritten store already owns this application — do not inject C2. - continue - } - - path := fieldDefaultGeneratedPath(mod.Path) - if virt := generatedFieldDefaults(existing); len(virt) > 0 { - // Prefer the canonical meta path so rebuilds match Persist / sameModule checks. - if p := strings.TrimSpace(virt[0].Path); p != "" { - path = filepath.ToSlash(filepath.Clean(p)) - } - } - b.rememberFieldDefaultInjectPath(path) - if modulesPath == "" { - modulesPath = filepath.Dir(mod.Path) - } - source := virtualFieldDefaultSource(modulesPath, app) - if registrar, ok := b.buildPlugin.(virtualSourceRegistrar); ok { - registrar.RegisterVirtualSource(path, source) - } - if registrar, ok := b.prebuildPlugin.(virtualSourceRegistrar); ok { - registrar.RegisterVirtualSource(path, source) - } - } - if len(b.fieldDefaultInjectPaths) == 0 { - return nil - } - imports := append(b.entryPointImports(), b.fieldDefaultInjectPaths...) - if setter, ok := b.buildPlugin.(interface{ SetEntryPointImports([]string) }); ok { - setter.SetEntryPointImports(imports) - } - if setter, ok := b.prebuildPlugin.(interface{ SetEntryPointImports([]string) }); ok { - setter.SetEntryPointImports(imports) - } - return nil -} - -func (b *ModuleBuilder) planAndInjectFieldDefault(prebuildResult *module.BuildResult) error { - plan, err := b.decideFieldDefaultPlan(module.ParserResults(prebuildResult)) - if err != nil { - return err - } - b.fieldDefaultPlan = plan - if err := b.applyFieldDefaultInject(plan); err != nil { - b.releaseFieldDefaultSchedule() - return xfmt.Errorf("error injecting FieldDefault: %w", err) - } - return nil -} - -func (b *ModuleBuilder) validateFieldDefault(buildResult *module.BuildResult) error { - if buildResult == nil || b == nil || b.module == nil { - return nil - } - app := strings.TrimSpace(b.module.ApplicationStr) - if app == "" || app == "core" { - return nil - } - - models := fieldDefaultsIn(module.ParserResults(buildResult), b.module.Path) - if len(models) <= 1 { - return nil - } - return xfmt.Errorf("%s: application %q build produced multiple FieldDefault models", fieldDefaultDuplicateCode, app) -} - -func (b *ModuleBuilder) supersedeVirtualFieldDefaults() error { - if b == nil || !b.fieldDefaultPlan.SupersedeVirtual || b.module == nil { - return nil - } - if b.runtimeScope == nil || b.runtimeScope.Session() == nil || b.runtimeScope.Session().DB == nil { - return nil - } - app := strings.TrimSpace(b.module.ApplicationStr) - if app == "" { - return nil - } - - absFalse := false - existing, err := meta.ListDeclarations(b.runtimeScope.Session().DB, meta.DeclarationQuery{ - Application: app, - Name: fieldDefaultModelName, - Abstract: &absFalse, - }) - if err != nil { - return xfmt.Errorf("load FieldDefault rows for supersede: %w", err) - } - - ids := make([]string, 0) - for _, m := range existing { - if m == nil || !isGeneratedFieldDefaultPath(m.Path) { - continue - } - if !m.Id.Valid || strings.TrimSpace(m.Id.String) == "" { - continue - } - ids = append(ids, m.Id.String) - } - if len(ids) == 0 { - return nil - } - - root := b.runtimeScope.Session().DB - if err := meta.DeleteDeclarationTrees(root, ids); err != nil { - return xfmt.Errorf("delete superseded virtual FieldDefault rows: %w", err) - } - if err := meta.RecomputeKeys(root, []meta.LogicalKey{{Application: app, Name: fieldDefaultModelName}}); err != nil { - return xfmt.Errorf("recompute FieldDefault after supersede: %w", err) - } - return nil -} - -// resetFieldDefaultScheduledAppsForTest clears the process-wide inject dedup map (tests only). -func resetFieldDefaultScheduledAppsForTest() { - fieldDefaultScheduledApps.Range(func(key, _ any) bool { - fieldDefaultScheduledApps.Delete(key) - return true - }) -} diff --git a/internal/module/artifact/build/backend/field_default_coverage_test.go b/internal/module/artifact/build/backend/field_default_coverage_test.go deleted file mode 100644 index c36cdb78..00000000 --- a/internal/module/artifact/build/backend/field_default_coverage_test.go +++ /dev/null @@ -1,876 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package backendbuilder - -import ( - "database/sql" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/choysum-dev/choysum/internal/esbplugins" - module "github.com/choysum-dev/choysum/internal/module/artifact/result" - "github.com/choysum-dev/choysum/internal/parser" - "github.com/choysum-dev/choysum/pkg/jsexecutor" - "github.com/choysum-dev/choysum/pkg/meta" - "github.com/choysum-dev/choysum/pkg/scope" - "github.com/evanw/esbuild/pkg/api" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -// bareEsbPlugin implements EsbPlugin without entry-import / virtual-source helpers. -type bareEsbPlugin struct{ name string } - -func (p bareEsbPlugin) DefinePlugins(_ scope.Scope, _ jsexecutor.ScriptExecutor, _ *meta.Module, _ ...esbplugins.EsbPluginOptions) []api.Plugin { - return []api.Plugin{{Name: p.name}} -} -func (p bareEsbPlugin) GetParserResults() ([]*parser.ParserResult, error) { return nil, nil } -func (p bareEsbPlugin) SetParserResults([]*parser.ParserResult) error { return nil } - -// stickyParserPlugin ignores SetParserResults so build can return fixed duplicates for validate. -type stickyParserPlugin struct { - results []*parser.ParserResult -} - -func (p *stickyParserPlugin) DefinePlugins(_ scope.Scope, _ jsexecutor.ScriptExecutor, _ *meta.Module, options ...esbplugins.EsbPluginOptions) []api.Plugin { - for _, opt := range options { - if opt != nil { - opt(p) - } - } - return []api.Plugin{{Name: "sticky", Setup: func(api.PluginBuild) {}}} -} -func (p *stickyParserPlugin) GetParserResults() ([]*parser.ParserResult, error) { - return p.results, nil -} -func (p *stickyParserPlugin) SetParserResults([]*parser.ParserResult) error { return nil } -func (p *stickyParserPlugin) SetEntryPointImports([]string) {} -func (p *stickyParserPlugin) RegisterVirtualSource(string, string) {} - -func TestFieldDefaultHelpers_EdgeCases(t *testing.T) { - if isGeneratedFieldDefaultPath("") || isGeneratedFieldDefaultPath(" ") { - t.Fatal("empty path should not be generated") - } - if !isGeneratedFieldDefaultPath(fieldDefaultGeneratedRelPath) { - t.Fatal("relative generated path should match") - } - - out := fieldDefaultsIn([]*parser.ParserResult{ - nil, - {Path: "/x", Model: nil}, - {Path: "/other/mod/service/models/field_default.ts", Model: &meta.Model{Name: "FieldDefault", Path: "/other/mod/service/models/field_default.ts"}}, - {Path: "/m/service/models/field_default.ts", Model: &meta.Model{Name: "FieldDefault", Path: "/m/service/models/field_default.ts", Abstract: true}}, - {Path: "/m/service/models/field_default.ts", Model: &meta.Model{Name: "Partner", Path: "/m/service/models/partner.ts"}}, - {Path: "/m/service/models/field_default.ts", Model: &meta.Model{Name: "FieldDefault", Path: "/m/service/models/field_default.ts"}}, - }, "/m") - if len(out) != 1 || out[0].Name != "FieldDefault" { - t.Fatalf("fieldDefaultsIn filter = %#v", out) - } - - if fieldDefaultSameModule(nil, nil) { - t.Fatal("nil module") - } - mod := &meta.Module{Path: "/m", BaseModel: meta.BaseModel{Id: sql.NullString{String: "mid", Valid: true}}} - if !fieldDefaultSameModule([]*meta.Model{ - nil, - {ModuleId: sql.NullString{String: "mid", Valid: true}}, - }, mod) { - t.Fatal("expected module id match") - } - if !fieldDefaultSameModule([]*meta.Model{ - {Path: "/m/service/models/field_default.ts"}, - }, &meta.Module{Path: "/m"}) { - t.Fatal("expected path match") - } - if fieldDefaultSameModule([]*meta.Model{{Path: "/other/x.ts"}}, &meta.Module{Path: "/m"}) { - t.Fatal("expected path mismatch") - } - - hand := handwrittenFieldDefaults([]*meta.Model{nil, {Path: fieldDefaultGeneratedPath("/m")}}) - if len(hand) != 0 { - t.Fatalf("handwritten filter: %#v", hand) - } - gen := generatedFieldDefaults([]*meta.Model{nil, {Path: "/m/service/models/field_default.ts"}}) - if len(gen) != 0 { - t.Fatalf("generated filter: %#v", gen) - } -} - -func TestDbLoadFieldDefaults_GuardsAndError(t *testing.T) { - if models, err := (*ModuleBuilder)(nil).dbLoadFieldDefaults("partner"); models != nil || err != nil { - t.Fatalf("nil builder: %#v %v", models, err) - } - builder, db := newFieldDefaultTestBuilder(t, &meta.Module{Name: "p", Path: "/m", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}) - if models, err := builder.dbLoadFieldDefaults(""); models != nil || err != nil { - t.Fatalf("empty app: %#v %v", models, err) - } - builder.runtimeScope = nil - if models, err := builder.dbLoadFieldDefaults("partner"); models != nil || err != nil { - t.Fatalf("nil scope: %#v %v", models, err) - } - - builder, db = newFieldDefaultTestBuilder(t, &meta.Module{Name: "p", Path: "/m", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}) - sqlDB, err := db.DB() - if err != nil { - t.Fatal(err) - } - _ = sqlDB.Close() - if _, err := builder.dbLoadFieldDefaults("partner"); err == nil { - t.Fatal("expected find error on closed db") - } - if _, err := builder.decideFieldDefaultPlan(nil); err == nil || !strings.Contains(err.Error(), "load FieldDefault") { - t.Fatalf("expected decide load error, got %v", err) - } -} - -func TestReleaseFieldDefaultSchedule_NilBuilder(t *testing.T) { - (*ModuleBuilder)(nil).releaseFieldDefaultSchedule() -} - -func TestDecideFieldDefaultPlan_MoreBranches(t *testing.T) { - if plan, err := (*ModuleBuilder)(nil).decideFieldDefaultPlan(nil); err != nil || plan.NeedInject { - t.Fatalf("nil builder: %+v %v", plan, err) - } - builder, _ := newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - handA := "/virtual/modules/partner/service/models/fd_a.ts" - handB := "/virtual/modules/partner/service/models/fd_b.ts" - _, err := builder.decideFieldDefaultPlan([]*parser.ParserResult{ - {Path: handA, Model: &meta.Model{Name: "FieldDefault", Path: handA}}, - {Path: handB, Model: &meta.Model{Name: "FieldDefault", Path: handB}}, - }) - if err == nil || !strings.Contains(err.Error(), fieldDefaultDuplicateCode) { - t.Fatalf("expected local duplicate, got %v", err) - } - - // Local handwritten, no DB virtual → no-op plan. - plan, err := builder.decideFieldDefaultPlan([]*parser.ParserResult{ - {Path: handA, Model: &meta.Model{Name: "FieldDefault", Path: handA}}, - }) - if err != nil || plan.NeedInject || plan.SupersedeVirtual { - t.Fatalf("expected no-op for local hand only, got %+v %v", plan, err) - } - - // Local generated (no handwritten) → skip inject. - virt := fieldDefaultGeneratedPath(builder.module.Path) - plan, err = builder.decideFieldDefaultPlan([]*parser.ParserResult{ - {Path: virt, Model: &meta.Model{Name: "FieldDefault", Path: virt}}, - }) - if err != nil || plan.NeedInject { - t.Fatalf("local generated should skip inject, got %+v %v", plan, err) - } - - // Same-module reclaim. - resetFieldDefaultScheduledAppsForTest() - plan, err = builder.decideFieldDefaultPlan(nil) - if err != nil || !plan.NeedInject { - t.Fatalf("first inject: %+v %v", plan, err) - } - plan2, err := builder.decideFieldDefaultPlan(nil) - if err != nil || !plan2.NeedInject || plan2.scheduledApp != "partner" { - t.Fatalf("reclaim: %+v %v", plan2, err) - } -} - -func TestApplyAndPlanInject_ErrorPaths(t *testing.T) { - if err := (*ModuleBuilder)(nil).applyFieldDefaultInject(FieldDefaultPlan{NeedInject: true}); err != nil { - t.Fatal(err) - } - builder, _ := newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - if err := builder.applyFieldDefaultInject(FieldDefaultPlan{NeedInject: true}); err == nil || !strings.Contains(err.Error(), "module path") { - t.Fatalf("expected path error, got %v", err) - } - - // Empty modulesPath in config → derive from module.Path; still register virtual source. - builder.module.Path = "/virtual/modules/partner" - builder.runtimeScope.(*builderTestScope).cfg.ModulesPath = "" - if err := builder.applyFieldDefaultInject(FieldDefaultPlan{NeedInject: true}); err != nil { - t.Fatalf("inject with empty modulesPath: %v", err) - } - stub := builder.buildPlugin.(*stubEsbPlugin) - if len(stub.virtualSources) == 0 { - t.Fatal("expected virtual source registration") - } - - // buildPlugin without SetEntryPointImports / RegisterVirtualSource still succeeds. - builder.buildPlugin = bareEsbPlugin{name: "bare"} - if err := builder.applyFieldDefaultInject(FieldDefaultPlan{NeedInject: true}); err != nil { - t.Fatalf("bare plugin: %v", err) - } - - builder, _ = newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - err := builder.planAndInjectFieldDefault(module.WithParserResults(&module.BuildResult{Module: builder.module}, []*parser.ParserResult{ - {Path: "/virtual/modules/partner/a.ts", Model: &meta.Model{Name: "FieldDefault", Path: "/virtual/modules/partner/a.ts"}}, - {Path: "/virtual/modules/partner/b.ts", Model: &meta.Model{Name: "FieldDefault", Path: "/virtual/modules/partner/b.ts"}}, - })) - if err == nil || !strings.Contains(err.Error(), fieldDefaultDuplicateCode) { - t.Fatalf("planAndInject decide error: %v", err) - } - - builder.fieldDefaultPlan = FieldDefaultPlan{} - resetFieldDefaultScheduledAppsForTest() - builder.module.Path = "" - builder.module.ApplicationStr = "partner" - builder.module.ServiceEntryPoint = "service/index.ts" - builder.module.Name = "partner" - err = builder.planAndInjectFieldDefault(module.WithParserResults(&module.BuildResult{Module: builder.module}, nil)) - if err == nil || !strings.Contains(err.Error(), "injecting FieldDefault") { - t.Fatalf("planAndInject inject error: %v", err) - } - if _, loaded := fieldDefaultScheduledApps.Load("partner"); loaded { - t.Fatal("schedule should be released after inject failure") - } -} - -func TestValidateFieldDefault_Guards(t *testing.T) { - if err := (*ModuleBuilder)(nil).validateFieldDefault(nil); err != nil { - t.Fatal(err) - } - builder, _ := newFieldDefaultTestBuilder(t, &meta.Module{Name: "core", Path: "/m", ApplicationStr: "core"}) - if err := builder.validateFieldDefault(&module.BuildResult{Module: builder.module}); err != nil { - t.Fatal(err) - } - builder.module.ApplicationStr = "" - if err := builder.validateFieldDefault(&module.BuildResult{Module: builder.module}); err != nil { - t.Fatal(err) - } - builder.module.ApplicationStr = "partner" - if err := builder.validateFieldDefault(module.WithParserResults(&module.BuildResult{Module: builder.module}, nil)); err != nil { - t.Fatal(err) - } -} - -func TestSupersedeVirtualFieldDefaults_GuardsAndDependents(t *testing.T) { - if err := (*ModuleBuilder)(nil).supersedeVirtualFieldDefaults(); err != nil { - t.Fatal(err) - } - builder, db := newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - if err := builder.supersedeVirtualFieldDefaults(); err != nil { - t.Fatal(err) - } - builder.fieldDefaultPlan = FieldDefaultPlan{SupersedeVirtual: true} - builder.runtimeScope = nil - if err := builder.supersedeVirtualFieldDefaults(); err != nil { - t.Fatal(err) - } - builder, db = newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.fieldDefaultPlan = FieldDefaultPlan{SupersedeVirtual: true} - builder.module.ApplicationStr = "" - if err := builder.supersedeVirtualFieldDefaults(); err != nil { - t.Fatal(err) - } - builder.module.ApplicationStr = "partner" - - // Generated row with empty id → filtered out (no delete). - if err := db.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: " ", Valid: true}}, - Name: "FieldDefault", - Path: "/virtual/modules/partner/service/models/__generated__/field_default.ts", - Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - if err := builder.supersedeVirtualFieldDefaults(); err != nil { - t.Fatal(err) - } - - // Session with nil DB handle (guard before Find). - builder.runtimeScope = &builderTestScope{session: &scope.Session{DB: nil}} - builder.fieldDefaultPlan = FieldDefaultPlan{SupersedeVirtual: true} - builder.module.ApplicationStr = "partner" - if err := builder.supersedeVirtualFieldDefaults(); err != nil { - t.Fatal(err) - } - - // Full dependent tree delete. - builder, db = newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.fieldDefaultPlan = FieldDefaultPlan{SupersedeVirtual: true} - virtID := "virt-dep" - if err := db.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: virtID, Valid: true}}, - Name: "FieldDefault", Path: "/virtual/modules/partner/service/models/__generated__/field_default.ts", Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - fieldID := "fld1" - svcID := "svc1" - decModel := "dec-m" - decField := "dec-f" - decSvc := "dec-s" - if err := db.Create(&meta.RawField{BaseModel: meta.BaseModel{Id: sql.NullString{String: fieldID, Valid: true}}, Name: "Model", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawService{BaseModel: meta.BaseModel{Id: sql.NullString{String: svcID, Valid: true}}, Name: "Get", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: decModel, Valid: true}}, Name: "Model", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: decField, Valid: true}}, Name: "Field", FieldId: sql.NullString{String: fieldID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: decSvc, Valid: true}}, Name: "Service", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawArgument{BaseModel: meta.BaseModel{Id: sql.NullString{String: "arg1", Valid: true}}, DecoratorId: sql.NullString{String: decModel, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawTypeParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "tp1", Valid: true}}, Name: "T", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.RawParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "p1", Valid: true}}, Name: "x", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := builder.supersedeVirtualFieldDefaults(); err != nil { - t.Fatalf("supersede dependents: %v", err) - } - var left int64 - if err := db.Unscoped().Model(&meta.RawModel{}).Where("id = ?", virtID).Count(&left).Error; err != nil || left != 0 { - t.Fatalf("virt model left=%d err=%v", left, err) - } -} - -func TestSupersedeVirtualFieldDefaults_RecomputeError(t *testing.T) { - builder, db := newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.fieldDefaultPlan = FieldDefaultPlan{SupersedeVirtual: true} - if err := db.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "virt-recompute", Valid: true}}, - Name: "FieldDefault", Path: "/virtual/modules/partner/service/models/__generated__/field_default.ts", Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - if err := db.Migrator().DropTable(&meta.Model{}); err != nil { - t.Fatal(err) - } - if err := builder.supersedeVirtualFieldDefaults(); err == nil || !strings.Contains(err.Error(), "recompute FieldDefault after supersede") { - t.Fatalf("expected recompute error, got %v", err) - } -} - -func TestSupersedeVirtualFieldDefaults_ErrorBranches(t *testing.T) { - builder, db := newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.fieldDefaultPlan = FieldDefaultPlan{SupersedeVirtual: true} - if err := db.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "virt", Valid: true}}, - Name: "FieldDefault", Path: "/virtual/modules/partner/service/models/__generated__/field_default.ts", Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - - // Find error - sqlDB, err := db.DB() - if err != nil { - t.Fatal(err) - } - _ = sqlDB.Close() - if err := builder.supersedeVirtualFieldDefaults(); err == nil || !strings.Contains(err.Error(), "load FieldDefault rows") { - t.Fatalf("expected load error, got %v", err) - } - - // Pluck / delete errors via callbacks on a fresh DB. - cases := []struct { - name string - table string - phase string // query|delete - substr string - }{ - {name: "service pluck", table: "meta_raw_service", phase: "query", substr: "load declaration services"}, - {name: "field pluck", table: "meta_raw_field", phase: "query", substr: "load declaration fields"}, - {name: "decorator pluck", table: "meta_raw_decorator", phase: "query", substr: "load declaration decorators"}, - {name: "argument delete", table: "meta_raw_argument", phase: "delete", substr: "delete declaration arguments"}, - {name: "decorator delete", table: "meta_raw_decorator", phase: "delete", substr: "delete declaration decorators"}, - {name: "type param delete", table: "meta_raw_type_parameter", phase: "delete", substr: "delete declaration type parameters"}, - {name: "parameter delete", table: "meta_raw_parameter", phase: "delete", substr: "delete declaration parameters"}, - {name: "service delete", table: "meta_raw_service", phase: "delete", substr: "delete declaration services"}, - {name: "field delete", table: "meta_raw_field", phase: "delete", substr: "delete declaration fields"}, - {name: "model delete", table: "meta_raw_model", phase: "delete", substr: "delete declaration models"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - b, gdb := newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - b.fieldDefaultPlan = FieldDefaultPlan{SupersedeVirtual: true} - virtID := "v-" + tc.name - if err := gdb.Create(&meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: virtID, Valid: true}}, - Name: "FieldDefault", Path: "/virtual/modules/partner/service/models/__generated__/field_default.ts", Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - fieldID := "f-" + tc.name - svcID := "s-" + tc.name - decID := "d-" + tc.name - if err := gdb.Create(&meta.RawField{BaseModel: meta.BaseModel{Id: sql.NullString{String: fieldID, Valid: true}}, Name: "Model", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawService{BaseModel: meta.BaseModel{Id: sql.NullString{String: svcID, Valid: true}}, Name: "Get", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawDecorator{BaseModel: meta.BaseModel{Id: sql.NullString{String: decID, Valid: true}}, Name: "Model", ModelId: sql.NullString{String: virtID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawArgument{BaseModel: meta.BaseModel{Id: sql.NullString{String: "a-" + tc.name, Valid: true}}, DecoratorId: sql.NullString{String: decID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawTypeParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "t-" + tc.name, Valid: true}}, Name: "T", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - if err := gdb.Create(&meta.RawParameter{BaseModel: meta.BaseModel{Id: sql.NullString{String: "p-" + tc.name, Valid: true}}, Name: "x", ServiceId: sql.NullString{String: svcID, Valid: true}}).Error; err != nil { - t.Fatal(err) - } - - boom := errors.New("forced-" + tc.name) - switch tc.phase { - case "query": - if err := gdb.Callback().Query().Before("gorm:query").Register("force-"+tc.name, func(tx *gorm.DB) { - if tx.Statement != nil && tx.Statement.Table == tc.table { - _ = tx.AddError(boom) - } - }); err != nil { - t.Fatal(err) - } - case "delete": - if err := gdb.Callback().Delete().Before("gorm:delete").Register("force-"+tc.name, func(tx *gorm.DB) { - if tx.Statement != nil && tx.Statement.Table == tc.table { - _ = tx.AddError(boom) - } - }); err != nil { - t.Fatal(err) - } - } - err := b.supersedeVirtualFieldDefaults() - if err == nil || !strings.Contains(err.Error(), tc.substr) { - t.Fatalf("expected %q in error, got %v", tc.substr, err) - } - }) - } -} - -func TestBuildLifecycle_FieldDefaultInjectAndRelease(t *testing.T) { - resetFieldDefaultScheduledAppsForTest() - db, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "fd-lifecycle")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate( - &meta.Application{}, &meta.Module{}, - &meta.RawModel{}, &meta.RawField{}, &meta.RawService{}, &meta.RawDecorator{}, &meta.RawArgument{}, - &meta.RawParameter{}, &meta.RawTypeParameter{}, - &meta.Model{}, &meta.Field{}, &meta.Service{}, &meta.Decorator{}, &meta.Argument{}, - &meta.Parameter{}, &meta.TypeParameter{}, - ); err != nil { - t.Fatal(err) - } - testScope := newBuilderTestScope() - testScope.session = &scope.Session{DB: db} - mod := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - buildPlugin := &stubEsbPlugin{name: "build"} - prebuildPlugin := &stubEsbPlugin{name: "prebuild"} - builder := &ModuleBuilder{ - runtimeScope: testScope, - module: mod, - buildPlugin: buildPlugin, - prebuildPlugin: prebuildPlugin, - entryPoint: "", - } - - result, err := builder.BuildWithoutPersist() - if err != nil { - t.Fatalf("BuildWithoutPersist: %v", err) - } - if builder.fieldDefaultInjectPath == "" || !builder.fieldDefaultPlan.NeedInject { - t.Fatalf("expected inject plan, path=%q plan=%+v", builder.fieldDefaultInjectPath, builder.fieldDefaultPlan) - } - if _, ok := buildPlugin.virtualSources[builder.fieldDefaultInjectPath]; !ok { - t.Fatal("expected virtual source on build plugin") - } - // buildOptions(false) should include inject path via SetEntryPointImports already called; - // also exercise append branch by calling buildOptions directly. - _ = builder.buildOptions(false) - - if err := builder.Persist(result); err != nil { - t.Fatalf("Persist: %v", err) - } - if builder.fieldDefaultPlan.scheduledApp != "" { - t.Fatal("expected schedule cleared after Persist") - } - - // Bundle releases schedule via defer. - resetFieldDefaultScheduledAppsForTest() - builder2 := &ModuleBuilder{ - runtimeScope: testScope, - module: &meta.Module{Name: "partner_bank", Path: "/virtual/modules/partner_bank", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, - prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - } - if _, err := builder2.Bundle(); err != nil { - t.Fatalf("Bundle: %v", err) - } - if builder2.fieldDefaultPlan.scheduledApp != "" { - t.Fatal("bundle should clear scheduledApp") - } -} - -func TestBuildWithoutPersist_ReleasesOnFailures(t *testing.T) { - resetFieldDefaultScheduledAppsForTest() - db, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "fd-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&meta.Application{}, &meta.Module{}, &meta.Model{}); err != nil { - t.Fatal(err) - } - testScope := newBuilderTestScope() - testScope.session = &scope.Session{DB: db} - - // prebuild failure - builder := &ModuleBuilder{ - runtimeScope: testScope, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "/no/such/prebuild-entry.ts", - } - if _, err := builder.BuildWithoutPersist(); err == nil { - t.Fatal("expected prebuild failure") - } - - // planAndInject failure (empty module path after NeedInject claim path uses empty Path). - mod := &meta.Module{Name: "partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"} - builder = &ModuleBuilder{ - runtimeScope: testScope, module: mod, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - } - if _, err := builder.BuildWithoutPersist(); err == nil { - t.Fatal("expected inject path error") - } - if _, loaded := fieldDefaultScheduledApps.Load("partner"); loaded { - t.Fatal("claim should be released") - } - - // updatePrebuildResult failure → release (NeedInject claims, then extends refresh fails). - resetFieldDefaultScheduledAppsForTest() - dbUpd, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "fd-upd-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := dbUpd.AutoMigrate(&meta.Model{}); err != nil { - t.Fatal(err) - } - parentPath := "/virtual/modules/partner/service/models/parent.ts" - latestPath := "/virtual/modules/partner/service/models/latest.ts" - childPath := "/virtual/modules/partner/service/models/child.ts" - if err := dbUpd.Create(&meta.Model{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "aaa", Valid: true}}, - Name: "X", Path: parentPath, Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - if err := dbUpd.Create(&meta.Model{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "zzz", Valid: true}}, - Name: "X", Path: latestPath, Application: "partner", - }).Error; err != nil { - t.Fatal(err) - } - scopeUpd := newBuilderTestScope() - scopeUpd.session = &scope.Session{DB: dbUpd} - pre := &stubEsbPlugin{name: "prebuild", parserResults: []*parser.ParserResult{{ - Path: childPath, - RawContent: "export default class X {}", - Model: &meta.Model{Name: "X", Path: childPath, Extends: parentPath}, - }}} - builder = &ModuleBuilder{ - runtimeScope: scopeUpd, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: pre, - entryPoint: "", - tsParser: fixedParser{err: errors.New("reparse boom")}, - tsPathAlias: map[string]string{}, - } - if _, err := builder.BuildWithoutPersist(); err == nil { - t.Fatal("expected update/prebuild downstream error") - } - if _, loaded := fieldDefaultScheduledApps.Load("partner"); loaded { - t.Fatal("claim should be released after update failure") - } - - // Persist failure releases. - resetFieldDefaultScheduledAppsForTest() - db2, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "fd-persist-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - testScope2 := newBuilderTestScope() - testScope2.session = &scope.Session{DB: db2} - builder = &ModuleBuilder{ - runtimeScope: testScope2, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - fieldDefaultPlan: FieldDefaultPlan{NeedInject: true, scheduledApp: "partner"}, - } - fieldDefaultScheduledApps.Store("partner", "partner") - if err := builder.Persist(&module.BuildResult{Module: builder.module}); err == nil { - t.Fatal("expected persist failure without migrated tables") - } - if _, loaded := fieldDefaultScheduledApps.Load("partner"); loaded { - t.Fatal("persist failure should release schedule") - } - - // Persist fails inside supersedeVirtualFieldDefaults (closed DB) and still releases. - resetFieldDefaultScheduledAppsForTest() - dbSupersede, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "fd-persist-supersede-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := dbSupersede.AutoMigrate(&meta.RawModel{}, &meta.Model{}); err != nil { - t.Fatal(err) - } - sqlDBSupersede, err := dbSupersede.DB() - if err != nil { - t.Fatal(err) - } - _ = sqlDBSupersede.Close() - scopeSupersede := newBuilderTestScope() - scopeSupersede.session = &scope.Session{DB: dbSupersede} - builder = &ModuleBuilder{ - runtimeScope: scopeSupersede, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - fieldDefaultPlan: FieldDefaultPlan{SupersedeVirtual: true, scheduledApp: "partner"}, - } - fieldDefaultScheduledApps.Store("partner", "partner") - if err := builder.Persist(&module.BuildResult{Module: builder.module}); err == nil { - t.Fatal("expected persist failure from supersede") - } - if _, loaded := fieldDefaultScheduledApps.Load("partner"); loaded { - t.Fatal("supersede persist failure should release schedule") - } - - // validate failure → release: claim NeedInject, then swap parser results to duplicates before validate - // by using a build plugin that returns two FieldDefaults while prebuild stayed empty. - resetFieldDefaultScheduledAppsForTest() - db3, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "fd-validate-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db3.AutoMigrate(&meta.Application{}, &meta.Module{}); err != nil { - t.Fatal(err) - } - if err := meta.EnsureDualStoreTables(db3); err != nil { - t.Fatal(err) - } - testScope3 := newBuilderTestScope() - testScope3.session = &scope.Session{DB: db3} - tmpVal := t.TempDir() - entryVal := filepath.Join(tmpVal, "entry.ts") - if err := os.WriteFile(entryVal, []byte("export const ok = 1;\n"), 0o644); err != nil { - t.Fatal(err) - } - modulesVal := filepath.Join(tmpVal, "modules") - if err := os.MkdirAll(modulesVal, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(modulesVal, "tsconfig.json"), []byte(`{"compilerOptions":{"baseUrl":"."}}`), 0o644); err != nil { - t.Fatal(err) - } - testScope3.cfg.ModulesPath = modulesVal - testScope3.cfg.DistPath = filepath.Join(tmpVal, "dist") - hand := filepath.Join(modulesVal, "partner/service/models/field_default.ts") - virt := filepath.Join(modulesVal, "partner/service/models/__generated__/field_default.ts") - dupes := []*parser.ParserResult{ - {Path: hand, Model: &meta.Model{Name: "FieldDefault", Path: hand, Application: "partner"}}, - {Path: virt, Model: &meta.Model{Name: "FieldDefault", Path: virt, Application: "partner"}}, - } - buildPlugVal := &stickyParserPlugin{results: dupes} - builder = &ModuleBuilder{ - runtimeScope: testScope3, - module: &meta.Module{ - Name: "partner", Path: filepath.Join(modulesVal, "partner"), - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }, - buildPlugin: buildPlugVal, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: entryVal, outFileName: "index.js", - } - if _, err := builder.BuildWithoutPersist(); err == nil || !strings.Contains(err.Error(), fieldDefaultDuplicateCode) { - t.Fatalf("expected validate duplicate, got %v", err) - } - if _, loaded := fieldDefaultScheduledApps.Load("partner"); loaded { - t.Fatal("validate failure should release") - } - - // build failure → release (non-empty entry that esbuild rejects after inject). - resetFieldDefaultScheduledAppsForTest() - db4, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "fd-build-fail")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db4.AutoMigrate(&meta.Application{}, &meta.Module{}); err != nil { - t.Fatal(err) - } - if err := meta.EnsureDualStoreTables(db4); err != nil { - t.Fatal(err) - } - testScope4 := newBuilderTestScope() - testScope4.session = &scope.Session{DB: db4} - tmp := t.TempDir() - entry := filepath.Join(tmp, "entry.ts") - if err := os.WriteFile(entry, []byte("export const ok = 1;\n"), 0o644); err != nil { - t.Fatal(err) - } - modulesDir := filepath.Join(tmp, "modules") - if err := os.MkdirAll(modulesDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(modulesDir, "tsconfig.json"), []byte(`{"compilerOptions":{"baseUrl":"."}}`), 0o644); err != nil { - t.Fatal(err) - } - testScope4.cfg.ModulesPath = modulesDir - testScope4.cfg.DistPath = filepath.Join(tmp, "dist") - buildPlug := &stubEsbPlugin{name: "build", getParserResultsErr: errors.New("parser boom")} - builder = &ModuleBuilder{ - runtimeScope: testScope4, - module: &meta.Module{ - Name: "partner", Path: filepath.Join(modulesDir, "partner"), - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }, - buildPlugin: buildPlug, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: entry, outFileName: "index.js", - } - if _, err := builder.BuildWithoutPersist(); err == nil { - t.Fatal("expected build parser error") - } - if _, loaded := fieldDefaultScheduledApps.Load("partner"); loaded { - t.Fatal("build failure should release") - } -} - -func TestBundle_FailurePaths(t *testing.T) { - resetFieldDefaultScheduledAppsForTest() - testScope := newBuilderTestScope() - // prebuild failure (bad entry) - builder := &ModuleBuilder{ - runtimeScope: testScope, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "/no/such/entry.ts", - } - if _, err := builder.Bundle(); err == nil { - t.Fatal("expected prebuild failure") - } - - resetFieldDefaultScheduledAppsForTest() - builder = &ModuleBuilder{ - runtimeScope: testScope, - module: &meta.Module{Name: "partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: "", - } - if _, err := builder.Bundle(); err == nil { - t.Fatal("expected inject failure") - } - - db, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "fd-bundle-upd")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&meta.Model{}); err != nil { - t.Fatal(err) - } - parentPath := "/virtual/modules/partner/service/models/parent.ts" - latestPath := "/virtual/modules/partner/service/models/latest.ts" - childPath := "/virtual/modules/partner/service/models/child.ts" - if err := db.Create(&meta.Model{BaseModel: meta.BaseModel{Id: sql.NullString{String: "aaa", Valid: true}}, Name: "X", Path: parentPath}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&meta.Model{BaseModel: meta.BaseModel{Id: sql.NullString{String: "zzz", Valid: true}}, Name: "X", Path: latestPath}).Error; err != nil { - t.Fatal(err) - } - scopeUpd := newBuilderTestScope() - scopeUpd.session = &scope.Session{DB: db} - pre := &stubEsbPlugin{name: "prebuild", parserResults: []*parser.ParserResult{{ - Path: childPath, RawContent: "export default class X {}", - Model: &meta.Model{Name: "X", Path: childPath, Extends: parentPath}, - }}} - builder = &ModuleBuilder{ - runtimeScope: scopeUpd, - module: &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts"}, - buildPlugin: &stubEsbPlugin{name: "build"}, prebuildPlugin: pre, - entryPoint: "", tsParser: fixedParser{err: errors.New("reparse boom")}, tsPathAlias: map[string]string{}, - } - if _, err := builder.Bundle(); err == nil { - t.Fatal("expected update failure in Bundle") - } - - // build failure in Bundle - resetFieldDefaultScheduledAppsForTest() - db2, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "fd-bundle-build")), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db2.AutoMigrate(&meta.Model{}); err != nil { - t.Fatal(err) - } - tmp := t.TempDir() - entry := filepath.Join(tmp, "entry.ts") - if err := os.WriteFile(entry, []byte("export const ok = 1;\n"), 0o644); err != nil { - t.Fatal(err) - } - modulesDir := filepath.Join(tmp, "modules") - if err := os.MkdirAll(modulesDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(modulesDir, "tsconfig.json"), []byte(`{"compilerOptions":{"baseUrl":"."}}`), 0o644); err != nil { - t.Fatal(err) - } - scopeBuild := newBuilderTestScope() - scopeBuild.session = &scope.Session{DB: db2} - scopeBuild.cfg.ModulesPath = modulesDir - scopeBuild.cfg.DistPath = filepath.Join(tmp, "dist") - builder = &ModuleBuilder{ - runtimeScope: scopeBuild, - module: &meta.Module{ - Name: "partner", Path: filepath.Join(modulesDir, "partner"), - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }, - buildPlugin: &stubEsbPlugin{name: "build", getParserResultsErr: errors.New("parser boom")}, - prebuildPlugin: &stubEsbPlugin{name: "prebuild"}, - entryPoint: entry, outFileName: "index.js", - } - if _, err := builder.Bundle(); err == nil { - t.Fatal("expected build failure in Bundle") - } -} diff --git a/internal/module/artifact/build/backend/field_default_test.go b/internal/module/artifact/build/backend/field_default_test.go deleted file mode 100644 index 74598b2b..00000000 --- a/internal/module/artifact/build/backend/field_default_test.go +++ /dev/null @@ -1,571 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package backendbuilder - -import ( - "database/sql" - "fmt" - "path/filepath" - "strings" - "sync/atomic" - "testing" - - module "github.com/choysum-dev/choysum/internal/module/artifact/result" - "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" -) - -// fieldDefaultTestDBSeq keeps shared-cache in-memory DSNs unique across -count=N reruns. -var fieldDefaultTestDBSeq atomic.Uint64 - -func fieldDefaultMemoryDSN(t *testing.T, prefix string) string { - t.Helper() - name := strings.ReplaceAll(t.Name(), "/", "_") - return fmt.Sprintf("file:%s-%s-%d?mode=memory&cache=shared", prefix, name, fieldDefaultTestDBSeq.Add(1)) -} - -func newFieldDefaultTestBuilder(t *testing.T, mod *meta.Module) (*ModuleBuilder, *gorm.DB) { - t.Helper() - resetFieldDefaultScheduledAppsForTest() - db, err := gorm.Open(sqlite.Open(fieldDefaultMemoryDSN(t, "field-default")), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := meta.EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure dual store: %v", err) - } - if err := db.AutoMigrate(&meta.Application{}, &meta.Module{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - testScope := newBuilderTestScope() - testScope.session = &scope.Session{DB: db} - buildPlugin := &stubEsbPlugin{name: "build"} - return &ModuleBuilder{ - runtimeScope: testScope, - module: mod, - buildPlugin: buildPlugin, - entryPoint: "/virtual/entry.ts", - }, db -} - -func seedFieldDefaultDeclaration(t *testing.T, db *gorm.DB, id, path, application, moduleID string) { - t.Helper() - m := &meta.Model{ - Name: "FieldDefault", - Path: path, - Application: application, - } - if id != "" { - m.Id = sql.NullString{String: id, Valid: true} - } - if moduleID != "" { - m.ModuleId = sql.NullString{String: moduleID, Valid: true} - } - if err := meta.PersistModelTreeAsRaw(db, m); err != nil { - t.Fatalf("seed FieldDefault declaration: %v", err) - } -} - -func TestIsGeneratedFieldDefaultPath(t *testing.T) { - if !isGeneratedFieldDefaultPath("/mods/partner/service/models/__generated__/field_default.ts") { - t.Fatal("expected generated path match") - } - if isGeneratedFieldDefaultPath("/mods/partner/service/models/field_default.ts") { - t.Fatal("expected handwritten path mismatch") - } -} - -func TestDecideFieldDefaultPlan_NeedInject(t *testing.T) { - mod := &meta.Module{ - Name: "partner", - Path: "/virtual/modules/partner", - ApplicationStr: "partner", - ServiceEntryPoint: "service/index.ts", - } - builder, _ := newFieldDefaultTestBuilder(t, mod) - plan, err := builder.decideFieldDefaultPlan(nil) - if err != nil { - t.Fatalf("decide: %v", err) - } - if !plan.NeedInject || plan.SupersedeVirtual { - t.Fatalf("expected NeedInject only, got %+v", plan) - } -} - -func TestDecideFieldDefaultPlan_SkipsCoreAndEmptyService(t *testing.T) { - builder, _ := newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "core", Path: "/virtual/modules/core", ApplicationStr: "core", ServiceEntryPoint: "service/index.ts", - }) - plan, err := builder.decideFieldDefaultPlan(nil) - if err != nil || plan.NeedInject { - t.Fatalf("core should skip, got plan=%+v err=%v", plan, err) - } - - builder.module = &meta.Module{Name: "web", Path: "/virtual/modules/web", ApplicationStr: "web"} - plan, err = builder.decideFieldDefaultPlan(nil) - if err != nil || plan.NeedInject { - t.Fatalf("empty ServiceEntryPoint should skip, got plan=%+v err=%v", plan, err) - } -} - -func TestDecideFieldDefaultPlan_DBVirtualReinjectsForOwner(t *testing.T) { - owner := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newFieldDefaultTestBuilder(t, owner) - seedFieldDefaultDeclaration(t, db, "fd1", "/virtual/modules/partner/service/models/__generated__/field_default.ts", "partner", "") - plan, err := builder.decideFieldDefaultPlan(nil) - if err != nil { - t.Fatalf("decide: %v", err) - } - if !plan.NeedInject || plan.SupersedeVirtual { - t.Fatalf("expected NeedInject re-inject for owning module, got %+v", plan) - } - - // Sibling module sharing the application must not inject a second store path. - builder.module = &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - plan, err = builder.decideFieldDefaultPlan(nil) - if err != nil { - t.Fatalf("decide sibling: %v", err) - } - if plan.NeedInject || plan.SupersedeVirtual { - t.Fatalf("expected sibling skip when owner virtual exists, got %+v", plan) - } -} - -func TestDecideFieldDefaultPlan_DBHandwrittenSkipsInject(t *testing.T) { - mod := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newFieldDefaultTestBuilder(t, mod) - seedFieldDefaultDeclaration(t, db, "hand", "/virtual/modules/partner/service/models/field_default.ts", "partner", "") - plan, err := builder.decideFieldDefaultPlan(nil) - if err != nil { - t.Fatalf("decide: %v", err) - } - if plan.NeedInject || plan.SupersedeVirtual { - t.Fatalf("expected skip when DB has handwritten FieldDefault, got %+v", plan) - } -} - -func TestDecideFieldDefaultPlan_SupersedeVirtual(t *testing.T) { - mod := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newFieldDefaultTestBuilder(t, mod) - seedFieldDefaultDeclaration(t, db, "virt", "/virtual/modules/partner/service/models/__generated__/field_default.ts", "partner", "mod-partner") - handPath := filepath.Join(mod.Path, "service/models/field_default.ts") - prebuild := []*parser.ParserResult{{ - Path: handPath, - Model: &meta.Model{Name: "FieldDefault", Path: handPath, Application: "partner"}, - }} - plan, err := builder.decideFieldDefaultPlan(prebuild) - if err != nil { - t.Fatalf("decide: %v", err) - } - if !plan.SupersedeVirtual || plan.NeedInject { - t.Fatalf("expected SupersedeVirtual, got %+v", plan) - } -} - -func TestDecideFieldDefaultPlan_DuplicateHandwritten(t *testing.T) { - mod := &meta.Module{ - Name: "partner_commercial", Path: "/virtual/modules/partner_commercial", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newFieldDefaultTestBuilder(t, mod) - seedFieldDefaultDeclaration(t, db, "hand", "/virtual/modules/partner_bank/service/models/field_default.ts", "partner", "mod-bank") - handPath := filepath.Join(mod.Path, "service/models/field_default.ts") - prebuild := []*parser.ParserResult{{ - Path: handPath, - Model: &meta.Model{Name: "FieldDefault", Path: handPath, Application: "partner"}, - }} - _, err := builder.decideFieldDefaultPlan(prebuild) - if err == nil || !strings.Contains(err.Error(), fieldDefaultDuplicateCode) { - t.Fatalf("expected FIELD_DEFAULT_DUPLICATE, got %v", err) - } -} - -func TestDecideFieldDefaultPlan_ProcessDedupOneInject(t *testing.T) { - resetFieldDefaultScheduledAppsForTest() - modA := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - modB := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builderA, _ := newFieldDefaultTestBuilder(t, modA) - planA, err := builderA.decideFieldDefaultPlan(nil) - if err != nil || !planA.NeedInject { - t.Fatalf("first module should inject, plan=%+v err=%v", planA, err) - } - builderB := &ModuleBuilder{runtimeScope: builderA.runtimeScope, module: modB, buildPlugin: &stubEsbPlugin{name: "build"}} - planB, err := builderB.decideFieldDefaultPlan(nil) - if err != nil { - t.Fatalf("second decide: %v", err) - } - if planB.NeedInject { - t.Fatalf("second module same app must not inject again, got %+v", planB) - } -} - -func TestApplyFieldDefaultInject_SetsEntryImportAndPath(t *testing.T) { - mod := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, _ := newFieldDefaultTestBuilder(t, mod) - stub := builder.buildPlugin.(*stubEsbPlugin) - if err := builder.applyFieldDefaultInject(FieldDefaultPlan{NeedInject: true}); err != nil { - t.Fatalf("inject: %v", err) - } - want := fieldDefaultGeneratedPath(mod.Path) - if builder.fieldDefaultInjectPath != want { - t.Fatalf("inject path = %q, want %q", builder.fieldDefaultInjectPath, want) - } - found := false - for _, imp := range stub.entryImports { - if imp == want { - found = true - break - } - } - if !found { - t.Fatalf("expected entry import %q in %#v", want, stub.entryImports) - } - src, ok := stub.virtualSources[want] - if !ok || src == "" { - t.Fatalf("expected virtual source registered for %q, got %#v", want, stub.virtualSources) - } - if !strings.Contains(src, `@Model('FieldDefault', { application: "partner" })`) { - t.Fatalf("unexpected virtual source: %s", src) - } -} - -func TestVirtualFieldDefaultSource_QuotesLiterals(t *testing.T) { - src := virtualFieldDefaultSource(`/tmp/mod"quote`, "app'name\\x") - if !strings.Contains(src, `from "/tmp/mod\"quote/core/service/index.ts"`) { - t.Fatalf("expected quoted core import, got:\n%s", src) - } - if !strings.Contains(src, `application: "app'name\\x"`) { - t.Fatalf("expected quoted application literal, got:\n%s", src) - } -} - -func TestEnsureFieldDefaultVirtualImports_SkipsHandwritten(t *testing.T) { - owner := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newFieldDefaultTestBuilder(t, owner) - seedFieldDefaultDeclaration(t, db, "hand", "/virtual/modules/partner/service/models/field_default.ts", "partner", "") - base := &meta.Module{ - Name: "base", Path: "/virtual/modules/base", - ApplicationStr: "base", ServiceEntryPoint: "service/index.ts", - } - if err := builder.EnsureFieldDefaultVirtualImports([]*meta.Module{owner, base}); err != nil { - t.Fatalf("ensure: %v", err) - } - wantBase := fieldDefaultGeneratedPath(base.Path) - if len(builder.fieldDefaultInjectPaths) != 1 || builder.fieldDefaultInjectPaths[0] != wantBase { - t.Fatalf("expected only base C2 path, got %#v", builder.fieldDefaultInjectPaths) - } - stub := builder.buildPlugin.(*stubEsbPlugin) - if _, ok := stub.virtualSources[fieldDefaultGeneratedPath(owner.Path)]; ok { - t.Fatal("handwritten app must not register C2 virtual source") - } - if _, ok := stub.virtualSources[wantBase]; !ok { - t.Fatalf("expected base virtual source, got %#v", stub.virtualSources) - } -} - -func TestEnsureFieldDefaultVirtualImports_PrefersMetaVirtualPath(t *testing.T) { - // Owner candidate is a sibling path; meta already points at the primary module. - sibling := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newFieldDefaultTestBuilder(t, sibling) - metaPath := "/virtual/modules/partner/service/models/__generated__/field_default.ts" - seedFieldDefaultDeclaration(t, db, "virt", metaPath, "partner", "") - if err := builder.EnsureFieldDefaultVirtualImports([]*meta.Module{sibling}); err != nil { - t.Fatalf("ensure: %v", err) - } - if len(builder.fieldDefaultInjectPaths) != 1 || builder.fieldDefaultInjectPaths[0] != metaPath { - t.Fatalf("expected meta virt path, got %#v", builder.fieldDefaultInjectPaths) - } -} - -func TestFieldDefaultPatchCoverage_Branches(t *testing.T) { - if src := virtualFieldDefaultSource("/virtual/modules", " "); !strings.Contains(src, `application: "application"`) { - t.Fatalf("empty application should default, got:\n%s", src) - } - - (*ModuleBuilder)(nil).rememberFieldDefaultInjectPath("/x") - builder, _ := newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - builder.rememberFieldDefaultInjectPath(" ") - if builder.fieldDefaultInjectPath != "" || len(builder.fieldDefaultInjectPaths) != 0 { - t.Fatalf("empty path must not remember, got path=%q paths=%#v", builder.fieldDefaultInjectPath, builder.fieldDefaultInjectPaths) - } - builder.rememberFieldDefaultInjectPath("/a") - builder.rememberFieldDefaultInjectPath("/a") - if len(builder.fieldDefaultInjectPaths) != 1 { - t.Fatalf("dedupe remember: %#v", builder.fieldDefaultInjectPaths) - } - - // Only fieldDefaultInjectPath (no slice) must still merge into buildOptions. - builder.fieldDefaultInjectPaths = nil - builder.fieldDefaultInjectPath = "/only-single" - stub := builder.buildPlugin.(*stubEsbPlugin) - stub.SetEntryPointImports(nil) - _ = builder.buildOptions(false) - found := false - for _, imp := range stub.entryImports { - if imp == "/only-single" { - found = true - break - } - } - if !found { - t.Fatalf("expected single inject path in entry imports, got %#v", stub.entryImports) - } - - if err := (*ModuleBuilder)(nil).EnsureFieldDefaultVirtualImports(nil); err != nil { - t.Fatalf("nil ensure: %v", err) - } - if err := builder.EnsureFieldDefaultVirtualImports([]*meta.Module{ - nil, - {Name: "core", Path: "/virtual/modules/core", ApplicationStr: "core", ServiceEntryPoint: "service/index.ts"}, - {Name: "noentry", Path: "/virtual/modules/x", ApplicationStr: "x", ServiceEntryPoint: ""}, - {Name: "nopath", Path: "", ApplicationStr: "y", ServiceEntryPoint: "service/index.ts"}, - {Name: "emptyapp", Path: "/virtual/modules/z", ApplicationStr: " ", ServiceEntryPoint: "service/index.ts"}, - }); err != nil { - t.Fatalf("skip-only ensure: %v", err) - } - // Clear paths then ensure only skipped apps → early return at empty inject paths. - builder.fieldDefaultInjectPaths = nil - builder.fieldDefaultInjectPath = "" - if err := builder.EnsureFieldDefaultVirtualImports([]*meta.Module{ - {Name: "core", Path: "/m/core", ApplicationStr: "core", ServiceEntryPoint: "service/index.ts"}, - }); err != nil { - t.Fatalf("empty ensure: %v", err) - } - if len(builder.fieldDefaultInjectPaths) != 0 { - t.Fatalf("expected no inject paths, got %#v", builder.fieldDefaultInjectPaths) - } - - // Handwritten-only ensure also returns with empty inject paths. - builder, db := newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - }) - seedFieldDefaultDeclaration(t, db, "hand2", "/virtual/modules/partner/service/models/field_default.ts", "partner", "") - if err := builder.EnsureFieldDefaultVirtualImports([]*meta.Module{builder.module}); err != nil { - t.Fatalf("handwritten-only ensure: %v", err) - } - if len(builder.fieldDefaultInjectPaths) != 0 { - t.Fatalf("handwritten-only should skip C2, got %#v", builder.fieldDefaultInjectPaths) - } - - // dbLoad error from Ensure. - builder, db = newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "base", Path: "/virtual/modules/base", - ApplicationStr: "base", ServiceEntryPoint: "service/index.ts", - }) - sqlDB, err := db.DB() - if err != nil { - t.Fatal(err) - } - _ = sqlDB.Close() - if err := builder.EnsureFieldDefaultVirtualImports([]*meta.Module{builder.module}); err == nil || !strings.Contains(err.Error(), "load FieldDefault") { - t.Fatalf("expected ensure load error, got %v", err) - } - - // Empty modulesPath + prebuild plugin registration / entry imports. - builder, _ = newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "base", Path: "/virtual/modules/base", - ApplicationStr: "base", ServiceEntryPoint: "service/index.ts", - }) - pre := &stubEsbPlugin{name: "prebuild"} - builder.prebuildPlugin = pre - builder.runtimeScope.(*builderTestScope).cfg.ModulesPath = "" - if err := builder.EnsureFieldDefaultVirtualImports([]*meta.Module{builder.module}); err != nil { - t.Fatalf("ensure empty modulesPath: %v", err) - } - want := fieldDefaultGeneratedPath(builder.module.Path) - if _, ok := pre.virtualSources[want]; !ok { - t.Fatalf("expected prebuild virtual source, got %#v", pre.virtualSources) - } - if len(pre.entryImports) == 0 { - t.Fatal("expected prebuild entry imports") - } - - // applyFieldDefaultInject also wires prebuild plugin. - builder, _ = newFieldDefaultTestBuilder(t, &meta.Module{ - Name: "meta", Path: "/virtual/modules/meta", - ApplicationStr: "meta", ServiceEntryPoint: "service/index.ts", - }) - pre = &stubEsbPlugin{name: "prebuild"} - builder.prebuildPlugin = pre - if err := builder.applyFieldDefaultInject(FieldDefaultPlan{NeedInject: true}); err != nil { - t.Fatalf("apply with prebuild: %v", err) - } - wantMeta := fieldDefaultGeneratedPath(builder.module.Path) - if _, ok := pre.virtualSources[wantMeta]; !ok { - t.Fatalf("apply should register on prebuild plugin: %#v", pre.virtualSources) - } - if len(pre.entryImports) == 0 { - t.Fatal("apply should set prebuild entry imports") - } -} - -func TestEnsureFieldDefaultVirtualImports_SurvivesBuildOptionsReplace(t *testing.T) { - // Multi-app bundles Decide/Inject against a core representative; Ensure must - // still get every app's FieldDefault into WithEntryPointImports. - core := &meta.Module{ - Name: "core", Path: "/virtual/modules/core", - ApplicationStr: "core", ServiceEntryPoint: "service/index.ts", - } - builder, _ := newFieldDefaultTestBuilder(t, core) - owners := []*meta.Module{ - {Name: "base", Path: "/virtual/modules/base", ApplicationStr: "base", ServiceEntryPoint: "service/index.ts"}, - {Name: "task", Path: "/virtual/modules/task", ApplicationStr: "task", ServiceEntryPoint: "service/index.ts"}, - {Name: "base_dup", Path: "/virtual/modules/base2", ApplicationStr: "base", ServiceEntryPoint: "service/index.ts"}, - } - if err := builder.EnsureFieldDefaultVirtualImports(owners); err != nil { - t.Fatalf("ensure: %v", err) - } - wantBase := fieldDefaultGeneratedPath(owners[0].Path) - wantTask := fieldDefaultGeneratedPath(owners[1].Path) - if len(builder.fieldDefaultInjectPaths) != 2 { - t.Fatalf("expected 2 inject paths (dedupe app), got %#v", builder.fieldDefaultInjectPaths) - } - - stub := builder.buildPlugin.(*stubEsbPlugin) - // Simulate a wipe of whatever Ensure put on the plugin; buildOptions must restore. - stub.SetEntryPointImports([]string{"/wiped"}) - _ = builder.buildOptions(false) - - foundBase, foundTask := false, false - for _, imp := range stub.entryImports { - if imp == wantBase { - foundBase = true - } - if imp == wantTask { - foundTask = true - } - } - if !foundBase || !foundTask { - t.Fatalf("expected inject paths in entry imports after buildOptions, got %#v", stub.entryImports) - } - if _, ok := stub.virtualSources[wantBase]; !ok { - t.Fatalf("missing virtual source for base: %#v", stub.virtualSources) - } - if _, ok := stub.virtualSources[wantTask]; !ok { - t.Fatalf("missing virtual source for task: %#v", stub.virtualSources) - } -} - -func TestReleaseFieldDefaultSchedule_AllowsRetryAfterFailure(t *testing.T) { - mod := &meta.Module{ - Name: "partner", Path: "/virtual/modules/partner", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, _ := newFieldDefaultTestBuilder(t, mod) - plan, err := builder.decideFieldDefaultPlan(nil) - if err != nil || !plan.NeedInject || plan.scheduledApp != "partner" { - t.Fatalf("expected NeedInject claim, got %+v err=%v", plan, err) - } - builder.fieldDefaultPlan = plan - builder.releaseFieldDefaultSchedule() - - plan2, err := builder.decideFieldDefaultPlan(nil) - if err != nil || !plan2.NeedInject { - t.Fatalf("expected retry inject after release, got %+v err=%v", plan2, err) - } -} - -func TestValidateFieldDefault_DuplicateHandAndVirtual(t *testing.T) { - mod := &meta.Module{Name: "partner", Path: "/virtual/modules/partner", ApplicationStr: "partner"} - builder, _ := newFieldDefaultTestBuilder(t, mod) - hand := filepath.Join(mod.Path, "service/models/field_default.ts") - virt := fieldDefaultGeneratedPath(mod.Path) - buildResult := module.WithParserResults(&module.BuildResult{Module: mod}, []*parser.ParserResult{ - {Path: hand, Model: &meta.Model{Name: "FieldDefault", Path: hand}}, - {Path: virt, Model: &meta.Model{Name: "FieldDefault", Path: virt}}, - }) - err := builder.validateFieldDefault(buildResult) - if err == nil || !strings.Contains(err.Error(), fieldDefaultDuplicateCode) { - t.Fatalf("expected duplicate error, got %v", err) - } -} - -func TestSupersedeVirtualFieldDefaults_DeletesGeneratedRows(t *testing.T) { - mod := &meta.Module{ - Name: "partner_bank", Path: "/virtual/modules/partner_bank", - ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", - } - builder, db := newFieldDefaultTestBuilder(t, mod) - builder.fieldDefaultPlan = FieldDefaultPlan{SupersedeVirtual: true} - seedFieldDefaultDeclaration(t, db, "virt", "/virtual/modules/partner/service/models/__generated__/field_default.ts", "partner", "") - seedFieldDefaultDeclaration(t, db, "hand", "/virtual/modules/partner_bank/service/models/field_default.ts", "partner", "") - if err := db.Create(&meta.Model{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "other", Valid: true}}, - Name: "Partner", - Path: "/virtual/modules/partner/service/models/partner.ts", - Application: "partner", - }).Error; err != nil { - t.Fatalf("seed other: %v", err) - } - if err := builder.supersedeVirtualFieldDefaults(); err != nil { - t.Fatalf("supersede: %v", err) - } - virtDecls, err := meta.ListDeclarations(db, meta.DeclarationQuery{ - Application: "partner", Name: "FieldDefault", - Path: "/virtual/modules/partner/service/models/__generated__/field_default.ts", - }) - if err != nil { - t.Fatalf("list virt: %v", err) - } - if len(virtDecls) != 0 { - t.Fatalf("expected virtual raw FieldDefault deleted, count=%d", len(virtDecls)) - } - handDecls, err := meta.ListDeclarations(db, meta.DeclarationQuery{ - Application: "partner", Name: "FieldDefault", - Path: "/virtual/modules/partner_bank/service/models/field_default.ts", - }) - if err != nil { - t.Fatalf("list hand: %v", err) - } - if len(handDecls) != 1 { - t.Fatalf("expected handwritten raw FieldDefault kept, count=%d", len(handDecls)) - } - var count int64 - if err := db.Model(&meta.Model{}).Where("name = ?", "FieldDefault").Count(&count).Error; err != nil { - t.Fatalf("count effective: %v", err) - } - if count != 1 { - t.Fatalf("expected handwritten effective FieldDefault after recompute, count=%d", count) - } - if err := db.Model(&meta.Model{}).Where("name = ?", "Partner").Count(&count).Error; err != nil { - t.Fatalf("count partner: %v", err) - } - if count != 1 { - t.Fatalf("expected unrelated model kept, count=%d", count) - } -} diff --git a/internal/module/artifact/build/backend/inject.go b/internal/module/artifact/build/backend/inject.go new file mode 100644 index 00000000..83a784ee --- /dev/null +++ b/internal/module/artifact/build/backend/inject.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package backendbuilder + +import ( + "strings" + + "github.com/choysum-dev/choysum/internal/module/artifact/build/injectappmodel" + module "github.com/choysum-dev/choysum/internal/module/artifact/result" + "github.com/choysum-dev/choysum/pkg/meta" +) + +func (b *ModuleBuilder) injectBuildCtx() injectappmodel.BuildCtx { + ctx := injectappmodel.BuildCtx{} + if b == nil { + return ctx + } + ctx.Module = b.module + ctx.ModulesPath = b.resolvedRuntimeOptions().modulesPath + if b.runtimeScope != nil && b.runtimeScope.Session() != nil { + ctx.DB = b.runtimeScope.Session().DB + } + return ctx +} + +func (b *ModuleBuilder) ensureInjectSession() *injectappmodel.Session { + if b == nil { + return nil + } + ctx := b.injectBuildCtx() + if b.injectSession == nil { + reg := b.injectRegistry + if reg == nil { + reg = injectappmodel.DefaultRegistry() + } + b.injectSession = injectappmodel.NewSession(ctx, reg) + } else { + *b.injectSession.Context() = ctx + } + return b.injectSession +} + +func (b *ModuleBuilder) releaseInjectSchedules() { + if b == nil || b.injectSession == nil { + return + } + b.injectSession.ReleaseSchedules() +} + +// applyInjectEffects registers virtual sources and merges entry-point imports. +func (b *ModuleBuilder) applyInjectEffects(fx injectappmodel.Effects) { + if b == nil { + return + } + for _, f := range fx.Files { + path := strings.TrimSpace(f.Path) + if path == "" { + continue + } + if registrar, ok := b.buildPlugin.(interface { + RegisterVirtualSource(path string, contents string) + }); ok { + registrar.RegisterVirtualSource(path, f.Contents) + } + if registrar, ok := b.prebuildPlugin.(interface { + RegisterVirtualSource(path string, contents string) + }); ok { + registrar.RegisterVirtualSource(path, f.Contents) + } + } + if len(fx.Imports) == 0 { + return + } + imports := mergeImportPaths(b.entryPointImports(), fx.Imports) + if setter, ok := b.buildPlugin.(interface{ SetEntryPointImports([]string) }); ok { + setter.SetEntryPointImports(imports) + } + if setter, ok := b.prebuildPlugin.(interface{ SetEntryPointImports([]string) }); ok { + setter.SetEntryPointImports(imports) + } +} + +func mergeImportPaths(existing, add []string) []string { + out := make([]string, 0, len(existing)+len(add)) + seen := make(map[string]struct{}, len(existing)+len(add)) + for _, group := range [][]string{existing, add} { + for _, p := range group { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + } + return out +} + +func (b *ModuleBuilder) injectAppModels(prebuildResult *module.BuildResult) error { + if b == nil { + return nil + } + sess := b.ensureInjectSession() + fx, err := injectappmodel.InjectAppModels(sess, module.ParserResults(prebuildResult)) + if err != nil { + b.releaseInjectSchedules() + return err + } + b.applyInjectEffects(fx) + return nil +} + +func (b *ModuleBuilder) supersedeInjectAppModels() error { + if b == nil { + return nil + } + return injectappmodel.SupersedeInjectAppModels(b.ensureInjectSession()) +} + +func (b *ModuleBuilder) validateInjectAppModels(buildResult *module.BuildResult) error { + if b == nil { + return nil + } + return injectappmodel.ValidateInjectAppModels(b.ensureInjectSession(), module.ParserResults(buildResult)) +} + +// BundleInjectAppModels registers inject sources for all Specs (multi-app bundles). +func (b *ModuleBuilder) BundleInjectAppModels(modules []*meta.Module) error { + if b == nil { + return nil + } + fx, err := injectappmodel.BundleInjectAppModels(b.ensureInjectSession(), modules) + if err != nil { + return err + } + b.applyInjectEffects(fx) + return nil +} diff --git a/internal/module/artifact/build/backend/inject_test.go b/internal/module/artifact/build/backend/inject_test.go new file mode 100644 index 00000000..b87df32c --- /dev/null +++ b/internal/module/artifact/build/backend/inject_test.go @@ -0,0 +1,437 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package backendbuilder + +import ( + "database/sql" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/choysum-dev/choysum/internal/module/artifact/build/injectappmodel" + module "github.com/choysum-dev/choysum/internal/module/artifact/result" + "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) { + t.Helper() + reg := injectappmodel.NewRegistryWithDefaults() + + dsn := filepath.Join(t.TempDir(), "inject-cov.sqlite") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := meta.EnsureDualStoreTables(db); err != nil { + t.Fatal(err) + } + _ = db.AutoMigrate(&meta.Application{}, &meta.Module{}) + + buildPlugin := &stubEsbPlugin{name: "build", parserResults: results, virtualSources: map[string]string{}} + prebuildPlugin := &stubEsbPlugin{name: "prebuild", parserResults: results, virtualSources: map[string]string{}} + testScope := newBuilderTestScope() + testScope.session = &scope.Session{DB: db} + builder := &ModuleBuilder{ + runtimeScope: testScope, + module: mod, + buildPlugin: buildPlugin, + prebuildPlugin: prebuildPlugin, + entryPoint: "", // skip esbuild; use plugin parser results + injectRegistry: reg, + } + return builder, buildPlugin, db +} + +func TestInjectEffects_NilGuards(t *testing.T) { + (*ModuleBuilder)(nil).applyInjectEffects(injectappmodel.Effects{ + Files: []injectappmodel.VirtualFile{{Path: "p", Contents: "c"}}, + Imports: []string{"x"}, + }) + if ctx := (*ModuleBuilder)(nil).injectBuildCtx(); ctx.Module != nil || ctx.DB != nil || ctx.ModulesPath != "" { + t.Fatalf("nil injectBuildCtx: %+v", ctx) + } + b := &ModuleBuilder{} + b.applyInjectEffects(injectappmodel.Effects{ + Files: []injectappmodel.VirtualFile{{Path: "", Contents: "c"}, {Path: "p2", Contents: "c2"}}, + Imports: []string{"y"}, + }) + if (*ModuleBuilder)(nil).ensureInjectSession() != nil { + t.Fatal("nil ensureInjectSession") + } + (*ModuleBuilder)(nil).releaseInjectSchedules() + b.releaseInjectSchedules() + if b.injectBuildCtx().DB != nil { + t.Fatal("nil builder injectBuildCtx DB") + } + if b.injectBuildCtx().Module != nil { + t.Fatal("empty injectBuildCtx module") + } + // runtimeScope set but Session() nil → DB stays nil. + ts := newBuilderTestScope() + ts.session = nil + b.runtimeScope = ts + if b.injectBuildCtx().DB != nil { + t.Fatal("expected nil DB when Session is nil") + } + + got := mergeImportPaths([]string{"a", " ", "a"}, []string{"", "a", "b", " "}) + if strings.Join(got, ",") != "a,b" { + t.Fatalf("mergeImportPaths=%#v", got) + } +} + +func TestInjectAppModelsWrappers(t *testing.T) { + if err := (*ModuleBuilder)(nil).injectAppModels(nil); err != nil { + t.Fatal(err) + } + if err := (*ModuleBuilder)(nil).supersedeInjectAppModels(); err != nil { + t.Fatal(err) + } + if err := (*ModuleBuilder)(nil).validateInjectAppModels(nil); err != nil { + t.Fatal(err) + } + if err := (*ModuleBuilder)(nil).BundleInjectAppModels(nil); err != nil { + t.Fatal(err) + } + + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + a := "/virtual/modules/partner/service/models/a.ts" + bPath := "/virtual/modules/partner/service/models/b.ts" + builder, buildPlugin, _ := newInjectTestBuilder(t, mod, nil) + + pre := module.WithParserResults(&module.BuildResult{Module: mod}, []*parser.ParserResult{ + {Path: a, Model: &meta.Model{Name: "FieldDefault", Path: a}}, + {Path: bPath, Model: &meta.Model{Name: "FieldDefault", Path: bPath}}, + }) + if err := builder.injectAppModels(pre); err == nil || !strings.Contains(err.Error(), "FIELD_DEFAULT_DUPLICATE") { + t.Fatalf("expected inject error with release, got %v", err) + } + if paths := builder.ensureInjectSession().AllInjectPaths(); len(paths) != 0 { + t.Fatalf("expected no stale inject paths after failed inject, got %#v", paths) + } + + if err := builder.injectAppModels(nil); err != nil { + t.Fatal(err) + } + builder.ensureInjectSession().SetPlan("AppSetting", injectappmodel.Plan{SupersedeInject: true}) + if err := builder.supersedeInjectAppModels(); err != nil { + t.Fatal(err) + } + + dupBuild := module.WithParserResults(&module.BuildResult{Module: mod}, []*parser.ParserResult{ + {Path: a, Model: &meta.Model{Name: "FieldDefault", Path: a}}, + {Path: bPath, Model: &meta.Model{Name: "FieldDefault", Path: bPath}}, + }) + if err := builder.validateInjectAppModels(dupBuild); err == nil { + t.Fatal("expected validate duplicate error") + } + + base := &meta.Module{ + Name: "base", Path: "/virtual/modules/base", + ApplicationStr: "base", ServiceEntryPoint: "service/index.ts", + } + if err := builder.BundleInjectAppModels([]*meta.Module{base}); err != nil { + t.Fatal(err) + } + if len(builder.ensureInjectSession().AllInjectPaths()) == 0 { + t.Fatal("expected bundle inject paths") + } + + ctx := builder.injectBuildCtx() + if ctx.Module != mod || ctx.DB == nil || ctx.ModulesPath == "" { + t.Fatalf("injectBuildCtx: %+v", ctx) + } + builder.applyInjectEffects(injectappmodel.Effects{ + Files: []injectappmodel.VirtualFile{{Path: "/v.ts", Contents: "export {}"}}, + Imports: []string{"/imp"}, + }) + if buildPlugin.virtualSources["/v.ts"] == "" { + t.Fatal("applyInjectEffects RegisterVirtualSource") + } +} + +func TestBuildOptions_IncludesInjectPaths(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + builder, _, _ := newInjectTestBuilder(t, mod, nil) + if err := builder.injectAppModels(nil); err != nil { + t.Fatal(err) + } + if len(builder.injectSession.AllInjectPaths()) == 0 { + t.Fatal("expected inject paths") + } + opts := builder.buildOptions(true) + if opts == nil || len(opts.Plugins) == 0 { + t.Fatal("expected build options with plugins") + } +} + +func TestPersistModuleModels_SupersedeFlushKeys(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + mod.Id = sql.NullString{String: "mod-persist", Valid: true} + builder, _, _ := newInjectTestBuilder(t, mod, nil) + sess := builder.ensureInjectSession() + sess.SetPlan("FieldDefault", injectappmodel.Plan{SupersedeInject: true}) + sess.SetPlan("AppSetting", injectappmodel.Plan{SupersedeInject: true}) + if err := builder.persistModuleModels(mod.Id.String, []*meta.Model{{ + Name: "Partner", Path: "/virtual/modules/partner/service/models/partner.ts", Application: "partner", + }}); err != nil { + t.Fatal(err) + } +} + +func TestValidate_InjectAppModelsError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + builder, _, _ := newInjectTestBuilder(t, mod, nil) + a := "/virtual/modules/partner/service/models/fd_a.ts" + bPath := "/virtual/modules/partner/service/models/fd_b.ts" + // Same-name models with extends so inheritance checks pass, then inject validate fails. + parent := &meta.Model{Name: "FieldDefault", Path: a} + child := &meta.Model{Name: "FieldDefault", Path: bPath, Extends: a} + result := module.WithParserResults(&module.BuildResult{Module: mod}, []*parser.ParserResult{ + {Path: a, Model: parent}, + {Path: bPath, Model: child}, + }) + if err := builder.validate(result); err == nil || !strings.Contains(err.Error(), "FIELD_DEFAULT_DUPLICATE") { + t.Fatalf("expected validate inject duplicate, got %v", err) + } +} + +func TestPersist_SupersedeInjectError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + 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 { + t.Fatal(err) + } + result := &module.BuildResult{Module: mod} + if err := builder.persist(result); err == nil { + t.Fatal("expected supersede error in persist") + } + if err := builder.Persist(result); err == nil { + t.Fatal("expected Persist wrapper error + release") + } +} + +func TestPersistModuleModels_EmptyModuleID(t *testing.T) { + builder := &ModuleBuilder{} + if err := builder.persistModuleModels(" ", nil); err != nil { + t.Fatal(err) + } +} + +func TestBuildWithoutPersist_PrebuildError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + builder, prebuildPlugin, _ := newInjectTestBuilder(t, mod, nil) + // Force prebuild esbuild path then fail: set entryPoint to missing file. + builder.entryPoint = filepath.Join(t.TempDir(), "missing-entry.ts") + builder.prebuildPlugin = prebuildPlugin + if _, err := builder.BuildWithoutPersist(); err == nil { + t.Fatal("expected prebuild error") + } +} + +func TestPersistModuleModels_EmptyAppSkipsSupersedeKeys(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: " ", ServiceEntryPoint: "service/index.ts", + } + mod.Id = sql.NullString{String: "mod-empty-app", Valid: true} + builder, _, _ := newInjectTestBuilder(t, mod, nil) + sess := builder.ensureInjectSession() + sess.SetPlan("FieldDefault", injectappmodel.Plan{SupersedeInject: true}) + if err := builder.persistModuleModels(mod.Id.String, nil); err != nil { + t.Fatal(err) + } +} + +func TestBuildWithoutPersist_InjectError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + a := "/virtual/modules/partner/service/models/a.ts" + bPath := "/virtual/modules/partner/service/models/b.ts" + results := []*parser.ParserResult{ + {Path: a, Model: &meta.Model{Name: "FieldDefault", Path: a}}, + {Path: bPath, Model: &meta.Model{Name: "FieldDefault", Path: bPath}}, + } + builder, _, _ := newInjectTestBuilder(t, mod, results) + if _, err := builder.BuildWithoutPersist(); err == nil { + t.Fatal("expected injectAppModels error") + } +} + +func TestBuildWithoutPersist_BuildErrorReleases(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + builder, buildPlugin, _ := newInjectTestBuilder(t, mod, nil) + buildPlugin.getParserResultsErr = fmt.Errorf("forced build parser results failure") + if _, err := builder.BuildWithoutPersist(); err == nil || !strings.Contains(err.Error(), "forced build parser results failure") { + t.Fatalf("expected build parser results failure, got %v", err) + } +} + +func TestBundle_InjectError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + a := "/virtual/modules/partner/service/models/a.ts" + bPath := "/virtual/modules/partner/service/models/b.ts" + results := []*parser.ParserResult{ + {Path: a, Model: &meta.Model{Name: "FieldDefault", Path: a}}, + {Path: bPath, Model: &meta.Model{Name: "FieldDefault", Path: bPath}}, + } + builder, _, _ := newInjectTestBuilder(t, mod, results) + if _, err := builder.Bundle(); err == nil { + t.Fatal("expected Bundle injectAppModels error") + } +} + +func TestBundleInjectAppModels_PropagatesError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + builder, _, db := newInjectTestBuilder(t, mod, nil) + if err := meta.DropRawModelTable(db); err != nil { + t.Fatal(err) + } + if err := builder.BundleInjectAppModels([]*meta.Module{mod}); err == nil { + t.Fatal("expected BundleInjectAppModels load error") + } +} + +func TestBuildWithoutPersist_UpdateAndValidateRelease(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + + // Validate error after inject: circular Partner inheritance. + p1 := "/virtual/modules/partner/service/models/p1.ts" + p2 := "/virtual/modules/partner/service/models/p2.ts" + builder, _, _ := newInjectTestBuilder(t, mod, []*parser.ParserResult{ + {Path: p1, Model: &meta.Model{Name: "Partner", Path: p1, Extends: p2}}, + {Path: p2, Model: &meta.Model{Name: "Partner", Path: p2, Extends: p1}}, + }) + if _, err := builder.BuildWithoutPersist(); err == nil { + t.Fatal("expected validate error with schedule release") + } + + // updatePrebuildResult error inside BuildWithoutPersist: getNewExtends returns a tip, + // then refreshModelExtendsProperty fails via fixedParser. + oldPath := "/virtual/modules/partner/service/models/partner_old.ts" + tipPath := "/virtual/modules/partner/service/models/partner_tip.ts" + curPath := "/virtual/modules/partner/service/models/partner.ts" + builder2, _, db := newInjectTestBuilder(t, mod, []*parser.ParserResult{{ + Path: curPath, + Content: "export default class Partner extends Base {}", + 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}}, + Name: "Partner", + Path: seed.path, + Application: "partner", + }); err != nil { + t.Fatal(err) + } + } + // Tip ordering: prefer tipPath by bumping timestamps via PreferDeclarationTip. + if err := meta.PreferDeclarationTip(db, "partner", "Partner", tipPath); err != nil { + t.Fatal(err) + } + builder2.tsParser = fixedParser{err: sql.ErrConnDone} + if _, err := builder2.BuildWithoutPersist(); err == nil { + t.Fatal("expected updatePrebuildResult error with schedule release") + } +} + +func TestInjectAppModels_LifecycleReleasesSchedule(t *testing.T) { + reg := injectappmodel.NewRegistryWithDefaults() + dsn := filepath.Join(t.TempDir(), "inject-lifecycle.sqlite") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := meta.EnsureDualStoreTables(db); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&meta.Application{}, &meta.Module{}); err != nil { + t.Fatal(err) + } + + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + buildPlugin := &stubEsbPlugin{name: "build"} + prebuildPlugin := &stubEsbPlugin{name: "prebuild"} + testScope := newBuilderTestScope() + testScope.session = &scope.Session{DB: db} + builder := &ModuleBuilder{ + runtimeScope: testScope, + module: mod, + buildPlugin: buildPlugin, + prebuildPlugin: prebuildPlugin, + entryPoint: "", + injectRegistry: reg, + } + + if err := builder.injectAppModels(nil); err != nil { + t.Fatalf("injectAppModels: %v", err) + } + sess := builder.ensureInjectSession() + plan := sess.Plan("FieldDefault") + if !plan.NeedInject || plan.ScheduledApp != "partner" { + t.Fatalf("expected NeedInject plan, got %+v", plan) + } + wantPath := filepath.ToSlash(filepath.Join(mod.Path, "service/models/__generated__/field_default.ts")) + if sess.LastInjectPath("FieldDefault") != wantPath { + t.Fatalf("inject path = %q want %q", sess.LastInjectPath("FieldDefault"), wantPath) + } + if _, ok := buildPlugin.virtualSources[wantPath]; !ok { + t.Fatal("expected virtual source on build plugin") + } + + builder.releaseInjectSchedules() + if plan := sess.Plan("FieldDefault"); plan.ScheduledApp != "" { + t.Fatalf("expected schedule cleared, got %+v", plan) + } + if _, ok := reg.ClaimOwner("FieldDefault", "partner"); ok { + t.Fatal("expected process claim cleared") + } +} diff --git a/internal/module/artifact/build/injectappmodel/bundle.go b/internal/module/artifact/build/injectappmodel/bundle.go new file mode 100644 index 00000000..d35ab1e4 --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/bundle.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import ( + "path/filepath" + "strings" + + "github.com/choysum-dev/choysum/pkg/meta" + xfmt "golang.org/x/exp/errors/fmt" +) + +// BundleInjectAppModels materializes C2 inject sources for each distinct non-core +// application represented by modules. The caller applies returned Effects. +func BundleInjectAppModels(sess *Session, modules []*meta.Module) (Effects, error) { + var out Effects + if sess == nil { + return out, nil + } + for _, spec := range sess.Registry().specsList() { + fx, err := BundleOne(sess, spec.ModelName, modules) + if err != nil { + sess.ClearAllInjectPaths() + return out, err + } + out.Files = append(out.Files, fx.Files...) + out.Imports = mergeUniqueStrings(out.Imports, fx.Imports) + } + return out, nil +} + +// BundleOne materializes inject sources for one Spec. +func BundleOne(sess *Session, modelName string, modules []*meta.Module) (Effects, error) { + var out Effects + if sess == nil { + return out, nil + } + spec, ok := sess.Registry().lookupPtr(modelName) + if !ok { + return out, nil + } + return bundleSpec(sess, spec, modules) +} + +func bundleSpec(sess *Session, spec *Spec, modules []*meta.Module) (Effects, error) { + var out Effects + modulesPath := strings.TrimSpace(sess.ctx.ModulesPath) + seenApp := make(map[string]struct{}) + for _, mod := range modules { + if mod == nil { + continue + } + app := strings.TrimSpace(mod.ApplicationStr) + if app == "" || app == "core" || strings.TrimSpace(mod.ServiceEntryPoint) == "" { + continue + } + if _, ok := seenApp[app]; ok { + continue + } + if strings.TrimSpace(mod.Path) == "" { + continue + } + seenApp[app] = struct{}{} + + existing, err := dbLoadModels(spec, sess.ctx.DB, app) + if err != nil { + return out, xfmt.Errorf("load %s models for application %q: %w", spec.ModelName, app, err) + } + if len(handwrittenModels(spec, existing)) > 0 { + continue + } + + path := generatedPath(spec, mod.Path) + if virt := generatedModels(spec, existing); len(virt) > 0 { + if p := strings.TrimSpace(virt[0].Path); p != "" { + path = filepath.ToSlash(filepath.Clean(p)) + } + } + sess.rememberInjectPath(spec.ModelName, path) + mp := modulesPath + if mp == "" { + mp = filepath.Dir(mod.Path) + } + source := generatedSource(spec, mp, app) + out.Files = append(out.Files, VirtualFile{Path: path, Contents: source}) + out.Imports = append(out.Imports, path) + } + return out, nil +} diff --git a/internal/module/artifact/build/injectappmodel/compat.go b/internal/module/artifact/build/injectappmodel/compat.go new file mode 100644 index 00000000..37bee8a7 --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/compat.go @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import "strings" + +// ReleaseSchedule clears one NeedInject claim for modelName/app on DefaultRegistry. +func ReleaseSchedule(modelName, app string) { + app = strings.TrimSpace(app) + if app == "" { + return + } + DefaultRegistry().ReleaseClaim(modelName, app) +} diff --git a/internal/module/artifact/build/injectappmodel/context.go b/internal/module/artifact/build/injectappmodel/context.go new file mode 100644 index 00000000..8d37cd42 --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/context.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import ( + "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/gorm" +) + +// BuildCtx is the read-only build context for Decide / Materialize / Supersede. +// Callers apply returned Effects (virtual sources, entry imports) themselves. +type BuildCtx struct { + Module *meta.Module + DB *gorm.DB // may be nil + ModulesPath string +} diff --git a/internal/module/artifact/build/injectappmodel/coverage_test.go b/internal/module/artifact/build/injectappmodel/coverage_test.go new file mode 100644 index 00000000..9f7a4c62 --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/coverage_test.go @@ -0,0 +1,757 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import ( + "database/sql" + "strings" + "testing" + + "github.com/choysum-dev/choysum/internal/parser" + "github.com/choysum-dev/choysum/pkg/meta" +) + +func TestNilSessionGuards(t *testing.T) { + if _, err := InjectAppModels(nil, nil); err != nil { + t.Fatal(err) + } + if _, err := DecideAndInjectOne(nil, "FieldDefault", nil); err != nil { + t.Fatal(err) + } + if _, err := DecideOne(nil, "FieldDefault", nil); err != nil { + t.Fatal(err) + } + if _, err := ApplyInjectOne(nil, "FieldDefault"); err != nil { + t.Fatal(err) + } + if err := ValidateInjectAppModels(nil, nil); err != nil { + t.Fatal(err) + } + if _, err := BundleInjectAppModels(nil, nil); err != nil { + t.Fatal(err) + } + if _, err := BundleOne(nil, "FieldDefault", nil); err != nil { + t.Fatal(err) + } + if err := SupersedeInjectAppModels(nil); err != nil { + t.Fatal(err) + } + if err := SupersedeOne(nil, "FieldDefault"); err != nil { + t.Fatal(err) + } + ReleaseSchedule("FieldDefault", "") + ReleaseSchedule("FieldDefault", "partner") + ReleaseSchedule("Missing", "partner") + + var nilSess *Session + if nilSess.Context() != nil { + t.Fatal("nil Context") + } + nilSess.ReleaseSchedules() + nilSess.SetPlan("x", Plan{}) + if p := nilSess.Plan("x"); p.NeedInject { + t.Fatal("nil Plan should be zero") + } + if nilSess.InjectPaths("x") != nil { + t.Fatal("nil InjectPaths") + } + if nilSess.LastInjectPath("x") != "" { + t.Fatal("nil LastInjectPath") + } + nilSess.ClearInjectPaths("x") + nilSess.ClearAllInjectPaths() + if nilSess.AllInjectPaths() != nil { + t.Fatal("nil AllInjectPaths") + } + if nilSess.Registry() != nil { + t.Fatal("nil Registry") + } + nilSess.rememberInjectPath("x", "p") + nilSess.releaseScheduleFor(nil) + nilSess.releaseScheduleFor(specByNameOrPanic("FieldDefault")) + + // NewSession with nil reg uses DefaultRegistry. + s := NewSession(BuildCtx{}, nil) + if s.Registry() != DefaultRegistry() { + t.Fatal("expected DefaultRegistry") + } +} + +func TestDecideOneAndApplyInjectOne(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + plan, err := DecideOne(sess, "FieldDefault", nil) + if err != nil { + t.Fatal(err) + } + if !plan.NeedInject { + t.Fatalf("expected NeedInject, got %+v", plan) + } + if _, err := DecideOne(sess, "NoSuch", nil); err == nil { + t.Fatal("expected unknown Spec error") + } + if _, err := ApplyInjectOne(sess, "NoSuch"); err == nil { + t.Fatal("expected unknown Spec error") + } + if _, err := ApplyInjectOne(sess, "FieldDefault"); err != nil { + t.Fatal(err) + } + if sess.LastInjectPath("FieldDefault") == "" { + t.Fatal("expected path after ApplyInjectOne") + } +} + +func TestDecideAndInjectOne_UnknownAndErrors(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + if _, err := DecideAndInjectOne(sess, "NoSuch", nil); err == nil { + t.Fatal("expected unknown Spec") + } + + a := "/virtual/modules/partner/service/models/fd_a.ts" + b := "/virtual/modules/partner/service/models/fd_b.ts" + if _, err := DecideAndInjectOne(sess, "FieldDefault", []*parser.ParserResult{ + {Path: a, Model: &meta.Model{Name: "FieldDefault", Path: a}}, + {Path: b, Model: &meta.Model{Name: "FieldDefault", Path: b}}, + }); err == nil || !strings.Contains(err.Error(), "FIELD_DEFAULT_DUPLICATE") { + t.Fatalf("expected duplicate handwritten error, got %v", err) + } +} + +func TestDecidePlan_Branches(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + + if plan, err := decidePlan(nil, sess, nil); err != nil || plan.NeedInject { + t.Fatalf("nil spec: %+v %v", plan, err) + } + spec := specByNameOrPanic("FieldDefault") + if plan, err := decidePlan(spec, nil, nil); err != nil || plan.NeedInject { + t.Fatalf("nil sess: %+v %v", plan, err) + } + sess.Context().Module = nil + if plan, err := decidePlan(spec, sess, nil); err != nil || plan.NeedInject { + t.Fatalf("nil module: %+v %v", plan, err) + } + sess.Context().Module = &meta.Module{Name: "x", Path: "/p", ApplicationStr: "partner"} // empty ServiceEntryPoint + if plan, err := decidePlan(spec, sess, nil); err != nil || plan.NeedInject { + t.Fatalf("empty entry: %+v %v", plan, err) + } + sess.Context().Module = &meta.Module{Name: "x", Path: "/p", ApplicationStr: "", ServiceEntryPoint: "s"} + if plan, err := decidePlan(spec, sess, nil); err != nil || plan.NeedInject { + t.Fatalf("empty app: %+v %v", plan, err) + } + + sess.Context().Module = mod + otherHand := "/virtual/modules/other/service/models/field_default.ts" + seedDeclaration(t, db, "FieldDefault", "other-hand", otherHand, "partner") + localHand := "/virtual/modules/partner/service/models/field_default.ts" + if plan, err := decidePlan(spec, sess, []*parser.ParserResult{ + {Path: localHand, Model: &meta.Model{Name: "FieldDefault", Path: localHand}}, + }); err == nil || !strings.Contains(err.Error(), "FIELD_DEFAULT_DUPLICATE") { + t.Fatalf("expected handwritten conflict, got plan=%+v err=%v", plan, err) + } + + // Local non-handwritten model present → skip inject. + sess.Registry().ResetClaims() + localVirt := "/virtual/modules/partner/service/models/__generated__/field_default.ts" + if plan, err := decidePlan(spec, sess, []*parser.ParserResult{ + {Path: localVirt, Model: &meta.Model{Name: "FieldDefault", Path: localVirt}}, + }); err != nil || plan.NeedInject { + t.Fatalf("local generated should skip NeedInject, got %+v %v", plan, err) + } + + // Existing virt owned by another module → skip. + sess.Registry().ResetClaims() + _ = meta.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 { + t.Fatalf("other-module virt should skip, got %+v %v", plan, err) + } + + // DB load error. + sess.Registry().ResetClaims() + if err := meta.DropRawModelTable(db); err != nil { + t.Fatal(err) + } + if _, err := decidePlan(spec, sess, nil); err == nil { + t.Fatal("expected db load error") + } +} + +func TestApplyInject_EmptyModulePathAndModulesPathFallback(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + sess.SetPlan("FieldDefault", Plan{NeedInject: true}) + if _, err := materializeInject(sess, specByNameOrPanic("FieldDefault"), Plan{NeedInject: true}); err == nil { + t.Fatal("expected empty path error") + } + + mod.Path = "/virtual/modules/partner" + sess.Context().ModulesPath = "" + fx, err := materializeInject(sess, specByNameOrPanic("FieldDefault"), Plan{NeedInject: true}) + if err != nil { + t.Fatal(err) + } + src := effectsFileMap(fx)[generatedPath(specByNameOrPanic("FieldDefault"), mod.Path)] + if src == "" { + t.Fatal("expected virtual source with modulesPath fallback") + } +} + +func TestRegistryNilAndLookup(t *testing.T) { + var nilReg *Registry + if nilReg.Specs() != nil { + t.Fatal("nil Specs") + } + if _, ok := nilReg.Lookup("x"); ok { + t.Fatal("nil Lookup") + } + if _, ok := nilReg.lookupPtr("x"); ok { + t.Fatal("nil lookupPtr") + } + if nilReg.specsList() != nil { + t.Fatal("nil specsList") + } + if owner, loaded := nilReg.TryClaim("x", "a", "m"); loaded || owner != "" { + t.Fatal("nil TryClaim") + } + nilReg.ReleaseClaim("x", "a") + if _, ok := nilReg.ClaimOwner("x", "a"); ok { + t.Fatal("nil ClaimOwner") + } + nilReg.ResetClaims() + + reg := NewRegistryWithDefaults() + if _, ok := reg.Lookup("NoSuch"); ok { + t.Fatal("missing Lookup") + } + spec, ok := reg.Lookup("FieldDefault") + if !ok || spec.ModelName != "FieldDefault" { + t.Fatalf("Lookup: %+v ok=%v", spec, ok) + } + reg.ReleaseClaim("FieldDefault", "") // empty app no-op + if specs := DefaultSpecs(); len(specs) != 2 { + t.Fatalf("DefaultSpecs=%d", len(specs)) + } + + // claimMapLocked: scheduled map nil, and per-model map absent. + empty := &Registry{} + if owner, loaded := empty.TryClaim("X", "app", "mod"); loaded || owner != "mod" { + t.Fatalf("empty registry TryClaim: owner=%q loaded=%v", owner, loaded) + } + if _, ok := empty.ClaimOwner("MissingModel", "app"); ok { + t.Fatal("ClaimOwner missing map") + } + if _, ok := NewRegistry().ClaimOwner("NoSuch", "app"); ok { + t.Fatal("ClaimOwner absent model map") + } + // claimMap for unknown model (ScheduledApps path helper). + m := NewRegistry().claimMap("BrandNew") + if m == nil { + t.Fatal("claimMap should allocate") + } +} + +func TestPackageSpecsList(t *testing.T) { + list := specsList() + if len(list) != 2 || list[0].ModelName != "FieldDefault" { + t.Fatalf("package specsList: %#v", list) + } +} + +func TestClaimNeedInject_RegistryClaims(t *testing.T) { + reg := NewRegistry() + reg.Register(Spec{ModelName: "Temp", ForeignClaimOnOwnerReinject: true}) + spec, _ := reg.lookupPtr("Temp") + plan := claimNeedInject(reg, spec, "app", "mod") + if !plan.NeedInject || plan.ScheduledApp != "app" { + t.Fatalf("expected claim, got %+v", plan) + } + plan2 := claimNeedInject(reg, spec, "app", "other") + if !plan2.NeedInject || plan2.ScheduledApp != "" { + t.Fatalf("foreign claim branch: %+v", plan2) + } + + reg2 := NewRegistry() + reg2.Register(Spec{ModelName: "T2"}) + spec2, _ := reg2.lookupPtr("T2") + plan3 := claimFirstNeedInject(reg2, spec2, "a", "m") + if !plan3.NeedInject || plan3.ScheduledApp != "a" { + t.Fatalf("claimFirst: %+v", plan3) + } + plan4 := claimFirstNeedInject(reg2, spec2, "a", "other") + if plan4.NeedInject { + t.Fatalf("claimFirst other owner should skip: %+v", plan4) + } +} + +func TestValidateInjectAppModels_Guards(t *testing.T) { + sess := &Session{} // empty BuildCtx + if err := ValidateInjectAppModels(sess, nil); err != nil { + t.Fatal(err) + } + mod := &meta.Module{Name: "p", Path: "/p", ApplicationStr: ""} + sess, _ = newTestSession(t, mod) + if err := ValidateInjectAppModels(sess, nil); err != nil { + t.Fatal(err) + } + mod.ApplicationStr = "core" + if err := ValidateInjectAppModels(sess, nil); err != nil { + t.Fatal(err) + } + mod.ApplicationStr = "partner" + if err := ValidateInjectAppModels(sess, nil); err != nil { + t.Fatal(err) + } + single := "/virtual/modules/partner/service/models/fd.ts" + if err := ValidateInjectAppModels(sess, []*parser.ParserResult{ + {Path: single, Model: &meta.Model{Name: "FieldDefault", Path: single}}, + }); err != nil { + t.Fatal(err) + } +} + +func TestSessionMapsAndPaths(t *testing.T) { + reg := NewRegistryWithDefaults() + s := NewSession(BuildCtx{}, reg) + s.SetPlan("FieldDefault", Plan{NeedInject: true, ScheduledApp: "partner"}) + if !s.Plan("FieldDefault").NeedInject { + t.Fatal("SetPlan lazy maps") + } + s.rememberInjectPath("FieldDefault", "") + s.rememberInjectPath("FieldDefault", " /a ") + s.rememberInjectPath("FieldDefault", "/a") // dedup + s.rememberInjectPath("FieldDefault", "/b") + paths := s.InjectPaths("FieldDefault") + if len(paths) != 2 || paths[0] != "/a" || paths[1] != "/b" { + t.Fatalf("paths=%#v", paths) + } + if s.InjectPaths("missing") != nil { + t.Fatal("empty paths") + } + if s.LastInjectPath("FieldDefault") != "/b" { + t.Fatal(s.LastInjectPath("FieldDefault")) + } + all := s.AllInjectPaths() + if len(all) < 2 { + t.Fatalf("AllInjectPaths=%#v", all) + } + if len(s.allInjectPaths()) != len(all) { + t.Fatal("allInjectPaths wrapper") + } + s.ClearInjectPaths("FieldDefault") + if s.InjectPaths("FieldDefault") != nil || s.LastInjectPath("FieldDefault") != "" { + t.Fatal("ClearInjectPaths") + } + + reg.TryClaim("FieldDefault", "partner", "mod") + spec, _ := reg.lookupPtr("FieldDefault") + s.SetPlan("FieldDefault", Plan{ScheduledApp: "partner"}) + s.releaseScheduleFor(spec) + if _, ok := reg.ClaimOwner("FieldDefault", "partner"); ok { + t.Fatal("releaseScheduleFor should clear") + } + if s.Plan("FieldDefault").ScheduledApp != "" { + t.Fatal("plan scheduled cleared") + } + s.releaseScheduleFor(spec) // empty ScheduledApp no-op + + s.ReleaseSchedules() // empty plans / no apps + s.SetPlan("FieldDefault", Plan{ScheduledApp: " "}) + s.ReleaseSchedules() // whitespace app skipped + + // Lazy map init on zero Session (NewSession pre-allocates maps). + bare := &Session{reg: reg} + bare.SetPlan("FieldDefault", Plan{NeedInject: true}) + bare.rememberInjectPath("FieldDefault", "/lazy") + if bare.LastInjectPath("FieldDefault") != "/lazy" || len(bare.InjectPaths("FieldDefault")) != 1 { + t.Fatalf("bare session lazy maps: %+v paths=%#v", bare.Plan("FieldDefault"), bare.InjectPaths("FieldDefault")) + } +} + +func TestReleaseScheduleCompat(t *testing.T) { + DefaultRegistry().ResetClaims() + t.Cleanup(DefaultRegistry().ResetClaims) + DefaultRegistry().TryClaim("FieldDefault", "partner", "mod") + ReleaseSchedule("FieldDefault", "partner") + if _, ok := DefaultRegistry().ClaimOwner("FieldDefault", "partner"); ok { + t.Fatal("expected cleared") + } +} + +func TestRegistryAndDeprecatedHelpers(t *testing.T) { + DefaultRegistry().ResetClaims() + t.Cleanup(DefaultRegistry().ResetClaims) + + m := ScheduledApps("FieldDefault") + if m == nil { + t.Fatal("ScheduledApps") + } + func() { + defer func() { + if recover() == nil { + t.Fatal("expected panic for unknown model") + } + }() + _ = ScheduledApps("NoSuchModel") + }() + + func() { + defer func() { + if recover() == nil { + t.Fatal("expected duplicate Register panic") + } + }() + Register(Spec{ModelName: "FieldDefault"}) + }() + + func() { + defer func() { + if recover() == nil { + t.Fatal("expected nil Registry Register panic") + } + }() + (*Registry)(nil).Register(Spec{ModelName: "x"}) + }() + + reg := NewRegistryWithDefaults() + reg.TryClaim("FieldDefault", "partner", "mod") + reg.ResetClaims() + if _, ok := reg.ClaimOwner("FieldDefault", "partner"); ok { + t.Fatal("ResetClaims should clear") + } + ResetScheduledForTest() // deprecated package helper still works +} + +func TestHelpersCoverage(t *testing.T) { + if isGeneratedPath(nil, "x") || isGeneratedPath(specByNameOrPanic("FieldDefault"), "") { + t.Fatal("isGeneratedPath guards") + } + if isGeneratedPath(specByNameOrPanic("FieldDefault"), "service/models/__generated__/field_default.ts") { + // exact rel match without leading slash — GeneratedRelPath == normalized + } + spec := specByNameOrPanic("FieldDefault") + if !isGeneratedPath(spec, spec.GeneratedRelPath) { + t.Fatal("exact rel equality") + } + _ = modelsIn(nil, nil, "") + _ = modelsIn(spec, []*parser.ParserResult{nil, {Model: nil}, {Path: "/x", Model: &meta.Model{Name: "Other", Path: "/x"}}}, "/mod") + if sameModule(nil, nil) || sameModule([]*meta.Model{{Path: "/a"}}, nil) { + t.Fatal("sameModule nil mod") + } + mod := &meta.Module{Path: "/virtual/modules/partner"} + mod.Id = sql.NullString{String: "mid", Valid: true} + if !sameModule([]*meta.Model{{ModuleId: sql.NullString{String: "mid", Valid: true}}}, mod) { + t.Fatal("sameModule by id") + } + if !sameModule([]*meta.Model{{Path: "/virtual/modules/partner/service/models/x.ts"}}, mod) { + t.Fatal("sameModule by path") + } + if sameModule([]*meta.Model{nil, {Path: "/other/x.ts"}}, mod) { + t.Fatal("sameModule miss") + } + if models, err := dbLoadModels(nil, nil, ""); err != nil || models != nil { + t.Fatal(err, models) + } + out := mergeUniqueStrings([]string{"a", " ", "a"}, []string{"b", ""}, []string{"a", "c"}) + if strings.Join(out, ",") != "a,b,c" { + t.Fatalf("merge=%#v", out) + } +} + +func TestEffects_Merge(t *testing.T) { + a := Effects{ + Files: []VirtualFile{{Path: "/a.ts", Contents: "a"}}, + Imports: []string{"/a.ts", " /x "}, + } + b := Effects{ + Files: []VirtualFile{{Path: "/b.ts", Contents: "b"}}, + Imports: []string{"/a.ts", "/b.ts"}, + } + merged := a.Merge(b) + if len(merged.Files) != 2 || merged.Files[0].Path != "/a.ts" || merged.Files[1].Path != "/b.ts" { + t.Fatalf("Files=%#v", merged.Files) + } + if strings.Join(merged.Imports, ",") != "/a.ts,/x,/b.ts" { + t.Fatalf("Imports=%#v", merged.Imports) + } +} + +func TestGeneratedSource_EmptyApplication(t *testing.T) { + src := generatedSource(specByNameOrPanic("FieldDefault"), "/mods", " ") + if !strings.Contains(src, strconvQuoteFallback()) { + // ensure default application literal used + } + if !strings.Contains(src, "'application'") && !strings.Contains(src, `"application"`) { + t.Fatalf("expected default application in source:\n%s", src) + } +} + +func strconvQuoteFallback() string { return "application" } + +func TestBundleInject_GuardsAndErrors(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + if _, err := BundleOne(sess, "NoSuch", []*meta.Module{mod}); err != nil { + t.Fatal(err) + } + sess2 := &Session{} + if _, err := BundleInjectAppModels(sess2, nil); err != nil { + t.Fatal(err) + } + + sess.Context().ModulesPath = "" + core := &meta.Module{Name: "core", Path: "/virtual/modules/core", ApplicationStr: "core", ServiceEntryPoint: "s"} + emptyApp := &meta.Module{Name: "e", Path: "/p", ApplicationStr: "", ServiceEntryPoint: "s"} + noEntry := &meta.Module{Name: "n", Path: "/p", ApplicationStr: "base", ServiceEntryPoint: ""} + noPath := &meta.Module{Name: "np", Path: " ", ApplicationStr: "base", ServiceEntryPoint: "s"} + dup := &meta.Module{Name: "partner2", Path: "/virtual/modules/partner2", ApplicationStr: "partner", ServiceEntryPoint: "s"} + if _, err := BundleInjectAppModels(sess, []*meta.Module{nil, core, emptyApp, noEntry, noPath, mod, dup}); err != nil { + t.Fatal(err) + } + if sess.Context().ModulesPath != "" { + t.Fatal("modulesPath stayed empty; fallback should still register") + } + if len(sess.AllInjectPaths()) == 0 { + t.Fatal("expected inject paths for partner") + } + + if err := meta.DropRawModelTable(db); err != nil { + t.Fatal(err) + } + sess3, _ := newTestSession(t, mod) + // reopen dropped on different db — use same dropped db + sess3.Context().DB = db + if _, err := BundleInjectAppModels(sess3, []*meta.Module{mod}); err == nil { + t.Fatal("expected load error") + } +} + +func TestSupersede_GuardsAndErrors(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + if err := SupersedeInjectAppModels(&Session{}); err != nil { + t.Fatal(err) + } + if err := SupersedeOne(sess, "NoSuch"); err != nil { + t.Fatal(err) + } + sess.SetPlan("AppSetting", Plan{}) + if err := SupersedeOne(sess, "AppSetting"); err != nil { + t.Fatal(err) + } + + sess.SetPlan("AppSetting", Plan{SupersedeInject: true}) + sess.Context().DB = nil + if err := SupersedeOne(sess, "AppSetting"); err != nil { + t.Fatal(err) + } + sess.Context().DB = db + sess.Context().Module = nil + if err := SupersedeOne(sess, "AppSetting"); err != nil { + t.Fatal(err) + } + sess.Context().Module = &meta.Module{ApplicationStr: " "} + if err := SupersedeOne(sess, "AppSetting"); err != nil { + t.Fatal(err) + } + sess.Context().Module = mod + + // blank id skipped + virt := "/virtual/modules/partner/service/models/__generated__/app_setting.ts" + // bypass PersistModelTreeAsRaw id normalization via raw table insert (whitespace id). + if err := db.Exec( + `INSERT INTO meta_raw_model (id, created_at, updated_at, name, path, application, abstract, auto_migrate, readonly) VALUES (?,?,?,?,?,?,0,1,0)`, + " ", "2020-01-01", "2020-01-01", "AppSetting", virt, "partner", + ).Error; err != nil { + t.Fatal(err) + } + sess.SetPlan("AppSetting", Plan{SupersedeInject: true}) + if err := SupersedeOne(sess, "AppSetting"); err != nil { + t.Fatal(err) + } + + if err := meta.DropRawModelTable(db); err != nil { + t.Fatal(err) + } + if err := supersedeGenerated(specByNameOrPanic("AppSetting"), db, "partner"); err == nil { + t.Fatal("expected list error after drop") + } +} + +func TestSupersede_DeleteError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + virt := "/virtual/modules/partner/service/models/__generated__/app_setting.ts" + seedDeclaration(t, db, "AppSetting", "del-me", virt, "partner") + sess.SetPlan("AppSetting", Plan{SupersedeInject: true}) + // Close DB to force delete failure after list — list may also fail. + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + _ = sqlDB.Close() + if err := SupersedeOne(sess, "AppSetting"); err == nil { + t.Fatal("expected supersede error on closed db") + } +} + +func TestDecideOne_DecidePlanError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + if err := meta.DropRawModelTable(db); err != nil { + t.Fatal(err) + } + if _, err := DecideOne(sess, "FieldDefault", nil); err == nil { + t.Fatal("expected DecideOne load error") + } +} + +func TestDecidePlan_LocalHandwrittenOnlyAndLocalGeneratedOnly(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + spec := specByNameOrPanic("FieldDefault") + hand := "/virtual/modules/partner/service/models/field_default.ts" + plan, err := decidePlan(spec, sess, []*parser.ParserResult{ + {Path: hand, Model: &meta.Model{Name: "FieldDefault", Path: hand}}, + }) + if err != nil || plan.NeedInject || plan.SupersedeInject { + t.Fatalf("local handwritten only: %+v %v", plan, err) + } + + virt := "/virtual/modules/partner/service/models/__generated__/field_default.ts" + plan2, err := decidePlan(spec, sess, []*parser.ParserResult{ + {Path: virt, Model: &meta.Model{Name: "FieldDefault", Path: virt}}, + }) + if err != nil || plan2.NeedInject { + t.Fatalf("local generated only: %+v %v", plan2, err) + } +} + +func TestApplyInject_NilModule(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + sess.Context().Module = nil + if _, err := materializeInject(sess, specByNameOrPanic("FieldDefault"), Plan{NeedInject: true}); err != nil { + t.Fatal(err) + } +} + +func TestValidateInjectAppModels_NilModule(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + sess.Context().Module = nil + if err := ValidateInjectAppModels(sess, nil); err != nil { + t.Fatal(err) + } +} + +func TestSupersedeInjectAppModels_PropagatesError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + sess.SetPlan("FieldDefault", Plan{SupersedeInject: true}) + sess.SetPlan("AppSetting", Plan{SupersedeInject: true}) + if err := meta.DropRawModelTable(db); err != nil { + t.Fatal(err) + } + if err := SupersedeInjectAppModels(sess); err == nil { + t.Fatal("expected supersede propagation error") + } +} + +func TestSupersedeGenerated_DeleteError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + _, 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 { + t.Fatal(err) + } + if err := supersedeGenerated(specByNameOrPanic("AppSetting"), db, "partner"); err == nil { + t.Fatal("expected delete error after dropping service table") + } +} + +func TestInjectAppModels_PropagatesDecideError(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + a := "/virtual/modules/partner/service/models/a.ts" + b := "/virtual/modules/partner/service/models/b.ts" + if _, err := InjectAppModels(sess, []*parser.ParserResult{ + {Path: a, Model: &meta.Model{Name: "FieldDefault", Path: a}}, + {Path: b, Model: &meta.Model{Name: "FieldDefault", Path: b}}, + }); err == nil { + t.Fatal("expected error") + } +} + +func TestDecideAndInjectOne_ApplyInjectErrorReleases(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + sess.Context().Module.Path = "" + if _, err := DecideAndInjectOne(sess, "FieldDefault", nil); err == nil { + t.Fatal("expected inject path error") + } +} + +func TestBundleOne_SuccessPath(t *testing.T) { + mod := &meta.Module{ + Name: "base", Path: "/virtual/modules/base", + ApplicationStr: "base", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + if _, err := BundleOne(sess, "FieldDefault", []*meta.Module{mod}); err != nil { + t.Fatal(err) + } + if sess.LastInjectPath("FieldDefault") == "" { + t.Fatal("expected path") + } +} diff --git a/internal/module/artifact/build/injectappmodel/doc.go b/internal/module/artifact/build/injectappmodel/doc.go new file mode 100644 index 00000000..672078df --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/doc.go @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +// Package injectappmodel unifies C2 inject for FieldDefault and AppSetting (and +// future app-scoped models) behind a Registry of Specs with Decide + Materialize, +// Supersede, and multi-app Bundle entry points. Sessions bind a read-only +// BuildCtx to a Registry and return Effects for the caller to apply. +package injectappmodel diff --git a/internal/module/artifact/build/injectappmodel/effects.go b/internal/module/artifact/build/injectappmodel/effects.go new file mode 100644 index 00000000..9bab0b5d --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/effects.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +// VirtualFile is a generated source the caller should register on the build plugin. +type VirtualFile struct { + Path string + Contents string +} + +// Effects are pure inject/bundle outputs. The caller applies them to the build +// pipeline (RegisterVirtualSource, SetEntryPointImports). +type Effects struct { + Files []VirtualFile + Imports []string // inject paths to merge into entry-point imports +} + +// Merge appends other's files and uniquely merges import paths. +func (e Effects) Merge(other Effects) Effects { + out := Effects{ + Files: append(append([]VirtualFile(nil), e.Files...), other.Files...), + Imports: mergeUniqueStrings(e.Imports, other.Imports), + } + return out +} diff --git a/internal/module/artifact/build/injectappmodel/helpers.go b/internal/module/artifact/build/injectappmodel/helpers.go new file mode 100644 index 00000000..a9480754 --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/helpers.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import ( + "path/filepath" + "strings" + + "github.com/choysum-dev/choysum/internal/esbplugins" + "github.com/choysum-dev/choysum/internal/parser" + "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/gorm" +) + +func pathWithinModuleRoot(path, root string) bool { + return esbplugins.PathWithinRoot(path, root) +} + +func isGeneratedPath(spec *Spec, path string) bool { + trimmed := strings.TrimSpace(path) + if trimmed == "" || spec == nil { + return false + } + normalized := filepath.ToSlash(filepath.Clean(trimmed)) + rel := spec.GeneratedRelPath + return strings.HasSuffix(normalized, "/"+rel) || normalized == rel +} + +func generatedPath(spec *Spec, modulePath string) string { + return filepath.ToSlash(filepath.Clean(filepath.Join(strings.TrimSpace(modulePath), spec.GeneratedRelPath))) +} + +func modelsIn(spec *Spec, results []*parser.ParserResult, modulePath string) []*meta.Model { + out := make([]*meta.Model, 0) + for _, result := range results { + if result == nil || result.Model == nil || spec == nil { + continue + } + m := result.Model + if m.Name != spec.ModelName || m.Abstract { + continue + } + if modulePath != "" && !pathWithinModuleRoot(result.Path, modulePath) && !pathWithinModuleRoot(m.Path, modulePath) { + continue + } + out = append(out, m) + } + return out +} + +func handwrittenModels(spec *Spec, models []*meta.Model) []*meta.Model { + out := make([]*meta.Model, 0, len(models)) + for _, m := range models { + if m == nil || isGeneratedPath(spec, m.Path) { + continue + } + out = append(out, m) + } + return out +} + +func generatedModels(spec *Spec, models []*meta.Model) []*meta.Model { + out := make([]*meta.Model, 0, len(models)) + for _, m := range models { + if m == nil || !isGeneratedPath(spec, m.Path) { + continue + } + out = append(out, m) + } + return out +} + +func sameModule(existing []*meta.Model, mod *meta.Module) bool { + if mod == nil { + return false + } + modPath := strings.TrimSpace(mod.Path) + modID := "" + if mod.Id.Valid { + modID = strings.TrimSpace(mod.Id.String) + } + for _, m := range existing { + if m == nil { + continue + } + if modID != "" && m.ModuleId.Valid && strings.TrimSpace(m.ModuleId.String) == modID { + return true + } + if modPath != "" && pathWithinModuleRoot(m.Path, modPath) { + return true + } + } + return false +} + +func dbLoadModels(spec *Spec, db *gorm.DB, app string) ([]*meta.Model, error) { + app = strings.TrimSpace(app) + if app == "" || db == nil || spec == nil { + return nil, nil + } + absFalse := false + return meta.ListDeclarations(db, meta.DeclarationQuery{ + Application: app, + Name: spec.ModelName, + Abstract: &absFalse, + }) +} + +// mergeUniqueStrings copies base and extras into a fresh slice, dropping empties and duplicates. +func mergeUniqueStrings(base []string, extras ...[]string) []string { + seen := make(map[string]struct{}, len(base)) + out := make([]string, 0, len(base)) + appendOne := func(s string) { + s = strings.TrimSpace(s) + if s == "" { + return + } + if _, ok := seen[s]; ok { + return + } + seen[s] = struct{}{} + out = append(out, s) + } + for _, s := range base { + appendOne(s) + } + for _, chunk := range extras { + for _, s := range chunk { + appendOne(s) + } + } + return out +} diff --git a/internal/module/artifact/build/injectappmodel/inject.go b/internal/module/artifact/build/injectappmodel/inject.go new file mode 100644 index 00000000..b7f33e22 --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/inject.go @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import ( + "path/filepath" + "strings" + + "github.com/choysum-dev/choysum/internal/parser" + xfmt "golang.org/x/exp/errors/fmt" +) + +// InjectAppModels runs Decide + Materialize for every Spec and returns Effects +// for the caller to apply (virtual sources / entry imports). +func InjectAppModels(sess *Session, prebuildResults []*parser.ParserResult) (Effects, error) { + var out Effects + if sess == nil { + return out, nil + } + for _, spec := range sess.Registry().specsList() { + fx, err := DecideAndInjectOne(sess, spec.ModelName, prebuildResults) + if err != nil { + // Paths from earlier Specs were not applied; drop them so a reused + // Session/builder cannot feed stale imports into buildOptions. + sess.ClearAllInjectPaths() + return out, err + } + out.Files = append(out.Files, fx.Files...) + out.Imports = mergeUniqueStrings(out.Imports, fx.Imports) + } + return out, nil +} + +// DecideAndInjectOne runs Decide + Materialize for a single Spec. +func DecideAndInjectOne(sess *Session, modelName string, prebuildResults []*parser.ParserResult) (Effects, error) { + var out Effects + if sess == nil { + return out, nil + } + spec, ok := sess.Registry().lookupPtr(modelName) + if !ok { + return out, xfmt.Errorf("injectappmodel: unknown Spec %q", modelName) + } + plan, err := decidePlan(spec, sess, prebuildResults) + if err != nil { + return out, err + } + sess.SetPlan(spec.ModelName, plan) + fx, err := materializeInject(sess, spec, plan) + if err != nil { + sess.releaseScheduleFor(spec) + return out, xfmt.Errorf("error injecting %s: %w", spec.ModelName, err) + } + return fx, nil +} + +// DecideOne runs Decide for one Spec and stores the plan. +func DecideOne(sess *Session, modelName string, prebuildResults []*parser.ParserResult) (Plan, error) { + plan := Plan{} + if sess == nil { + return plan, nil + } + spec, ok := sess.Registry().lookupPtr(modelName) + if !ok { + return plan, xfmt.Errorf("injectappmodel: unknown Spec %q", modelName) + } + plan, err := decidePlan(spec, sess, prebuildResults) + if err != nil { + return plan, err + } + sess.SetPlan(spec.ModelName, plan) + return plan, nil +} + +// ApplyInjectOne materializes NeedInject for one Spec using the stored plan. +func ApplyInjectOne(sess *Session, modelName string) (Effects, error) { + var out Effects + if sess == nil { + return out, nil + } + spec, ok := sess.Registry().lookupPtr(modelName) + if !ok { + return out, xfmt.Errorf("injectappmodel: unknown Spec %q", modelName) + } + return materializeInject(sess, spec, sess.Plan(modelName)) +} + +func decidePlan(spec *Spec, sess *Session, prebuildResults []*parser.ParserResult) (Plan, error) { + plan := Plan{} + if spec == nil || sess == nil { + return plan, nil + } + mod := sess.ctx.Module + if mod == nil { + return plan, nil + } + if strings.TrimSpace(mod.ServiceEntryPoint) == "" || + strings.TrimSpace(mod.ApplicationStr) == "" || + strings.TrimSpace(mod.ApplicationStr) == "core" { + return plan, nil + } + + app := strings.TrimSpace(mod.ApplicationStr) + local := modelsIn(spec, prebuildResults, mod.Path) + localHand := handwrittenModels(spec, local) + if len(localHand) > 1 { + return plan, xfmt.Errorf("%s: application %q has multiple handwritten %s models in module %q", spec.DuplicateCode, app, spec.ModelName, mod.Name) + } + + existing, err := dbLoadModels(spec, sess.ctx.DB, app) + if err != nil { + return plan, xfmt.Errorf("load %s models for application %q: %w", spec.ModelName, app, err) + } + existingVirt := generatedModels(spec, existing) + existingHand := handwrittenModels(spec, existing) + + if len(localHand) > 0 { + if len(existingHand) > 0 && !sameModule(existingHand, mod) { + return plan, xfmt.Errorf("%s: application %q already has a handwritten %s outside module %q", spec.DuplicateCode, app, spec.ModelName, mod.Name) + } + if len(existingVirt) > 0 { + return Plan{SupersedeInject: true}, nil + } + return plan, nil + } + + // No local handwritten model — consider C2 inject. + if len(existingHand) > 0 { + return plan, nil + } + if len(local) > 0 { + return plan, nil + } + if len(existingVirt) > 0 { + if sameModule(existingVirt, mod) { + return claimNeedInject(sess.Registry(), spec, app, mod.Name), nil + } + return plan, nil + } + return claimFirstNeedInject(sess.Registry(), spec, app, mod.Name), nil +} + +func claimNeedInject(reg *Registry, spec *Spec, app, modName string) Plan { + if spec.ForeignClaimOnOwnerReinject { + owner, loaded := reg.TryClaim(spec.ModelName, app, modName) + if loaded && owner != "" && owner != modName { + return Plan{NeedInject: true} + } + return Plan{NeedInject: true, ScheduledApp: app} + } + return Plan{NeedInject: true, ScheduledApp: app} +} + +func claimFirstNeedInject(reg *Registry, spec *Spec, app, modName string) Plan { + owner, loaded := reg.TryClaim(spec.ModelName, app, modName) + if loaded { + if owner == modName { + return Plan{NeedInject: true, ScheduledApp: app} + } + return Plan{} + } + return Plan{NeedInject: true, ScheduledApp: app} +} + +// materializeInject builds Effects for a NeedInject plan (no build side effects). +func materializeInject(sess *Session, spec *Spec, plan Plan) (Effects, error) { + var out Effects + if sess == nil || spec == nil || !plan.NeedInject { + return out, nil + } + mod := sess.ctx.Module + if mod == nil { + return out, nil + } + if strings.TrimSpace(mod.Path) == "" { + return out, xfmt.Errorf("%s inject requires a non-empty module path", spec.ModelName) + } + path := generatedPath(spec, mod.Path) + sess.rememberInjectPath(spec.ModelName, path) + + modulesPath := strings.TrimSpace(sess.ctx.ModulesPath) + if modulesPath == "" { + modulesPath = filepath.Dir(mod.Path) + } + source := generatedSource(spec, modulesPath, mod.ApplicationStr) + out.Files = []VirtualFile{{Path: path, Contents: source}} + out.Imports = []string{path} + return out, nil +} + +// ValidateInjectAppModels checks build output for duplicate inject models per application. +func ValidateInjectAppModels(sess *Session, buildResults []*parser.ParserResult) error { + if sess == nil { + return nil + } + mod := sess.ctx.Module + if mod == nil { + return nil + } + app := strings.TrimSpace(mod.ApplicationStr) + if app == "" || app == "core" { + return nil + } + for _, spec := range sess.Registry().specsList() { + models := modelsIn(spec, buildResults, mod.Path) + if len(models) <= 1 { + continue + } + return xfmt.Errorf("%s: application %q build produced multiple %s models", spec.DuplicateCode, app, spec.ModelName) + } + return nil +} diff --git a/internal/module/artifact/build/injectappmodel/injectappmodel_test.go b/internal/module/artifact/build/injectappmodel/injectappmodel_test.go new file mode 100644 index 00000000..14f7383f --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/injectappmodel_test.go @@ -0,0 +1,429 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/choysum-dev/choysum/internal/parser" + "github.com/choysum-dev/choysum/pkg/meta" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var testDBSeq atomic.Uint64 + +func testMemoryDSN(t *testing.T, prefix string) string { + t.Helper() + name := strings.ReplaceAll(t.Name(), "/", "_") + return fmt.Sprintf("file:%s-%s-%d?mode=memory&cache=shared", prefix, name, testDBSeq.Add(1)) +} + +func newTestSession(t *testing.T, mod *meta.Module) (*Session, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(testMemoryDSN(t, "injectappmodel")), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := meta.EnsureDualStoreTables(db); err != nil { + t.Fatalf("EnsureDualStoreTables: %v", err) + } + reg := NewRegistryWithDefaults() + sess := NewSession(BuildCtx{ + Module: mod, + DB: db, + ModulesPath: "/virtual/modules", + }, reg) + return sess, 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} + if id != "" { + m.Id = sql.NullString{String: id, Valid: true} + } + if err := meta.PersistModelTreeAsRaw(db, m); err != nil { + t.Fatalf("seed %s: %v", name, err) + } +} + +func effectsFileMap(fx Effects) map[string]string { + out := make(map[string]string, len(fx.Files)) + for _, f := range fx.Files { + out[f.Path] = f.Contents + } + return out +} + +func TestSpecs_BuiltinsRegistered(t *testing.T) { + specs := Specs() + if len(specs) != 2 { + t.Fatalf("expected 2 builtins, got %d", len(specs)) + } + if specs[0].ModelName != "FieldDefault" || specs[1].ModelName != "AppSetting" { + t.Fatalf("unexpected order/names: %#v", specs) + } +} + +func TestDecideFieldDefault_NeedInject(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + if _, err := InjectAppModels(sess, nil); err != nil { + t.Fatalf("inject: %v", err) + } + plan := sess.Plan("FieldDefault") + if !plan.NeedInject || plan.SupersedeInject || plan.ScheduledApp != "partner" { + t.Fatalf("expected NeedInject with scheduledApp, got %+v", plan) + } +} + +func TestDecideAppSetting_SupersedeInject(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + hand := "/virtual/modules/partner/service/models/app_setting.ts" + seedDeclaration(t, db, "AppSetting", "as-virt", "/virtual/modules/partner/service/models/__generated__/app_setting.ts", "partner") + if _, err := InjectAppModels(sess, []*parser.ParserResult{ + {Path: hand, Model: &meta.Model{Name: "AppSetting", Path: hand}}, + }); err != nil { + t.Fatalf("inject: %v", err) + } + plan := sess.Plan("AppSetting") + if plan.NeedInject || !plan.SupersedeInject { + t.Fatalf("expected SupersedeInject only, got %+v", plan) + } +} + +func TestDecideAppSetting_ForeignClaimOnOwnerReinject(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + virt := "/virtual/modules/partner/service/models/__generated__/app_setting.ts" + seedDeclaration(t, db, "AppSetting", "as1", virt, "partner") + + sess.Registry().TryClaim("AppSetting", "partner", "other_builder") + + if _, err := InjectAppModels(sess, nil); err != nil { + t.Fatalf("inject: %v", err) + } + plan := sess.Plan("AppSetting") + if !plan.NeedInject || plan.ScheduledApp != "" { + t.Fatalf("expected NeedInject without adopting foreign claim, got %+v", plan) + } + if owner, ok := sess.Registry().ClaimOwner("AppSetting", "partner"); !ok || owner != "other_builder" { + t.Fatalf("foreign claim must remain, got %#v ok=%v", owner, ok) + } +} + +func TestDecideFieldDefault_SameModuleReclaimWithoutForeignBranch(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + virt := "/virtual/modules/partner/service/models/__generated__/field_default.ts" + seedDeclaration(t, db, "FieldDefault", "fd1", virt, "partner") + + sess.Registry().TryClaim("FieldDefault", "partner", "other_builder") + + if _, err := InjectAppModels(sess, nil); err != nil { + t.Fatalf("inject: %v", err) + } + plan := sess.Plan("FieldDefault") + if !plan.NeedInject || plan.ScheduledApp != "partner" { + t.Fatalf("FieldDefault re-injects with scheduledApp even under foreign claim, got %+v", plan) + } +} + +func TestProcessDedup_SameModuleReclaim(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + if _, err := InjectAppModels(sess, nil); err != nil { + t.Fatalf("first inject: %v", err) + } + if _, err := InjectAppModels(sess, nil); err != nil { + t.Fatalf("reclaim inject: %v", err) + } + plan := sess.Plan("FieldDefault") + if !plan.NeedInject || plan.ScheduledApp != "partner" { + t.Fatalf("expected reclaim with scheduledApp, got %+v", plan) + } +} + +func TestReleaseSchedules_ClearsClaim(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + if _, err := InjectAppModels(sess, nil); err != nil { + t.Fatalf("inject: %v", err) + } + if owner, ok := sess.Registry().ClaimOwner("FieldDefault", "partner"); !ok || owner == "" { + t.Fatalf("expected scheduled claim, got owner=%q ok=%v", owner, ok) + } + sess.ReleaseSchedules() + if _, ok := sess.Registry().ClaimOwner("FieldDefault", "partner"); ok { + t.Fatal("expected claim cleared") + } + if plan := sess.Plan("FieldDefault"); plan.ScheduledApp != "" { + t.Fatalf("expected plan scheduledApp cleared, got %+v", plan) + } +} + +func TestSupersedeInjectAppModels_DeletesGeneratedOnly(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + hand := "/virtual/modules/partner/service/models/app_setting.ts" + virt := "/virtual/modules/partner/service/models/__generated__/app_setting.ts" + seedDeclaration(t, db, "AppSetting", "virt-id", virt, "partner") + seedDeclaration(t, db, "AppSetting", "hand-id", hand, "partner") + + sess.plans["AppSetting"] = Plan{SupersedeInject: true} + if err := SupersedeInjectAppModels(sess); err != nil { + t.Fatalf("supersede: %v", err) + } + + remaining, err := meta.ListDeclarations(db, meta.DeclarationQuery{ + Application: "partner", + Name: "AppSetting", + }) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(remaining) != 1 { + t.Fatalf("expected 1 remaining row (handwritten), got %d", len(remaining)) + } + if isGeneratedPath(specByNameOrPanic("AppSetting"), remaining[0].Path) { + t.Fatalf("expected handwritten row to remain, path=%q", remaining[0].Path) + } +} + +func specByNameOrPanic(name string) *Spec { + s, ok := specByName(name) + if !ok { + panic("spec missing: " + name) + } + return s +} + +func TestInjectRegistersVirtualSource(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + fx, err := InjectAppModels(sess, nil) + if err != nil { + t.Fatalf("inject: %v", err) + } + wantFD := generatedPath(specByNameOrPanic("FieldDefault"), mod.Path) + wantAS := generatedPath(specByNameOrPanic("AppSetting"), mod.Path) + files := effectsFileMap(fx) + if _, ok := files[wantFD]; !ok { + t.Fatalf("expected virtual source at %q, got keys %#v", wantFD, files) + } + if sess.LastInjectPath("FieldDefault") != wantFD { + t.Fatalf("LastInjectPath = %q want %q", sess.LastInjectPath("FieldDefault"), wantFD) + } + if len(fx.Imports) != 2 { + t.Fatalf("Imports = %#v, want unique [FieldDefault, AppSetting] paths", fx.Imports) + } + seen := map[string]int{} + for _, p := range fx.Imports { + seen[p]++ + } + if seen[wantFD] != 1 || seen[wantAS] != 1 { + t.Fatalf("Imports = %#v, want one each of %q and %q", fx.Imports, wantFD, wantAS) + } +} + +func TestValidateInjectAppModels_Duplicate(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + a := "/virtual/modules/partner/service/models/fd_a.ts" + b := "/virtual/modules/partner/service/models/fd_b.ts" + err := ValidateInjectAppModels(sess, []*parser.ParserResult{ + {Path: a, Model: &meta.Model{Name: "FieldDefault", Path: a}}, + {Path: b, Model: &meta.Model{Name: "FieldDefault", Path: b}}, + }) + if err == nil || !strings.Contains(err.Error(), "FIELD_DEFAULT_DUPLICATE") { + t.Fatalf("expected duplicate error, got %v", err) + } +} + +func TestInjectAppModels_ClearsPathsOnPartialFailure(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + // FieldDefault materializes first; AppSetting then fails Decide on duplicate handwritten. + a := "/virtual/modules/partner/service/models/as_a.ts" + b := "/virtual/modules/partner/service/models/as_b.ts" + _, err := InjectAppModels(sess, []*parser.ParserResult{ + {Path: a, Model: &meta.Model{Name: "AppSetting", Path: a}}, + {Path: b, Model: &meta.Model{Name: "AppSetting", Path: b}}, + }) + if err == nil || !strings.Contains(err.Error(), "APP_SETTING_DUPLICATE") { + t.Fatalf("expected AppSetting duplicate, got %v", err) + } + if paths := sess.AllInjectPaths(); len(paths) != 0 { + t.Fatalf("stale inject paths after failed multi-spec inject: %#v", paths) + } + if sess.LastInjectPath("FieldDefault") != "" { + t.Fatal("FieldDefault path should be cleared") + } +} + +func TestDecideSkipsCore(t *testing.T) { + mod := &meta.Module{ + Name: "core", Path: "/virtual/modules/core", + ApplicationStr: "core", ServiceEntryPoint: "service/index.ts", + } + sess, _ := newTestSession(t, mod) + if _, err := InjectAppModels(sess, nil); err != nil { + t.Fatalf("inject: %v", err) + } + for _, name := range []string{"FieldDefault", "AppSetting"} { + if plan := sess.Plan(name); plan.NeedInject || plan.SupersedeInject { + t.Fatalf("%s should skip core, got %+v", name, plan) + } + } +} + +func TestDecide_HandwrittenSkipsInject(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + hand := "/virtual/modules/partner/service/models/field_default.ts" + seedDeclaration(t, db, "FieldDefault", "hand", hand, "partner") + if _, err := InjectAppModels(sess, nil); err != nil { + t.Fatalf("inject: %v", err) + } + plan := sess.Plan("FieldDefault") + if plan.NeedInject || plan.SupersedeInject { + t.Fatalf("expected skip when DB handwritten exists, got %+v", plan) + } +} + +func TestProcessDedup_OtherModuleSkips(t *testing.T) { + modA := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sessA, db := newTestSession(t, modA) + if _, err := InjectAppModels(sessA, nil); err != nil { + t.Fatalf("inject A: %v", err) + } + if !sessA.Plan("FieldDefault").NeedInject { + t.Fatal("A should inject") + } + + modB := &meta.Module{ + Name: "partner_bank", Path: "/virtual/modules/partner_bank", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sessB := NewSession(BuildCtx{ + Module: modB, + DB: db, + ModulesPath: "/virtual/modules", + }, sessA.Registry()) + if _, err := InjectAppModels(sessB, nil); err != nil { + t.Fatalf("inject B: %v", err) + } + if plan := sessB.Plan("FieldDefault"); plan.NeedInject { + t.Fatalf("B should skip after A claimed app, got %+v", plan) + } +} + +func TestBundleInjectAppModels_SkipsHandwritten(t *testing.T) { + mod := &meta.Module{ + Name: "partner", Path: "/virtual/modules/partner", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + seedDeclaration(t, db, "FieldDefault", "hand", "/virtual/modules/partner/service/models/field_default.ts", "partner") + base := &meta.Module{ + Name: "base", Path: "/virtual/modules/base", + ApplicationStr: "base", ServiceEntryPoint: "service/index.ts", + } + fx, err := BundleInjectAppModels(sess, []*meta.Module{mod, base}) + if err != nil { + t.Fatalf("bundle: %v", err) + } + if paths := sess.InjectPaths("FieldDefault"); len(paths) != 1 { + t.Fatalf("expected only base C2 path, got %#v", paths) + } + want := generatedPath(specByNameOrPanic("FieldDefault"), base.Path) + if paths := sess.InjectPaths("FieldDefault"); paths[0] != want { + t.Fatalf("path = %q want %q", paths[0], want) + } + files := effectsFileMap(fx) + if _, ok := files[want]; !ok { + t.Fatalf("expected virtual source for base at %q", want) + } + partnerGen := generatedPath(specByNameOrPanic("FieldDefault"), mod.Path) + if _, ok := files[partnerGen]; ok { + t.Fatal("must not inject C2 for handwritten-owned app") + } +} + +func TestGeneratedSource_AppSettingSoftDeleteFalse(t *testing.T) { + src := generatedSource(specByNameOrPanic("AppSetting"), `/tmp/mod"quote`, "app'name") + if !strings.Contains(src, "softDelete: false") { + t.Fatalf("AppSetting source must set softDelete: false:\n%s", src) + } + if !strings.Contains(src, strconv.Quote("app'name")) { + t.Fatalf("application must be quoted:\n%s", src) + } + fd := generatedSource(specByNameOrPanic("FieldDefault"), "/tmp/mod", "partner") + if strings.Contains(fd, "softDelete") { + t.Fatalf("FieldDefault must not set softDelete:\n%s", fd) + } +} + +func TestBundlePrefersExistingGeneratedPath(t *testing.T) { + mod := &meta.Module{ + Name: "sibling", Path: "/virtual/modules/sibling", + ApplicationStr: "partner", ServiceEntryPoint: "service/index.ts", + } + sess, db := newTestSession(t, mod) + canon := "/virtual/modules/partner/service/models/__generated__/field_default.ts" + seedDeclaration(t, db, "FieldDefault", "virt", canon, "partner") + fx, err := BundleInjectAppModels(sess, []*meta.Module{mod}) + if err != nil { + t.Fatalf("bundle: %v", err) + } + if sess.LastInjectPath("FieldDefault") != canon { + t.Fatalf("path = %q want canonical %q", sess.LastInjectPath("FieldDefault"), canon) + } + if _, ok := effectsFileMap(fx)[canon]; !ok { + t.Fatal("expected virtual source at canonical meta path") + } +} diff --git a/internal/module/artifact/build/injectappmodel/plan.go b/internal/module/artifact/build/injectappmodel/plan.go new file mode 100644 index 00000000..70cab3dd --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/plan.go @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +// Plan is the Decide output for C2 app-model inject. +type Plan struct { + NeedInject bool + SupersedeInject bool + // ScheduledApp is set when this session claimed the process-wide NeedInject slot. + // Cleared via Session.ReleaseSchedules on failure or after Persist/Bundle. + ScheduledApp string +} diff --git a/internal/module/artifact/build/injectappmodel/registry.go b/internal/module/artifact/build/injectappmodel/registry.go new file mode 100644 index 00000000..0069650f --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/registry.go @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import "sync" + +// Registry holds the Spec catalog and NeedInject claims shared across builders +// that participate in the same process (DefaultRegistry) or install scope. +type Registry struct { + mu sync.RWMutex + order []string + byName map[string]*Spec + scheduled map[string]*sync.Map // modelName → app → owning module name +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{ + byName: make(map[string]*Spec), + scheduled: make(map[string]*sync.Map), + } +} + +// NewRegistryWithDefaults returns a registry preloaded with DefaultSpecs(). +func NewRegistryWithDefaults() *Registry { + r := NewRegistry() + for _, s := range DefaultSpecs() { + r.Register(s) + } + return r +} + +// Register adds a Spec. ModelName must be unique within r. +func (r *Registry) Register(spec Spec) { + if r == nil { + panic("injectappmodel: Register on nil Registry") + } + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.byName[spec.ModelName]; exists { + panic("injectappmodel: duplicate Register for " + spec.ModelName) + } + s := spec + r.byName[spec.ModelName] = &s + r.order = append(r.order, spec.ModelName) + if r.scheduled[spec.ModelName] == nil { + r.scheduled[spec.ModelName] = &sync.Map{} + } +} + +// Specs returns a snapshot in registration order. +func (r *Registry) Specs() []Spec { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Spec, 0, len(r.order)) + for _, name := range r.order { + if s, ok := r.byName[name]; ok { + out = append(out, *s) + } + } + return out +} + +// Lookup returns the registered Spec by ModelName. +func (r *Registry) Lookup(modelName string) (Spec, bool) { + if r == nil { + return Spec{}, false + } + r.mu.RLock() + defer r.mu.RUnlock() + s, ok := r.byName[modelName] + if !ok { + return Spec{}, false + } + return *s, true +} + +func (r *Registry) lookupPtr(modelName string) (*Spec, bool) { + if r == nil { + return nil, false + } + r.mu.RLock() + defer r.mu.RUnlock() + s, ok := r.byName[modelName] + return s, ok +} + +func (r *Registry) specsList() []*Spec { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*Spec, 0, len(r.order)) + for _, name := range r.order { + if s, ok := r.byName[name]; ok { + out = append(out, s) + } + } + return out +} + +func (r *Registry) claimMapLocked(modelName string) *sync.Map { + if r.scheduled == nil { + r.scheduled = make(map[string]*sync.Map) + } + m := r.scheduled[modelName] + if m == nil { + m = &sync.Map{} + r.scheduled[modelName] = m + } + return m +} + +func (r *Registry) claimMap(modelName string) *sync.Map { + r.mu.Lock() + defer r.mu.Unlock() + return r.claimMapLocked(modelName) +} + +// TryClaim records that modName owns NeedInject for (modelName, app). +// loaded is true when another (or the same) owner already held the slot. +func (r *Registry) TryClaim(modelName, app, modName string) (owner string, loaded bool) { + if r == nil { + return "", false + } + r.mu.Lock() + defer r.mu.Unlock() + v, loaded := r.claimMapLocked(modelName).LoadOrStore(app, modName) + owner, _ = v.(string) + return owner, loaded +} + +// ReleaseClaim drops the claim for (modelName, app) if present. +func (r *Registry) ReleaseClaim(modelName, app string) { + if r == nil || app == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + m := r.scheduled[modelName] + if m != nil { + m.Delete(app) + } +} + +// ClaimOwner returns the module that currently owns NeedInject for (modelName, app). +func (r *Registry) ClaimOwner(modelName, app string) (modName string, ok bool) { + if r == nil { + return "", false + } + r.mu.RLock() + defer r.mu.RUnlock() + m := r.scheduled[modelName] + if m == nil { + return "", false + } + v, ok := m.Load(app) + if !ok { + return "", false + } + modName, _ = v.(string) + return modName, true +} + +// ResetClaims clears all NeedInject claims (tests / install boundary). +func (r *Registry) ResetClaims() { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + for name := range r.scheduled { + r.scheduled[name] = &sync.Map{} + } +} + +var ( + defaultRegistry *Registry + defaultRegistryOnce sync.Once +) + +// DefaultSpecs returns builtin C2 Spec definitions without registering them. +func DefaultSpecs() []Spec { + return []Spec{ + { + ModelName: "FieldDefault", + GeneratedRelPath: "service/models/__generated__/field_default.ts", + DuplicateCode: "FIELD_DEFAULT_DUPLICATE", + BaseModelFile: "core/service/orm/model/field_default_base_model.ts", + }, + { + ModelName: "AppSetting", + GeneratedRelPath: "service/models/__generated__/app_setting.ts", + DuplicateCode: "APP_SETTING_DUPLICATE", + BaseModelFile: "core/service/orm/model/app_setting_base_model.ts", + SoftDeleteFalse: true, + ForeignClaimOnOwnerReinject: true, + }, + } +} + +// DefaultRegistry returns the process-wide shared registry, lazily initialized +// once with DefaultSpecs(). +func DefaultRegistry() *Registry { + defaultRegistryOnce.Do(func() { + defaultRegistry = NewRegistryWithDefaults() + }) + return defaultRegistry +} diff --git a/internal/module/artifact/build/injectappmodel/session.go b/internal/module/artifact/build/injectappmodel/session.go new file mode 100644 index 00000000..92e80edf --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/session.go @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import "strings" + +// Session holds per-build inject plans and paths for Specs in a Registry. +type Session struct { + ctx BuildCtx + reg *Registry + plans map[string]Plan + injectPaths map[string][]string + lastInjectPath map[string]string +} + +// NewSession creates a Session bound to ctx and reg. +// If reg is nil, DefaultRegistry() is used. +func NewSession(ctx BuildCtx, reg *Registry) *Session { + if reg == nil { + reg = DefaultRegistry() + } + return &Session{ + ctx: ctx, + reg: reg, + plans: make(map[string]Plan), + injectPaths: make(map[string][]string), + lastInjectPath: make(map[string]string), + } +} + +// Context returns a pointer to the session BuildCtx (tests may mutate fields). +func (s *Session) Context() *BuildCtx { + if s == nil { + return nil + } + return &s.ctx +} + +// Registry returns the Spec/claim registry for this session. +func (s *Session) Registry() *Registry { + if s == nil { + return nil + } + if s.reg == nil { + return DefaultRegistry() + } + return s.reg +} + +// ReleaseSchedules clears all scheduled apps claimed by this session. +func (s *Session) ReleaseSchedules() { + if s == nil { + return + } + reg := s.Registry() + for modelName, plan := range s.plans { + app := strings.TrimSpace(plan.ScheduledApp) + if app == "" { + continue + } + reg.ReleaseClaim(modelName, app) + plan.ScheduledApp = "" + s.SetPlan(modelName, plan) + } +} + +// SetPlan stores a plan for modelName (tests / legacy adapters). +func (s *Session) SetPlan(modelName string, plan Plan) { + if s == nil { + return + } + if s.plans == nil { + s.plans = make(map[string]Plan) + } + s.plans[modelName] = plan +} + +// Plan returns the stored plan for modelName. +func (s *Session) Plan(modelName string) Plan { + if s == nil { + return Plan{} + } + return s.plans[modelName] +} + +// InjectPaths returns accumulated inject paths for modelName. +func (s *Session) InjectPaths(modelName string) []string { + if s == nil { + return nil + } + paths := s.injectPaths[modelName] + if len(paths) == 0 { + return nil + } + out := make([]string, len(paths)) + copy(out, paths) + return out +} + +// LastInjectPath returns the most recent inject path for modelName (tests). +func (s *Session) LastInjectPath(modelName string) string { + if s == nil { + return "" + } + return s.lastInjectPath[modelName] +} + +func (s *Session) rememberInjectPath(modelName, path string) { + path = strings.TrimSpace(path) + if s == nil || path == "" { + return + } + if s.lastInjectPath == nil { + s.lastInjectPath = make(map[string]string) + } + if s.injectPaths == nil { + s.injectPaths = make(map[string][]string) + } + s.lastInjectPath[modelName] = path + for _, existing := range s.injectPaths[modelName] { + if existing == path { + return + } + } + s.injectPaths[modelName] = append(s.injectPaths[modelName], path) +} + +// ClearInjectPaths resets remembered inject paths for modelName. +func (s *Session) ClearInjectPaths(modelName string) { + if s == nil { + return + } + delete(s.injectPaths, modelName) + delete(s.lastInjectPath, modelName) +} + +// ClearAllInjectPaths drops every remembered inject path (failed inject/bundle +// before Effects are applied, so buildOptions cannot import stale generated paths). +func (s *Session) ClearAllInjectPaths() { + if s == nil { + return + } + s.injectPaths = nil + s.lastInjectPath = nil +} + +func (s *Session) allInjectPaths() []string { + return s.AllInjectPaths() +} + +// AllInjectPaths returns inject paths for every Spec in the session registry. +func (s *Session) AllInjectPaths() []string { + if s == nil { + return nil + } + var out []string + for _, spec := range s.Registry().specsList() { + out = append(out, s.injectPaths[spec.ModelName]...) + } + return out +} + +func (s *Session) releaseScheduleFor(spec *Spec) { + if s == nil || spec == nil { + return + } + plan := s.plans[spec.ModelName] + app := strings.TrimSpace(plan.ScheduledApp) + if app == "" { + return + } + s.Registry().ReleaseClaim(spec.ModelName, app) + plan.ScheduledApp = "" + s.SetPlan(spec.ModelName, plan) +} diff --git a/internal/module/artifact/build/injectappmodel/source.go b/internal/module/artifact/build/injectappmodel/source.go new file mode 100644 index 00000000..355a984a --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/source.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" +) + +// generatedSource builds C2 thin-class source with absolute imports so esbuild can +// resolve them even when the pseudo __generated__ directory is not on disk. +func generatedSource(spec *Spec, modulesPath, application string) string { + modulesPath = filepath.ToSlash(filepath.Clean(strings.TrimSpace(modulesPath))) + application = strings.TrimSpace(application) + if application == "" { + application = "application" + } + coreService := filepath.ToSlash(filepath.Join(modulesPath, "core/service/index.ts")) + baseModel := filepath.ToSlash(filepath.Join(modulesPath, spec.BaseModelFile)) + baseImportName := spec.ModelName + "BaseModel" + + var modelOpts string + if spec.SoftDeleteFalse { + modelOpts = fmt.Sprintf("{ application: %s, softDelete: false }", strconv.Quote(application)) + } else { + modelOpts = fmt.Sprintf("{ application: %s }", strconv.Quote(application)) + } + + return fmt.Sprintf(`import { Model } from %s +import %s from %s + +@Model('%s', %s) +export default class %s extends %s {} +`, strconv.Quote(coreService), baseImportName, strconv.Quote(baseModel), spec.ModelName, modelOpts, spec.ModelName, baseImportName) +} diff --git a/internal/module/artifact/build/injectappmodel/spec.go b/internal/module/artifact/build/injectappmodel/spec.go new file mode 100644 index 00000000..a4ad55a1 --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/spec.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import "sync" + +// Spec describes one injectable app-scoped model (FieldDefault, AppSetting, …). +type Spec struct { + ModelName string + GeneratedRelPath string + DuplicateCode string + BaseModelFile string // relative under modules, e.g. core/service/orm/model/field_default_base_model.ts + SoftDeleteFalse bool // AppSetting: emit softDelete: false in @Model options + // ForeignClaimOnOwnerReinject: when DB virtual rows belong to this module but another + // in-process builder holds the schedule claim, still NeedInject without adopting release. + ForeignClaimOnOwnerReinject bool +} + +// Register adds a Spec to DefaultRegistry(). ModelName must be unique. +func Register(spec Spec) { + DefaultRegistry().Register(spec) +} + +// Specs returns specs from DefaultRegistry() in registration order. +func Specs() []Spec { + return DefaultRegistry().Specs() +} + +func specByName(name string) (*Spec, bool) { + return DefaultRegistry().lookupPtr(name) +} + +func specsList() []*Spec { + return DefaultRegistry().specsList() +} + +// ResetScheduledForTest clears DefaultRegistry claims (tests only). +// Deprecated: prefer Registry.ResetClaims on a test-owned registry, or +// DefaultRegistry().ResetClaims(). +func ResetScheduledForTest() { + DefaultRegistry().ResetClaims() +} + +// ScheduledApps returns the NeedInject dedup map for modelName on DefaultRegistry (tests). +// Deprecated: prefer Registry.ClaimOwner / TryClaim / ResetClaims. +func ScheduledApps(modelName string) *sync.Map { + r := DefaultRegistry() + if _, ok := r.Lookup(modelName); !ok { + panic("injectappmodel: ScheduledApps unknown model " + modelName) + } + return r.claimMap(modelName) +} diff --git a/internal/module/artifact/build/injectappmodel/supersede.go b/internal/module/artifact/build/injectappmodel/supersede.go new file mode 100644 index 00000000..b3bab0de --- /dev/null +++ b/internal/module/artifact/build/injectappmodel/supersede.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package injectappmodel + +import ( + "strings" + + "github.com/choysum-dev/choysum/pkg/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). +func SupersedeInjectAppModels(sess *Session) error { + if sess == nil { + return nil + } + for _, spec := range sess.Registry().specsList() { + if err := SupersedeOne(sess, spec.ModelName); err != nil { + return err + } + } + return nil +} + +// SupersedeOne supersedes generated declarations for one Spec when its plan says so. +func SupersedeOne(sess *Session, modelName string) error { + if sess == nil { + return nil + } + spec, ok := sess.Registry().lookupPtr(modelName) + if !ok { + return nil + } + plan := sess.plans[spec.ModelName] + if !plan.SupersedeInject { + return nil + } + db := sess.ctx.DB + mod := sess.ctx.Module + if db == nil || mod == nil { + return nil + } + app := strings.TrimSpace(mod.ApplicationStr) + if app == "" { + return nil + } + return supersedeGenerated(spec, db, app) +} + +func supersedeGenerated(spec *Spec, db *gorm.DB, app string) error { + absFalse := false + existing, err := meta.ListDeclarations(db, meta.DeclarationQuery{ + Application: app, + Name: spec.ModelName, + Abstract: &absFalse, + }) + if err != nil { + return xfmt.Errorf("load %s rows for supersede: %w", spec.ModelName, err) + } + + ids := make([]string, 0) + for _, m := range existing { + if m == nil || !isGeneratedPath(spec, m.Path) { + continue + } + if !m.Id.Valid || strings.TrimSpace(m.Id.String) == "" { + continue + } + ids = append(ids, m.Id.String) + } + if len(ids) == 0 { + return nil + } + if err := meta.DeleteDeclarationTrees(db, ids); err != nil { + return xfmt.Errorf("delete superseded generated %s rows: %w", spec.ModelName, err) + } + return nil +} diff --git a/internal/module/lifecycle/bundles.go b/internal/module/lifecycle/bundles.go index 068314ee..72dd8d60 100644 --- a/internal/module/lifecycle/bundles.go +++ b/internal/module/lifecycle/bundles.go @@ -137,23 +137,40 @@ func (m *ModuleManager) buildBackendBundlesToDir(ctx context.Context, distBundle return nil } -// ensureBundleC2VirtualImports registers FieldDefault then AppSetting C2 virtual -// sources on a multi-app bundle builder. Extracted for unit tests of error wrapping. +// ensureBundleC2VirtualImports registers C2 inject sources on a multi-app bundle +// builder. Extracted for unit tests of error wrapping. func ensureBundleC2VirtualImports(builder any, fieldDefaultOwners, appSettingOwners []*meta.Module) error { - if injector, ok := builder.(interface { - EnsureFieldDefaultVirtualImports([]*meta.Module) error - }); ok { - if err := injector.EnsureFieldDefaultVirtualImports(fieldDefaultOwners); err != nil { - return xfmt.Errorf("inject FieldDefault virtual imports for bundles: %w", err) - } + injector, ok := builder.(interface { + BundleInjectAppModels([]*meta.Module) error + }) + if !ok { + return nil } - if injector, ok := builder.(interface { - EnsureAppSettingVirtualImports([]*meta.Module) error - }); ok { - if err := injector.EnsureAppSettingVirtualImports(appSettingOwners); err != nil { - return xfmt.Errorf("inject AppSetting virtual imports for bundles: %w", err) + // Owners are usually the same eligible backend modules; merge preserving order + // and uniqueness so one BundleInjectAppModels covers every Spec. + seen := make(map[string]struct{}) + owners := make([]*meta.Module, 0, len(fieldDefaultOwners)+len(appSettingOwners)) + appendUnique := func(mods []*meta.Module) { + for _, mod := range mods { + if mod == nil { + continue + } + key := strings.TrimSpace(mod.Name) + "\x00" + strings.TrimSpace(mod.Path) + if key == "\x00" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + owners = append(owners, mod) } } + appendUnique(fieldDefaultOwners) + appendUnique(appSettingOwners) + if err := injector.BundleInjectAppModels(owners); err != nil { + return xfmt.Errorf("inject app models for bundles: %w", err) + } return nil } @@ -161,8 +178,8 @@ func ensureBundleC2VirtualImports(builder any, fieldDefaultOwners, appSettingOwn // virtual file for an application (one store per app). // // Prefer the last eligible backend module so the owner matches how the multi-app -// Bundle picks its representative (`mods[len-1]`). EnsureFieldDefaultVirtualImports -// still rewrites the path to any existing virtual meta row when present. +// Bundle picks its representative (`mods[len-1]`). BundleInjectAppModels still +// rewrites the path to any existing generated meta row when present. func pickFieldDefaultOwnerModule(app string, mods []*meta.Module) *meta.Module { app = strings.TrimSpace(app) if app == "" || app == "core" || len(mods) == 0 { diff --git a/internal/module/lifecycle/bundles_app_setting_test.go b/internal/module/lifecycle/bundles_app_setting_test.go index 23179b3d..36addf50 100644 --- a/internal/module/lifecycle/bundles_app_setting_test.go +++ b/internal/module/lifecycle/bundles_app_setting_test.go @@ -43,95 +43,64 @@ func TestPickAppSettingOwnerModule_LastEligible(t *testing.T) { } type stubBundleC2Injector struct { - fdErr error - asErr error - fdN int - asN int + err error + n int + got []*meta.Module } -func (s *stubBundleC2Injector) EnsureFieldDefaultVirtualImports([]*meta.Module) error { - s.fdN++ - return s.fdErr -} - -func (s *stubBundleC2Injector) EnsureAppSettingVirtualImports([]*meta.Module) error { - s.asN++ - return s.asErr +func (s *stubBundleC2Injector) BundleInjectAppModels(mods []*meta.Module) error { + s.n++ + s.got = append([]*meta.Module(nil), mods...) + return s.err } -func TestEnsureBundleC2VirtualImports_AppSettingError(t *testing.T) { +func TestEnsureBundleC2VirtualImports_BundleInject(t *testing.T) { owners := []*meta.Module{{Name: "crm_partner", Path: "/m", ApplicationStr: "crm", ServiceEntryPoint: "service/main.ts"}} + asOwners := []*meta.Module{ + {Name: "crm_partner", Path: "/m", ApplicationStr: "crm", ServiceEntryPoint: "service/main.ts"}, + {Name: "crm_extra", Path: "/m2", ApplicationStr: "crm", ServiceEntryPoint: "service/main.ts"}, + } okStub := &stubBundleC2Injector{} - if err := ensureBundleC2VirtualImports(okStub, owners, owners); err != nil { + if err := ensureBundleC2VirtualImports(okStub, owners, asOwners); err != nil { t.Fatalf("unexpected error: %v", err) } - if okStub.fdN != 1 || okStub.asN != 1 { - t.Fatalf("expected both Ensures called once, got fd=%d as=%d", okStub.fdN, okStub.asN) + if okStub.n != 1 { + t.Fatalf("expected BundleInjectAppModels once, got %d", okStub.n) + } + if len(okStub.got) != 2 { + t.Fatalf("expected merged unique owners, got %#v", okStub.got) } - asFail := &stubBundleC2Injector{asErr: errors.New("as boom")} - err := ensureBundleC2VirtualImports(asFail, owners, owners) - if err == nil || !strings.Contains(err.Error(), "inject AppSetting virtual imports") { - t.Fatalf("expected AppSetting Ensure wrap, got %v", err) + // Cover appendUnique skips: nil module and empty name+path key. + skipStub := &stubBundleC2Injector{} + if err := ensureBundleC2VirtualImports(skipStub, []*meta.Module{ + nil, + {Name: "", Path: "", ApplicationStr: "crm"}, + {Name: " ", Path: " ", ApplicationStr: "crm"}, + {Name: "keep", Path: "/keep", ApplicationStr: "crm"}, + }, nil); err != nil { + t.Fatalf("unexpected error: %v", err) } - if asFail.fdN != 1 || asFail.asN != 1 { - t.Fatalf("FieldDefault should succeed before AppSetting fails: fd=%d as=%d", asFail.fdN, asFail.asN) + if len(skipStub.got) != 1 || skipStub.got[0].Name != "keep" { + t.Fatalf("expected only keep owner, got %#v", skipStub.got) } - fdFail := &stubBundleC2Injector{fdErr: errors.New("fd boom")} - err = ensureBundleC2VirtualImports(fdFail, owners, owners) - if err == nil || !strings.Contains(err.Error(), "inject FieldDefault virtual imports") { - t.Fatalf("expected FieldDefault Ensure wrap, got %v", err) - } - if fdFail.asN != 0 { - t.Fatal("AppSetting Ensure must not run after FieldDefault failure") + fail := &stubBundleC2Injector{err: errors.New("boom")} + err := ensureBundleC2VirtualImports(fail, owners, owners) + if err == nil || !strings.Contains(err.Error(), "inject app models for bundles") { + t.Fatalf("expected wrap, got %v", err) } - // Builder without Ensure methods is a no-op. if err := ensureBundleC2VirtualImports(struct{}{}, owners, owners); err != nil { t.Fatalf("unexpected error for bare builder: %v", err) } - - fdOnly := &fieldDefaultOnlyBundleInjector{} - if err := ensureBundleC2VirtualImports(fdOnly, owners, owners); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if fdOnly.n != 1 { - t.Fatalf("expected FieldDefault-only Ensure once, got %d", fdOnly.n) - } - - asOnly := &appSettingOnlyBundleInjector{} - if err := ensureBundleC2VirtualImports(asOnly, owners, owners); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if asOnly.n != 1 { - t.Fatalf("expected AppSetting-only Ensure once, got %d", asOnly.n) - } -} - -type fieldDefaultOnlyBundleInjector struct { - n int -} - -func (s *fieldDefaultOnlyBundleInjector) EnsureFieldDefaultVirtualImports([]*meta.Module) error { - s.n++ - return nil -} - -type appSettingOnlyBundleInjector struct { - n int -} - -func (s *appSettingOnlyBundleInjector) EnsureAppSettingVirtualImports([]*meta.Module) error { - s.n++ - return nil } func TestBuildBackendBundlesToDir_AppSettingOwnerAndEnsureError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } modPath := filepath.Join(modulesPath, "crm_partner") @@ -153,20 +122,13 @@ func TestBuildBackendBundlesToDir_AppSettingOwnerAndEnsureError(t *testing.T) { manager.bootstrapOnce.Do(func() {}) distBundlesDir := t.TempDir() - // Drop meta_raw_model so Ensure*VirtualImports fails while owners were collected. - // FieldDefault Ensure runs first, so the surfaced error usually names FieldDefault; - // AppSetting Ensure shares the same owner-collection + entry-write path. - if err := db.Migrator().DropTable(&meta.RawModel{}); err != nil { + // Drop meta_raw_model so BundleInjectAppModels fails while owners were collected. + if err := meta.DropRawModelTable(db); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } err := manager.buildBackendBundlesToDir(context.Background(), distBundlesDir, nil) - if err == nil { - t.Fatal("expected Ensure error") - } - msg := err.Error() - if !strings.Contains(msg, "inject FieldDefault virtual imports") && - !strings.Contains(msg, "inject AppSetting virtual imports") { - t.Fatalf("expected Ensure virtual-imports error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "inject app models for bundles") { + t.Fatalf("expected BundleInject error, got %v", err) } entryFilePath := filepath.Join(distBundlesDir, "__choysum_bundles_entry.ts") diff --git a/internal/module/lifecycle/bundles_field_default_test.go b/internal/module/lifecycle/bundles_field_default_test.go index 3eb01094..23116683 100644 --- a/internal/module/lifecycle/bundles_field_default_test.go +++ b/internal/module/lifecycle/bundles_field_default_test.go @@ -44,7 +44,7 @@ func TestPickFieldDefaultOwnerModule_LastEligible(t *testing.T) { func TestBuildBackendBundlesToDir_FieldDefaultOwnerAndEnsureError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } modPath := filepath.Join(modulesPath, "crm_partner") @@ -66,13 +66,13 @@ func TestBuildBackendBundlesToDir_FieldDefaultOwnerAndEnsureError(t *testing.T) manager.bootstrapOnce.Do(func() {}) distBundlesDir := t.TempDir() - // Drop meta_raw_model so EnsureFieldDefaultVirtualImports fails while owners were collected. - if err := db.Migrator().DropTable(&meta.RawModel{}); err != nil { + // Drop meta_raw_model so BundleInjectAppModels fails while owners were collected. + if err := meta.DropRawModelTable(db); err != nil { t.Fatalf("drop meta_raw_model: %v", err) } err := manager.buildBackendBundlesToDir(context.Background(), distBundlesDir, nil) - if err == nil || !strings.Contains(err.Error(), "inject FieldDefault virtual imports") { - t.Fatalf("expected Ensure error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "inject app models for bundles") { + t.Fatalf("expected BundleInject error, got %v", err) } entryFilePath := filepath.Join(distBundlesDir, "__choysum_bundles_entry.ts") diff --git a/internal/module/lifecycle/install_module_test.go b/internal/module/lifecycle/install_module_test.go index d52fc1e9..98358c37 100644 --- a/internal/module/lifecycle/install_module_test.go +++ b/internal/module/lifecycle/install_module_test.go @@ -17,7 +17,7 @@ import ( func TestPrepareInstallAliasesPrefetchInstallModules(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.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 801b7df1..1eabde57 100644 --- a/internal/module/lifecycle/install_prefetch_test.go +++ b/internal/module/lifecycle/install_prefetch_test.go @@ -50,7 +50,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.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate: %v", err) } diff --git a/internal/module/lifecycle/module_index_sync_test.go b/internal/module/lifecycle/module_index_sync_test.go index 229f38d6..c4f0d890 100644 --- a/internal/module/lifecycle/module_index_sync_test.go +++ b/internal/module/lifecycle/module_index_sync_test.go @@ -140,7 +140,7 @@ func TestModuleManagerBuildGlobalWebToDirResolvesRelativeEntryPath(t *testing.T) modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -213,7 +213,7 @@ func TestModuleManagerRefreshModuleIndexForLocalModules(t *testing.T) { func TestModuleManagerBuildBackendAppToDirWritesModuleBasedEntryImports(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -250,7 +250,7 @@ func TestModuleManagerBuildBackendAppToDirWritesModuleBasedEntryImports(t *testi func TestModuleManagerGenerateAppToDirsPropagatesGeneratorError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -276,7 +276,7 @@ func TestModuleManagerGenerateAppToDirsPropagatesGeneratorError(t *testing.T) { func TestModuleManagerBuildBackendBundlesToDirWritesModuleBasedEntryImports(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -912,7 +912,7 @@ func TestModuleManagerInstallRunsAppStageCallbacks(t *testing.T) { defaultChoysumPath := filepath.Join(t.TempDir(), ".choysum") db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -980,7 +980,7 @@ func TestModuleManagerUninstallRunsAppStageCallbacks(t *testing.T) { defaultChoysumPath := filepath.Join(t.TempDir(), ".choysum") db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -1049,7 +1049,7 @@ func TestModuleManagerInstallPropagatesGeneratedAPIRootError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -1098,7 +1098,7 @@ func TestModuleManagerUninstallPropagatesGeneratedAPIRootError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -1138,7 +1138,7 @@ func TestModuleManagerUpgradePropagatesGeneratedAPIRootError(t *testing.T) { modulesPath := t.TempDir() db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } @@ -1178,7 +1178,7 @@ func TestModuleManagerUpgradeRunsAppStageCallbacks(t *testing.T) { defaultChoysumPath := filepath.Join(t.TempDir(), ".choysum") db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.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 035ae805..cfd8dbf8 100644 --- a/internal/module/lifecycle/modulemanager.go +++ b/internal/module/lifecycle/modulemanager.go @@ -1646,8 +1646,9 @@ func NewModuleManager(runtimeScope scope.Scope, jsExecutor jsexecutor.ScriptExec moduleIndexSyncLocal: SyncLocalModuleIndex, } - // Default entity set: shared IR, module admin/ops, and lease models. - m.entities = append(meta.Entities(), metadata.Entities()...) + // 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()...) // Apply external injections. diff --git a/internal/module/lifecycle/modulemanager_coverage_test.go b/internal/module/lifecycle/modulemanager_coverage_test.go index 3fdf4b29..0e784c79 100644 --- a/internal/module/lifecycle/modulemanager_coverage_test.go +++ b/internal/module/lifecycle/modulemanager_coverage_test.go @@ -28,7 +28,7 @@ func TestBootstrapMetaTablesSkipsWhenBaseTablesExist(t *testing.T) { func TestModuleManagerListInstalledNonWebApps(t *testing.T) { db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } for _, row := range []meta.Module{ @@ -63,7 +63,7 @@ func TestModuleManagerListInstalledNonWebApps(t *testing.T) { func TestModuleManagerListInstalledNonWebAppsQueryError(t *testing.T) { db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } if err := db.Migrator().DropTable(&meta.Module{}); err != nil { @@ -81,7 +81,7 @@ func TestModuleManagerListInstalledNonWebAppsQueryError(t *testing.T) { func TestModuleOpCtxBinderDelegatesToCtxMethods(t *testing.T) { db := newModuleIndexSyncDB(t) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.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 5464ada2..22b7a856 100644 --- a/internal/module/lifecycle/modulemanager_fastfail_test.go +++ b/internal/module/lifecycle/modulemanager_fastfail_test.go @@ -68,7 +68,7 @@ func TestModuleManagerUninstallFastFailWhenModuleMissing(t *testing.T) { manager := NewModuleManager(runtimeScope, nil, WithLockerFactory(func(scope.Scope) statepkg.Locker { return locker })) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } manager.bootstrapOnce.Do(func() {}) @@ -104,7 +104,7 @@ func TestModuleManagerUpgradeFastFailWhenLocalModuleMissing(t *testing.T) { manager := NewModuleManager(runtimeScope, nil, WithLockerFactory(func(scope.Scope) statepkg.Locker { return locker })) - if err := db.AutoMigrate(meta.Entities()...); err != nil { + if err := db.AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) } manager.bootstrapOnce.Do(func() {}) diff --git a/internal/module/lifecycle/uninstaller_clean_models_test.go b/internal/module/lifecycle/uninstaller_clean_models_test.go index 872c6bc5..bf831897 100644 --- a/internal/module/lifecycle/uninstaller_clean_models_test.go +++ b/internal/module/lifecycle/uninstaller_clean_models_test.go @@ -14,23 +14,23 @@ import ( ) type cleanModelsSeed struct { - module *meta.Module - rawModel *meta.RawModel - rawService *meta.RawService - rawField *meta.RawField - rawDecorator *meta.RawDecorator - rawArgument *meta.RawArgument - component *meta.Component - compDecorator *meta.Decorator - compArgument *meta.Argument - menuUI *meta.UiResource - routeUI *meta.UiResource - actionUI *meta.UiResource - menuRoute *meta.UiResourceMenuRoute - routeAction *meta.UiResourceRouteAction - rawTypeParam *meta.RawTypeParameter - rawParameter *meta.RawParameter - dependModule *meta.Module + module *meta.Module + rawModelID string + rawServiceID string + rawFieldID string + rawDecoratorID string + rawArgumentID string + component *meta.Component + compDecorator *meta.Decorator + compArgument *meta.Argument + menuUI *meta.UiResource + routeUI *meta.UiResource + actionUI *meta.UiResource + menuRoute *meta.UiResourceMenuRoute + routeAction *meta.UiResourceRouteAction + rawTypeParamID string + rawParameterID string + dependModule *meta.Module } func seedCleanModelsFixture(t *testing.T, db *gorm.DB) cleanModelsSeed { @@ -42,34 +42,46 @@ func seedCleanModelsFixture(t *testing.T, db *gorm.DB) cleanModelsSeed { t.Fatalf("create module: %v", err) } - rawModel := &meta.RawModel{ + rawModelID := xid.New().String() + rawServiceID := xid.New().String() + rawFieldID := xid.New().String() + rawDecoratorID := xid.New().String() + rawArgumentID := xid.New().String() + rawTypeParamID := xid.New().String() + rawParameterID := xid.New().String() + if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: rawModelID, Valid: true}}, Name: "demo.model", Path: "demo/model", Application: "demo", ModuleId: mod.Id, - } - if err := db.Create(rawModel).Error; err != nil { - t.Fatalf("create raw model: %v", err) - } - - rawService := &meta.RawService{Name: "search", ModelId: rawModel.Id} - if err := db.Create(rawService).Error; err != nil { - t.Fatalf("create raw service: %v", err) - } - - rawField := &meta.RawField{Name: "name", ModelId: rawModel.Id} - if err := db.Create(rawField).Error; err != nil { - t.Fatalf("create raw field: %v", err) - } - - rawDecorator := &meta.RawDecorator{Name: "readonly", ModelId: rawModel.Id} - if err := db.Create(rawDecorator).Error; err != nil { - t.Fatalf("create raw decorator: %v", err) - } - - rawArgument := &meta.RawArgument{Type: "string", Value: "true", DecoratorId: rawDecorator.Id} - if err := db.Create(rawArgument).Error; err != nil { - t.Fatalf("create raw argument: %v", err) + Fields: []*meta.Field{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: rawFieldID, Valid: true}}, + Name: "name", + }}, + Services: []*meta.Service{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: rawServiceID, Valid: true}}, + Name: "search", + TypeParameters: []*meta.TypeParameter{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: rawTypeParamID, Valid: true}}, + Name: "T", + }}, + Parameters: []*meta.Parameter{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: rawParameterID, Valid: true}}, + Name: "id", + }}, + }}, + Decorators: []*meta.Decorator{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: rawDecoratorID, Valid: true}}, + Name: "readonly", + Arguments: []*meta.Argument{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: rawArgumentID, Valid: true}}, + Type: "string", + Value: "true", + }}, + }}, + }); err != nil { + t.Fatalf("persist raw model tree: %v", err) } component := &meta.Component{Name: "demo.comp", Path: "demo/comp", ModuleId: mod.Id} @@ -112,16 +124,6 @@ func seedCleanModelsFixture(t *testing.T, db *gorm.DB) cleanModelsSeed { t.Fatalf("create ui route-action relation: %v", err) } - rawTypeParam := &meta.RawTypeParameter{Name: "T", ServiceId: rawService.Id} - if err := db.Create(rawTypeParam).Error; err != nil { - t.Fatalf("create raw type parameter: %v", err) - } - - rawParameter := &meta.RawParameter{Name: "id", ServiceId: rawService.Id} - if err := db.Create(rawParameter).Error; err != nil { - t.Fatalf("create raw parameter: %v", err) - } - dependModule := &meta.Module{Name: "dep", Status: meta.Installed, Version: "1.0.0"} dependModule.Id = sql.NullString{String: xid.New().String(), Valid: true} if err := db.Create(dependModule).Error; err != nil { @@ -135,30 +137,30 @@ func seedCleanModelsFixture(t *testing.T, db *gorm.DB) cleanModelsSeed { } return cleanModelsSeed{ - module: mod, - rawModel: rawModel, - rawService: rawService, - rawField: rawField, - rawDecorator: rawDecorator, - rawArgument: rawArgument, - component: component, - compDecorator: compDecorator, - compArgument: compArgument, - menuUI: menuUI, - routeUI: routeUI, - actionUI: actionUI, - menuRoute: menuRoute, - routeAction: routeAction, - rawTypeParam: rawTypeParam, - rawParameter: rawParameter, - dependModule: dependModule, + module: mod, + rawModelID: rawModelID, + rawServiceID: rawServiceID, + rawFieldID: rawFieldID, + rawDecoratorID: rawDecoratorID, + rawArgumentID: rawArgumentID, + component: component, + compDecorator: compDecorator, + compArgument: compArgument, + menuUI: menuUI, + routeUI: routeUI, + actionUI: actionUI, + menuRoute: menuRoute, + routeAction: routeAction, + rawTypeParamID: rawTypeParamID, + rawParameterID: rawParameterID, + dependModule: dependModule, } } -func dropMetaTable(t *testing.T, db *gorm.DB, model any) { +func dropMetaTable(t *testing.T, db *gorm.DB, table string) { t.Helper() - if err := db.Migrator().DropTable(model); err != nil { - t.Fatalf("drop table for %#T: %v", model, err) + if err := db.Migrator().DropTable(table); err != nil { + t.Fatalf("drop table %s: %v", table, err) } } @@ -176,34 +178,27 @@ END` } } -func deleteRawCleanModelsPrefix(t *testing.T, db *gorm.DB, seed cleanModelsSeed) { +func deleteRawRow(t *testing.T, db *gorm.DB, table, id string) { t.Helper() - if err := db.Unscoped().Delete(seed.rawArgument).Error; err != nil { - t.Fatalf("delete raw argument: %v", err) - } - if err := db.Unscoped().Delete(seed.rawDecorator).Error; err != nil { - t.Fatalf("delete raw decorator: %v", err) - } - if err := db.Unscoped().Delete(seed.rawTypeParam).Error; err != nil { - t.Fatalf("delete raw type parameter: %v", err) - } - if err := db.Unscoped().Delete(seed.rawParameter).Error; err != nil { - t.Fatalf("delete raw parameter: %v", err) + if err := db.Unscoped().Table(table).Where("id = ?", id).Delete(nil).Error; err != nil { + t.Fatalf("delete %s id=%s: %v", table, id, err) } } +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) +} + func deleteRawModelTree(t *testing.T, db *gorm.DB, seed cleanModelsSeed) { t.Helper() deleteRawCleanModelsPrefix(t, db, seed) - if err := db.Unscoped().Delete(seed.rawService).Error; err != nil { - t.Fatalf("delete raw service: %v", err) - } - if err := db.Unscoped().Delete(seed.rawField).Error; err != nil { - t.Fatalf("delete raw field: %v", err) - } - if err := db.Unscoped().Delete(seed.rawModel).Error; err != nil { - t.Fatalf("delete raw model: %v", err) - } + deleteRawRow(t, db, meta.RawServiceTable, seed.rawServiceID) + deleteRawRow(t, db, meta.RawFieldTable, seed.rawFieldID) + deleteRawRow(t, db, meta.RawModelTable, seed.rawModelID) } func TestModuleUninstallerCleanModelsErrorPaths(t *testing.T) { @@ -230,7 +225,7 @@ END`).Error; err != nil { name: "raw models", wantMsg: "error deleting raw models", setup: func(t *testing.T, db *gorm.DB, _ cleanModelsSeed) { - dropMetaTable(t, db, &meta.RawArgument{}) + dropMetaTable(t, db, meta.RawArgumentTable) }, }, { @@ -238,7 +233,7 @@ END`).Error; err != nil { wantMsg: "error deleting component decorator arguments", setup: func(t *testing.T, db *gorm.DB, seed cleanModelsSeed) { deleteRawModelTree(t, db, seed) - dropMetaTable(t, db, &meta.Argument{}) + dropMetaTable(t, db, "meta_argument") }, }, { @@ -256,7 +251,7 @@ END`).Error; err != nil { name: "recompute effective", wantMsg: "error recomputing effective models after uninstall", setup: func(t *testing.T, db *gorm.DB, seed cleanModelsSeed) { - dropMetaTable(t, db, &meta.Model{}) + dropMetaTable(t, db, "meta_model") }, }, { @@ -287,7 +282,7 @@ END`).Error; err != nil { if err := db.Unscoped().Delete(seed.component).Error; err != nil { t.Fatalf("delete component: %v", err) } - dropMetaTable(t, db, &meta.UiResourceMenuRoute{}) + dropMetaTable(t, db, "meta_ui_resource_menu_route") }, }, { @@ -307,7 +302,7 @@ END`).Error; err != nil { if err := db.Unscoped().Delete(seed.menuRoute).Error; err != nil { t.Fatalf("delete menu-route relation: %v", err) } - dropMetaTable(t, db, &meta.UiResourceRouteAction{}) + dropMetaTable(t, db, "meta_ui_resource_route_action") }, }, { diff --git a/internal/module/lifecycle/uninstaller_model_data_test.go b/internal/module/lifecycle/uninstaller_model_data_test.go index 5125d3b9..37ed15ed 100644 --- a/internal/module/lifecycle/uninstaller_model_data_test.go +++ b/internal/module/lifecycle/uninstaller_model_data_test.go @@ -5,14 +5,12 @@ package lifecycle import ( "database/sql" - "errors" "strings" "testing" metadata "github.com/choysum-dev/choysum/internal/module/metadata" "github.com/choysum-dev/choysum/pkg/meta" "github.com/rs/xid" - "gorm.io/gorm" ) func TestModuleUninstallerCleanModelsClearsMetaModelDataOnly(t *testing.T) { @@ -123,27 +121,27 @@ func TestModuleUninstallerCleanModelsRecomputesAfterExtRawDelete(t *testing.T) { } basePath := "@/partner/service/models/partner.ts" - baseRaw := &meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, + baseRawID := xid.New().String() + if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: baseRawID, Valid: true}}, Name: "Partner", Path: basePath, Application: "partner", ModelTable: "partner_partner", ModuleId: baseMod.Id, - } - if err := db.Create(baseRaw).Error; err != nil { + }); err != nil { t.Fatalf("create base raw model: %v", err) } - extRaw := &meta.RawModel{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, + extRawID := xid.New().String() + if err := meta.PersistModelTreeAsRaw(db, &meta.Model{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: extRawID, Valid: true}}, Name: "Partner", Path: "@/partner_commercial/service/models/partner.ts", Application: "partner", ModelTable: "partner_partner", ModuleId: extMod.Id, Extends: basePath, - } - if err := db.Create(extRaw).Error; err != nil { + }); err != nil { t.Fatalf("create ext raw model: %v", err) } @@ -157,14 +155,16 @@ func TestModuleUninstallerCleanModelsRecomputesAfterExtRawDelete(t *testing.T) { t.Fatalf("cleanModels() error = %v", err) } - var extRawDeleted meta.RawModel - err := db.Unscoped().Where("id = ?", extRaw.Id.String).First(&extRawDeleted).Error - if !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("ext raw model should be hard-deleted, got err=%v row=%+v", err, extRawDeleted) + extCount, err := meta.CountRawModelsByID(db, extRawID, true) + if err != nil { + t.Fatalf("count ext raw: %v", err) + } + if extCount != 0 { + t.Fatalf("ext raw model should be hard-deleted, count=%d", extCount) } - var baseRawCount int64 - if err := db.Model(&meta.RawModel{}).Where("id = ?", baseRaw.Id.String).Count(&baseRawCount).Error; err != nil { + baseRawCount, err := meta.CountRawModelsByID(db, baseRawID, false) + if err != nil { t.Fatalf("count base raw: %v", err) } if baseRawCount != 1 { @@ -223,7 +223,7 @@ func TestModuleUninstallerCleanModelsListingRawModelsError(t *testing.T) { runtimeScope := newLifecycleCommitTestScope(t) db := runtimeScope.Session().DB seed := seedCleanModelsFixture(t, db) - dropMetaTable(t, db, &meta.RawModel{}) + dropMetaTable(t, db, meta.RawModelTable) uninstaller := &moduleUninstaller{ runtimeScope: runtimeScope, diff --git a/internal/module/lifecycle/upgrade_uninstall_commit_test.go b/internal/module/lifecycle/upgrade_uninstall_commit_test.go index 97d75c53..508d3664 100644 --- a/internal/module/lifecycle/upgrade_uninstall_commit_test.go +++ b/internal/module/lifecycle/upgrade_uninstall_commit_test.go @@ -35,7 +35,7 @@ func newLifecycleCommitTestScope(t *testing.T) scope.Scope { scopetest.FactoryInputFromConfig(cfg), logger, ) - if err := runtimeScope.Session().AutoMigrate(meta.Entities()...); err != nil { + if err := runtimeScope.Session().AutoMigrate(meta.CatalogEntities()...); err != nil { t.Fatalf("AutoMigrate: %v", err) } if err := runtimeScope.Session().AutoMigrate(&metadata.ModelData{}); err != nil { diff --git a/internal/module/metaeff/recompute.go b/internal/module/metaeff/recompute.go deleted file mode 100644 index 635399d5..00000000 --- a/internal/module/metaeff/recompute.go +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -// Package metaeff owns incremental effective-catalog recompute for EDS-2+. -package metaeff - -import ( - "github.com/choysum-dev/choysum/pkg/meta" - "gorm.io/gorm" -) - -// LogicalKey identifies one effective catalog row. -type LogicalKey = meta.LogicalKey - -// RecomputeEffective rebuilds one effective projection from live raw rows. -func RecomputeEffective(tx *gorm.DB, application, name string) error { - return meta.RecomputeEffective(tx, application, name) -} - -// RecomputeKeys rebuilds effective projections for each logical key. -func RecomputeKeys(tx *gorm.DB, keys []LogicalKey) error { - return meta.RecomputeKeys(tx, keys) -} diff --git a/internal/module/metaeff/recompute_test.go b/internal/module/metaeff/recompute_test.go deleted file mode 100644 index 289268c2..00000000 --- a/internal/module/metaeff/recompute_test.go +++ /dev/null @@ -1,129 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package metaeff_test - -import ( - "database/sql" - "path/filepath" - "testing" - "time" - - "github.com/choysum-dev/choysum/internal/module/metaeff" - "github.com/choysum-dev/choysum/pkg/meta" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" -) - -func openMetaeffTestDB(t *testing.T) *gorm.DB { - t.Helper() - dsn := filepath.Join(t.TempDir(), "metaeff.sqlite") - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - if err != nil { - t.Fatalf("open: %v", err) - } - if err := meta.EnsureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - return db -} - -// Partner + bank + commercial §4-style fixture: union fields, stable effective id. -func TestRecomputeKeys_PartnerBankCommercialFixture(t *testing.T) { - db := openMetaeffTestDB(t) - ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - - partner := &meta.Model{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "raw-partner", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Application: "partner", - Path: "/partner/partner.ts", - ModuleId: sql.NullString{String: "mod-partner", Valid: true}, - Fields: []*meta.Field{{BaseModel: meta.BaseModel{Id: sql.NullString{String: "f-name", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name"}}, - } - bank := &meta.Model{ - BaseModel: meta.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", - Extends: "/partner/partner.ts", - ModuleId: sql.NullString{String: "mod-bank", Valid: true}, - Fields: []*meta.Field{{BaseModel: meta.BaseModel{Id: sql.NullString{String: "f-bank", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "BankAccounts"}}, - } - commercial := &meta.Model{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "raw-commercial", Valid: true}, CreatedAt: ts.Add(2 * time.Hour), UpdatedAt: ts.Add(2 * time.Hour)}, - Name: "Partner", - Application: "partner", - Path: "/partner_commercial/partner.ts", - Extends: "/partner/partner.ts", - ModuleId: sql.NullString{String: "mod-commercial", Valid: true}, - Fields: []*meta.Field{{BaseModel: meta.BaseModel{Id: sql.NullString{String: "f-vat", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Vat"}}, - } - for _, row := range []*meta.Model{partner, bank, commercial} { - if err := meta.PersistModelTreeAsRaw(db, row); err != nil { - t.Fatalf("persist declaration: %v", err) - } - } - - key := metaeff.LogicalKey{Application: "partner", Name: "Partner"} - if err := metaeff.RecomputeKeys(db, []metaeff.LogicalKey{key, key}); err != nil { - t.Fatalf("triangle recompute: %v", err) - } - var first meta.Model - if err := db.Preload("Fields").Where("application = ? AND name = ?", "partner", "Partner").Take(&first).Error; err != nil { - t.Fatalf("load effective: %v", err) - } - if len(first.Fields) != 3 { - t.Fatalf("want 3 union fields, got %d", len(first.Fields)) - } - firstID := first.Id.String - - // Uninstall commercial: field Vat gone, id stable. - if err := meta.DeleteDeclarationTrees(db, []string{commercial.Id.String}); err != nil { - t.Fatalf("delete commercial: %v", err) - } - if err := metaeff.RecomputeEffective(db, "partner", "Partner"); err != nil { - t.Fatalf("recompute after commercial: %v", err) - } - var afterCommercial meta.Model - if err := db.Preload("Fields").Where("application = ? AND name = ?", "partner", "Partner").Take(&afterCommercial).Error; err != nil { - t.Fatalf("load after commercial: %v", err) - } - if afterCommercial.Id.String != firstID { - t.Fatalf("effective id changed: %s -> %s", firstID, afterCommercial.Id.String) - } - if len(afterCommercial.Fields) != 2 { - t.Fatalf("want 2 fields after commercial uninstall, got %d", len(afterCommercial.Fields)) - } - - // Uninstall bank. - if err := meta.DeleteDeclarationTrees(db, []string{bank.Id.String}); err != nil { - t.Fatalf("delete bank: %v", err) - } - if err := metaeff.RecomputeEffective(db, "partner", "Partner"); err != nil { - t.Fatalf("recompute after bank: %v", err) - } - var afterBank meta.Model - if err := db.Preload("Fields").Where("application = ? AND name = ?", "partner", "Partner").Take(&afterBank).Error; err != nil { - t.Fatalf("load after bank: %v", err) - } - if afterBank.Id.String != firstID || len(afterBank.Fields) != 1 { - t.Fatalf("after bank: id=%s fields=%d", afterBank.Id.String, len(afterBank.Fields)) - } - - // Full uninstall: effective gone. - if err := meta.DeleteDeclarationTrees(db, []string{partner.Id.String}); err != nil { - t.Fatalf("delete partner: %v", err) - } - if err := metaeff.RecomputeEffective(db, "partner", "Partner"); err != nil { - t.Fatalf("recompute after full: %v", err) - } - var left int64 - if err := db.Model(&meta.Model{}).Where("application = ? AND name = ?", "partner", "Partner").Count(&left).Error; err != nil { - t.Fatalf("count: %v", err) - } - if left != 0 { - t.Fatalf("want no effective after full uninstall, got %d", left) - } -} diff --git a/modules/meta/i18n/meta.pot b/modules/meta/i18n/meta.pot index aa9f8e32..44f185bb 100644 --- a/modules/meta/i18n/meta.pot +++ b/modules/meta/i18n/meta.pot @@ -57,6 +57,21 @@ msgctxt "meta.model.MetaArgument.fields" msgid "Value" msgstr "" +#: service/models/argument_raw.ts:19 +msgctxt "meta.model.MetaArgumentRaw.fields" +msgid "Decorator" +msgstr "" + +#: service/models/argument_raw.ts:13 +msgctxt "meta.model.MetaArgumentRaw.fields" +msgid "Type" +msgstr "" + +#: service/models/argument_raw.ts:16 +msgctxt "meta.model.MetaArgumentRaw.fields" +msgid "Value" +msgstr "" + #: service/models/component.ts:22 msgctxt "meta.model.MetaComponent.fields" msgid "Extends" @@ -122,6 +137,31 @@ msgctxt "meta.model.MetaDecorator.fields" msgid "Service" msgstr "" +#: service/models/decorator_raw.ts:31 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Arguments" +msgstr "" + +#: service/models/decorator_raw.ts:25 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Field" +msgstr "" + +#: service/models/decorator_raw.ts:19 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Model" +msgstr "" + +#: service/models/decorator_raw.ts:16 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Name" +msgstr "" + +#: service/models/decorator_raw.ts:22 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Service" +msgstr "" + #: service/models/field.ts:69 msgctxt "meta.model.MetaField.fields" msgid "Accessibility Modifier" @@ -277,77 +317,107 @@ msgctxt "meta.model.MetaField.fields" msgid "Title Text" msgstr "" -#: service/models/model.ts:55 +#: service/models/field_raw.ts:33 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Decorators" +msgstr "" + +#: service/models/field_raw.ts:24 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Field Type" +msgstr "" + +#: service/models/field_raw.ts:27 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Model" +msgstr "" + +#: service/models/field_raw.ts:15 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Name" +msgstr "" + +#: service/models/field_raw.ts:21 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Origin Model Path" +msgstr "" + +#: service/models/field_raw.ts:18 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "TS Type Annotation" +msgstr "" + +#: service/models/model.ts:60 msgctxt "meta.model.MetaModel.fields" msgid "Abstract" msgstr "" -#: service/models/model.ts:46 +#: service/models/model.ts:51 msgctxt "meta.model.MetaModel.fields" msgid "Application" msgstr "" -#: service/models/model.ts:58 +#: service/models/model.ts:63 msgctxt "meta.model.MetaModel.fields" msgid "Auto Migrate" msgstr "" -#: service/models/model.ts:49 +#: service/models/model.ts:54 msgctxt "meta.model.MetaModel.fields" msgid "Class Name" msgstr "" -#: service/models/model.ts:70 +#: service/models/model.ts:75 msgctxt "meta.model.MetaModel.fields" msgid "Company Field" msgstr "" -#: service/models/model.ts:79 +#: service/models/model.ts:85 msgctxt "meta.model.MetaModel.fields" msgid "Decorators" msgstr "" -#: service/models/model.ts:67 +#: service/models/model.ts:72 msgctxt "meta.model.MetaModel.fields" msgid "Extends" msgstr "" -#: service/models/model.ts:93 +#: service/models/model.ts:99 msgctxt "meta.model.MetaModel.fields" msgid "Fields" msgstr "" -#: service/models/model.ts:73 +#: service/models/model.ts:78 msgctxt "meta.model.MetaModel.fields" msgid "Module" msgstr "" -#: service/models/model.ts:40 +#: service/models/model.ts:45 msgctxt "meta.model.MetaModel.fields" msgid "Name" msgstr "" -#: service/models/model.ts:43 +#: service/models/model.ts:48 msgctxt "meta.model.MetaModel.fields" msgid "Path" msgstr "" -#: service/models/model.ts:64 +#: service/models/model.ts:69 msgctxt "meta.model.MetaModel.fields" msgid "Raw Extends" msgstr "" -#: service/models/model.ts:61 +#: service/models/model.ts:66 msgctxt "meta.model.MetaModel.fields" msgid "Readonly" msgstr "" -#: service/models/model.ts:86 +#: service/models/model.ts:92 msgctxt "meta.model.MetaModel.fields" msgid "Services" msgstr "" -#: service/models/model.ts:52 +#: service/models/model.ts:57 msgctxt "meta.model.MetaModel.fields" msgid "Table Name" msgstr "" @@ -357,12 +427,12 @@ msgctxt "meta.model.MetaModelData.fields" msgid "Application" msgstr "" -#: service/models/model_data.ts:60 +#: service/models/model_data.ts:61 msgctxt "meta.model.MetaModelData.fields" msgid "Model" msgstr "" -#: service/models/model_data.ts:63 +#: service/models/model_data.ts:64 msgctxt "meta.model.MetaModelData.fields" msgid "Model Name" msgstr "" @@ -377,16 +447,91 @@ msgctxt "meta.model.MetaModelData.fields" msgid "Name" msgstr "" -#: service/models/model_data.ts:69 +#: service/models/model_data.ts:70 msgctxt "meta.model.MetaModelData.fields" msgid "No Update" msgstr "" -#: service/models/model_data.ts:66 +#: service/models/model_data.ts:67 msgctxt "meta.model.MetaModelData.fields" msgid "Resource Id" msgstr "" +#: service/models/model_raw.ts:36 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Abstract" +msgstr "" + +#: service/models/model_raw.ts:27 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Application" +msgstr "" + +#: service/models/model_raw.ts:39 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Auto Migrate" +msgstr "" + +#: service/models/model_raw.ts:30 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Class Name" +msgstr "" + +#: service/models/model_raw.ts:51 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Company Field" +msgstr "" + +#: service/models/model_raw.ts:65 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Decorators" +msgstr "" + +#: service/models/model_raw.ts:48 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Extends" +msgstr "" + +#: service/models/model_raw.ts:79 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Fields" +msgstr "" + +#: service/models/model_raw.ts:58 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Module" +msgstr "" + +#: service/models/model_raw.ts:21 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Name" +msgstr "" + +#: service/models/model_raw.ts:24 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Path" +msgstr "" + +#: service/models/model_raw.ts:45 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Raw Extends" +msgstr "" + +#: service/models/model_raw.ts:42 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Readonly" +msgstr "" + +#: service/models/model_raw.ts:72 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Services" +msgstr "" + +#: service/models/model_raw.ts:33 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Table Name" +msgstr "" + #: service/models/module.ts:137 #: service/models/module.ts:176 msgctxt "meta.model.MetaModule.fields" @@ -669,6 +814,16 @@ msgctxt "meta.model.MetaParameter.fields" msgid "TS Type Annotation" msgstr "" +#: service/models/parameter_raw.ts:13 +msgctxt "meta.model.MetaParameterRaw.fields" +msgid "Name" +msgstr "" + +#: service/models/parameter_raw.ts:16 +msgctxt "meta.model.MetaParameterRaw.fields" +msgid "Service" +msgstr "" + #: service/models/service.ts:23 msgctxt "meta.model.MetaService.fields" msgid "Accessibility Modifier" @@ -719,6 +874,46 @@ msgctxt "meta.model.MetaService.fields" msgid "Type Parameters" msgstr "" +#: service/models/service_raw.ts:23 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Accessibility Modifier" +msgstr "" + +#: service/models/service_raw.ts:49 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Decorators" +msgstr "" + +#: service/models/service_raw.ts:29 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Model" +msgstr "" + +#: service/models/service_raw.ts:17 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Name" +msgstr "" + +#: service/models/service_raw.ts:20 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Origin Model Path" +msgstr "" + +#: service/models/service_raw.ts:42 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Parameters" +msgstr "" + +#: service/models/service_raw.ts:26 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Static" +msgstr "" + +#: service/models/service_raw.ts:35 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Type Parameters" +msgstr "" + #: service/models/type_parameter.ts:16 msgctxt "meta.model.MetaTypeParameter.fields" msgid "Module Spec Path" @@ -739,6 +934,16 @@ msgctxt "meta.model.MetaTypeParameter.fields" msgid "Service" msgstr "" +#: service/models/type_parameter_raw.ts:13 +msgctxt "meta.model.MetaTypeParameterRaw.fields" +msgid "Name" +msgstr "" + +#: service/models/type_parameter_raw.ts:16 +msgctxt "meta.model.MetaTypeParameterRaw.fields" +msgid "Service" +msgstr "" + #: service/models/ui_resource.ts:127 msgctxt "meta.model.MetaUiResource.fields" msgid "Application" @@ -900,7 +1105,7 @@ msgctxt "meta.model.ModuleManagementLog.fields" msgid "Summary" msgstr "" -#: service/models/model_data.ts:80 +#: service/models/model_data.ts:81 msgctxt "service/models/model_data" msgid "external id %s not found" msgstr "" diff --git a/modules/meta/i18n/zh_CN.po b/modules/meta/i18n/zh_CN.po index 8da49728..41052d48 100644 --- a/modules/meta/i18n/zh_CN.po +++ b/modules/meta/i18n/zh_CN.po @@ -51,6 +51,21 @@ msgctxt "meta.model.MetaArgument.fields" msgid "Value" msgstr "值" +#: service/models/argument_raw.ts:19 +msgctxt "meta.model.MetaArgumentRaw.fields" +msgid "Decorator" +msgstr "装饰器" + +#: service/models/argument_raw.ts:13 +msgctxt "meta.model.MetaArgumentRaw.fields" +msgid "Type" +msgstr "类型" + +#: service/models/argument_raw.ts:16 +msgctxt "meta.model.MetaArgumentRaw.fields" +msgid "Value" +msgstr "值" + #: service/models/component.ts:22 msgctxt "meta.model.MetaComponent.fields" msgid "Extends" @@ -116,6 +131,31 @@ msgctxt "meta.model.MetaDecorator.fields" msgid "Service" msgstr "服务" +#: service/models/decorator_raw.ts:31 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Arguments" +msgstr "参数" + +#: service/models/decorator_raw.ts:25 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Field" +msgstr "字段" + +#: service/models/decorator_raw.ts:19 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Model" +msgstr "模型" + +#: service/models/decorator_raw.ts:16 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Name" +msgstr "名称" + +#: service/models/decorator_raw.ts:22 +msgctxt "meta.model.MetaDecoratorRaw.fields" +msgid "Service" +msgstr "服务" + #: service/models/field.ts:69 msgctxt "meta.model.MetaField.fields" msgid "Accessibility Modifier" @@ -271,77 +311,107 @@ msgctxt "meta.model.MetaField.fields" msgid "Title Text" msgstr "标题文本" -#: service/models/model.ts:55 +#: service/models/field_raw.ts:33 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Decorators" +msgstr "装饰器" + +#: service/models/field_raw.ts:24 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Field Type" +msgstr "字段类型" + +#: service/models/field_raw.ts:27 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Model" +msgstr "模型" + +#: service/models/field_raw.ts:15 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Name" +msgstr "名称" + +#: service/models/field_raw.ts:21 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "Origin Model Path" +msgstr "来源模型路径" + +#: service/models/field_raw.ts:18 +msgctxt "meta.model.MetaFieldRaw.fields" +msgid "TS Type Annotation" +msgstr "TS 类型注解" + +#: service/models/model.ts:60 msgctxt "meta.model.MetaModel.fields" msgid "Abstract" msgstr "抽象" -#: service/models/model.ts:46 +#: service/models/model.ts:51 msgctxt "meta.model.MetaModel.fields" msgid "Application" msgstr "应用" -#: service/models/model.ts:58 +#: service/models/model.ts:63 msgctxt "meta.model.MetaModel.fields" msgid "Auto Migrate" msgstr "自动迁移" -#: service/models/model.ts:49 +#: service/models/model.ts:54 msgctxt "meta.model.MetaModel.fields" msgid "Class Name" msgstr "类名" -#: service/models/model.ts:70 +#: service/models/model.ts:75 msgctxt "meta.model.MetaModel.fields" msgid "Company Field" msgstr "公司字段" -#: service/models/model.ts:79 +#: service/models/model.ts:85 msgctxt "meta.model.MetaModel.fields" msgid "Decorators" msgstr "装饰器" -#: service/models/model.ts:67 +#: service/models/model.ts:72 msgctxt "meta.model.MetaModel.fields" msgid "Extends" msgstr "扩展" -#: service/models/model.ts:93 +#: service/models/model.ts:99 msgctxt "meta.model.MetaModel.fields" msgid "Fields" msgstr "字段" -#: service/models/model.ts:73 +#: service/models/model.ts:78 msgctxt "meta.model.MetaModel.fields" msgid "Module" msgstr "模块" -#: service/models/model.ts:40 +#: service/models/model.ts:45 msgctxt "meta.model.MetaModel.fields" msgid "Name" msgstr "名称" -#: service/models/model.ts:43 +#: service/models/model.ts:48 msgctxt "meta.model.MetaModel.fields" msgid "Path" msgstr "路径" -#: service/models/model.ts:64 +#: service/models/model.ts:69 msgctxt "meta.model.MetaModel.fields" msgid "Raw Extends" msgstr "原始扩展" -#: service/models/model.ts:61 +#: service/models/model.ts:66 msgctxt "meta.model.MetaModel.fields" msgid "Readonly" msgstr "只读" -#: service/models/model.ts:86 +#: service/models/model.ts:92 msgctxt "meta.model.MetaModel.fields" msgid "Services" msgstr "服务" -#: service/models/model.ts:52 +#: service/models/model.ts:57 msgctxt "meta.model.MetaModel.fields" msgid "Table Name" msgstr "表名" @@ -351,12 +421,12 @@ msgctxt "meta.model.MetaModelData.fields" msgid "Application" msgstr "应用" -#: service/models/model_data.ts:60 +#: service/models/model_data.ts:61 msgctxt "meta.model.MetaModelData.fields" msgid "Model" msgstr "模型" -#: service/models/model_data.ts:63 +#: service/models/model_data.ts:64 msgctxt "meta.model.MetaModelData.fields" msgid "Model Name" msgstr "模型名" @@ -371,16 +441,91 @@ msgctxt "meta.model.MetaModelData.fields" msgid "Name" msgstr "名称" -#: service/models/model_data.ts:69 +#: service/models/model_data.ts:70 msgctxt "meta.model.MetaModelData.fields" msgid "No Update" msgstr "禁止更新" -#: service/models/model_data.ts:66 +#: service/models/model_data.ts:67 msgctxt "meta.model.MetaModelData.fields" msgid "Resource Id" msgstr "资源 ID" +#: service/models/model_raw.ts:36 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Abstract" +msgstr "抽象" + +#: service/models/model_raw.ts:27 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Application" +msgstr "应用" + +#: service/models/model_raw.ts:39 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Auto Migrate" +msgstr "自动迁移" + +#: service/models/model_raw.ts:30 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Class Name" +msgstr "类名" + +#: service/models/model_raw.ts:51 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Company Field" +msgstr "公司字段" + +#: service/models/model_raw.ts:65 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Decorators" +msgstr "装饰器" + +#: service/models/model_raw.ts:48 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Extends" +msgstr "扩展" + +#: service/models/model_raw.ts:79 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Fields" +msgstr "字段" + +#: service/models/model_raw.ts:58 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Module" +msgstr "模块" + +#: service/models/model_raw.ts:21 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Name" +msgstr "名称" + +#: service/models/model_raw.ts:24 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Path" +msgstr "路径" + +#: service/models/model_raw.ts:45 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Raw Extends" +msgstr "原始扩展" + +#: service/models/model_raw.ts:42 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Readonly" +msgstr "只读" + +#: service/models/model_raw.ts:72 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Services" +msgstr "服务" + +#: service/models/model_raw.ts:33 +msgctxt "meta.model.MetaModelRaw.fields" +msgid "Table Name" +msgstr "表名" + #: service/models/module.ts:137 service/models/module.ts:176 msgctxt "meta.model.MetaModule.fields" msgid "Application" @@ -661,6 +806,16 @@ msgctxt "meta.model.MetaParameter.fields" msgid "TS Type Annotation" msgstr "TS 类型注解" +#: service/models/parameter_raw.ts:13 +msgctxt "meta.model.MetaParameterRaw.fields" +msgid "Name" +msgstr "名称" + +#: service/models/parameter_raw.ts:16 +msgctxt "meta.model.MetaParameterRaw.fields" +msgid "Service" +msgstr "服务" + #: service/models/service.ts:23 msgctxt "meta.model.MetaService.fields" msgid "Accessibility Modifier" @@ -711,6 +866,46 @@ msgctxt "meta.model.MetaService.fields" msgid "Type Parameters" msgstr "类型参数" +#: service/models/service_raw.ts:23 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Accessibility Modifier" +msgstr "可访问性修饰符" + +#: service/models/service_raw.ts:49 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Decorators" +msgstr "装饰器" + +#: service/models/service_raw.ts:29 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Model" +msgstr "模型" + +#: service/models/service_raw.ts:17 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Name" +msgstr "名称" + +#: service/models/service_raw.ts:20 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Origin Model Path" +msgstr "来源模型路径" + +#: service/models/service_raw.ts:42 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Parameters" +msgstr "参数" + +#: service/models/service_raw.ts:26 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Static" +msgstr "静态" + +#: service/models/service_raw.ts:35 +msgctxt "meta.model.MetaServiceRaw.fields" +msgid "Type Parameters" +msgstr "类型参数" + #: service/models/type_parameter.ts:16 msgctxt "meta.model.MetaTypeParameter.fields" msgid "Module Spec Path" @@ -731,6 +926,16 @@ msgctxt "meta.model.MetaTypeParameter.fields" msgid "Service" msgstr "服务" +#: service/models/type_parameter_raw.ts:13 +msgctxt "meta.model.MetaTypeParameterRaw.fields" +msgid "Name" +msgstr "名称" + +#: service/models/type_parameter_raw.ts:16 +msgctxt "meta.model.MetaTypeParameterRaw.fields" +msgid "Service" +msgstr "服务" + #: service/models/ui_resource.ts:127 msgctxt "meta.model.MetaUiResource.fields" msgid "Application" @@ -891,7 +1096,7 @@ msgctxt "meta.model.ModuleManagementLog.fields" msgid "Summary" msgstr "摘要" -#: service/models/model_data.ts:80 +#: service/models/model_data.ts:81 msgctxt "service/models/model_data" msgid "external id %s not found" msgstr "未找到外部 ID %s" diff --git a/pkg/meta/acl_remap.go b/pkg/meta/acl_remap.go index 2b7c3450..d8e975e1 100644 --- a/pkg/meta/acl_remap.go +++ b/pkg/meta/acl_remap.go @@ -62,12 +62,12 @@ var ( } ) -// RemapACLToEffectiveModelIDs rewrites auth ACL meta_model_id (and related field/service +// 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 { +func remapACLToEffectiveModelIDs(db *gorm.DB) error { if db == nil { return fmt.Errorf("db is nil") } diff --git a/pkg/meta/acl_remap_coverage_test.go b/pkg/meta/acl_remap_coverage_test.go index cea4fa72..ad8bca83 100644 --- a/pkg/meta/acl_remap_coverage_test.go +++ b/pkg/meta/acl_remap_coverage_test.go @@ -45,7 +45,7 @@ func seedACLTables(t *testing.T, db *gorm.DB) { 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 { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("empty live: %v", err) } }) @@ -117,7 +117,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { rows = append(rows, Model{Name: "Ghost", Application: "auth"}) // Id invalid return rows, nil } - if err := RemapACLToEffectiveModelIDs(db); err != nil { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("remap: %v", err) } var mid string @@ -237,7 +237,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { t.Fatalf("norepl: %v", err) } - if err := RemapACLToEffectiveModelIDs(db); err != nil { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("happy path branches: %v", err) } @@ -248,7 +248,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "load live") { t.Fatalf("got %v", err) } }) @@ -256,7 +256,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "including deleted") { t.Fatalf("got %v", err) } }) @@ -264,7 +264,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "remap") { t.Fatalf("got %v", err) } }) @@ -272,7 +272,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "load field rules") { t.Fatalf("got %v", err) } }) @@ -280,7 +280,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "lookup field rule") { t.Fatalf("got %v", err) } }) @@ -288,7 +288,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "replacement field") { t.Fatalf("got %v", err) } }) @@ -306,7 +306,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { n++ return prev(db, sql, values...) } - if err := RemapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "field_id") { + 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 @@ -321,7 +321,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "load method access") { t.Fatalf("got %v", err) } }) @@ -329,7 +329,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "lookup method access") { t.Fatalf("got %v", err) } }) @@ -337,7 +337,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "lookup live service") { t.Fatalf("got %v", err) } }) @@ -345,7 +345,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "historical model") { t.Fatalf("got %v", err) } }) @@ -353,7 +353,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "lookup effective model") { t.Fatalf("got %v", err) } }) @@ -363,7 +363,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { aclLookupEffective = func(*gorm.DB, string, string) (*Model, error) { return &Model{}, nil // Id invalid } - if err := RemapACLToEffectiveModelIDs(db); err != nil { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("nil id should continue: %v", err) } }) @@ -384,7 +384,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { } 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 { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("blank hist application must not abort remap: %v", err) } }) @@ -392,7 +392,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(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") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "replacement service") { t.Fatalf("got %v", err) } }) @@ -406,7 +406,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { } return prev(db, sql, values...) } - if err := RemapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "service_id") { + if err := remapACLToEffectiveModelIDs(db); err == nil || !strings.Contains(err.Error(), "service_id") { t.Fatalf("got %v", err) } }) @@ -443,7 +443,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { // Leave Id invalid return nil } - if err := RemapACLToEffectiveModelIDs(db); err != nil { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("got %v", err) } }) @@ -457,7 +457,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { }).Error; err != nil { t.Fatalf("create model: %v", err) } - if err := RemapACLToEffectiveModelIDs(db); err != nil { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("no auth tables: %v", err) } }) @@ -469,7 +469,7 @@ func TestRemapACLToEffectiveModelIDs_CoverageBranches(t *testing.T) { return []Model{{Application: "a", Name: "X"}}, nil // invalid Id } db := openACLRemapDB(t, "acl-inv-live") - if err := RemapACLToEffectiveModelIDs(db); err != nil { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("got %v", err) } }) @@ -492,7 +492,7 @@ func TestMigrateAndRecompute_RemapACLWiring(t *testing.T) { 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") { + if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "index boom") { t.Fatalf("expected index error, got %v", err) } }) @@ -513,7 +513,7 @@ func TestMigrateAndRecompute_RemapACLWiring(t *testing.T) { 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") { + if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "remap boom") { t.Fatalf("expected remap error, got %v", err) } }) @@ -524,7 +524,7 @@ func TestMigrateAndRecompute_RemapACLWiring(t *testing.T) { t.Fatalf("ensure: %v", err) } ts := time.Now().UTC() - raw := &RawModel{ + 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}, } @@ -534,7 +534,7 @@ func TestMigrateAndRecompute_RemapACLWiring(t *testing.T) { 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") { + if err := recomputeAllEffectiveFromRaw(db); err == nil || !strings.Contains(err.Error(), "recompute index boom") { t.Fatalf("expected index error, got %v", err) } }) @@ -545,7 +545,7 @@ func TestMigrateAndRecompute_RemapACLWiring(t *testing.T) { t.Fatalf("ensure: %v", err) } ts := time.Now().UTC() - raw := &RawModel{ + 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}, } @@ -555,7 +555,7 @@ func TestMigrateAndRecompute_RemapACLWiring(t *testing.T) { 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") { + 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 index 41eed774..53b452bd 100644 --- a/pkg/meta/acl_remap_test.go +++ b/pkg/meta/acl_remap_test.go @@ -118,8 +118,8 @@ func TestRemapACLToEffectiveModelIDs(t *testing.T) { t.Fatalf("seed method access: %v", err) } - if err := RemapACLToEffectiveModelIDs(db); err != nil { - t.Fatalf("RemapACLToEffectiveModelIDs: %v", err) + if err := remapACLToEffectiveModelIDs(db); err != nil { + t.Fatalf("remapACLToEffectiveModelIDs: %v", err) } var rrModelID, frModelID, frFieldID, maModelID, maServiceID string @@ -143,7 +143,7 @@ func TestRemapACLToEffectiveModelIDs(t *testing.T) { } // Idempotent: values stay on effective ids. - if err := RemapACLToEffectiveModelIDs(db); err != nil { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("second RemapACL: %v", err) } var rr2, fr2Model, fr2Field, ma2Model, ma2Svc string @@ -160,7 +160,7 @@ func TestRemapACLToEffectiveModelIDs(t *testing.T) { 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 { + if err := remapACLToEffectiveModelIDs(nil); err == nil { t.Fatal("expected nil db error") } } @@ -216,7 +216,7 @@ func TestRemapACLToEffectiveModelIDs_FieldOnlyWhenModelAlreadyEffective(t *testi t.Fatalf("seed field rule: %v", err) } - if err := RemapACLToEffectiveModelIDs(db); err != nil { + if err := remapACLToEffectiveModelIDs(db); err != nil { t.Fatalf("RemapACL: %v", err) } var modelID, fieldID string diff --git a/pkg/meta/declaration.go b/pkg/meta/declaration.go index 657774c4..5a4c7c6f 100644 --- a/pkg/meta/declaration.go +++ b/pkg/meta/declaration.go @@ -30,7 +30,7 @@ func ListDeclarations(db *gorm.DB, q DeclarationQuery) ([]*Model, error) { if db == nil { return nil, fmt.Errorf("db is nil") } - query := db.Model(&RawModel{}) + query := db.Model(&rawModel{}) if app := strings.TrimSpace(q.Application); app != "" { query = query.Where("application = ?", app) } @@ -61,11 +61,11 @@ func ListDeclarations(db *gorm.DB, q DeclarationQuery) ([]*Model, error) { Preload("Decorators.Arguments", orderID) } - var raws []*RawModel + 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 + return rawModelsAsModels(raws), nil } // UpsertDeclaration ensures a declaration at (application, path) exists. @@ -85,7 +85,7 @@ func UpsertDeclaration(db *gorm.DB, src *Model) error { } return db.Transaction(func(tx *gorm.DB) error { - var existing RawModel + var existing rawModel err := tx.Where("path = ? AND application = ?", path, app). Order("created_at DESC, id DESC"). Take(&existing).Error @@ -108,7 +108,7 @@ func UpsertDeclaration(db *gorm.DB, src *Model) error { if s == nil || strings.TrimSpace(s.Name) == "" { continue } - var svc RawService + var svc rawService takeErr := tx.Where("model_id = ? AND name = ?", existing.Id.String, s.Name).Take(&svc).Error if takeErr == nil { continue @@ -146,7 +146,7 @@ func PreferDeclarationTip(db *gorm.DB, application, name, path string) error { return fmt.Errorf("prefer tip requires application, name, and path") } - var canonical RawModel + var canonical rawModel if err := db.Where("path = ? AND application = ?", path, application). Order("created_at DESC, id DESC"). Take(&canonical).Error; err != nil { @@ -156,7 +156,7 @@ func PreferDeclarationTip(db *gorm.DB, application, name, path string) error { return fmt.Errorf("tip declaration id is empty") } - var siblings []RawModel + 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 { @@ -207,14 +207,14 @@ func DeleteDeclarationTrees(db *gorm.DB, modelIDs []string) 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 { + 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 { + 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) + decoratorQ := fresh().Model(&rawDecorator{}).Where("model_id IN ?", ids) if len(serviceIDs) > 0 { decoratorQ = decoratorQ.Or("service_id IN ?", serviceIDs) } @@ -227,30 +227,30 @@ func DeleteDeclarationTrees(db *gorm.DB, modelIDs []string) error { } if len(decoratorIDs) > 0 { - if err := deleteWhereFn(fresh(), &RawArgument{}, "decorator_id IN ?", decoratorIDs); err != nil { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if err := deleteWhereFn(fresh(), &rawModel{}, "id IN ?", ids); err != nil { return fmt.Errorf("delete declaration models: %w", err) } return nil @@ -263,7 +263,7 @@ func HasDeclarationCatalog(db *gorm.DB) bool { return false } m := db.Migrator() - return m.HasTable((&RawModel{}).TableName()) && m.HasTable((&RawService{}).TableName()) + return m.HasTable((&rawModel{}).TableName()) && m.HasTable((&rawService{}).TableName()) } // HasEffectiveCatalog reports whether meta_model / meta_service exist. diff --git a/pkg/meta/declaration_test.go b/pkg/meta/declaration_test.go index f7a1dc21..bd843db5 100644 --- a/pkg/meta/declaration_test.go +++ b/pkg/meta/declaration_test.go @@ -101,21 +101,21 @@ func TestPreferDeclarationTip_PromotesCanonical(t *testing.T) { } older := time.Now().UTC().Add(-time.Hour) newer := older.Add(time.Minute) - ext := &RawModel{ + 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{ + 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} { + for _, row := range []*rawModel{ext, canon} { if err := db.Create(row).Error; err != nil { t.Fatal(err) } @@ -123,7 +123,7 @@ func TestPreferDeclarationTip_PromotesCanonical(t *testing.T) { if err := PreferDeclarationTip(db, "auth", "I18n", "go://i18n/auth"); err != nil { t.Fatal(err) } - var tip RawModel + var tip rawModel if err := db.Where("path = ?", "go://i18n/auth").Take(&tip).Error; err != nil { t.Fatal(err) } @@ -150,10 +150,10 @@ func TestDeleteDeclarationTrees_Cascade(t *testing.T) { t.Fatal(err) } var n int64 - if err := db.Unscoped().Model(&RawModel{}).Where("id = ?", id).Count(&n).Error; err != nil || n != 0 { + 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{}} { + 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) diff --git a/pkg/meta/dual_store_migrate.go b/pkg/meta/dual_store_migrate.go index b552dc7f..e5c9ee94 100644 --- a/pkg/meta/dual_store_migrate.go +++ b/pkg/meta/dual_store_migrate.go @@ -24,9 +24,9 @@ var execDDL = func(db *gorm.DB, sql string) error { // Test hooks (production defaults). Override in *_test.go to force error paths. var ( - countMetaRawModels = func(db *gorm.DB) (int64, error) { + countMetarawModels = func(db *gorm.DB) (int64, error) { var n int64 - err := db.Unscoped().Model(&RawModel{}).Count(&n).Error + err := db.Unscoped().Model(&rawModel{}).Count(&n).Error return n, err } loadIMDModelsForMigrate = func(tx *gorm.DB) ([]*Model, error) { @@ -45,8 +45,8 @@ var ( Find(&sources).Error return sources, err } - loadRawModelsForRecompute = func(tx *gorm.DB) ([]*RawModel, error) { - var raws []*RawModel + loadrawModelsForRecompute = func(tx *gorm.DB) ([]*rawModel, error) { + var raws []*rawModel err := tx. Preload("Fields"). Preload("Fields.Decorators"). @@ -65,7 +65,7 @@ var ( persistEffectiveProjectionFn = func(db *gorm.DB, merged *Model, effectiveID string) error { return persistEffectiveProjection(db, merged, effectiveID, nil) } - remapACLToEffectiveModelIDsFn = RemapACLToEffectiveModelIDs + remapACLToEffectiveModelIDsFn = remapACLToEffectiveModelIDs ) // EnsureDualStoreTables creates raw + effective catalog tables (idempotent). @@ -80,7 +80,7 @@ func EnsureDualStoreTables(db *gorm.DB) error { return nil } -// MigrateIMDCatalogToDualStore copies today's live meta_model* IMD rows into meta_raw_*, +// 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. @@ -94,7 +94,7 @@ func EnsureDualStoreTables(db *gorm.DB) error { // 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 { +func migrateIMDCatalogToDualStore(db *gorm.DB) error { if db == nil { return fmt.Errorf("db is nil") } @@ -102,7 +102,7 @@ func MigrateIMDCatalogToDualStore(db *gorm.DB) error { return err } - rawCount, err := countMetaRawModels(db) + rawCount, err := countMetarawModels(db) if err != nil { return fmt.Errorf("count meta_raw_model: %w", err) } @@ -165,11 +165,11 @@ func migrateIMDSources(tx *gorm.DB, sources []*Model) error { return recomputeAllEffectiveFromRawTx(tx) } -// RecomputeAllEffectiveFromRaw hard-deletes all effective meta_model* shape rows, then +// 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 { +func recomputeAllEffectiveFromRaw(db *gorm.DB) error { if db == nil { return fmt.Errorf("db is nil") } @@ -178,28 +178,33 @@ func RecomputeAllEffectiveFromRaw(db *gorm.DB) error { }); err != nil { return err } - if err := EnsureEffectiveAppNameUniqueIndex(db); err != nil { + if err := ensureEffectiveAppNameUniqueIndex(db); err != nil { return err } return remapACLToEffectiveModelIDsFn(db) } -// EnsureEffectiveAppNameUniqueIndex enforces one live effective row per (application, name). +// 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 { +func ensureEffectiveAppNameUniqueIndex(db *gorm.DB) error { if db == nil { return fmt.Errorf("db is nil") } - return ensureEffectiveAppNameUniqueIndex(db) + switch db.Dialector.Name() { + case "sqlite", "postgres": + return ensurePartialAliveAppNameUniqueIndex(db) + default: + return ensureFullAppNameUniqueIndex(db) + } } func recomputeAllEffectiveFromRawTx(tx *gorm.DB) error { - raws, err := loadRawModelsForRecompute(tx) + raws, err := loadrawModelsForRecompute(tx) if err != nil { return fmt.Errorf("load meta_raw_model: %w", err) } - groups := map[string][]*RawModel{} + groups := map[string][]*rawModel{} tipIDs := map[string]string{} for _, raw := range raws { key := logicalModelKey(raw.Application, raw.Name) @@ -216,7 +221,7 @@ func recomputeAllEffectiveFromRawTx(tx *gorm.DB) error { } for key, group := range groups { - merged, err := MergeEffectiveModel(group) + merged, err := mergeEffectiveModel(group) if err != nil { return fmt.Errorf("E2 merge %s: %w", key, err) } @@ -235,7 +240,7 @@ func logicalModelKey(application, name string) string { return strings.TrimSpace(application) + "\x00" + strings.TrimSpace(name) } -func findRawByID(raws []*RawModel, id string) *RawModel { +func findRawByID(raws []*rawModel, id string) *rawModel { for _, r := range raws { if r != nil && r.Id.String == id { return r @@ -244,7 +249,7 @@ func findRawByID(raws []*RawModel, id string) *RawModel { return nil } -func rawIsNewerTip(candidate, previous *RawModel) bool { +func rawIsNewerTip(candidate, previous *rawModel) bool { if previous == nil { return true } @@ -267,7 +272,7 @@ func ensureBaseModelID(b *BaseModel) { } func copyModelTreeToRaw(db *gorm.DB, src *Model) error { - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: src.Id, CreatedAt: src.CreatedAt, UpdatedAt: src.UpdatedAt}, Name: src.Name, Path: src.Path, @@ -316,7 +321,7 @@ func copyModelTreeToRaw(db *gorm.DB, src *Model) error { if p == nil { continue } - rp := &RawParameter{ + rp := &rawParameter{ BaseModel: BaseModel{Id: p.Id, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt}, Name: p.Name, TsTypeAnnotation: p.TsTypeAnnotation, @@ -332,7 +337,7 @@ func copyModelTreeToRaw(db *gorm.DB, src *Model) error { if tp == nil { continue } - rtp := &RawTypeParameter{ + rtp := &rawTypeParameter{ BaseModel: BaseModel{Id: tp.Id, CreatedAt: tp.CreatedAt, UpdatedAt: tp.UpdatedAt}, Name: tp.Name, ModuleSpecPath: tp.ModuleSpecPath, @@ -359,8 +364,8 @@ func copyModelTreeToRaw(db *gorm.DB, src *Model) error { return nil } -func rawFieldFromField(f *Field, modelID sql.NullString) *RawField { - return &RawField{ +func rawFieldFromField(f *Field, modelID sql.NullString) *rawField { + return &rawField{ BaseModel: BaseModel{Id: f.Id, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt}, Name: f.Name, TsTypeAnnotation: f.TsTypeAnnotation, @@ -403,8 +408,8 @@ func rawFieldFromField(f *Field, modelID sql.NullString) *RawField { } } -func rawServiceFromService(s *Service, modelID sql.NullString) *RawService { - return &RawService{ +func rawServiceFromService(s *Service, modelID sql.NullString) *rawService { + return &rawService{ BaseModel: BaseModel{Id: s.Id, CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt}, Name: s.Name, OriginModelPath: s.OriginModelPath, @@ -420,7 +425,7 @@ func copyDecoratorToRaw(db *gorm.DB, d *Decorator, modelID, fieldID, serviceID s if d == nil { return nil } - rd := &RawDecorator{ + rd := &rawDecorator{ BaseModel: BaseModel{Id: d.Id, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt}, Name: d.Name, ModuleSpecPath: d.ModuleSpecPath, @@ -437,7 +442,7 @@ func copyDecoratorToRaw(db *gorm.DB, d *Decorator, modelID, fieldID, serviceID s if a == nil { continue } - ra := &RawArgument{ + ra := &rawArgument{ BaseModel: BaseModel{Id: a.Id, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt}, Type: a.Type, Value: a.Value, @@ -491,7 +496,7 @@ func clearEffectiveShapeTrees(db *gorm.DB) error { // 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{ @@ -642,15 +647,6 @@ func persistDecoratorTree(db *gorm.DB, d *Decorator, modelID, fieldID, serviceID return nil } -func ensureEffectiveAppNameUniqueIndex(db *gorm.DB) error { - switch db.Dialector.Name() { - case "sqlite", "postgres": - return ensurePartialAliveAppNameUniqueIndex(db) - default: - return ensureFullAppNameUniqueIndex(db) - } -} - // ensurePartialAliveAppNameUniqueIndex creates the live-only unique index without a // drop-before-create window: temp index first, then replace the final name. func ensurePartialAliveAppNameUniqueIndex(db *gorm.DB) error { diff --git a/pkg/meta/dual_store_migrate_coverage_test.go b/pkg/meta/dual_store_migrate_coverage_test.go index 3212b3b6..3778dda3 100644 --- a/pkg/meta/dual_store_migrate_coverage_test.go +++ b/pkg/meta/dual_store_migrate_coverage_test.go @@ -32,7 +32,7 @@ func closeDualStoreDB(t *testing.T, db *gorm.DB) { } func TestEnsureEffectiveAppNameUniqueIndex_NilDB(t *testing.T) { - if err := EnsureEffectiveAppNameUniqueIndex(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { + if err := ensureEffectiveAppNameUniqueIndex(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { t.Fatalf("expected nil db error, got %v", err) } } @@ -53,7 +53,7 @@ func TestEnsureDualStoreTables_NilAndClosed(t *testing.T) { } func TestMigrateIMDCatalogToDualStore_NilAndCountError(t *testing.T) { - if err := MigrateIMDCatalogToDualStore(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { + 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) @@ -61,7 +61,7 @@ func TestMigrateIMDCatalogToDualStore_NilAndCountError(t *testing.T) { t.Fatalf("ensure: %v", err) } closeDualStoreDB(t, db) - if err := MigrateIMDCatalogToDualStore(db); err == nil { + if err := migrateIMDCatalogToDualStore(db); err == nil { t.Fatal("expected count/ensure error on closed db") } } @@ -84,7 +84,7 @@ func TestMigrateIMDCatalogToDualStore_DuplicateModulePath(t *testing.T) { t.Fatalf("create %s: %v", id, err) } } - if err := MigrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "duplicate live") { + if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "duplicate live") { t.Fatalf("expected duplicate path error, got %v", err) } } @@ -205,7 +205,7 @@ func TestMigrateIMDCatalogToDualStore_FullTreeCopy(t *testing.T) { // 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 { + if err := migrateIMDCatalogToDualStore(db); err != nil { t.Fatalf("migrate: %v", err) } @@ -265,7 +265,7 @@ func TestMigrateIMDCatalogToDualStore_FullTreeCopy(t *testing.T) { } func TestRecomputeAllEffectiveFromRaw_NilAndErrors(t *testing.T) { - if err := RecomputeAllEffectiveFromRaw(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { + if err := recomputeAllEffectiveFromRaw(nil); err == nil || !strings.Contains(err.Error(), "db is nil") { t.Fatalf("expected nil db error, got %v", err) } @@ -274,7 +274,7 @@ func TestRecomputeAllEffectiveFromRaw_NilAndErrors(t *testing.T) { t.Fatalf("ensure: %v", err) } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "id-older", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", @@ -284,7 +284,7 @@ func TestRecomputeAllEffectiveFromRaw_NilAndErrors(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - newer := &RawModel{ + newer := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "id-newer", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", @@ -294,7 +294,7 @@ func TestRecomputeAllEffectiveFromRaw_NilAndErrors(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(newer).Error; err != nil { t.Fatalf("create newer: %v", err) } - olderTime := &RawModel{ + olderTime := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "zzz", Valid: true}, CreatedAt: ts.Add(-time.Hour), UpdatedAt: ts}, Name: "Y", Application: "b", @@ -304,7 +304,7 @@ func TestRecomputeAllEffectiveFromRaw_NilAndErrors(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(olderTime).Error; err != nil { t.Fatalf("create olderTime: %v", err) } - younger := &RawModel{ + younger := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "aaa", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Y", Application: "b", @@ -315,7 +315,7 @@ func TestRecomputeAllEffectiveFromRaw_NilAndErrors(t *testing.T) { t.Fatalf("create younger: %v", err) } - if err := RecomputeAllEffectiveFromRaw(db); err != nil { + if err := recomputeAllEffectiveFromRaw(db); err != nil { t.Fatalf("recompute: %v", err) } @@ -342,7 +342,7 @@ func TestRecomputeAllEffectiveFromRaw_MergeError(t *testing.T) { t.Fatalf("ensure: %v", err) } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", @@ -352,7 +352,7 @@ func TestRecomputeAllEffectiveFromRaw_MergeError(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - f := &RawField{ + f := &rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "f1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Kind", FieldType: "selection", @@ -373,7 +373,7 @@ func TestRecomputeAllEffectiveFromRaw_MergeError(t *testing.T) { 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") { + if err := recomputeAllEffectiveFromRaw(db); err == nil || !strings.Contains(err.Error(), "selectionAdd") { t.Fatalf("expected merge error, got %v", err) } } @@ -384,19 +384,19 @@ func TestRecomputeAllEffectiveFromRaw_TxErrorOnClosed(t *testing.T) { t.Fatalf("ensure: %v", err) } closeDualStoreDB(t, db) - if err := RecomputeAllEffectiveFromRaw(db); err == nil { + 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 { + 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 { + 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) { @@ -405,11 +405,11 @@ func TestFindRawByIDAndRawIsNewerTip(t *testing.T) { 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)}} + 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)}} + 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") } @@ -421,7 +421,7 @@ func TestCopyModelTreeToRaw_CreateFailures(t *testing.T) { t.Fatalf("ensure: %v", err) } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - existing := &RawModel{ + existing := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "existing", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", @@ -452,7 +452,7 @@ func TestCopyModelTreeToRaw_ChildTableFailures(t *testing.T) { }{ { name: "raw field", - drop: &RawField{}, + drop: &rawField{}, src: &Model{ BaseModel: BaseModel{Id: sql.NullString{String: "m", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", @@ -467,7 +467,7 @@ func TestCopyModelTreeToRaw_ChildTableFailures(t *testing.T) { }, { name: "raw service", - drop: &RawService{}, + drop: &rawService{}, src: &Model{ BaseModel: BaseModel{Id: sql.NullString{String: "m2", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Y", @@ -482,7 +482,7 @@ func TestCopyModelTreeToRaw_ChildTableFailures(t *testing.T) { }, { name: "raw parameter", - drop: &RawParameter{}, + drop: &rawParameter{}, src: &Model{ BaseModel: BaseModel{Id: sql.NullString{String: "m3", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Z", @@ -498,7 +498,7 @@ func TestCopyModelTreeToRaw_ChildTableFailures(t *testing.T) { }, { name: "raw type parameter", - drop: &RawTypeParameter{}, + drop: &rawTypeParameter{}, src: &Model{ BaseModel: BaseModel{Id: sql.NullString{String: "m4", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "W", @@ -514,7 +514,7 @@ func TestCopyModelTreeToRaw_ChildTableFailures(t *testing.T) { }, { name: "raw decorator", - drop: &RawDecorator{}, + drop: &rawDecorator{}, src: &Model{ BaseModel: BaseModel{Id: sql.NullString{String: "m5", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "V", @@ -529,7 +529,7 @@ func TestCopyModelTreeToRaw_ChildTableFailures(t *testing.T) { }, { name: "raw argument", - drop: &RawArgument{}, + drop: &rawArgument{}, src: &Model{ BaseModel: BaseModel{Id: sql.NullString{String: "m6", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "U", @@ -921,7 +921,7 @@ func TestMigrateIMDCatalogToDualStore_FindError(t *testing.T) { 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") { + if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "load meta_model") { t.Fatalf("expected load meta_model error, got %v", err) } } @@ -931,12 +931,12 @@ func TestMigrateIMDCatalogToDualStore_CountError(t *testing.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) { + 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") { + if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "count meta_raw_model") { t.Fatalf("expected count error, got %v", err) } } @@ -947,23 +947,23 @@ func TestRecomputeHooks_LoadClearPersistErrorsAndEmptyTip(t *testing.T) { t.Fatalf("ensure: %v", err) } - prevLoad, prevClear, prevPersist := loadRawModelsForRecompute, clearEffectiveShapeTreesFn, persistEffectiveProjectionFn + prevLoad, prevClear, prevPersist := loadrawModelsForRecompute, clearEffectiveShapeTreesFn, persistEffectiveProjectionFn t.Cleanup(func() { - loadRawModelsForRecompute = prevLoad + loadrawModelsForRecompute = prevLoad clearEffectiveShapeTreesFn = prevClear persistEffectiveProjectionFn = prevPersist }) - loadRawModelsForRecompute = func(*gorm.DB) ([]*RawModel, error) { + 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") { + 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{{ + 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", @@ -971,19 +971,19 @@ func TestRecomputeHooks_LoadClearPersistErrorsAndEmptyTip(t *testing.T) { }}, nil } clearEffectiveShapeTreesFn = func(*gorm.DB) error { return errors.New("clear boom") } - if err := RecomputeAllEffectiveFromRaw(db); err == nil || !strings.Contains(err.Error(), "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") { + 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{{ + loadrawModelsForRecompute = func(*gorm.DB) ([]*rawModel, error) { + return []*rawModel{{ BaseModel: BaseModel{Id: sql.NullString{String: "", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "EmptyTip", Application: "a", @@ -994,7 +994,7 @@ func TestRecomputeHooks_LoadClearPersistErrorsAndEmptyTip(t *testing.T) { return persistEffectiveProjection(db, merged, effectiveID, nil) } clearEffectiveShapeTreesFn = clearEffectiveShapeTrees - if err := RecomputeAllEffectiveFromRaw(db); err != nil { + if err := recomputeAllEffectiveFromRaw(db); err != nil { t.Fatalf("empty tip recompute: %v", err) } var count int64 @@ -1059,7 +1059,7 @@ func TestCopyModelTreeToRaw_NestedDecoratorFailures(t *testing.T) { 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 { + if err := db.Migrator().DropTable(&rawDecorator{}); err != nil { t.Fatalf("drop: %v", err) } if err := copyModelTreeToRaw(db, src); err == nil { @@ -1082,7 +1082,7 @@ func TestCopyModelTreeToRaw_NestedDecoratorFailures(t *testing.T) { 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 { + if err := db2.Migrator().DropTable(&rawDecorator{}); err != nil { t.Fatalf("drop2: %v", err) } if err := copyModelTreeToRaw(db2, src2); err == nil { @@ -1134,7 +1134,7 @@ func TestMigrateIMDSources_CopyErrorPropagates(t *testing.T) { t.Fatalf("ensure: %v", err) } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - existing := &RawModel{ + existing := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "ex", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", diff --git a/pkg/meta/dual_store_migrate_test.go b/pkg/meta/dual_store_migrate_test.go index 7e96766c..078a0895 100644 --- a/pkg/meta/dual_store_migrate_test.go +++ b/pkg/meta/dual_store_migrate_test.go @@ -65,7 +65,7 @@ func TestPersistModelTreeAsRaw_AssignsIDsWhenMissing(t *testing.T) { t.Fatalf("PersistModelTreeAsRaw: %v", err) } - var raw RawModel + 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 { @@ -162,12 +162,12 @@ func TestMigrateIMDCatalogToDualStore_CollapsesSameNameToEffective(t *testing.T) } } - if err := MigrateIMDCatalogToDualStore(db); err != nil { - t.Fatalf("MigrateIMDCatalogToDualStore: %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 { + if err := db.Model(&rawModel{}).Count(&rawCount).Error; err != nil { t.Fatalf("count raw: %v", err) } if rawCount != 2 { @@ -256,7 +256,7 @@ func TestMigrateIMDCatalogToDualStore_RejectsMissingModuleId(t *testing.T) { 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") { + if err := migrateIMDCatalogToDualStore(db); err == nil || !strings.Contains(err.Error(), "missing module_id") { t.Fatalf("expected missing module_id error, got %v", err) } } @@ -266,7 +266,7 @@ func TestMigrateIMDCatalogToDualStore_RefusesNonEmptyRaw(t *testing.T) { if err := EnsureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}}, Name: "X", Application: "a", @@ -276,7 +276,7 @@ func TestMigrateIMDCatalogToDualStore_RefusesNonEmptyRaw(t *testing.T) { 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 { + if err := migrateIMDCatalogToDualStore(db); err == nil { t.Fatal("expected refuse when raw non-empty") } } @@ -299,8 +299,8 @@ func TestMigrateIMDCatalogToDualStore_EmptySourcesIsNoOp(t *testing.T) { 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) + 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 { @@ -317,7 +317,7 @@ func TestRecomputeAllEffectiveFromRaw_OmitsThisParameter(t *testing.T) { t.Fatalf("ensure: %v", err) } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "r-svc", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Partner", Application: "partner", @@ -327,7 +327,7 @@ func TestRecomputeAllEffectiveFromRaw_OmitsThisParameter(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - svc := &RawService{ + svc := &rawService{ BaseModel: BaseModel{Id: sql.NullString{String: "s1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Create", ModelId: raw.Id, @@ -336,7 +336,7 @@ func TestRecomputeAllEffectiveFromRaw_OmitsThisParameter(t *testing.T) { t.Fatalf("create service: %v", err) } for i, name := range []string{"this", "vals"} { - p := &RawParameter{ + p := &rawParameter{ BaseModel: BaseModel{Id: sql.NullString{String: fmt.Sprintf("p%d", i), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: name, ServiceId: svc.Id, @@ -345,8 +345,8 @@ func TestRecomputeAllEffectiveFromRaw_OmitsThisParameter(t *testing.T) { t.Fatalf("create param %s: %v", name, err) } } - if err := RecomputeAllEffectiveFromRaw(db); err != nil { - t.Fatalf("RecomputeAllEffectiveFromRaw: %v", 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 { @@ -359,7 +359,7 @@ func TestRecomputeAllEffectiveFromRaw_OmitsThisParameter(t *testing.T) { if len(params) != 1 || params[0].Name != "vals" { t.Fatalf("expected only vals on effective service, got %#v", params) } - var rawParams []*RawParameter + var rawParams []*rawParameter if err := db.Where("service_id = ?", svc.Id).Find(&rawParams).Error; err != nil { t.Fatalf("load raw params: %v", err) } diff --git a/pkg/meta/effective_merge.go b/pkg/meta/effective_merge.go index 07c4858d..663663d9 100644 --- a/pkg/meta/effective_merge.go +++ b/pkg/meta/effective_merge.go @@ -133,14 +133,14 @@ func MergeSameNameModelsByExtensionChain(models []*Model) (*Model, error) { return &merged, nil } -// MergeEffectiveModel runs E2 over declaration-layer RawModel rows. -func MergeEffectiveModel(raws []*RawModel) (*Model, error) { - return MergeSameNameModelsByExtensionChain(RawModelsAsModels(raws)) +// mergeEffectiveModel runs E2 over declaration-layer rawModel rows. +func mergeEffectiveModel(raws []*rawModel) (*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 Model trees for E2 merge. // IDs and timestamps are preserved for ranking; FK targets are not DB-backed. -func RawModelsAsModels(raws []*RawModel) []*Model { +func rawModelsAsModels(raws []*rawModel) []*Model { out := make([]*Model, 0, len(raws)) for _, raw := range raws { if raw == nil { @@ -151,7 +151,7 @@ func RawModelsAsModels(raws []*RawModel) []*Model { return out } -func rawModelAsModel(raw *RawModel) *Model { +func rawModelAsModel(raw *rawModel) *Model { m := &Model{ BaseModel: raw.BaseModel, Name: raw.Name, @@ -188,7 +188,7 @@ func rawModelAsModel(raw *RawModel) *Model { return m } -func rawFieldAsField(f *RawField) *Field { +func rawFieldAsField(f *rawField) *Field { out := &Field{ BaseModel: f.BaseModel, Name: f.Name, @@ -238,7 +238,7 @@ func rawFieldAsField(f *RawField) *Field { return out } -func rawServiceAsService(s *RawService) *Service { +func rawServiceAsService(s *rawService) *Service { svc := &Service{ BaseModel: s.BaseModel, Name: s.Name, @@ -281,7 +281,7 @@ func rawServiceAsService(s *RawService) *Service { return svc } -func rawDecoratorAsDecorator(d *RawDecorator) *Decorator { +func rawDecoratorAsDecorator(d *rawDecorator) *Decorator { out := &Decorator{ BaseModel: d.BaseModel, Name: d.Name, diff --git a/pkg/meta/effective_merge_coverage_test.go b/pkg/meta/effective_merge_coverage_test.go index 8a63b420..3700fb37 100644 --- a/pkg/meta/effective_merge_coverage_test.go +++ b/pkg/meta/effective_merge_coverage_test.go @@ -142,48 +142,48 @@ func TestMergeSameNameModelsByExtensionChain_SameUpdatedAtIdTieBreak(t *testing. } func TestRawModelsAsModels_SkipsNilAndMapsTrees(t *testing.T) { - raws := []*RawModel{ + raws := []*rawModel{ nil, { BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}}, Name: "X", Path: "/x.ts", - Fields: []*RawField{ + Fields: []*rawField{ nil, { Name: "Name", - Decorators: []*RawDecorator{ + Decorators: []*rawDecorator{ nil, - {Name: "Field", Arguments: []*RawArgument{nil, {Type: "object", Value: "{}"}}}, + {Name: "Field", Arguments: []*rawArgument{nil, {Type: "object", Value: "{}"}}}, }, }, }, - Services: []*RawService{ + Services: []*rawService{ nil, { Name: "Create", - Parameters: []*RawParameter{ + Parameters: []*rawParameter{ nil, {Name: "this"}, {Name: "vals"}, }, - TypeParameters: []*RawTypeParameter{ + TypeParameters: []*rawTypeParameter{ nil, {Name: "T", ModuleSpecPath: "/m", ReferenceIdent: "T"}, }, - Decorators: []*RawDecorator{ + Decorators: []*rawDecorator{ nil, {Name: "Rpc"}, }, }, }, - Decorators: []*RawDecorator{ + Decorators: []*rawDecorator{ nil, - {Name: "Model", Arguments: []*RawArgument{{Type: "string", Value: `"X"`}}}, + {Name: "Model", Arguments: []*rawArgument{{Type: "string", Value: `"X"`}}}, }, }, } - models := RawModelsAsModels(raws) + models := rawModelsAsModels(raws) if len(models) != 1 { t.Fatalf("models = %d", len(models)) } diff --git a/pkg/meta/effective_merge_test.go b/pkg/meta/effective_merge_test.go index 85aa0ff4..299a2d08 100644 --- a/pkg/meta/effective_merge_test.go +++ b/pkg/meta/effective_merge_test.go @@ -60,24 +60,24 @@ func TestMergeSameNameModelsByExtensionChain_PartnerStyleUnion(t *testing.T) { func TestMergeEffectiveModel_FromRaw(t *testing.T) { basePath := "@/partner/service/models/partner.ts" bankPath := "@/partner_bank/service/models/partner.ts" - raws := []*RawModel{ + raws := []*rawModel{ { 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: []*RawField{{Name: "Name"}}, + 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}}, Name: "Partner", Path: bankPath, Extends: basePath, - Fields: []*RawField{{Name: "BankAccounts"}}, + Fields: []*rawField{{Name: "BankAccounts"}}, }, } - merged, err := MergeEffectiveModel(raws) + merged, err := mergeEffectiveModel(raws) if err != nil { - t.Fatalf("MergeEffectiveModel: %v", err) + t.Fatalf("mergeEffectiveModel: %v", err) } names := map[string]bool{} for _, f := range merged.Fields { @@ -91,22 +91,22 @@ func TestMergeEffectiveModel_FromRaw(t *testing.T) { } func TestMergeEffectiveModel_OmitsThisParameter(t *testing.T) { - raws := []*RawModel{{ + raws := []*rawModel{{ BaseModel: 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", - Services: []*RawService{{ + Services: []*rawService{{ Name: "Create", - Parameters: []*RawParameter{ + Parameters: []*rawParameter{ {Name: "this"}, {Name: "vals"}, }, }}, }} - merged, err := MergeEffectiveModel(raws) + merged, err := mergeEffectiveModel(raws) if err != nil { - t.Fatalf("MergeEffectiveModel: %v", err) + t.Fatalf("mergeEffectiveModel: %v", err) } if merged == nil || len(merged.Services) != 1 { t.Fatalf("expected one service, got %#v", merged) @@ -200,7 +200,7 @@ func TestRawFieldAndFieldExportedColumnsMatch(t *testing.T) { "ModelId": true, "Model": true, "Decorators": true, } fieldType := reflect.TypeOf(Field{}) - rawType := reflect.TypeOf(RawField{}) + rawType := reflect.TypeOf(rawField{}) fieldCols := map[string]reflect.Type{} for i := 0; i < fieldType.NumField(); i++ { f := fieldType.Field(i) @@ -218,15 +218,15 @@ 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("Field cols=%d rawField cols=%d", len(fieldCols), len(rawCols)) } for name, typ := range fieldCols { got, ok := rawCols[name] if !ok { - t.Fatalf("RawField missing column %s", name) + 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, Field.%s type %v", name, got, name, typ) } } } diff --git a/pkg/meta/extends_expand.go b/pkg/meta/extends_expand.go index 5567ab7d..2c701195 100644 --- a/pkg/meta/extends_expand.go +++ b/pkg/meta/extends_expand.go @@ -149,11 +149,11 @@ func resolveExtendsModel( if len(raws) == 0 { return nil, nil } - converted := RawModelsAsModels([]*RawModel{&raws[0]}) + converted := rawModelsAsModels([]*rawModel{&raws[0]}) return converted[0], nil } -func loadRawParentsByPath(db *gorm.DB, extendsPath, application string) ([]RawModel, error) { +func loadRawParentsByPath(db *gorm.DB, extendsPath, application string) ([]rawModel, error) { q := db. Preload("Fields", func(tx *gorm.DB) *gorm.DB { return tx.Order("id ASC") }). Preload("Fields.Decorators", func(tx *gorm.DB) *gorm.DB { return tx.Order("id ASC") }). @@ -167,7 +167,7 @@ func loadRawParentsByPath(db *gorm.DB, extendsPath, application string) ([]RawMo if application != "" { q = q.Where("application = ?", application) } - var raws []RawModel + var raws []rawModel if err := q.Order("id DESC").Find(&raws).Error; err != nil { return nil, fmt.Errorf("load raw parent by path %s: %w", extendsPath, err) } diff --git a/pkg/meta/extends_expand_coverage_test.go b/pkg/meta/extends_expand_coverage_test.go index 36240ea3..9fc51649 100644 --- a/pkg/meta/extends_expand_coverage_test.go +++ b/pkg/meta/extends_expand_coverage_test.go @@ -43,7 +43,7 @@ func TestExpandModelsAlongExtends_CacheHitViaTwoChildren(t *testing.T) { t.Fatalf("ensure: %v", err) } parentPath := "/parent.ts" - parent := &RawModel{ + parent := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "raw-p", Valid: true}}, Name: "Parent", Path: parentPath, @@ -52,14 +52,14 @@ func TestExpandModelsAlongExtends_CacheHitViaTwoChildren(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(parent).Error; err != nil { t.Fatalf("create parent: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: 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{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawService{ BaseModel: BaseModel{Id: sql.NullString{String: "ps", Valid: true}}, Name: "ParentSvc", ModelId: parent.Id, @@ -126,7 +126,7 @@ func TestResolveExtendsModel_DBError(t *testing.T) { if err := EnsureDualStoreTables(db); err != nil { t.Fatalf("ensure: %v", err) } - if err := db.Migrator().DropTable(&RawModel{}); err != nil { + 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"}}} @@ -144,7 +144,7 @@ func TestExpandModelsAlongExtends_PrefersSameApplicationParent(t *testing.T) { homeCreated := time.Now().UTC().Add(-time.Hour) foreignCreated := homeCreated.Add(time.Minute) // Newer foreign-app parent with a distinctive field would win if path-only. - foreign := &RawModel{ + foreign := &rawModel{ BaseModel: BaseModel{ Id: sql.NullString{String: "foreign-parent", Valid: true}, CreatedAt: foreignCreated, @@ -155,7 +155,7 @@ func TestExpandModelsAlongExtends_PrefersSameApplicationParent(t *testing.T) { Application: "other", ModuleId: sql.NullString{String: "mod-other", Valid: true}, } - home := &RawModel{ + home := &rawModel{ BaseModel: BaseModel{ Id: sql.NullString{String: "home-parent", Valid: true}, CreatedAt: homeCreated, @@ -166,19 +166,19 @@ func TestExpandModelsAlongExtends_PrefersSameApplicationParent(t *testing.T) { Application: "home", ModuleId: sql.NullString{String: "mod-home", Valid: true}, } - for _, row := range []*RawModel{home, foreign} { + for _, row := range []*rawModel{home, foreign} { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(row).Error; err != nil { t.Fatalf("create parent: %v", err) } } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: 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{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "hf", Valid: true}}, Name: "HomeOnly", ModelId: home.Id, @@ -214,7 +214,7 @@ func TestExpandModelsAlongExtends_BatchKeepsPerApplicationParentShapes(t *testin parentPath := "/shared/base-batch.ts" homeCreated := time.Now().UTC().Add(-time.Hour) foreignCreated := homeCreated.Add(time.Minute) - foreign := &RawModel{ + foreign := &rawModel{ BaseModel: BaseModel{ Id: sql.NullString{String: "batch-foreign-parent", Valid: true}, CreatedAt: foreignCreated, @@ -225,7 +225,7 @@ func TestExpandModelsAlongExtends_BatchKeepsPerApplicationParentShapes(t *testin Application: "other", ModuleId: sql.NullString{String: "mod-other", Valid: true}, } - home := &RawModel{ + home := &rawModel{ BaseModel: BaseModel{ Id: sql.NullString{String: "batch-home-parent", Valid: true}, CreatedAt: homeCreated, @@ -236,19 +236,19 @@ func TestExpandModelsAlongExtends_BatchKeepsPerApplicationParentShapes(t *testin Application: "home", ModuleId: sql.NullString{String: "mod-home", Valid: true}, } - for _, row := range []*RawModel{home, foreign} { + for _, row := range []*rawModel{home, foreign} { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(row).Error; err != nil { t.Fatalf("create parent: %v", err) } } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: 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{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "batch-hf", Valid: true}}, Name: "HomeOnly", ModelId: home.Id, @@ -301,7 +301,7 @@ func TestExpandModelsAlongExtends_CrossAppParentFallback(t *testing.T) { t.Fatalf("ensure: %v", err) } parentPath := "/shared/only-other.ts" - foreign := &RawModel{ + foreign := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "only-other", Valid: true}}, Name: "Base", Path: parentPath, @@ -311,7 +311,7 @@ func TestExpandModelsAlongExtends_CrossAppParentFallback(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(foreign).Error; err != nil { t.Fatalf("create foreign parent: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "of", Valid: true}}, Name: "OtherField", ModelId: foreign.Id, @@ -663,7 +663,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { t.Fatalf("ensure: %v", err) } parentPath := "/db/parent.ts" - parent := &RawModel{ + parent := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "db-parent", Valid: true}}, Name: "DbParent", Path: parentPath, @@ -672,7 +672,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(parent).Error; err != nil { t.Fatalf("create parent: %v", err) } - field := &RawField{ + field := &rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "dbf", Valid: true}}, Name: "F", ModelId: parent.Id, @@ -680,7 +680,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(field).Error; err != nil { t.Fatalf("create field: %v", err) } - fieldDec := &RawDecorator{ + fieldDec := &rawDecorator{ BaseModel: BaseModel{Id: sql.NullString{String: "dbfd", Valid: true}}, Name: "FieldDec", FieldId: sql.NullString{String: field.Id.String, Valid: true}, @@ -688,7 +688,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(fieldDec).Error; err != nil { t.Fatalf("create field dec: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawArgument{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawArgument{ BaseModel: BaseModel{Id: sql.NullString{String: "dbfa", Valid: true}}, Type: "string", Value: `"x"`, @@ -696,7 +696,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { }).Error; err != nil { t.Fatalf("create field arg: %v", err) } - svc := &RawService{ + svc := &rawService{ BaseModel: BaseModel{Id: sql.NullString{String: "dbs", Valid: true}}, Name: "Svc", ModelId: parent.Id, @@ -704,7 +704,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svc).Error; err != nil { t.Fatalf("create service: %v", err) } - svcDec := &RawDecorator{ + svcDec := &rawDecorator{ BaseModel: BaseModel{Id: sql.NullString{String: "dbsd", Valid: true}}, Name: "SvcDec", ServiceId: svc.Id, @@ -712,7 +712,7 @@ func TestResolveExtendsModel_FullPreloadFromDB(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svcDec).Error; err != nil { t.Fatalf("create svc dec: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawArgument{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawArgument{ BaseModel: BaseModel{Id: sql.NullString{String: "dbsa", Valid: true}}, Type: "string", Value: `"y"`, diff --git a/pkg/meta/extends_expand_test.go b/pkg/meta/extends_expand_test.go index 5d54d959..7ed75955 100644 --- a/pkg/meta/extends_expand_test.go +++ b/pkg/meta/extends_expand_test.go @@ -25,7 +25,7 @@ func TestExpandModelsAlongExtends_MergesParentFields(t *testing.T) { parentPath := "/core/service/orm/model/model.ts" childPath := "/base/service/models/address.ts" - parent := &RawModel{ + parent := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "raw-parent", Valid: true}}, Name: "BaseModel", Path: parentPath, @@ -35,7 +35,7 @@ func TestExpandModelsAlongExtends_MergesParentFields(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(parent).Error; err != nil { t.Fatalf("create parent: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "f-id", Valid: true}}, Name: "Id", ResolvedSpec: `{"fieldName":"Id","structural":{"name":"Id","fieldType":"varchar"},"migration":{"shouldCreateColumn":true,"resolvedColumnType":"varchar"}}`, @@ -43,7 +43,7 @@ func TestExpandModelsAlongExtends_MergesParentFields(t *testing.T) { }).Error; err != nil { t.Fatalf("create parent field: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawService{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawService{ BaseModel: BaseModel{Id: sql.NullString{String: "s-create", Valid: true}}, Name: "Create", ModelId: parent.Id, diff --git a/pkg/meta/facade.go b/pkg/meta/facade.go new file mode 100644 index 00000000..7a429dbc --- /dev/null +++ b/pkg/meta/facade.go @@ -0,0 +1,122 @@ +// 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 new file mode 100644 index 00000000..9cab0a19 --- /dev/null +++ b/pkg/meta/facade_coverage_test.go @@ -0,0 +1,202 @@ +// 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 new file mode 100644 index 00000000..8e93d2aa --- /dev/null +++ b/pkg/meta/facade_test.go @@ -0,0 +1,120 @@ +// 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_model.go b/pkg/meta/meta_model.go index bad818f6..6c9077c7 100644 --- a/pkg/meta/meta_model.go +++ b/pkg/meta/meta_model.go @@ -9,7 +9,7 @@ import ( // Model is the effective (E2) logical-model projection stored in meta_model. // After dual-store (EDS), each live (application, name) has at most one row; -// per-module/file declarations live in RawModel / meta_raw_model. +// per-module/file declarations live in rawModel / meta_raw_model. // UNIQUE(application, name) on live rows is applied by dual-store migrate/backfill // (not via a GORM tag yet — IMD multi-row persists until EDS-2 rewires Persist). type Model struct { @@ -34,7 +34,7 @@ type Model struct { Services []*Service `gorm:"foreignKey:ModelId;constraint:OnDelete:CASCADE;" json:"services"` Fields []*Field `gorm:"foreignKey:ModelId;constraint:OnDelete:CASCADE;" json:"fields"` // ModuleId is legacy for IMD declaration rows. Effective projections leave it empty - // after dual-store recompute (EDS-2+); prefer RawModel.ModuleId for module ownership. + // after dual-store recompute (EDS-2+); prefer rawModel.ModuleId for module ownership. ModuleId sql.NullString `gorm:"type:char(20)" json:"module_id"` Module *Module `gorm:"foreignKey:ModuleId" json:"module"` } diff --git a/pkg/meta/meta_raw_argument.go b/pkg/meta/meta_raw_argument.go index 5b445014..ceba66b0 100644 --- a/pkg/meta/meta_raw_argument.go +++ b/pkg/meta/meta_raw_argument.go @@ -5,8 +5,8 @@ package meta import "database/sql" -// RawArgument is a declaration-layer decorator argument. -type RawArgument struct { +// rawArgument is a declaration-layer decorator argument. +type rawArgument struct { BaseModel `gorm:"embedded"` Type string `gorm:"type:varchar" json:"ts_type"` @@ -16,9 +16,9 @@ type RawArgument struct { ModuleSpecPath string `gorm:"type:varchar" json:"module_spec_path"` DecoratorId sql.NullString `gorm:"type:char(20)" json:"decorator_id"` - Decorator *RawDecorator `gorm:"foreignKey:DecoratorId" json:"decorator"` + Decorator *rawDecorator `gorm:"foreignKey:DecoratorId" json:"decorator"` } -func (arg *RawArgument) TableName() string { +func (arg *rawArgument) TableName() string { return "meta_raw_argument" } diff --git a/pkg/meta/meta_raw_decorator.go b/pkg/meta/meta_raw_decorator.go index cd1adbbe..27f44d37 100644 --- a/pkg/meta/meta_raw_decorator.go +++ b/pkg/meta/meta_raw_decorator.go @@ -5,8 +5,8 @@ package meta import "database/sql" -// RawDecorator is a declaration-layer decorator under Raw Model/Field/Service. -type RawDecorator struct { +// rawDecorator is a declaration-layer decorator under Raw Model/Field/Service. +type rawDecorator struct { BaseModel `gorm:"embedded"` Name string `gorm:"type:varchar(255);not null" json:"name"` @@ -14,18 +14,18 @@ type RawDecorator struct { ModuleSpecPath string `gorm:"type:varchar(255);" json:"module_spec_path"` ReferenceIdent string `gorm:"type:varchar(255);" json:"reference_ident"` - Arguments []*RawArgument `gorm:"foreignKey:DecoratorId;constraint:OnDelete:CASCADE;" json:"arguments"` + Arguments []*rawArgument `gorm:"foreignKey:DecoratorId;constraint:OnDelete:CASCADE;" json:"arguments"` ModelId sql.NullString `gorm:"type:char(20)" json:"model_id"` - Model *RawModel `gorm:"foreignKey:ModelId" json:"model"` + Model *rawModel `gorm:"foreignKey:ModelId" json:"model"` ServiceId sql.NullString `gorm:"type:char(20)" json:"service_id"` - Service *RawService `gorm:"foreignKey:ServiceId" json:"service"` + Service *rawService `gorm:"foreignKey:ServiceId" json:"service"` FieldId sql.NullString `gorm:"type:char(20)" json:"field_id"` - Field *RawField `gorm:"foreignKey:FieldId" json:"field"` + Field *rawField `gorm:"foreignKey:FieldId" json:"field"` } -func (dec *RawDecorator) TableName() string { +func (dec *rawDecorator) TableName() string { return "meta_raw_decorator" } diff --git a/pkg/meta/meta_raw_field.go b/pkg/meta/meta_raw_field.go index 6ae28b16..7b1390c7 100644 --- a/pkg/meta/meta_raw_field.go +++ b/pkg/meta/meta_raw_field.go @@ -9,8 +9,8 @@ import ( "strings" ) -// RawField is a declaration-layer field row under RawModel (table meta_raw_field). -type RawField struct { +// rawField is a declaration-layer field row under rawModel (table meta_raw_field). +type rawField struct { BaseModel `gorm:"embedded"` Name string `gorm:"type:varchar(255);not null;index:idx_raw_model_field_name,unique" json:"name"` @@ -73,12 +73,12 @@ type RawField struct { ResolvedSpec string `gorm:"type:text" json:"resolved_spec,omitempty"` ModelId sql.NullString `gorm:"type:char(20);index:idx_raw_model_field_name,unique" json:"model_id"` - Model *RawModel `gorm:"foreignKey:ModelId" json:"model"` + Model *rawModel `gorm:"foreignKey:ModelId" json:"model"` - Decorators []*RawDecorator `gorm:"foreignKey:FieldId;constraint:OnDelete:CASCADE;" json:"decorators"` + Decorators []*rawDecorator `gorm:"foreignKey:FieldId;constraint:OnDelete:CASCADE;" json:"decorators"` } -func (field *RawField) TableName() string { +func (field *rawField) TableName() string { return "meta_raw_field" } @@ -88,7 +88,7 @@ var ( rawFieldJSONUnmarshal = json.Unmarshal ) -func (field *RawField) SetResolvedSpec(spec *FieldResolvedSpec) error { +func (field *rawField) SetResolvedSpec(spec *FieldResolvedSpec) error { if field == nil { return nil } @@ -104,7 +104,7 @@ func (field *RawField) SetResolvedSpec(spec *FieldResolvedSpec) error { return nil } -func (field *RawField) GetResolvedSpec() (*FieldResolvedSpec, error) { +func (field *rawField) GetResolvedSpec() (*FieldResolvedSpec, error) { if field == nil { return nil, nil } diff --git a/pkg/meta/meta_raw_field_test.go b/pkg/meta/meta_raw_field_test.go index 8f7aa0e5..0852a35b 100644 --- a/pkg/meta/meta_raw_field_test.go +++ b/pkg/meta/meta_raw_field_test.go @@ -11,14 +11,14 @@ import ( func TestRawField_SetResolvedSpec(t *testing.T) { t.Run("nil receiver", func(t *testing.T) { - var field *RawField + var field *rawField if err := field.SetResolvedSpec(&FieldResolvedSpec{FieldName: "x"}); err != nil { t.Fatalf("nil receiver: unexpected error: %v", err) } }) t.Run("nil spec clears ResolvedSpec", func(t *testing.T) { - field := &RawField{ResolvedSpec: `{"fieldName":"old"}`} + field := &rawField{ResolvedSpec: `{"fieldName":"old"}`} if err := field.SetResolvedSpec(nil); err != nil { t.Fatalf("nil spec: unexpected error: %v", err) } @@ -28,7 +28,7 @@ func TestRawField_SetResolvedSpec(t *testing.T) { }) t.Run("valid spec serialises to JSON", func(t *testing.T) { - field := &RawField{} + field := &rawField{} spec := &FieldResolvedSpec{ FieldName: "amount", Structural: FieldStructuralSpec{ @@ -57,7 +57,7 @@ func TestRawField_SetResolvedSpec(t *testing.T) { rawFieldJSONMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal boom") } - field := &RawField{} + field := &rawField{} if err := field.SetResolvedSpec(&FieldResolvedSpec{FieldName: "x"}); err == nil || err.Error() != "marshal boom" { t.Fatalf("expected marshal boom, got %v", err) } @@ -66,7 +66,7 @@ func TestRawField_SetResolvedSpec(t *testing.T) { func TestRawField_GetResolvedSpec(t *testing.T) { t.Run("nil receiver", func(t *testing.T) { - var field *RawField + var field *rawField spec, err := field.GetResolvedSpec() if err != nil || spec != nil { t.Fatalf("expected nil,nil got %#v %v", spec, err) @@ -74,7 +74,7 @@ func TestRawField_GetResolvedSpec(t *testing.T) { }) t.Run("empty ResolvedSpec", func(t *testing.T) { - field := &RawField{ResolvedSpec: " "} + field := &rawField{ResolvedSpec: " "} spec, err := field.GetResolvedSpec() if err != nil || spec != nil { t.Fatalf("expected nil,nil got %#v %v", spec, err) @@ -82,7 +82,7 @@ func TestRawField_GetResolvedSpec(t *testing.T) { }) t.Run("round trip", func(t *testing.T) { - field := &RawField{} + field := &rawField{} want := &FieldResolvedSpec{FieldName: "n"} if err := field.SetResolvedSpec(want); err != nil { t.Fatalf("set: %v", err) @@ -97,7 +97,7 @@ func TestRawField_GetResolvedSpec(t *testing.T) { }) t.Run("unmarshal error", func(t *testing.T) { - field := &RawField{ResolvedSpec: "{not-json"} + field := &rawField{ResolvedSpec: "{not-json"} _, err := field.GetResolvedSpec() if err == nil { t.Fatal("expected unmarshal error") diff --git a/pkg/meta/meta_raw_model.go b/pkg/meta/meta_raw_model.go index d6f2f1e0..5935f8f8 100644 --- a/pkg/meta/meta_raw_model.go +++ b/pkg/meta/meta_raw_model.go @@ -7,9 +7,9 @@ import ( "database/sql" ) -// RawModel is a per-module/file declaration row (IMD may have many rows per logical name). +// 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). -type RawModel struct { +type rawModel struct { BaseModel `gorm:"embedded"` Name string `gorm:"type:varchar(255);not null;index:idx_raw_model_app_name" json:"name"` @@ -27,13 +27,13 @@ type RawModel struct { CompanyField *string `gorm:"type:varchar(255);" json:"company_field"` - Decorators []*RawDecorator `gorm:"foreignKey:ModelId;constraint:OnDelete:CASCADE;" json:"decorators"` - Services []*RawService `gorm:"foreignKey:ModelId;constraint:OnDelete:CASCADE;" json:"services"` - Fields []*RawField `gorm:"foreignKey:ModelId;constraint:OnDelete:CASCADE;" json:"fields"` + Decorators []*rawDecorator `gorm:"foreignKey:ModelId;constraint:OnDelete:CASCADE;" json:"decorators"` + 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"` } -func (model *RawModel) TableName() string { +func (model *rawModel) TableName() string { return "meta_raw_model" } diff --git a/pkg/meta/meta_raw_parameter.go b/pkg/meta/meta_raw_parameter.go index ae517cae..084fd1b0 100644 --- a/pkg/meta/meta_raw_parameter.go +++ b/pkg/meta/meta_raw_parameter.go @@ -7,17 +7,17 @@ import ( "database/sql" ) -// RawParameter is a declaration-layer parameter under RawService. -type RawParameter struct { +// 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"` ServiceId sql.NullString `gorm:"type:char(20)" json:"service_id"` - Service *RawService `gorm:"foreignKey:ServiceId" json:"service"` + Service *rawService `gorm:"foreignKey:ServiceId" json:"service"` } -func (param *RawParameter) TableName() string { +func (param *rawParameter) TableName() string { return "meta_raw_parameter" } diff --git a/pkg/meta/meta_raw_service.go b/pkg/meta/meta_raw_service.go index dbd5a4e8..a6a8fcd3 100644 --- a/pkg/meta/meta_raw_service.go +++ b/pkg/meta/meta_raw_service.go @@ -7,8 +7,8 @@ import ( "database/sql" ) -// RawService is a declaration-layer service row under RawModel. -type RawService struct { +// 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"` @@ -19,14 +19,14 @@ type RawService struct { ProtobufType string `gorm:"type:varchar(255);" json:"protobuf_type"` IsStatic bool `json:"is_static"` - TypeParameters []*RawTypeParameter `gorm:"foreignKey:ServiceId;constraint:OnDelete:CASCADE;" json:"type_parameters"` - Parameters []*RawParameter `gorm:"foreignKey:ServiceId;constraint:OnDelete:CASCADE;" json:"parameters"` + TypeParameters []*rawTypeParameter `gorm:"foreignKey:ServiceId;constraint:OnDelete:CASCADE;" json:"type_parameters"` + Parameters []*rawParameter `gorm:"foreignKey:ServiceId;constraint:OnDelete:CASCADE;" json:"parameters"` ModelId sql.NullString `gorm:"type:char(20);index:idx_raw_model_service_name,unique" json:"model_id"` - Model *RawModel `gorm:"foreignKey:ModelId" json:"model"` + Model *rawModel `gorm:"foreignKey:ModelId" json:"model"` - Decorators []*RawDecorator `gorm:"foreignKey:ServiceId;constraint:OnDelete:CASCADE;" json:"decorators"` + Decorators []*rawDecorator `gorm:"foreignKey:ServiceId;constraint:OnDelete:CASCADE;" json:"decorators"` } -func (svc *RawService) TableName() string { +func (svc *rawService) TableName() string { return "meta_raw_service" } diff --git a/pkg/meta/meta_raw_typeparameter.go b/pkg/meta/meta_raw_typeparameter.go index e6e3119c..083fdacc 100644 --- a/pkg/meta/meta_raw_typeparameter.go +++ b/pkg/meta/meta_raw_typeparameter.go @@ -7,17 +7,17 @@ import ( "database/sql" ) -// RawTypeParameter is a declaration-layer type parameter under RawService. -type RawTypeParameter struct { +// 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"` ServiceId sql.NullString `gorm:"type:char(20)" json:"service_id"` - Service *RawService `gorm:"foreignKey:ServiceId" json:"service"` + Service *rawService `gorm:"foreignKey:ServiceId" json:"service"` } -func (tp *RawTypeParameter) TableName() string { +func (tp *rawTypeParameter) TableName() string { return "meta_raw_type_parameter" } diff --git a/pkg/meta/model.go b/pkg/meta/model.go index 6383a6f1..c534a89b 100644 --- a/pkg/meta/model.go +++ b/pkg/meta/model.go @@ -28,43 +28,38 @@ func (m *BaseModel) BeforeCreate(tx *gorm.DB) error { return nil } -// Entities returns the core model set for external use. +// 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). func Entities() []any { - return []any{ + out := []any{ &Application{}, &Module{}, &Component{}, - &Model{}, - &Field{}, - &Service{}, - &TypeParameter{}, - &Parameter{}, - &Decorator{}, - &Argument{}, &UiResource{}, &UiResourceMenuRoute{}, &UiResourceRouteAction{}, - // Declaration layer (EDS raw dual-store). Empty until Persist is rewired (EDS-2). - &RawModel{}, - &RawField{}, - &RawService{}, - &RawTypeParameter{}, - &RawParameter{}, - &RawDecorator{}, - &RawArgument{}, } + 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{}, + &rawModel{}, + &rawField{}, + &rawService{}, + &rawTypeParameter{}, + &rawParameter{}, + &rawDecorator{}, + &rawArgument{}, } } diff --git a/pkg/meta/model_test.go b/pkg/meta/model_test.go index 235b791d..c8039d2f 100644 --- a/pkg/meta/model_test.go +++ b/pkg/meta/model_test.go @@ -36,6 +36,9 @@ func TestEntitiesAndTableNames(t *testing.T) { reflect.TypeOf(&Application{}), reflect.TypeOf(&Module{}), reflect.TypeOf(&Component{}), + reflect.TypeOf(&UiResource{}), + reflect.TypeOf(&UiResourceMenuRoute{}), + reflect.TypeOf(&UiResourceRouteAction{}), reflect.TypeOf(&Model{}), reflect.TypeOf(&Field{}), reflect.TypeOf(&Service{}), @@ -43,16 +46,6 @@ func TestEntitiesAndTableNames(t *testing.T) { reflect.TypeOf(&Parameter{}), reflect.TypeOf(&Decorator{}), reflect.TypeOf(&Argument{}), - reflect.TypeOf(&UiResource{}), - reflect.TypeOf(&UiResourceMenuRoute{}), - reflect.TypeOf(&UiResourceRouteAction{}), - reflect.TypeOf(&RawModel{}), - reflect.TypeOf(&RawField{}), - reflect.TypeOf(&RawService{}), - reflect.TypeOf(&RawTypeParameter{}), - reflect.TypeOf(&RawParameter{}), - reflect.TypeOf(&RawDecorator{}), - reflect.TypeOf(&RawArgument{}), } if len(entities) != len(expectedTypes) { @@ -64,6 +57,29 @@ 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 @@ -82,13 +98,13 @@ 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"}, + {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 new file mode 100644 index 00000000..fc969f1b --- /dev/null +++ b/pkg/meta/raw_facade.go @@ -0,0 +1,45 @@ +// 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/recompute.go b/pkg/meta/recompute.go index 26884826..62c9090d 100644 --- a/pkg/meta/recompute.go +++ b/pkg/meta/recompute.go @@ -15,10 +15,10 @@ import ( // Test hooks (production defaults). Override in *_test.go to force error paths. var ( - lockLogicalKeyFn = lockLogicalKey - expandModelsAlongExtendsFn = ExpandModelsAlongExtends + lockLogicalKeyFn = lockLogicalKey + expandModelsAlongExtendsFn = ExpandModelsAlongExtends mergeSameNameModelsByExtensionChainFn = MergeSameNameModelsByExtensionChain - deleteWhereFn = func(db *gorm.DB, value interface{}, query interface{}, args ...interface{}) error { + deleteWhereFn = func(db *gorm.DB, value interface{}, query interface{}, args ...interface{}) error { return db.Where(query, args...).Delete(value).Error } ) @@ -63,6 +63,12 @@ func RecomputeKeys(tx *gorm.DB, keys []LogicalKey) error { return nil } +// FlushEffective rebuilds effective projections for the given keys. Prefer this name +// at install/persist boundaries; RecomputeKeys remains as the historical alias. +func FlushEffective(tx *gorm.DB, keys []LogicalKey) error { + return RecomputeKeys(tx, keys) +} + // 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. @@ -86,7 +92,7 @@ func recomputeEffectiveTx(tx *gorm.DB, key LogicalKey) error { return err } - var raws []*RawModel + var raws []*rawModel if err := tx. Preload("Fields"). Preload("Fields.Decorators"). @@ -111,7 +117,7 @@ func recomputeEffectiveTx(tx *gorm.DB, key LogicalKey) error { if len(raws) == 0 { for _, row := range existing { if row.Id.Valid { - if err := DeleteEffectiveModelTree(tx, row.Id.String); err != nil { + if err := deleteEffectiveModelTree(tx, row.Id.String); err != nil { return err } } @@ -119,7 +125,7 @@ func recomputeEffectiveTx(tx *gorm.DB, key LogicalKey) error { return nil } - models := RawModelsAsModels(raws) + models := rawModelsAsModels(raws) // Pull differently-named Extends parents (e.g. BaseModel) into each declaration // before E2 same-name union — replaces pre-persist materialize (EDS4). if err := expandModelsAlongExtendsFn(tx, models); err != nil { @@ -166,7 +172,7 @@ func recomputeEffectiveTx(tx *gorm.DB, key LogicalKey) error { for _, row := range existing { if row.Id.Valid { - if err := DeleteEffectiveModelTree(tx, row.Id.String); err != nil { + if err := deleteEffectiveModelTree(tx, row.Id.String); err != nil { return err } } @@ -198,8 +204,8 @@ func loadEffectiveServiceIDsByName(tx *gorm.DB, modelID string) (map[string]stri return out, nil } -func pickTipRaw(raws []*RawModel) *RawModel { - var tip *RawModel +func pickTipRaw(raws []*rawModel) *rawModel { + var tip *rawModel for _, raw := range raws { if raw == nil { continue @@ -229,9 +235,9 @@ func lockLogicalKey(tx *gorm.DB, key LogicalKey) error { return nil } -// DeleteEffectiveModelTree hard-deletes one effective model and its shape children. +// deleteEffectiveModelTree hard-deletes one effective model and its shape children. // IDs are plucked first so SQLite does not reject DELETE … WHERE id IN (SELECT … same table). -func DeleteEffectiveModelTree(db *gorm.DB, modelID string) error { +func deleteEffectiveModelTree(db *gorm.DB, modelID string) error { modelID = strings.TrimSpace(modelID) if modelID == "" { return nil @@ -290,13 +296,6 @@ func DeleteEffectiveModelTree(db *gorm.DB, modelID string) error { return nil } -// PersistEffectiveProjection writes one E2-merged model as the effective tree. -// Exported for migrate helpers and metaeff. Service ids are minted fresh unless -// callers use persistEffectiveProjection with a reuse map (RecomputeEffective). -func PersistEffectiveProjection(db *gorm.DB, merged *Model, effectiveID string) error { - return persistEffectiveProjection(db, merged, effectiveID, nil) -} - // PersistModelTreeAsRaw copies a declaration Model tree into meta_raw_*. func PersistModelTreeAsRaw(db *gorm.DB, src *Model) error { return copyModelTreeToRaw(db, src) @@ -314,7 +313,7 @@ func DeleteRawModelsForModule(db *gorm.DB, moduleID string) error { } fresh := db.Session(&gorm.Session{NewDB: true}).Unscoped() var modelIDs []string - if err := fresh.Model(&RawModel{}).Where("module_id = ?", moduleID).Pluck("id", &modelIDs).Error; err != nil { + if err := fresh.Model(&rawModel{}).Where("module_id = ?", moduleID).Pluck("id", &modelIDs).Error; err != nil { return fmt.Errorf("load raw models: %w", err) } return DeleteDeclarationTrees(db, modelIDs) diff --git a/pkg/meta/recompute_coverage_test.go b/pkg/meta/recompute_coverage_test.go index 1f5f22f1..394afa21 100644 --- a/pkg/meta/recompute_coverage_test.go +++ b/pkg/meta/recompute_coverage_test.go @@ -21,7 +21,7 @@ func TestRecomputeKeys_NilAndFailure(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", @@ -51,7 +51,7 @@ func TestRecomputeEffective_PostgresLockBranch(t *testing.T) { db := openRecomputeTestDB(t) db.Dialector = namedDialector{Dialector: db.Dialector, name: "postgres"} ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "pg-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "PgModel", Application: "pg", @@ -112,21 +112,21 @@ func TestRecomputeEffective_TipSelectionAmongExisting(t *testing.T) { } } - rawTip := &RawModel{ + rawTip := &rawModel{ BaseModel: 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{ + 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} { + 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) } @@ -158,7 +158,7 @@ func TestRecomputeEffective_TipSelectionAmongExisting(t *testing.T) { func TestRecomputeEffective_EffIDFromXidWhenNoExistingAndEmptyRawTip(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Fresh", Application: "fresh", @@ -223,20 +223,20 @@ 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)}} - tip := pickTipRaw([]*RawModel{nil, a, nil, b}) + 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)}} + tip := pickTipRaw([]*rawModel{nil, a, nil, b}) if tip == nil || tip.Id.String != "b" { t.Fatalf("pickTipRaw: got %#v", tip) } } func TestDeleteEffectiveModelTree_Coverage(t *testing.T) { - if err := DeleteEffectiveModelTree(nil, ""); err != nil { + if err := deleteEffectiveModelTree(nil, ""); err != nil { t.Fatalf("empty modelID no-op: %v", err) } db := openRecomputeTestDB(t) - if err := DeleteEffectiveModelTree(db, " "); err != nil { + if err := deleteEffectiveModelTree(db, " "); err != nil { t.Fatalf("whitespace no-op: %v", err) } @@ -316,7 +316,7 @@ func TestDeleteEffectiveModelTree_Coverage(t *testing.T) { t.Fatalf("create model dec: %v", err) } - if err := DeleteEffectiveModelTree(db, eff.Id.String); err != nil { + if err := deleteEffectiveModelTree(db, eff.Id.String); err != nil { t.Fatalf("delete tree: %v", err) } var count int64 @@ -360,14 +360,14 @@ func TestDeleteEffectiveModelTree_Coverage(t *testing.T) { _ = 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.Migrator().DropTable(tc.drop) - if err := DeleteEffectiveModelTree(db2, eff2.Id.String); err == nil { + if err := deleteEffectiveModelTree(db2, eff2.Id.String); err == nil { t.Fatalf("expected delete error after dropping %s", tc.name) } }) } } -func TestPersistEffectiveProjection_ExportedWrapper(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{ @@ -377,8 +377,8 @@ func TestPersistEffectiveProjection_ExportedWrapper(t *testing.T) { Path: "/wrap.ts", Fields: []*Field{{Name: "Name"}}, } - if err := PersistEffectiveProjection(db, merged, "wrap-eff"); err != nil { - t.Fatalf("PersistEffectiveProjection: %v", err) + 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 { @@ -403,7 +403,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) modID := "mod-del" - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "raw-del", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "RawDel", Application: "raw", @@ -413,7 +413,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - field := &RawField{ + field := &rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "rf", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: raw.Id, @@ -421,7 +421,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(field).Error; err != nil { t.Fatalf("create field: %v", err) } - fieldDec := &RawDecorator{ + fieldDec := &rawDecorator{ BaseModel: BaseModel{Id: sql.NullString{String: "rfd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Field", FieldId: sql.NullString{String: field.Id.String, Valid: true}, @@ -429,7 +429,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(fieldDec).Error; err != nil { t.Fatalf("create field dec: %v", err) } - fieldArg := &RawArgument{ + fieldArg := &rawArgument{ BaseModel: BaseModel{Id: sql.NullString{String: "rfa", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Type: "string", Value: `"x"`, @@ -438,7 +438,7 @@ func TestDeleteRawModelsForModule_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 := &RawService{ + svc := &rawService{ BaseModel: BaseModel{Id: sql.NullString{String: "rs", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Create", ModelId: raw.Id, @@ -446,7 +446,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svc).Error; err != nil { t.Fatalf("create service: %v", err) } - param := &RawParameter{ + param := &rawParameter{ BaseModel: BaseModel{Id: sql.NullString{String: "rp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "vals", ServiceId: svc.Id, @@ -454,7 +454,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(param).Error; err != nil { t.Fatalf("create param: %v", err) } - tp := &RawTypeParameter{ + tp := &rawTypeParameter{ BaseModel: BaseModel{Id: sql.NullString{String: "rtp", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "T", ServiceId: svc.Id, @@ -462,7 +462,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(tp).Error; err != nil { t.Fatalf("create tp: %v", err) } - svcDec := &RawDecorator{ + svcDec := &rawDecorator{ BaseModel: BaseModel{Id: sql.NullString{String: "rsd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Rpc", ServiceId: svc.Id, @@ -470,7 +470,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(svcDec).Error; err != nil { t.Fatalf("create svc dec: %v", err) } - modelDec := &RawDecorator{ + modelDec := &rawDecorator{ BaseModel: BaseModel{Id: sql.NullString{String: "rmd", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Model", ModelId: raw.Id, @@ -483,7 +483,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { t.Fatalf("delete raw module: %v", err) } var count int64 - if err := db.Model(&RawModel{}).Where("module_id = ?", modID).Count(&count).Error; err != nil { + if err := db.Model(&rawModel{}).Where("module_id = ?", modID).Count(&count).Error; err != nil { t.Fatalf("count raw: %v", err) } if count != 0 { @@ -494,18 +494,18 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { name string drop any }{ - {"raw models pluck", &RawModel{}}, - {"raw services", &RawService{}}, - {"raw fields", &RawField{}}, - {"raw decorators", &RawDecorator{}}, - {"raw arguments", &RawArgument{}}, - {"raw type parameters", &RawTypeParameter{}}, - {"raw parameters", &RawParameter{}}, + {"raw models pluck", &rawModel{}}, + {"raw services", &rawService{}}, + {"raw fields", &rawField{}}, + {"raw decorators", &rawDecorator{}}, + {"raw arguments", &rawArgument{}}, + {"raw type parameters", &rawTypeParameter{}}, + {"raw parameters", &rawParameter{}}, } for _, tc := range dropCases { t.Run(tc.name, func(t *testing.T) { db2 := openRecomputeTestDB(t) - r2 := &RawModel{ + r2 := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "r2-" + tc.name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "R", Application: "r", @@ -513,15 +513,15 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { 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: 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: 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: 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}) + _ = 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.Migrator().DropTable(tc.drop) if err := DeleteRawModelsForModule(db2, "m2"); err == nil { @@ -533,7 +533,7 @@ func TestDeleteRawModelsForModule_Coverage(t *testing.T) { func TestRecomputeEffective_LoadAndExpandErrors(t *testing.T) { db := openRecomputeTestDB(t) - if err := db.Migrator().DropTable(&RawModel{}); err != nil { + 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") { @@ -545,7 +545,7 @@ func TestRecomputeEffective_LoadAndExpandErrors(t *testing.T) { t.Fatalf("drop model: %v", err) } ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "r", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a", @@ -584,7 +584,7 @@ func TestRecomputeEffective_DeleteExistingError(t *testing.T) { func TestRecomputeEffective_ExpandExtendsError(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - a := &RawModel{ + a := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "ra", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Circ", Application: "circ", @@ -592,7 +592,7 @@ func TestRecomputeEffective_ExpandExtendsError(t *testing.T) { Extends: "/b.ts", ModuleId: sql.NullString{String: "m1", Valid: true}, } - b := &RawModel{ + b := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "rb", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Circ", Application: "circ", @@ -600,7 +600,7 @@ func TestRecomputeEffective_ExpandExtendsError(t *testing.T) { Extends: "/a.ts", ModuleId: sql.NullString{String: "m2", Valid: true}, } - for _, r := range []*RawModel{a, b} { + for _, r := range []*rawModel{a, b} { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(r).Error; err != nil { t.Fatalf("create raw: %v", err) } @@ -622,7 +622,7 @@ func TestRecomputeEffective_LoadPriorServicesError(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(eff).Error; err != nil { t.Fatalf("create effective: %v", err) } - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "prior-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Prior", Application: "prior", @@ -657,7 +657,7 @@ func TestRecomputeEffective_DeleteExistingWithRawsError(t *testing.T) { Name: "N", ModelId: eff.Id, }) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "del-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "DelRaw", Application: "delraw", @@ -678,7 +678,7 @@ func TestRecomputeEffective_DeleteExistingWithRawsError(t *testing.T) { func TestRecomputeEffective_PersistError(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "persist-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "PersistErr", Application: "perr", @@ -688,7 +688,7 @@ func TestRecomputeEffective_PersistError(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.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "pf", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: raw.Id, @@ -706,7 +706,7 @@ func TestRecomputeEffective_PersistError(t *testing.T) { func TestRecomputeEffective_PersistFailureRollsBackExisting(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "rb-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Rollback", Application: "rb", @@ -716,7 +716,7 @@ func TestRecomputeEffective_PersistFailureRollsBackExisting(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.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "rb-f", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: raw.Id, @@ -756,7 +756,7 @@ func TestRecomputeEffective_PersistFailureRollsBackExisting(t *testing.T) { func TestRecomputeEffective_EffIDFromRawTipWhenNoExisting(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "raw-tip-id", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "RawTip", Application: "rtip", @@ -781,7 +781,7 @@ func TestRecomputeEffective_EffIDFromRawTipWhenNoExisting(t *testing.T) { func TestRecomputeEffective_HookErrorPaths(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "hook-raw", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Hook", Application: "hook", @@ -905,7 +905,7 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { } return prev(db, value, query, args...) } - err := DeleteEffectiveModelTree(db, id) + err := deleteEffectiveModelTree(db, id) if err == nil || !strings.Contains(err.Error(), tc.wantMsg) { t.Fatalf("want %q, got %v", tc.wantMsg, err) } @@ -915,7 +915,7 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { seedRaw := func(t *testing.T, db *gorm.DB, moduleID string) { t.Helper() ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "RawHook", Application: "rh", @@ -925,7 +925,7 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(raw).Error; err != nil { t.Fatalf("create raw: %v", err) } - rsvc := &RawService{ + rsvc := &rawService{ BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Create", ModelId: raw.Id, @@ -933,14 +933,14 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(rsvc).Error; err != nil { t.Fatalf("create raw service: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + 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}, Name: "Name", ModelId: raw.Id, }).Error; err != nil { t.Fatalf("create raw field: %v", err) } - rdec := &RawDecorator{ + rdec := &rawDecorator{ BaseModel: BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "D", ModelId: raw.Id, @@ -948,20 +948,20 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(rdec).Error; err != nil { t.Fatalf("create raw decorator: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawArgument{ + 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}, DecoratorId: rdec.Id, }).Error; err != nil { t.Fatalf("create raw arg: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawParameter{ + 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}, 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{ + 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}, Name: "T", ServiceId: rsvc.Id, @@ -971,12 +971,12 @@ func TestDeleteTrees_LateDeleteHookErrors(t *testing.T) { } rawCases := []failCase{ - {func(v interface{}) bool { _, ok := v.(*RawDecorator); return ok }, "delete declaration decorators"}, - {func(v interface{}) bool { _, ok := v.(*RawTypeParameter); return ok }, "delete declaration type parameters"}, - {func(v interface{}) bool { _, ok := v.(*RawParameter); return ok }, "delete declaration parameters"}, - {func(v interface{}) bool { _, ok := v.(*RawService); return ok }, "delete declaration services"}, - {func(v interface{}) bool { _, ok := v.(*RawField); return ok }, "delete declaration fields"}, - {func(v interface{}) bool { _, ok := v.(*RawModel); return ok }, "delete declaration models"}, + {func(v interface{}) bool { _, ok := v.(*rawDecorator); return ok }, "delete declaration decorators"}, + {func(v interface{}) bool { _, ok := v.(*rawTypeParameter); return ok }, "delete declaration type parameters"}, + {func(v interface{}) bool { _, ok := v.(*rawParameter); return ok }, "delete declaration parameters"}, + {func(v interface{}) bool { _, ok := v.(*rawService); return ok }, "delete declaration services"}, + {func(v interface{}) bool { _, ok := v.(*rawField); return ok }, "delete declaration fields"}, + {func(v interface{}) bool { _, ok := v.(*rawModel); return ok }, "delete declaration models"}, } for i, tc := range rawCases { db := openRecomputeTestDB(t) diff --git a/pkg/meta/recompute_test.go b/pkg/meta/recompute_test.go index 44e29eba..6a160a4d 100644 --- a/pkg/meta/recompute_test.go +++ b/pkg/meta/recompute_test.go @@ -31,7 +31,7 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - base := &RawModel{ + base := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "raw-base", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Partner", Application: "partner", @@ -41,7 +41,7 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(base).Error; err != nil { t.Fatalf("create base: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "f-name", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "Name", ModelId: base.Id, @@ -61,7 +61,7 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { } firstID := first.Id.String - bank := &RawModel{ + bank := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "raw-bank", Valid: true}, CreatedAt: ts.Add(time.Hour), UpdatedAt: ts.Add(time.Hour)}, Name: "Partner", Application: "partner", @@ -72,7 +72,7 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { if err := db.Session(&gorm.Session{SkipHooks: true}).Create(bank).Error; err != nil { t.Fatalf("create bank: %v", err) } - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawField{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawField{ BaseModel: BaseModel{Id: sql.NullString{String: "f-bank", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "BankAccounts", ModelId: bank.Id, @@ -100,10 +100,10 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { t.Fatalf("expected union fields, got %#v", names) } - if err := db.Unscoped().Where("id = ?", bank.Id.String).Delete(&RawModel{}).Error; err != nil { + if err := db.Unscoped().Where("id = ?", bank.Id.String).Delete(&rawModel{}).Error; err != nil { t.Fatalf("delete bank raw: %v", err) } - if err := db.Unscoped().Where("model_id = ?", bank.Id.String).Delete(&RawField{}).Error; err != nil { + if err := db.Unscoped().Where("model_id = ?", bank.Id.String).Delete(&rawField{}).Error; err != nil { t.Fatalf("delete bank raw fields: %v", err) } @@ -121,10 +121,10 @@ func TestRecomputeEffective_CreatesPreservesAndDeletes(t *testing.T) { t.Fatalf("expected only Name after bank gone, got %#v", third.Fields) } - if err := db.Unscoped().Where("id = ?", base.Id.String).Delete(&RawModel{}).Error; err != nil { + if err := db.Unscoped().Where("id = ?", base.Id.String).Delete(&rawModel{}).Error; err != nil { t.Fatalf("delete base raw: %v", err) } - if err := db.Unscoped().Where("model_id = ?", base.Id.String).Delete(&RawField{}).Error; err != nil { + 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 { @@ -143,7 +143,7 @@ func TestRecomputeEffective_PreservesServiceIDsByName(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "raw-i18n", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "I18n", Application: "base", @@ -155,7 +155,7 @@ func TestRecomputeEffective_PreservesServiceIDsByName(t *testing.T) { t.Fatalf("create raw: %v", err) } for _, name := range []string{"SearchTerms", "UpdateTerm"} { - if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&RawService{ + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&rawService{ BaseModel: BaseModel{Id: sql.NullString{String: "rs-" + name, Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: name, AccessibilityModifier: "public", @@ -209,7 +209,7 @@ func TestRecomputeEffective_PreservesServiceIDsByName(t *testing.T) { func TestRecomputeKeys_Dedupes(t *testing.T) { db := openRecomputeTestDB(t) ts := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - raw := &RawModel{ + raw := &rawModel{ BaseModel: BaseModel{Id: sql.NullString{String: "r1", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, Name: "X", Application: "a",