功能修改

This commit is contained in:
npc0-hue
2026-07-20 16:42:33 +08:00
parent 48b8ad8d6c
commit a0e69417db
224 changed files with 22015 additions and 884 deletions
+45
View File
@@ -36,6 +36,12 @@ type StoreSnapshot struct {
AuditEvents []domain.AuditEvent `json:"auditEvents"`
MetricSamples []domain.MetricSample `json:"metricSamples"`
Backups []domain.BackupRecord `json:"backups"`
Alerts []domain.AlertRecord `json:"alerts"`
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
}
type FileStore struct {
@@ -158,6 +164,33 @@ func (store *FileStore) Backups() BackupRepository {
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
}
func (store *FileStore) Alerts() AlertRepository {
return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist}
}
func (store *FileStore) PluginLifecycles() PluginLifecycleRepository {
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
}
func (store *FileStore) AIConfigDiffs() AIConfigDiffRepository {
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
}
func (store *FileStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
return &persistentGameClientBridgeCommandRepository{
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
repository: store.MemoryStore.bridgeCommands,
}
}
func (store *FileStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository {
return &persistentRepository[domain.GameClientBridgeSnapshot, domain.GameClientBridgeSnapshotFilter]{repository: store.MemoryStore.bridgeSnapshots, persist: store.persist}
}
func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
}
func (store *FileStore) load() error {
data, err := os.ReadFile(store.path)
if err != nil {
@@ -224,6 +257,12 @@ func (store *FileStore) snapshot() StoreSnapshot {
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
Backups: snapshotRepository(store.MemoryStore.backups),
Alerts: snapshotRepository(store.MemoryStore.alerts),
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
}
}
@@ -251,6 +290,12 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
loadRepository(store.MemoryStore.backups, snapshot.Backups)
loadRepository(store.MemoryStore.alerts, snapshot.Alerts)
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
}
type mutableRepository[T any, F any] interface {
+73
View File
@@ -0,0 +1,73 @@
package repo
import (
"sort"
"browser.local/platform/domain"
)
type memoryGameClientBridgeCommandRepository struct {
*memoryRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]
}
func newMemoryGameClientBridgeCommandRepository() *memoryGameClientBridgeCommandRepository {
return &memoryGameClientBridgeCommandRepository{memoryRepository: newMemoryRepository(
func(command domain.GameClientBridgeCommand) string { return command.ID },
domain.CopyGameClientBridgeCommand,
matchGameClientBridgeCommand,
)}
}
func (repository *memoryGameClientBridgeCommandRepository) GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey string) (domain.GameClientBridgeCommand, error) {
values, err := repository.List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverInstanceID, RequesterID: requesterID, CommandType: commandType, IdempotencyKey: idempotencyKey})
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if len(values) == 0 {
return domain.GameClientBridgeCommand{}, ErrNotFound
}
return values[0], nil
}
type persistentGameClientBridgeCommandRepository struct {
*persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]
repository GameClientBridgeCommandRepository
}
func (repository *persistentGameClientBridgeCommandRepository) GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey string) (domain.GameClientBridgeCommand, error) {
return repository.repository.GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey)
}
type memoryGameClientBridgeSnapshotRepository struct {
*memoryRepository[domain.GameClientBridgeSnapshot, domain.GameClientBridgeSnapshotFilter]
}
func newMemoryGameClientBridgeSnapshotRepository() *memoryGameClientBridgeSnapshotRepository {
return &memoryGameClientBridgeSnapshotRepository{memoryRepository: newMemoryRepository(
func(snapshot domain.GameClientBridgeSnapshot) string { return snapshot.ID },
domain.CopyGameClientBridgeSnapshot,
matchGameClientBridgeSnapshot,
)}
}
func (repository *memoryGameClientBridgeSnapshotRepository) List(filter domain.GameClientBridgeSnapshotFilter) ([]domain.GameClientBridgeSnapshot, error) {
limit := filter.Limit
filter.Limit = 0
values, err := repository.memoryRepository.List(filter)
if err != nil {
return nil, err
}
sort.SliceStable(values, func(left, right int) bool {
if !values[left].ObservedAt.Equal(values[right].ObservedAt) {
return values[left].ObservedAt.After(values[right].ObservedAt)
}
if values[left].Sequence != values[right].Sequence {
return values[left].Sequence > values[right].Sequence
}
return values[left].ID < values[right].ID
})
if limit > 0 && len(values) > limit {
values = values[:limit]
}
return values, nil
}
+132
View File
@@ -0,0 +1,132 @@
package repo
import (
"errors"
"path/filepath"
"testing"
"time"
"browser.local/platform/domain"
)
func TestGameClientBridgeCommandRepositoryIdempotencyAndRetention(t *testing.T) {
store := NewMemoryStore()
now := time.Now().UTC()
command := domain.GameClientBridgeCommand{ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", IdempotencyKey: "same-key", RequesterID: "user-1", State: domain.GameClientBridgeCommandSucceeded, Payload: map[string]any{"nested": map[string]any{"value": "original"}}, ExpiresAt: now.Add(-time.Minute), CompletedAt: now.Add(-time.Minute)}
if err := store.GameClientBridgeCommands().Create(command); err != nil {
t.Fatalf("create command: %v", err)
}
command.Payload["nested"].(map[string]any)["value"] = "mutated"
loaded, err := store.GameClientBridgeCommands().GetByIdempotency("server-1", "user-1", "announcement.send", "same-key")
if err != nil {
t.Fatalf("get by idempotency: %v", err)
}
if loaded.Payload["nested"].(map[string]any)["value"] != "original" {
t.Fatal("repository did not isolate nested payload")
}
for _, lookup := range [][4]string{{"server-2", "user-1", "announcement.send", "same-key"}, {"server-1", "user-2", "announcement.send", "same-key"}, {"server-1", "user-1", "diagnostic.safe", "same-key"}} {
if _, err := store.GameClientBridgeCommands().GetByIdempotency(lookup[0], lookup[1], lookup[2], lookup[3]); !errors.Is(err, ErrNotFound) {
t.Fatalf("idempotency scope leaked for %v: %v", lookup, err)
}
}
retained, err := store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{CompletedBefore: now})
if err != nil || len(retained) != 1 {
t.Fatalf("completed retention query: len=%d err=%v", len(retained), err)
}
}
func TestGameClientBridgeSnapshotRepositoryOrderingLimitAndStreams(t *testing.T) {
store := NewMemoryStore()
now := time.Now().UTC()
for _, snapshot := range []domain.GameClientBridgeSnapshot{
{ID: "snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", Sequence: 1, ObservedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)},
{ID: "snapshot-2", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", Sequence: 2, ObservedAt: now, ExpiresAt: now.Add(time.Hour)},
{ID: "snapshot-expired", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "vehicles", StreamKey: "current", Sequence: 1, ObservedAt: now, ExpiresAt: now.Add(-time.Minute)},
} {
if err := store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
t.Fatalf("create snapshot: %v", err)
}
}
items, err := store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", Type: "players", Limit: 1})
if err != nil || len(items) != 1 || items[0].Sequence != 2 {
t.Fatalf("newest snapshot query: %#v err=%v", items, err)
}
expired, err := store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ExpiresBefore: now})
if err != nil || len(expired) != 1 || expired[0].ID != "snapshot-expired" {
t.Fatalf("snapshot retention query: %#v err=%v", expired, err)
}
for _, stream := range []domain.GameClientBridgeSnapshotStream{
{ID: "stream-players", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", LatestSequence: 2},
{ID: "stream-vehicles", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "vehicles", StreamKey: "current", LatestSequence: 7},
} {
if err := store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
t.Fatalf("create stream: %v", err)
}
}
streams, err := store.GameClientBridgeSnapshotStreams().List(domain.GameClientBridgeSnapshotStreamFilter{ServerInstanceID: "server-1"})
if err != nil || len(streams) != 2 || streams[0].LatestSequence == streams[1].LatestSequence {
t.Fatalf("independent stream sequences: %#v err=%v", streams, err)
}
}
func TestGameClientBridgeFileStorePersistsAndDeletes(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := NewFileStore(path)
if err != nil {
t.Fatalf("new file store: %v", err)
}
command := domain.GameClientBridgeCommand{ID: "command-1", ServerInstanceID: "server-1", CommandType: "diagnostic.safe", RequesterID: "user-1", IdempotencyKey: "diag-1", State: domain.GameClientBridgeCommandClaimed, Claim: domain.GameClientBridgeClaim{FencingToken: 3}}
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ServerInstanceID: "server-1", Type: "health", StreamKey: "current", Sequence: 4}
stream := domain.GameClientBridgeSnapshotStream{ID: "stream-1", ServerInstanceID: "server-1", Type: "health", StreamKey: "current", LatestSequence: 4}
if err := store.GameClientBridgeCommands().Create(command); err != nil {
t.Fatal(err)
}
if err := store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
t.Fatal(err)
}
if err := store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
t.Fatal(err)
}
restarted, err := NewFileStore(path)
if err != nil {
t.Fatalf("restart file store: %v", err)
}
if got, err := restarted.GameClientBridgeCommands().Get(command.ID); err != nil || got.Claim.FencingToken != 3 {
t.Fatalf("persisted command: %#v err=%v", got, err)
}
if got, err := restarted.GameClientBridgeSnapshots().Get(snapshot.ID); err != nil || got.Sequence != 4 {
t.Fatalf("persisted snapshot: %#v err=%v", got, err)
}
if got, err := restarted.GameClientBridgeSnapshotStreams().Get(stream.ID); err != nil || got.LatestSequence != 4 {
t.Fatalf("persisted stream: %#v err=%v", got, err)
}
if err := restarted.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
t.Fatalf("delete snapshot: %v", err)
}
reloaded, err := NewFileStore(path)
if err != nil {
t.Fatalf("reload file store: %v", err)
}
if _, err := reloaded.GameClientBridgeSnapshots().Get(snapshot.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("deleted snapshot revived: %v", err)
}
}
func TestMySQLSnapshotRoundTripIncludesGameClientBridge(t *testing.T) {
source := &MySQLStore{MemoryStore: NewMemoryStore()}
_ = source.MemoryStore.GameClientBridgeCommands().Create(domain.GameClientBridgeCommand{ID: "command-1"})
_ = source.MemoryStore.GameClientBridgeSnapshots().Create(domain.GameClientBridgeSnapshot{ID: "snapshot-1"})
_ = source.MemoryStore.GameClientBridgeSnapshotStreams().Create(domain.GameClientBridgeSnapshotStream{ID: "stream-1", LatestSequence: 8})
target := &MySQLStore{MemoryStore: NewMemoryStore()}
target.loadSnapshot(source.snapshot())
if _, err := target.MemoryStore.GameClientBridgeCommands().Get("command-1"); err != nil {
t.Fatal(err)
}
if _, err := target.MemoryStore.GameClientBridgeSnapshots().Get("snapshot-1"); err != nil {
t.Fatal(err)
}
if stream, err := target.MemoryStore.GameClientBridgeSnapshotStreams().Get("stream-1"); err != nil || stream.LatestSequence != 8 {
t.Fatalf("stream round trip: %#v err=%v", stream, err)
}
}
+39
View File
@@ -145,6 +145,33 @@ func (store *MySQLStore) Backups() BackupRepository {
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
}
func (store *MySQLStore) Alerts() AlertRepository {
return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist}
}
func (store *MySQLStore) PluginLifecycles() PluginLifecycleRepository {
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
}
func (store *MySQLStore) AIConfigDiffs() AIConfigDiffRepository {
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
}
func (store *MySQLStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
return &persistentGameClientBridgeCommandRepository{
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
repository: store.MemoryStore.bridgeCommands,
}
}
func (store *MySQLStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository {
return &persistentRepository[domain.GameClientBridgeSnapshot, domain.GameClientBridgeSnapshotFilter]{repository: store.MemoryStore.bridgeSnapshots, persist: store.persist}
}
func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
}
func (store *MySQLStore) initialize() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -228,6 +255,12 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
Backups: snapshotRepository(store.MemoryStore.backups),
Alerts: snapshotRepository(store.MemoryStore.alerts),
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
}
}
@@ -255,4 +288,10 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
loadRepository(store.MemoryStore.backups, snapshot.Backups)
loadRepository(store.MemoryStore.alerts, snapshot.Alerts)
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
}
+143
View File
@@ -178,6 +178,52 @@ type BackupRepository interface {
Delete(id string) error
}
type AlertRepository interface {
Create(domain.AlertRecord) error
Get(id string) (domain.AlertRecord, error)
List(domain.AlertFilter) ([]domain.AlertRecord, error)
Update(domain.AlertRecord) error
}
type PluginLifecycleRepository interface {
Create(domain.PluginLifecycleInstallation) error
Get(id string) (domain.PluginLifecycleInstallation, error)
List(domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
Update(domain.PluginLifecycleInstallation) error
}
type AIConfigDiffRepository interface {
Create(domain.AIConfigDiffPreview) error
Get(id string) (domain.AIConfigDiffPreview, error)
List(domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
Update(domain.AIConfigDiffPreview) error
}
type GameClientBridgeCommandRepository interface {
Create(domain.GameClientBridgeCommand) error
Get(id string) (domain.GameClientBridgeCommand, error)
GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey string) (domain.GameClientBridgeCommand, error)
List(domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
Update(domain.GameClientBridgeCommand) error
Delete(id string) error
}
type GameClientBridgeSnapshotRepository interface {
Create(domain.GameClientBridgeSnapshot) error
Get(id string) (domain.GameClientBridgeSnapshot, error)
List(domain.GameClientBridgeSnapshotFilter) ([]domain.GameClientBridgeSnapshot, error)
Update(domain.GameClientBridgeSnapshot) error
Delete(id string) error
}
type GameClientBridgeSnapshotStreamRepository interface {
Create(domain.GameClientBridgeSnapshotStream) error
Get(id string) (domain.GameClientBridgeSnapshotStream, error)
List(domain.GameClientBridgeSnapshotStreamFilter) ([]domain.GameClientBridgeSnapshotStream, error)
Update(domain.GameClientBridgeSnapshotStream) error
Delete(id string) error
}
type Store interface {
Users() UserRepository
AuthSessions() AuthSessionRepository
@@ -202,6 +248,12 @@ type Store interface {
AuditEvents() AuditEventRepository
MetricSamples() MetricSampleRepository
Backups() BackupRepository
Alerts() AlertRepository
PluginLifecycles() PluginLifecycleRepository
AIConfigDiffs() AIConfigDiffRepository
GameClientBridgeCommands() GameClientBridgeCommandRepository
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
}
type MemoryStore struct {
@@ -228,6 +280,12 @@ type MemoryStore struct {
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
bridgeCommands *memoryGameClientBridgeCommandRepository
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
}
func NewMemoryStore() *MemoryStore {
@@ -343,6 +401,28 @@ func NewMemoryStore() *MemoryStore {
domain.CopyBackupRecord,
matchBackup,
),
alerts: newMemoryRepository(
func(alert domain.AlertRecord) string { return alert.ID },
domain.CopyAlertRecord,
matchAlert,
),
pluginLifecycle: newMemoryRepository(
func(installation domain.PluginLifecycleInstallation) string { return installation.ID },
domain.CopyPluginLifecycleInstallation,
matchPluginLifecycle,
),
aiConfigDiffs: newMemoryRepository(
func(preview domain.AIConfigDiffPreview) string { return preview.ID },
domain.CopyAIConfigDiffPreview,
matchAIConfigDiff,
),
bridgeCommands: newMemoryGameClientBridgeCommandRepository(),
bridgeSnapshots: newMemoryGameClientBridgeSnapshotRepository(),
bridgeStreams: newMemoryRepository(
func(stream domain.GameClientBridgeSnapshotStream) string { return stream.ID },
domain.CopyGameClientBridgeSnapshotStream,
matchGameClientBridgeSnapshotStream,
),
}
}
@@ -381,6 +461,20 @@ func (store *MemoryStore) LogStreams() LogStreamRepository { return store.
func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents }
func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
func (store *MemoryStore) Backups() BackupRepository { return store.backups }
func (store *MemoryStore) Alerts() AlertRepository { return store.alerts }
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
return store.pluginLifecycle
}
func (store *MemoryStore) AIConfigDiffs() AIConfigDiffRepository { return store.aiConfigDiffs }
func (store *MemoryStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
return store.bridgeCommands
}
func (store *MemoryStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository {
return store.bridgeSnapshots
}
func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return store.bridgeStreams
}
type memoryRepository[T any, F any] struct {
mu sync.RWMutex
@@ -632,3 +726,52 @@ func matchBackup(record domain.BackupRecord, filter domain.BackupFilter) bool {
return (filter.ServerInstanceID == "" || record.ServerInstanceID == filter.ServerInstanceID) &&
(filter.State == "" || record.State == filter.State)
}
func matchAlert(alert domain.AlertRecord, filter domain.AlertFilter) bool {
return (filter.State == "" || alert.State == filter.State) &&
(filter.SourceKind == "" || alert.SourceKind == filter.SourceKind) &&
(filter.SourceID == "" || alert.SourceID == filter.SourceID) &&
(filter.Severity == "" || alert.Severity == filter.Severity)
}
func matchPluginLifecycle(installation domain.PluginLifecycleInstallation, filter domain.PluginLifecycleFilter) bool {
return (filter.PluginID == "" || installation.PluginID == filter.PluginID) &&
(filter.ServerInstanceID == "" || installation.ServerInstanceID == filter.ServerInstanceID) &&
(filter.CurrentState == "" || installation.CurrentState == filter.CurrentState)
}
func matchAIConfigDiff(preview domain.AIConfigDiffPreview, filter domain.AIConfigDiffFilter) bool {
return (filter.ServerInstanceID == "" || preview.ServerInstanceID == filter.ServerInstanceID) &&
(filter.PluginID == "" || preview.PluginID == filter.PluginID) &&
(filter.State == "" || preview.State == filter.State)
}
func matchGameClientBridgeCommand(command domain.GameClientBridgeCommand, filter domain.GameClientBridgeCommandFilter) bool {
return (filter.ServerInstanceID == "" || command.ServerInstanceID == filter.ServerInstanceID) &&
(filter.PluginID == "" || command.PluginID == filter.PluginID) &&
(filter.ProfileKey == "" || command.ProfileKey == filter.ProfileKey) &&
(filter.State == "" || command.State == filter.State) &&
(filter.RequesterID == "" || command.RequesterID == filter.RequesterID) &&
(filter.CommandType == "" || command.CommandType == filter.CommandType) &&
(filter.IdempotencyKey == "" || command.IdempotencyKey == filter.IdempotencyKey) &&
(filter.ExpiresBefore.IsZero() || !command.ExpiresAt.After(filter.ExpiresBefore)) &&
(filter.CompletedBefore.IsZero() || (!command.CompletedAt.IsZero() && !command.CompletedAt.After(filter.CompletedBefore)))
}
func matchGameClientBridgeSnapshot(snapshot domain.GameClientBridgeSnapshot, filter domain.GameClientBridgeSnapshotFilter) bool {
return (filter.ServerInstanceID == "" || snapshot.ServerInstanceID == filter.ServerInstanceID) &&
(filter.PluginID == "" || snapshot.PluginID == filter.PluginID) &&
(filter.ProfileKey == "" || snapshot.ProfileKey == filter.ProfileKey) &&
(filter.Type == "" || snapshot.Type == filter.Type) &&
(filter.StreamKey == "" || snapshot.StreamKey == filter.StreamKey) &&
(filter.ObservedAfter.IsZero() || snapshot.ObservedAt.After(filter.ObservedAfter)) &&
(filter.ExpiresBefore.IsZero() || !snapshot.ExpiresAt.After(filter.ExpiresBefore))
}
func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotStream, filter domain.GameClientBridgeSnapshotStreamFilter) bool {
return (filter.ServerInstanceID == "" || stream.ServerInstanceID == filter.ServerInstanceID) &&
(filter.PluginID == "" || stream.PluginID == filter.PluginID) &&
(filter.ProfileKey == "" || stream.ProfileKey == filter.ProfileKey) &&
(filter.Type == "" || stream.Type == filter.Type) &&
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
}
+77 -4
View File
@@ -129,7 +129,7 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
LastReconciledAt: stamp,
ReconcileCount: 2,
ReconcileOutcome: "confirmed active attempt",
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "name=approved\n", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("1", 64), MaxReadBytes: 64 * 1024},
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "name=approved\n", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("1", 64), MaxReadBytes: 64 * 1024, Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"}},
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"},
}
if err := store.Jobs().Create(job); err != nil {
@@ -194,9 +194,17 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
if err != nil {
t.Fatalf("get reloaded job by idempotency: %v", err)
}
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionResult.Content != "private-read" {
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionInput.Inputs["templateKey"] != "players.by-id" || gotJob.ExecutionInput.Inputs["playerId"] != "steam-123" || gotJob.ExecutionResult.Content != "private-read" {
t.Fatalf("unexpected reloaded job: %+v", gotJob)
}
gotJob.ExecutionInput.Inputs["playerId"] = "mutated"
againJob, err := reloaded.Jobs().GetByIdempotency("run-local", "idem-1")
if err != nil {
t.Fatalf("get reloaded job again: %v", err)
}
if againJob.ExecutionInput.Inputs["playerId"] != "steam-123" {
t.Fatalf("expected reloaded job inputs to be isolated, got %+v", againJob.ExecutionInput.Inputs)
}
gotPlugin, err := reloaded.GamePlugins().Get(plugin.ID)
if err != nil || len(gotPlugin.RuntimeProfiles.LifecycleProfiles) != 1 || gotPlugin.RuntimeProfiles.LifecycleProfiles[0].Key != "local" {
t.Fatalf("unexpected reloaded runtime profiles: plugin=%+v err=%v", gotPlugin, err)
@@ -240,7 +248,7 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
Attempt: 2, QueueEligibleAt: stamp, LeaseTokenHash: strings.Repeat("e", 64), LeaseSessionGen: 4,
LeaseExpiresAt: stamp.Add(time.Minute), LastProgressSeq: 8, CancelReason: "stop", CancelRequestedAt: stamp,
LastReconciledAt: stamp, ReconcileCount: 3, ReconcileOutcome: "confirmed active attempt", CreatedAt: stamp, UpdatedAt: stamp,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "mysql-approved", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("3", 64), MaxReadBytes: 64 * 1024},
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "mysql-approved", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("3", 64), MaxReadBytes: 64 * 1024, Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-456"}},
ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, AuditSummary: "atomic write"},
}
if err := source.MemoryStore.Jobs().Create(job); err != nil {
@@ -257,9 +265,74 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
target := &MySQLStore{MemoryStore: NewMemoryStore()}
target.loadSnapshot(snapshot)
got, err := target.MemoryStore.Jobs().Get(job.ID)
if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum {
if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionInput.Inputs["templateKey"] != "players.by-id" || got.ExecutionInput.Inputs["playerId"] != "steam-456" || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum {
t.Fatalf("unexpected MySQL snapshot job: job=%+v err=%v", got, err)
}
got.ExecutionInput.Inputs["playerId"] = "mutated"
again, err := target.MemoryStore.Jobs().Get(job.ID)
if err != nil {
t.Fatalf("get MySQL snapshot job again: %v", err)
}
if again.ExecutionInput.Inputs["playerId"] != "steam-456" {
t.Fatalf("expected MySQL snapshot job inputs to be isolated, got %+v", again.ExecutionInput.Inputs)
}
}
func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "production-operations.json")
store, err := NewFileStore(path)
if err != nil {
t.Fatalf("create file store: %v", err)
}
stamp := time.Date(2026, 7, 18, 14, 0, 0, 0, time.UTC)
alert := domain.AlertRecord{
ID: "alert-capacity", SourceKind: "run-endpoint", SourceID: "run-1", RuleKey: "capacity.pressure",
Severity: domain.AlertSeverityWarning, State: domain.AlertStateAcknowledged, Title: "Capacity pressure",
Message: "endpoint capacity is temporarily under pressure", OccurrenceCount: 2, Retryable: true,
RetryAfterSeconds: 30, LastAuditEventID: "audit-1", LastSeenAt: stamp, AcknowledgedBy: "operator-1",
AcknowledgedAt: stamp, CreatedAt: stamp.Add(-time.Minute), UpdatedAt: stamp,
}
installation := domain.PluginLifecycleInstallation{
ID: "plugin-lifecycle-1", PluginID: "game.scum", ServerInstanceID: "server-1",
CurrentVersion: "1.0.0", TargetVersion: "2.0.0", PreviousVersion: "0.9.0",
DesiredState: domain.PluginLifecycleStateEnabled, CurrentState: domain.PluginLifecycleStateUpgrading,
LastOperation: domain.PluginLifecycleOperationUpgrade, Compatibility: "compatible",
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade", AlertID: alert.ID,
AuditEventID: "audit-2", IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
}
diff := domain.AIConfigDiffPreview{
ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1",
ServerInstanceID: "server-1", PluginID: "game.scum", ProviderID: "ai.openai", Model: "gpt-4.1",
Key: "server.properties", ConfigVersion: 4, CurrentConfigChecksum: "sha256:" + strings.Repeat("a", 64),
ProposedConfig: "MaxPlayers=80\n", DiffSummary: "review required before config write dispatch",
State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(30 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp,
}
if err := store.Alerts().Create(alert); err != nil {
t.Fatalf("create alert: %v", err)
}
if err := store.PluginLifecycles().Create(installation); err != nil {
t.Fatalf("create plugin lifecycle: %v", err)
}
if err := store.AIConfigDiffs().Create(diff); err != nil {
t.Fatalf("create AI config diff: %v", err)
}
restarted, err := NewFileStore(path)
if err != nil {
t.Fatalf("restart file store: %v", err)
}
gotAlert, alertErr := restarted.Alerts().Get(alert.ID)
if alertErr != nil || gotAlert.State != alert.State || gotAlert.OccurrenceCount != 2 || gotAlert.LastAuditEventID != alert.LastAuditEventID {
t.Fatalf("unexpected durable alert: alert=%+v err=%v", gotAlert, alertErr)
}
gotInstallation, lifecycleErr := restarted.PluginLifecycles().Get(installation.ID)
if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID {
t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr)
}
gotDiff, diffErr := restarted.AIConfigDiffs().Get(diff.ID)
if diffErr != nil || gotDiff.State != diff.State || gotDiff.CurrentConfigChecksum != diff.CurrentConfigChecksum || gotDiff.ProposedConfig != diff.ProposedConfig {
t.Fatalf("unexpected durable AI config diff: diff=%+v err=%v", gotDiff, diffErr)
}
}
func TestMySQLStoreRequiresDSN(t *testing.T) {