Prune stale plugin metadata
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -308,7 +308,7 @@ func runtimeMetadataPath(path string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileStore) snapshot() StoreSnapshot {
|
func (store *FileStore) snapshot() StoreSnapshot {
|
||||||
return StoreSnapshot{
|
return normalizeStoreSnapshot(StoreSnapshot{
|
||||||
Users: snapshotRepository(store.MemoryStore.users),
|
Users: snapshotRepository(store.MemoryStore.users),
|
||||||
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
|
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
|
||||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||||
@@ -337,7 +337,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
|
|||||||
SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles),
|
SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles),
|
||||||
SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks),
|
SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks),
|
||||||
SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks),
|
SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileStore) runtimeSnapshot() runtimeSnapshot {
|
func (store *FileStore) runtimeSnapshot() runtimeSnapshot {
|
||||||
@@ -349,6 +349,7 @@ func (store *FileStore) runtimeSnapshot() runtimeSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||||
|
snapshot = normalizeStoreSnapshot(snapshot)
|
||||||
loadRepository(store.MemoryStore.users, snapshot.Users)
|
loadRepository(store.MemoryStore.users, snapshot.Users)
|
||||||
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
|
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
|
||||||
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -264,7 +264,7 @@ ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysq
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *MySQLStore) snapshot() StoreSnapshot {
|
func (store *MySQLStore) snapshot() StoreSnapshot {
|
||||||
return StoreSnapshot{
|
return normalizeStoreSnapshot(StoreSnapshot{
|
||||||
Users: snapshotRepository(store.MemoryStore.users),
|
Users: snapshotRepository(store.MemoryStore.users),
|
||||||
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
|
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
|
||||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||||
@@ -293,7 +293,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
|
|||||||
SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles),
|
SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles),
|
||||||
SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks),
|
SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks),
|
||||||
SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks),
|
SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *MySQLStore) runtimeSnapshot() runtimeSnapshot {
|
func (store *MySQLStore) runtimeSnapshot() runtimeSnapshot {
|
||||||
@@ -305,6 +305,7 @@ func (store *MySQLStore) runtimeSnapshot() runtimeSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||||
|
snapshot = normalizeStoreSnapshot(snapshot)
|
||||||
loadRepository(store.MemoryStore.users, snapshot.Users)
|
loadRepository(store.MemoryStore.users, snapshot.Users)
|
||||||
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
|
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
|
||||||
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type GamePluginRepository interface {
|
|||||||
Get(id string) (domain.GamePlugin, error)
|
Get(id string) (domain.GamePlugin, error)
|
||||||
List(domain.GamePluginFilter) ([]domain.GamePlugin, error)
|
List(domain.GamePluginFilter) ([]domain.GamePlugin, error)
|
||||||
Update(domain.GamePlugin) error
|
Update(domain.GamePlugin) error
|
||||||
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerInstanceRepository interface {
|
type ServerInstanceRepository interface {
|
||||||
|
|||||||
@@ -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) {
|
func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
||||||
stamp := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC)
|
stamp := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC)
|
||||||
source := &MySQLStore{MemoryStore: NewMemoryStore()}
|
source := &MySQLStore{MemoryStore: NewMemoryStore()}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
"browser.local/platform/repo"
|
"browser.local/platform/repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPluginDataListLimit = 500
|
||||||
|
maxPluginDataListLimit = 2000
|
||||||
|
)
|
||||||
|
|
||||||
func (svc *CoreService) ListPluginDataForSession(sessionID string, filter domain.PluginDataFilter) ([]domain.PluginDataRecord, error) {
|
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 {
|
if err := svc.authorizePluginData(sessionID, filter.PluginID, filter.ServerInstanceID, filter.Collection); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -16,8 +22,18 @@ func (svc *CoreService) ListPluginDataForSession(sessionID string, filter domain
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if filter.Limit > 0 && len(values) > filter.Limit {
|
sort.SliceStable(values, func(left, right int) bool {
|
||||||
values = values[:filter.Limit]
|
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
|
return values, nil
|
||||||
}
|
}
|
||||||
@@ -39,6 +55,9 @@ func (svc *CoreService) applyPluginDataTransaction(transaction domain.PluginData
|
|||||||
if len(transaction.Mutations) == 0 {
|
if len(transaction.Mutations) == 0 {
|
||||||
return nil, validationError("plugin data mutations are required")
|
return nil, validationError("plugin data mutations are required")
|
||||||
}
|
}
|
||||||
|
if err := svc.validatePluginDataMutations(transaction); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
stamp := svc.now()
|
stamp := svc.now()
|
||||||
upserts := make([]domain.PluginDataRecord, 0, len(transaction.Mutations))
|
upserts := make([]domain.PluginDataRecord, 0, len(transaction.Mutations))
|
||||||
deleteIDs := make([]string, 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 {
|
if value.Value == nil {
|
||||||
return domain.PluginDataRecord{}, validationError("plugin data value is required")
|
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)
|
value.ID = pluginDataID(value.ServerInstanceID, value.PluginID, value.Collection, value.Key)
|
||||||
stamp := svc.now()
|
stamp := svc.now()
|
||||||
existing, err := svc.store.PluginDataRecords().Get(value.ID)
|
existing, err := svc.store.PluginDataRecords().Get(value.ID)
|
||||||
@@ -128,6 +150,41 @@ func (svc *CoreService) authorizePluginData(sessionID, pluginID, serverInstanceI
|
|||||||
return nil
|
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 {
|
func pluginDataID(serverID, pluginID, collection, key string) string {
|
||||||
return "plugin-data-" + fingerprintID(serverID, pluginID+"\x00"+collection+"\x00"+key)
|
return "plugin-data-" + fingerprintID(serverID, pluginID+"\x00"+collection+"\x00"+key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"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) {
|
func TestPluginDataTransactionAppliesPutAndDeleteTogether(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
|||||||
@@ -728,10 +728,7 @@ func (svc *CoreService) CreateGamePlugin(plugin domain.GamePlugin) (domain.GameP
|
|||||||
if err := validator.ValidateGamePlugin(plugin); err != nil {
|
if err := validator.ValidateGamePlugin(plugin); err != nil {
|
||||||
return domain.GamePlugin{}, err
|
return domain.GamePlugin{}, err
|
||||||
}
|
}
|
||||||
if err := svc.store.GamePlugins().Create(plugin); err != nil {
|
return svc.upsertLatestGamePlugin(plugin)
|
||||||
return domain.GamePlugin{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyGamePlugin(plugin), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) RegisterGamePluginManifest(registration domain.GamePluginManifestRegistration) (domain.GamePlugin, error) {
|
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 {
|
if err := validator.ValidateGamePlugin(plugin); err != nil {
|
||||||
return domain.GamePlugin{}, err
|
return domain.GamePlugin{}, err
|
||||||
}
|
}
|
||||||
if existing, err := svc.store.GamePlugins().Get(plugin.ID); err == nil {
|
return svc.upsertLatestGamePlugin(plugin)
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
}
|
||||||
|
|
||||||
|
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
|
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
|
return domain.GamePlugin{}, err
|
||||||
}
|
}
|
||||||
} else if errors.Is(err, repo.ErrNotFound) {
|
} else if errors.Is(err, repo.ErrNotFound) {
|
||||||
@@ -757,9 +778,128 @@ func (svc *CoreService) RegisterGamePluginManifest(registration domain.GamePlugi
|
|||||||
} else {
|
} else {
|
||||||
return domain.GamePlugin{}, err
|
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
|
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
|
// refreshServerPluginReferences keeps existing server projections usable when a
|
||||||
// manifest is refreshed in place. The server and its logical runtime binding
|
// manifest is refreshed in place. The server and its logical runtime binding
|
||||||
// carry the manifest version used for lifecycle validation; leaving either at a
|
// carry the manifest version used for lifecycle validation; leaving either at a
|
||||||
|
|||||||
@@ -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) {
|
func TestCoreServiceRejectsUnsafeGamePluginManifest(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
registration := validPluginManifestRegistration()
|
registration := validPluginManifestRegistration()
|
||||||
|
|||||||
Reference in New Issue
Block a user