From 63e2340db7dc993c8a8ab54343d36865a100c0b1 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 14 Sep 2026 17:32:51 +0800 Subject: [PATCH] Prune stale plugin metadata --- platform/domain/plugin_versions.go | 101 +++++++++++++++ platform/repo/file_store.go | 5 +- platform/repo/metadata_retention.go | 178 +++++++++++++++++++++++++++ platform/repo/mysql_store.go | 5 +- platform/repo/resources.go | 1 + platform/repo/resources_test.go | 56 +++++++++ platform/service/plugin_data.go | 61 ++++++++- platform/service/plugin_data_test.go | 47 +++++++ platform/service/resources.go | 154 +++++++++++++++++++++-- platform/service/resources_test.go | 64 ++++++++++ 10 files changed, 659 insertions(+), 13 deletions(-) create mode 100644 platform/domain/plugin_versions.go create mode 100644 platform/repo/metadata_retention.go diff --git a/platform/domain/plugin_versions.go b/platform/domain/plugin_versions.go new file mode 100644 index 0000000..6260f58 --- /dev/null +++ b/platform/domain/plugin_versions.go @@ -0,0 +1,101 @@ +package domain + +import "strings" + +// GamePluginIdentityKey collapses generated manifest IDs for the same first-party +// plugin so platform storage keeps one current registry record per plugin. +func GamePluginIdentityKey(plugin GamePlugin) string { + baseID := CanonicalGamePluginID(plugin.ID) + return "id:" + baseID +} + +func CanonicalGamePluginID(id string) string { + id = strings.ToLower(strings.TrimSpace(id)) + if marker := strings.Index(id, ".codex."); marker > 0 { + return id[:marker] + } + return id +} + +func GamePluginIsNewer(candidate GamePlugin, current GamePlugin) bool { + if comparison := CompareGamePluginVersions(candidate.Version, current.Version); comparison != 0 { + return comparison > 0 + } + if score := gamePluginPreferenceScore(candidate) - gamePluginPreferenceScore(current); score != 0 { + return score > 0 + } + return strings.Compare(strings.ToLower(strings.TrimSpace(candidate.ID)), strings.ToLower(strings.TrimSpace(current.ID))) < 0 +} + +func CompareGamePluginVersions(left string, right string) int { + leftParts := gamePluginVersionParts(left) + rightParts := gamePluginVersionParts(right) + limit := len(leftParts) + if len(rightParts) > limit { + limit = len(rightParts) + } + for index := 0; index < limit; index++ { + var leftValue, rightValue int + if index < len(leftParts) { + leftValue = leftParts[index] + } + if index < len(rightParts) { + rightValue = rightParts[index] + } + if leftValue != rightValue { + if leftValue > rightValue { + return 1 + } + return -1 + } + } + leftText := strings.ToLower(strings.TrimSpace(left)) + rightText := strings.ToLower(strings.TrimSpace(right)) + if leftText == rightText { + return 0 + } + if leftText == "" { + return -1 + } + if rightText == "" { + return 1 + } + if strings.Contains(leftText, "-") != strings.Contains(rightText, "-") { + if strings.Contains(leftText, "-") { + return -1 + } + return 1 + } + if leftText > rightText { + return 1 + } + return -1 +} + +func gamePluginVersionParts(version string) []int { + fields := strings.FieldsFunc(version, func(r rune) bool { return r < '0' || r > '9' }) + parts := make([]int, 0, len(fields)) + for _, field := range fields { + value := 0 + for _, r := range field { + value = value*10 + int(r-'0') + } + parts = append(parts, value) + } + return parts +} + +func gamePluginPreferenceScore(plugin GamePlugin) int { + score := 0 + id := strings.ToLower(strings.TrimSpace(plugin.ID)) + if id == CanonicalGamePluginID(id) { + score += 4 + } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(plugin.ManifestRef)), "artifact://") { + score += 2 + } + if plugin.Status == GamePluginStatusInstalled { + score++ + } + return score +} diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index f4a661f..8418177 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -308,7 +308,7 @@ func runtimeMetadataPath(path string) string { } func (store *FileStore) snapshot() StoreSnapshot { - return StoreSnapshot{ + return normalizeStoreSnapshot(StoreSnapshot{ Users: snapshotRepository(store.MemoryStore.users), AuthSessions: snapshotRepository(store.MemoryStore.authSessions), RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), @@ -337,7 +337,7 @@ func (store *FileStore) snapshot() StoreSnapshot { SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks), SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks), - } + }) } func (store *FileStore) runtimeSnapshot() runtimeSnapshot { @@ -349,6 +349,7 @@ func (store *FileStore) runtimeSnapshot() runtimeSnapshot { } func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { + snapshot = normalizeStoreSnapshot(snapshot) loadRepository(store.MemoryStore.users, snapshot.Users) loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions) loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions) diff --git a/platform/repo/metadata_retention.go b/platform/repo/metadata_retention.go new file mode 100644 index 0000000..a3f47ed --- /dev/null +++ b/platform/repo/metadata_retention.go @@ -0,0 +1,178 @@ +package repo + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" + + "browser.local/platform/domain" +) + +func normalizeStoreSnapshot(snapshot StoreSnapshot) StoreSnapshot { + plugins, replacements := latestGamePlugins(snapshot.GamePlugins) + snapshot.GamePlugins = plugins + snapshot.ServerInstances = rewriteSnapshotServerInstances(snapshot.ServerInstances, replacements) + snapshot.RuntimeBindings = rewriteSnapshotRuntimeBindings(snapshot.RuntimeBindings, replacements) + snapshot.PluginLifecycles = rewriteSnapshotPluginLifecycles(snapshot.PluginLifecycles, replacements) + snapshot.AIConfigDiffs = rewriteSnapshotAIConfigDiffs(snapshot.AIConfigDiffs, replacements) + snapshot.PluginDataRecords = retainedSnapshotPluginData(snapshot.PluginDataRecords, replacements) + snapshot.Jobs = retainedSnapshotJobs(snapshot.Jobs, snapshot.ServerInstances, plugins) + return snapshot +} + +func latestGamePlugins(values []domain.GamePlugin) ([]domain.GamePlugin, map[string]domain.GamePlugin) { + byIdentity := map[string]domain.GamePlugin{} + for _, plugin := range values { + key := domain.GamePluginIdentityKey(plugin) + if current, exists := byIdentity[key]; !exists || domain.GamePluginIsNewer(plugin, current) { + byIdentity[key] = plugin + } + } + replacements := map[string]domain.GamePlugin{} + out := make([]domain.GamePlugin, 0, len(byIdentity)) + for _, plugin := range byIdentity { + out = append(out, domain.CopyGamePlugin(plugin)) + } + sort.SliceStable(out, func(left, right int) bool { return out[left].ID < out[right].ID }) + for _, plugin := range values { + kept, ok := byIdentity[domain.GamePluginIdentityKey(plugin)] + if ok && plugin.ID != kept.ID { + replacements[plugin.ID] = kept + } + } + return out, replacements +} + +func rewriteSnapshotServerInstances(values []domain.ServerInstance, replacements map[string]domain.GamePlugin) []domain.ServerInstance { + out := make([]domain.ServerInstance, len(values)) + for index, value := range values { + if kept, ok := replacements[value.PluginID]; ok { + value.PluginID = kept.ID + value.PluginVersion = kept.Version + } + out[index] = domain.CopyServerInstance(value) + } + return out +} + +func rewriteSnapshotRuntimeBindings(values []domain.RuntimeBinding, replacements map[string]domain.GamePlugin) []domain.RuntimeBinding { + out := make([]domain.RuntimeBinding, len(values)) + for index, value := range values { + if kept, ok := replacements[value.PluginID]; ok { + value.PluginID = kept.ID + value.PluginVersion = kept.Version + } + out[index] = domain.CopyRuntimeBinding(value) + } + return out +} + +func rewriteSnapshotPluginLifecycles(values []domain.PluginLifecycleInstallation, replacements map[string]domain.GamePlugin) []domain.PluginLifecycleInstallation { + out := make([]domain.PluginLifecycleInstallation, len(values)) + for index, value := range values { + if kept, ok := replacements[value.PluginID]; ok { + value.PluginID = kept.ID + if value.TargetVersion != "" { + value.TargetVersion = kept.Version + } + } + out[index] = domain.CopyPluginLifecycleInstallation(value) + } + return out +} + +func rewriteSnapshotAIConfigDiffs(values []domain.AIConfigDiffPreview, replacements map[string]domain.GamePlugin) []domain.AIConfigDiffPreview { + out := make([]domain.AIConfigDiffPreview, len(values)) + for index, value := range values { + if kept, ok := replacements[value.PluginID]; ok { + value.PluginID = kept.ID + } + out[index] = domain.CopyAIConfigDiffPreview(value) + } + return out +} + +func retainedSnapshotPluginData(values []domain.PluginDataRecord, replacements map[string]domain.GamePlugin) []domain.PluginDataRecord { + byID := map[string]domain.PluginDataRecord{} + for _, value := range values { + if isLegacySCUMPluginData(value.PluginID, value.Collection) { + continue + } + if kept, ok := replacements[value.PluginID]; ok { + value.PluginID = kept.ID + value.ID = snapshotPluginDataID(value.ServerInstanceID, kept.ID, value.Collection, value.Key) + } + if current, exists := byID[value.ID]; !exists || value.UpdatedAt.After(current.UpdatedAt) || (value.UpdatedAt.Equal(current.UpdatedAt) && value.CreatedAt.After(current.CreatedAt)) { + byID[value.ID] = domain.CopyPluginDataRecord(value) + } + } + ids := make([]string, 0, len(byID)) + for id := range byID { + ids = append(ids, id) + } + sort.Strings(ids) + out := make([]domain.PluginDataRecord, 0, len(ids)) + for _, id := range ids { + out = append(out, domain.CopyPluginDataRecord(byID[id])) + } + return out +} + +func retainedSnapshotJobs(values []domain.Job, instances []domain.ServerInstance, plugins []domain.GamePlugin) []domain.Job { + pluginByID := map[string]domain.GamePlugin{} + for _, plugin := range plugins { + pluginByID[plugin.ID] = plugin + } + serverPlugin := map[string]domain.GamePlugin{} + for _, instance := range instances { + if plugin, ok := pluginByID[instance.PluginID]; ok { + serverPlugin[instance.ID] = plugin + } + } + out := make([]domain.Job, 0, len(values)) + for _, job := range values { + if isLegacySCUMSQLiteQueryJob(job, serverPlugin) { + continue + } + out = append(out, domain.CopyJob(job)) + } + return out +} + +func isLegacySCUMSQLiteQueryJob(job domain.Job, serverPlugin map[string]domain.GamePlugin) bool { + if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery { + return false + } + plugin, ok := serverPlugin[job.ServerInstanceID] + if !ok { + return false + } + return isSCUMGamePlugin(plugin) +} + +func isLegacySCUMPluginData(pluginID string, collection string) bool { + if !isSCUMPluginID(pluginID) { + return false + } + switch strings.ToLower(strings.TrimSpace(collection)) { + case "scum_users", "scum_players", "scum_trajectories", "scum_user_trajectories", "scum_vehicles", "scum_vehicle_trajectories", "scum_vehicle_locks": + return true + default: + return false + } +} + +func isSCUMGamePlugin(plugin domain.GamePlugin) bool { + return strings.EqualFold(strings.TrimSpace(plugin.ServerType), "scum") || isSCUMPluginID(plugin.ID) +} + +func isSCUMPluginID(pluginID string) bool { + canonical := domain.CanonicalGamePluginID(pluginID) + return canonical == "game.scum" || canonical == "server.scum" +} + +func snapshotPluginDataID(serverID, pluginID, collection, key string) string { + sum := sha256.Sum256([]byte(serverID + "\x00" + pluginID + "\x00" + collection + "\x00" + key)) + return "plugin-data-" + hex.EncodeToString(sum[:])[:24] +} diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index 6aead49..6570844 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -264,7 +264,7 @@ ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysq } func (store *MySQLStore) snapshot() StoreSnapshot { - return StoreSnapshot{ + return normalizeStoreSnapshot(StoreSnapshot{ Users: snapshotRepository(store.MemoryStore.users), AuthSessions: snapshotRepository(store.MemoryStore.authSessions), RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), @@ -293,7 +293,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot { SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks), SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks), - } + }) } func (store *MySQLStore) runtimeSnapshot() runtimeSnapshot { @@ -305,6 +305,7 @@ func (store *MySQLStore) runtimeSnapshot() runtimeSnapshot { } func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { + snapshot = normalizeStoreSnapshot(snapshot) loadRepository(store.MemoryStore.users, snapshot.Users) loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions) loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions) diff --git a/platform/repo/resources.go b/platform/repo/resources.go index 3e4786f..d097e89 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -46,6 +46,7 @@ type GamePluginRepository interface { Get(id string) (domain.GamePlugin, error) List(domain.GamePluginFilter) ([]domain.GamePlugin, error) Update(domain.GamePlugin) error + Delete(id string) error } type ServerInstanceRepository interface { diff --git a/platform/repo/resources_test.go b/platform/repo/resources_test.go index 0805bfb..9e9d190 100644 --- a/platform/repo/resources_test.go +++ b/platform/repo/resources_test.go @@ -255,6 +255,62 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) { } } +func TestFileStoreNormalizesPluginVersionsAndPrunesLegacySCUMSnapshotData(t *testing.T) { + path := filepath.Join(t.TempDir(), "metadata.json") + stalePlugin := domain.GamePlugin{ID: "game.scum.codex.20260804095301", Name: "SCUM Server", Version: "0.1.4", ServerType: "scum", ServerDisplayName: "SCUM Dedicated Server", ManifestRef: "plugins/examples/scum-server-plugin/game.scum.codex.20260804095301/manifest.json", Status: domain.GamePluginStatusInstalled} + latestPlugin := domain.GamePlugin{ID: "game.scum", Name: "SCUM Server", Version: "0.1.15", ServerType: "scum", ServerDisplayName: "SCUM Dedicated Server", ManifestRef: "artifact://manifests/game.scum/0.1.15", Status: domain.GamePluginStatusInstalled} + stamp := time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC) + snapshot := StoreSnapshot{ + GamePlugins: []domain.GamePlugin{stalePlugin, latestPlugin}, + ServerInstances: []domain.ServerInstance{{ID: "server-scum", PluginID: stalePlugin.ID, PluginVersion: stalePlugin.Version, Name: "SCUM", State: domain.ServerInstanceStateDraft, CreatedAt: stamp, UpdatedAt: stamp}}, + RuntimeBindings: []domain.RuntimeBinding{{ID: "runtime-binding-server-scum", ServerInstanceID: "server-scum", PluginID: stalePlugin.ID, PluginVersion: stalePlugin.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: stamp, UpdatedAt: stamp}}, + Jobs: []domain.Job{ + {ID: "job-scum-sqlite-query", ServerInstanceID: "server-scum", RunEndpointID: "run-local", Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, IdempotencyKey: "legacy-query", State: domain.JobStateSucceeded, CreatedAt: stamp, UpdatedAt: stamp}, + {ID: "job-scum-start", ServerInstanceID: "server-scum", RunEndpointID: "run-local", Capability: domain.JobCapabilityRemoteRunProgram, IdempotencyKey: "start", State: domain.JobStateSucceeded, CreatedAt: stamp, UpdatedAt: stamp}, + }, + PluginDataRecords: []domain.PluginDataRecord{ + {ID: snapshotPluginDataID("server-scum", stalePlugin.ID, "scum_gifts", "starter"), PluginID: stalePlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_gifts", Key: "starter", Value: map[string]any{"name": "Starter"}, CreatedAt: stamp, UpdatedAt: stamp}, + {ID: snapshotPluginDataID("server-scum", stalePlugin.ID, "scum_trajectories", "point-1"), PluginID: stalePlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_trajectories", Key: "point-1", Value: map[string]any{"source": "legacy"}, CreatedAt: stamp, UpdatedAt: stamp}, + }, + } + payload, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("marshal stale snapshot: %v", err) + } + if err := os.WriteFile(path, payload, 0o600); err != nil { + t.Fatalf("write stale snapshot: %v", err) + } + + store, err := NewFileStore(path) + if err != nil { + t.Fatalf("reload stale snapshot: %v", err) + } + plugins, err := store.GamePlugins().List(domain.GamePluginFilter{ServerType: "scum"}) + if err != nil || len(plugins) != 1 || plugins[0].ID != latestPlugin.ID || plugins[0].Version != latestPlugin.Version { + t.Fatalf("expected only latest plugin, plugins=%+v err=%v", plugins, err) + } + server, err := store.ServerInstances().Get("server-scum") + if err != nil || server.PluginID != latestPlugin.ID || server.PluginVersion != latestPlugin.Version { + t.Fatalf("expected server reference migration, server=%+v err=%v", server, err) + } + binding, err := store.RuntimeBindings().Get("runtime-binding-server-scum") + if err != nil || binding.PluginID != latestPlugin.ID || binding.PluginVersion != latestPlugin.Version { + t.Fatalf("expected runtime binding migration, binding=%+v err=%v", binding, err) + } + config, err := store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: latestPlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_gifts"}) + if err != nil || len(config) != 1 || config[0].Key != "starter" { + t.Fatalf("expected plugin-owned config data to migrate, data=%+v err=%v", config, err) + } + legacy, err := store.PluginDataRecords().List(domain.PluginDataFilter{ServerInstanceID: "server-scum", Collection: "scum_trajectories"}) + if err != nil || len(legacy) != 0 { + t.Fatalf("expected legacy SCUM projection data to be pruned, data=%+v err=%v", legacy, err) + } + jobs, err := store.Jobs().List(domain.JobFilter{ServerInstanceID: "server-scum"}) + if err != nil || len(jobs) != 1 || jobs[0].ID != "job-scum-start" { + t.Fatalf("expected legacy SCUM sqlite query job to be pruned, jobs=%+v err=%v", jobs, err) + } +} + func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) { stamp := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) source := &MySQLStore{MemoryStore: NewMemoryStore()} diff --git a/platform/service/plugin_data.go b/platform/service/plugin_data.go index c1acbe5..39c5b2c 100644 --- a/platform/service/plugin_data.go +++ b/platform/service/plugin_data.go @@ -2,12 +2,18 @@ package service import ( "errors" + "sort" "strings" "browser.local/platform/domain" "browser.local/platform/repo" ) +const ( + defaultPluginDataListLimit = 500 + maxPluginDataListLimit = 2000 +) + func (svc *CoreService) ListPluginDataForSession(sessionID string, filter domain.PluginDataFilter) ([]domain.PluginDataRecord, error) { if err := svc.authorizePluginData(sessionID, filter.PluginID, filter.ServerInstanceID, filter.Collection); err != nil { return nil, err @@ -16,8 +22,18 @@ func (svc *CoreService) ListPluginDataForSession(sessionID string, filter domain if err != nil { return nil, err } - if filter.Limit > 0 && len(values) > filter.Limit { - values = values[:filter.Limit] + sort.SliceStable(values, func(left, right int) bool { + if !values[left].UpdatedAt.Equal(values[right].UpdatedAt) { + return values[left].UpdatedAt.After(values[right].UpdatedAt) + } + if !values[left].CreatedAt.Equal(values[right].CreatedAt) { + return values[left].CreatedAt.After(values[right].CreatedAt) + } + return values[left].Key < values[right].Key + }) + limit := boundedPluginDataLimit(filter.Limit) + if len(values) > limit { + values = values[:limit] } return values, nil } @@ -39,6 +55,9 @@ func (svc *CoreService) applyPluginDataTransaction(transaction domain.PluginData if len(transaction.Mutations) == 0 { return nil, validationError("plugin data mutations are required") } + if err := svc.validatePluginDataMutations(transaction); err != nil { + return nil, err + } stamp := svc.now() upserts := make([]domain.PluginDataRecord, 0, len(transaction.Mutations)) deleteIDs := make([]string, 0, len(transaction.Mutations)) @@ -91,6 +110,9 @@ func (svc *CoreService) PutPluginDataForSession(sessionID string, value domain.P if value.Value == nil { return domain.PluginDataRecord{}, validationError("plugin data value is required") } + if legacySCUMPluginDataCollection(value.PluginID, value.Collection) { + return domain.PluginDataRecord{}, validationError("SCUM user, vehicle, and trajectory data must be written to platform SCUM tables") + } value.ID = pluginDataID(value.ServerInstanceID, value.PluginID, value.Collection, value.Key) stamp := svc.now() existing, err := svc.store.PluginDataRecords().Get(value.ID) @@ -128,6 +150,41 @@ func (svc *CoreService) authorizePluginData(sessionID, pluginID, serverInstanceI return nil } +func (svc *CoreService) validatePluginDataMutations(transaction domain.PluginDataTransaction) error { + if !legacySCUMPluginDataCollection(transaction.PluginID, transaction.Collection) { + return nil + } + for _, mutation := range transaction.Mutations { + if mutation.Operation == domain.PluginDataMutationPut { + return validationError("SCUM user, vehicle, and trajectory data must be written to platform SCUM tables") + } + } + return nil +} + +func boundedPluginDataLimit(requested int) int { + if requested <= 0 { + return defaultPluginDataListLimit + } + if requested > maxPluginDataListLimit { + return maxPluginDataListLimit + } + return requested +} + +func legacySCUMPluginDataCollection(pluginID string, collection string) bool { + canonical := domain.CanonicalGamePluginID(pluginID) + if canonical != "game.scum" && canonical != "server.scum" { + return false + } + switch strings.ToLower(strings.TrimSpace(collection)) { + case "scum_users", "scum_players", "scum_trajectories", "scum_user_trajectories", "scum_vehicles", "scum_vehicle_trajectories", "scum_vehicle_locks": + return true + default: + return false + } +} + func pluginDataID(serverID, pluginID, collection, key string) string { return "plugin-data-" + fingerprintID(serverID, pluginID+"\x00"+collection+"\x00"+key) } diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go index e3ef369..e5ec8ea 100644 --- a/platform/service/plugin_data_test.go +++ b/platform/service/plugin_data_test.go @@ -1,6 +1,8 @@ package service import ( + "fmt" + "strings" "testing" "browser.local/platform/domain" @@ -44,6 +46,51 @@ func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) { } } +func TestPluginDataListIsBoundedAndNewestFirst(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Plugin bounded owner", Email: "plugin-bounded@example.test", Password: "secret-password"}) + if err != nil { + t.Fatalf("register owner: %v", err) + } + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-plugin-bounded", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "Bounded"}); err != nil { + t.Fatalf("create server: %v", err) + } + for index := 0; index < defaultPluginDataListLimit+5; index++ { + key := fmt.Sprintf("record-%04d", index) + if _, err := svc.PutPluginDataForSession(owner.SessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-plugin-bounded", Collection: "records", Key: key, Value: map[string]any{"index": index}}); err != nil { + t.Fatalf("put plugin data %d: %v", index, err) + } + } + items, err := svc.ListPluginDataForSession(owner.SessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-plugin-bounded", Collection: "records"}) + if err != nil || len(items) != defaultPluginDataListLimit { + t.Fatalf("expected default bounded list, len=%d err=%v", len(items), err) + } + limited, err := svc.ListPluginDataForSession(owner.SessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-plugin-bounded", Collection: "records", Limit: 3}) + if err != nil || len(limited) != 3 { + t.Fatalf("expected explicit bounded list, len=%d err=%v", len(limited), err) + } +} + +func TestSCUMLegacyProjectionPluginDataWritesAreRejected(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "SCUM projection owner", Email: "scum-projection@example.test", Password: "secret-password"}) + if err != nil { + t.Fatalf("register owner: %v", err) + } + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-scum-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "SCUM Projection"}); err != nil { + t.Fatalf("create server: %v", err) + } + if _, err := svc.PutPluginDataForSession(owner.SessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-scum-projection", Collection: "scum_trajectories", Key: "point-1", Value: map[string]any{"x": 1}}); err == nil || !strings.Contains(err.Error(), "platform SCUM tables") { + t.Fatalf("expected legacy projection put rejection, got %v", err) + } + _, err = svc.ApplyPluginDataTransactionForSession(owner.SessionID, domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: "server-scum-projection", Collection: "scum_users", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "steam-1", Value: map[string]any{"steamId": "steam-1"}}}}) + if err == nil || !strings.Contains(err.Error(), "platform SCUM tables") { + t.Fatalf("expected legacy projection transaction rejection, got %v", err) + } +} + func TestPluginDataTransactionAppliesPutAndDeleteTogether(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) diff --git a/platform/service/resources.go b/platform/service/resources.go index f5bc5d6..b330fa2 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -728,10 +728,7 @@ func (svc *CoreService) CreateGamePlugin(plugin domain.GamePlugin) (domain.GameP if err := validator.ValidateGamePlugin(plugin); err != nil { return domain.GamePlugin{}, err } - if err := svc.store.GamePlugins().Create(plugin); err != nil { - return domain.GamePlugin{}, err - } - return domain.CopyGamePlugin(plugin), nil + return svc.upsertLatestGamePlugin(plugin) } func (svc *CoreService) RegisterGamePluginManifest(registration domain.GamePluginManifestRegistration) (domain.GamePlugin, error) { @@ -743,11 +740,35 @@ func (svc *CoreService) RegisterGamePluginManifest(registration domain.GamePlugi if err := validator.ValidateGamePlugin(plugin); err != nil { return domain.GamePlugin{}, err } - if existing, err := svc.store.GamePlugins().Get(plugin.ID); err == nil { - if err := svc.store.GamePlugins().Update(plugin); err != nil { + return svc.upsertLatestGamePlugin(plugin) +} + +func (svc *CoreService) upsertLatestGamePlugin(plugin domain.GamePlugin) (domain.GamePlugin, error) { + plugins, err := svc.store.GamePlugins().List(domain.GamePluginFilter{}) + if err != nil { + return domain.GamePlugin{}, err + } + identity := domain.GamePluginIdentityKey(plugin) + var current domain.GamePlugin + matched := make([]domain.GamePlugin, 0, len(plugins)) + for _, candidate := range plugins { + if domain.GamePluginIdentityKey(candidate) != identity { + continue + } + matched = append(matched, candidate) + if current.ID == "" || domain.GamePluginIsNewer(candidate, current) { + current = candidate + } + } + if current.ID != "" && !domain.GamePluginIsNewer(plugin, current) { + if err := svc.pruneOlderGamePluginVersions(current, matched); err != nil { return domain.GamePlugin{}, err } - if err := svc.refreshServerPluginReferences(existing.ID, plugin.Version); err != nil { + return domain.CopyGamePlugin(current), nil + } + + if _, err := svc.store.GamePlugins().Get(plugin.ID); err == nil { + if err := svc.store.GamePlugins().Update(plugin); err != nil { return domain.GamePlugin{}, err } } else if errors.Is(err, repo.ErrNotFound) { @@ -757,9 +778,128 @@ func (svc *CoreService) RegisterGamePluginManifest(registration domain.GamePlugi } else { return domain.GamePlugin{}, err } + for _, stale := range matched { + if stale.ID == plugin.ID { + continue + } + if err := svc.replaceGamePluginReference(stale.ID, plugin.ID, plugin.Version); err != nil { + return domain.GamePlugin{}, err + } + if err := svc.store.GamePlugins().Delete(stale.ID); err != nil && !errors.Is(err, repo.ErrNotFound) { + return domain.GamePlugin{}, err + } + } + if err := svc.refreshServerPluginReferences(plugin.ID, plugin.Version); err != nil { + return domain.GamePlugin{}, err + } return domain.CopyGamePlugin(plugin), nil } +func (svc *CoreService) pruneOlderGamePluginVersions(latest domain.GamePlugin, plugins []domain.GamePlugin) error { + for _, plugin := range plugins { + if plugin.ID == latest.ID { + continue + } + if err := svc.replaceGamePluginReference(plugin.ID, latest.ID, latest.Version); err != nil { + return err + } + if err := svc.store.GamePlugins().Delete(plugin.ID); err != nil && !errors.Is(err, repo.ErrNotFound) { + return err + } + } + return svc.refreshServerPluginReferences(latest.ID, latest.Version) +} + +func (svc *CoreService) replaceGamePluginReference(fromPluginID, toPluginID, toPluginVersion string) error { + if strings.TrimSpace(fromPluginID) == "" || fromPluginID == toPluginID { + return nil + } + stamp := svc.now() + instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{PluginID: fromPluginID}) + if err != nil { + return err + } + for _, instance := range instances { + instance.PluginID = toPluginID + instance.PluginVersion = toPluginVersion + instance.UpdatedAt = stamp + if err := validator.ValidateStoredServerInstance(instance); err != nil { + return err + } + if err := svc.store.ServerInstances().Update(instance); err != nil { + return err + } + } + bindings, err := svc.store.RuntimeBindings().List(domain.RuntimeBindingFilter{}) + if err != nil { + return err + } + for _, binding := range bindings { + if binding.PluginID != fromPluginID { + continue + } + binding.PluginID = toPluginID + binding.PluginVersion = toPluginVersion + binding.UpdatedAt = stamp + if err := svc.store.RuntimeBindings().Update(binding); err != nil { + return err + } + } + installations, err := svc.store.PluginLifecycles().List(domain.PluginLifecycleFilter{PluginID: fromPluginID}) + if err != nil { + return err + } + for _, installation := range installations { + installation.PluginID = toPluginID + if installation.TargetVersion != "" { + installation.TargetVersion = toPluginVersion + } + installation.UpdatedAt = stamp + if err := svc.store.PluginLifecycles().Update(installation); err != nil { + return err + } + } + previews, err := svc.store.AIConfigDiffs().List(domain.AIConfigDiffFilter{PluginID: fromPluginID}) + if err != nil { + return err + } + for _, preview := range previews { + preview.PluginID = toPluginID + preview.UpdatedAt = stamp + if err := svc.store.AIConfigDiffs().Update(preview); err != nil { + return err + } + } + return svc.replacePluginDataReferences(fromPluginID, toPluginID) +} + +func (svc *CoreService) replacePluginDataReferences(fromPluginID, toPluginID string) error { + items, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: fromPluginID}) + if err != nil { + return err + } + upserts := make([]domain.PluginDataRecord, 0, len(items)) + deleteIDs := make([]string, 0, len(items)) + for _, item := range items { + deleteIDs = append(deleteIDs, item.ID) + if legacySCUMPluginDataCollection(fromPluginID, item.Collection) { + continue + } + item.PluginID = toPluginID + item.ID = pluginDataID(item.ServerInstanceID, toPluginID, item.Collection, item.Key) + if existing, getErr := svc.store.PluginDataRecords().Get(item.ID); getErr == nil && existing.UpdatedAt.After(item.UpdatedAt) { + continue + } else if getErr != nil && !errors.Is(getErr, repo.ErrNotFound) { + return getErr + } + upserts = append(upserts, item) + } + if len(upserts) == 0 && len(deleteIDs) == 0 { + return nil + } + return svc.store.PluginDataRecords().Apply(upserts, deleteIDs) +} + // refreshServerPluginReferences keeps existing server projections usable when a // manifest is refreshed in place. The server and its logical runtime binding // carry the manifest version used for lifecycle validation; leaving either at a diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index fb31bca..372d280 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -1933,6 +1933,70 @@ func TestCoreServiceUpsertsDuplicateGamePluginManifest(t *testing.T) { } } +func TestCoreServiceKeepsOnlyLatestPluginVersionAndMigratesReferences(t *testing.T) { + svc := newTestCoreService() + stale := validPluginManifestRegistration() + stale.Manifest.ID = "game.scum.codex.20260804095301" + stale.Manifest.Name = "SCUM Server" + stale.Manifest.Version = "0.1.4" + stale.Manifest.Server.Type = "scum" + stale.Manifest.Server.DisplayName = "SCUM Dedicated Server" + stale.ManifestRef = "plugins/examples/scum-server-plugin/game.scum.codex.20260804095301/manifest.json" + if _, err := svc.RegisterGamePluginManifest(stale); err != nil { + t.Fatalf("register stale manifest: %v", err) + } + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "stale-plugin-server", PluginID: stale.Manifest.ID, PluginVersion: stale.Manifest.Version, Name: "SCUM Old Plugin"}); err != nil { + t.Fatalf("create stale plugin server: %v", err) + } + if err := svc.store.RuntimeBindings().Create(domain.RuntimeBinding{ID: "runtime-binding-stale-plugin-server", ServerInstanceID: "stale-plugin-server", PluginID: stale.Manifest.ID, PluginVersion: stale.Manifest.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}); err != nil { + t.Fatalf("create stale runtime binding: %v", err) + } + if err := svc.store.PluginDataRecords().Create(domain.PluginDataRecord{ID: pluginDataID("stale-plugin-server", stale.Manifest.ID, "scum_gifts", "starter"), PluginID: stale.Manifest.ID, ServerInstanceID: "stale-plugin-server", Collection: "scum_gifts", Key: "starter", Value: map[string]any{"name": "Starter"}, CreatedAt: fixedTime, UpdatedAt: fixedTime}); err != nil { + t.Fatalf("seed plugin config data: %v", err) + } + if err := svc.store.PluginDataRecords().Create(domain.PluginDataRecord{ID: pluginDataID("stale-plugin-server", stale.Manifest.ID, "scum_trajectories", "point-1"), PluginID: stale.Manifest.ID, ServerInstanceID: "stale-plugin-server", Collection: "scum_trajectories", Key: "point-1", Value: map[string]any{"source": "legacy"}, CreatedAt: fixedTime, UpdatedAt: fixedTime}); err != nil { + t.Fatalf("seed legacy projection data: %v", err) + } + + latest := validPluginManifestRegistration() + latest.Manifest.ID = "game.scum" + latest.Manifest.Name = "SCUM Server" + latest.Manifest.Version = "0.1.15" + latest.Manifest.Server.Type = "scum" + latest.Manifest.Server.DisplayName = "SCUM Dedicated Server" + latest.ManifestRef = "artifact://manifests/game.scum/0.1.15" + registered, err := svc.RegisterGamePluginManifest(latest) + if err != nil { + t.Fatalf("register latest manifest: %v", err) + } + if registered.ID != "game.scum" || registered.Version != "0.1.15" { + t.Fatalf("expected latest plugin to win, got %+v", registered) + } + if _, err := svc.GetGamePlugin(stale.Manifest.ID); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected stale plugin record to be removed, got %v", err) + } + plugins, err := svc.ListGamePlugins(domain.GamePluginFilter{ServerType: "scum", Status: domain.GamePluginStatusInstalled}) + if err != nil || len(plugins) != 1 || plugins[0].ID != "game.scum" || plugins[0].Version != "0.1.15" { + t.Fatalf("expected only latest SCUM plugin, plugins=%+v err=%v", plugins, err) + } + server, err := svc.GetServerInstance("stale-plugin-server") + if err != nil || server.PluginID != "game.scum" || server.PluginVersion != "0.1.15" { + t.Fatalf("expected server to migrate to latest plugin, server=%+v err=%v", server, err) + } + binding, err := svc.store.RuntimeBindings().Get("runtime-binding-stale-plugin-server") + if err != nil || binding.PluginID != "game.scum" || binding.PluginVersion != "0.1.15" { + t.Fatalf("expected runtime binding to migrate to latest plugin, binding=%+v err=%v", binding, err) + } + configRecords, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: "game.scum", ServerInstanceID: "stale-plugin-server", Collection: "scum_gifts"}) + if err != nil || len(configRecords) != 1 || configRecords[0].Key != "starter" { + t.Fatalf("expected plugin config data to migrate, records=%+v err=%v", configRecords, err) + } + legacyRecords, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{ServerInstanceID: "stale-plugin-server", Collection: "scum_trajectories"}) + if err != nil || len(legacyRecords) != 0 { + t.Fatalf("expected legacy SCUM projection data to be dropped, records=%+v err=%v", legacyRecords, err) + } +} + func TestCoreServiceRejectsUnsafeGamePluginManifest(t *testing.T) { svc := newTestCoreService() registration := validPluginManifestRegistration()