Prune stale plugin metadata

This commit is contained in:
npc0-hue
2026-09-14 17:32:51 +08:00
parent ecdca8e28b
commit 63e2340db7
10 changed files with 659 additions and 13 deletions
+101
View File
@@ -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
}
+3 -2
View File
@@ -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)
+178
View File
@@ -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]
}
+3 -2
View File
@@ -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)
+1
View File
@@ -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 {
+56
View File
@@ -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()}
+59 -2
View File
@@ -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)
}
+47
View File
@@ -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)
+147 -7
View File
@@ -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
}
if err := svc.refreshServerPluginReferences(existing.ID, plugin.Version); err != nil {
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
}
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
+64
View File
@@ -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()