Rebuild SCUM plugin data ownership

This commit is contained in:
npc0-hue
2026-08-14 10:03:58 +08:00
parent c8b49c711c
commit a6c4cdac5d
79 changed files with 532 additions and 1842 deletions
+10 -42
View File
@@ -23,7 +23,7 @@ func (svc *CoreService) SaveGameGiftCatalogForSession(sessionID, serverID string
if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.GameGiftCatalog{}, err
}
if err = svc.validateGiftItems(serverID, request.GameVersion, request.Items); err != nil {
if err = validateGiftItems(request.GameVersion, request.Items); err != nil {
return domain.GameGiftCatalog{}, err
}
stamp := svc.now()
@@ -60,7 +60,7 @@ func (svc *CoreService) PublishGameGiftCatalogForSession(sessionID, catalogID st
if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil {
return domain.GameGiftRevision{}, err
}
if err = svc.validateGiftItems(catalog.ServerInstanceID, catalog.GameVersion, catalog.DraftItems); err != nil {
if err = validateGiftItems(catalog.GameVersion, catalog.DraftItems); err != nil {
return domain.GameGiftRevision{}, err
}
revisions, err := svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalog.ID})
@@ -111,7 +111,7 @@ func (svc *CoreService) RequestGameGiftGrantForSession(sessionID, serverID strin
if err != nil || revision.ServerInstanceID != serverID {
return domain.GameGiftGrant{}, repo.ErrNotFound
}
if err = svc.validateGiftItems(serverID, revision.GameVersion, revision.Items); err != nil {
if err = validateGiftItems(revision.GameVersion, revision.Items); err != nil {
return domain.GameGiftGrant{}, err
}
player, err := svc.store.GamePlayers().Get(request.GamePlayerRecordID)
@@ -148,7 +148,7 @@ func (svc *CoreService) ApproveGameGiftGrantForSession(sessionID, grantID string
if grant.Status != domain.GameGiftGrantPendingApproval {
return domain.GameGiftGrant{}, validationError("gift grant is not awaiting approval")
}
if err = svc.validateGiftItems(grant.ServerInstanceID, grant.GameVersion, grant.Items); err != nil {
if err = validateGiftItems(grant.GameVersion, grant.Items); err != nil {
return domain.GameGiftGrant{}, err
}
sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: grant.GamePlayerRecordID, OpenOnly: true})
@@ -198,55 +198,23 @@ func (svc *CoreService) ListGameGiftGrantsForSession(sessionID, serverID string)
sort.Slice(grants, func(i, j int) bool { return grants[i].CreatedAt.After(grants[j].CreatedAt) })
return grants, nil
}
func (svc *CoreService) validateGiftItems(serverID, version string, items []domain.GameGiftItem) error {
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return err
}
catalog, ok := scumGiftCatalogFromPlugin(plugin, version)
if !ok {
return validationError("installed SCUM plugin has no verified gift item catalog for this game version")
func validateGiftItems(version string, items []domain.GameGiftItem) error {
if _, ok := domain.SCUMGiftCatalogForVersion(version); !ok {
return validationError("SCUM game version has no verified gift item catalog")
}
if len(items) == 0 || len(items) > 8 {
return validationError("gift requires 1 to 8 catalog items")
}
seen := map[string]bool{}
for index, item := range items {
def, ok := scumGiftItem(catalog, item.CatalogItemKey)
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity {
for _, item := range items {
def, ok := domain.SCUMGiftItemForVersion(version, item.CatalogItemKey)
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity || item.Label != def.Label {
return validationError("gift item is not valid for this SCUM version")
}
items[index].Label = def.Label
seen[item.CatalogItemKey] = true
}
return nil
}
func scumGiftCatalogFromPlugin(plugin domain.GamePlugin, version string) (domain.SCUMGiftItemCatalog, bool) {
for _, asset := range plugin.LifecycleAssets {
if !strings.HasPrefix(asset.Path, "data-packs/") || !strings.HasSuffix(asset.Path, "/gift-items.json") || strings.TrimSpace(asset.Content) == "" {
continue
}
catalog, ok := domain.ParseSCUMGiftItemCatalog(asset.Content)
if ok && catalog.GameVersion == version {
return catalog, true
}
}
return domain.SCUMGiftItemCatalog{}, false
}
func scumGiftItem(catalog domain.SCUMGiftItemCatalog, key string) (domain.SCUMGiftItemDefinition, bool) {
for _, item := range catalog.Items {
if item.Key == key {
return item, true
}
}
return domain.SCUMGiftItemDefinition{}, false
}
func giftDeliveryPayload(grant domain.GameGiftGrant) map[string]any {
items := make([]any, len(grant.Items))
for i, item := range grant.Items {
-1
View File
@@ -100,7 +100,6 @@ func gameGiftFixture(t *testing.T, online bool) (*CoreService, string, domain.Ga
svc, clock := newGameClientBridgeService(t)
plugin, _ := svc.store.GamePlugins().Get("game.scum")
plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{Type: domain.SCUMRewardDeliverCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 4096}, {Type: domain.SCUMGiftNotificationCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 60, MaxPayloadBytes: 2048}}
plugin.LifecycleAssets = append(plugin.LifecycleAssets, domain.PluginAssetFile{Path: "data-packs/scum-db-v57/gift-items.json", Content: `{"gameVersion":"0.9.700.90357","items":[{"key":"bandage","label":"绷带","maximumQuantity":20},{"key":"water-bottle","label":"饮用水","maximumQuantity":10},{"key":"improvised-spear","label":"简易长矛","maximumQuantity":2}]}`})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
+73
View File
@@ -0,0 +1,73 @@
package service
import (
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
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
}
values, err := svc.store.PluginDataRecords().List(filter)
if err != nil {
return nil, err
}
if filter.Limit > 0 && len(values) > filter.Limit {
values = values[:filter.Limit]
}
return values, nil
}
func (svc *CoreService) PutPluginDataForSession(sessionID string, value domain.PluginDataRecord) (domain.PluginDataRecord, error) {
if err := svc.authorizePluginData(sessionID, value.PluginID, value.ServerInstanceID, value.Collection); err != nil {
return domain.PluginDataRecord{}, err
}
if strings.TrimSpace(value.Key) == "" {
return domain.PluginDataRecord{}, validationError("plugin data key is required")
}
if value.Value == nil {
return domain.PluginDataRecord{}, validationError("plugin data value is required")
}
value.ID = pluginDataID(value.ServerInstanceID, value.PluginID, value.Collection, value.Key)
stamp := svc.now()
existing, err := svc.store.PluginDataRecords().Get(value.ID)
if err == repo.ErrNotFound {
value.CreatedAt, value.UpdatedAt = stamp, stamp
if err := svc.store.PluginDataRecords().Create(value); err != nil {
return domain.PluginDataRecord{}, err
}
return domain.CopyPluginDataRecord(value), nil
}
if err != nil {
return domain.PluginDataRecord{}, err
}
existing.Value, existing.UpdatedAt = domain.CopyGameClientBridgePayload(value.Value), stamp
if err := svc.store.PluginDataRecords().Update(existing); err != nil {
return domain.PluginDataRecord{}, err
}
return domain.CopyPluginDataRecord(existing), nil
}
func (svc *CoreService) authorizePluginData(sessionID, pluginID, serverInstanceID, collection string) error {
if strings.TrimSpace(pluginID) == "" || strings.TrimSpace(serverInstanceID) == "" || strings.TrimSpace(collection) == "" {
return validationError("pluginId, serverInstanceId, and collection are required")
}
if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil {
return err
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return err
}
if instance.PluginID != pluginID {
return ErrForbidden
}
return nil
}
func pluginDataID(serverID, pluginID, collection, key string) string {
return "plugin-data-" + fingerprintID(serverID, pluginID+"\x00"+collection+"\x00"+key)
}
+31
View File
@@ -0,0 +1,31 @@
package service
import (
"testing"
"browser.local/platform/domain"
)
func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("register owner: %v", err)
}
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "SCUM"}); err != nil {
t.Fatalf("create server: %v", err)
}
sessionID := owner.SessionID
stored, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "futureField": true}})
if err != nil || stored.Value["futureField"] != true {
t.Fatalf("put plugin data=%+v err=%v", stored, err)
}
items, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_users"})
if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["steamId"] != "steam-1" {
t.Fatalf("list plugin data=%+v err=%v", items, err)
}
if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "other.plugin", ServerInstanceID: "server-1", Collection: "scum_users"}); err != ErrForbidden {
t.Fatalf("expected plugin isolation error, got %v", err)
}
}
+2 -1
View File
@@ -191,6 +191,8 @@ type Core interface {
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
PutPluginDataForSession(string, domain.PluginDataRecord) (domain.PluginDataRecord, error)
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
@@ -231,7 +233,6 @@ type Core interface {
ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
ListSCUMDataRowsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error)
RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error)
ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error)
+34 -133
View File
@@ -59,28 +59,12 @@ func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservation
return observation, nil
}
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt}
target, err := svc.scumRowTarget(result.PluginID, result.QueryKey)
if err != nil {
return domain.SCUMDataObservation{}, err
}
if err := svc.applySCUMRows(target, result.PluginID, result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
return domain.SCUMDataObservation{}, err
}
return observation, nil
}
func (svc *CoreService) ListSCUMDataRowsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMDataRows().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
@@ -190,39 +174,44 @@ func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObserva
return svc.store.SCUMDataObservations().Create(observation)
}
func (svc *CoreService) applySCUMRows(target domain.SCUMRowTargetDeclaration, pluginID, queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
if target.TargetTable != "" {
func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
lower := strings.ToLower(queryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
for _, row := range rows {
if err := svc.upsertSCUMDataRow(target, pluginID, queryKey, serverID, row, freshness); err != nil {
return err
}
}
return nil
}
// Compatibility declarations retain the old projections without guessing from substrings.
for _, row := range rows {
switch queryKey {
case "scum.player.profile":
if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil {
return err
}
case "scum.squads":
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
return err
}
case "scum.squad-members":
}
}
if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") {
for _, row := range rows {
if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil {
return err
}
case "scum.vehicles":
}
} else if strings.Contains(lower, "squad") {
for _, row := range rows {
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "vehicle") {
for _, row := range rows {
if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil {
return err
}
case "scum.flags":
}
}
if strings.Contains(lower, "flag") {
for _, row := range rows {
if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil {
return err
}
case "scum.positions":
}
}
if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") {
for _, row := range rows {
if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil {
return err
}
@@ -231,78 +220,6 @@ func (svc *CoreService) applySCUMRows(target domain.SCUMRowTargetDeclaration, pl
return nil
}
func (svc *CoreService) scumRowTarget(pluginID, queryKey string) (domain.SCUMRowTargetDeclaration, error) {
plugin, err := svc.store.GamePlugins().Get(pluginID)
if err != nil {
return domain.SCUMRowTargetDeclaration{}, err
}
for _, template := range plugin.GameClientBridge.QueryTemplates {
if template.Key == queryKey && template.RowTarget != nil {
return domain.CopySCUMRowTargetDeclaration(*template.RowTarget), nil
}
}
if _, ok := legacySCUMQueryKeys[queryKey]; ok {
return domain.SCUMRowTargetDeclaration{}, nil
}
return domain.SCUMRowTargetDeclaration{}, validationError("queryKey does not declare a SCUM row target")
}
var legacySCUMQueryKeys = map[string]struct{}{
"scum.player.profile": {}, "scum.squads": {}, "scum.squad-members": {}, "scum.vehicles": {}, "scum.flags": {}, "scum.positions": {},
}
func (svc *CoreService) upsertSCUMDataRow(target domain.SCUMRowTargetDeclaration, pluginID, queryKey, serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
table := domain.SCUMDataSet(strings.TrimSpace(target.TargetTable))
if !validSCUMDataSet(table) || len(target.UpsertKeys) == 0 {
return validationError("SCUM row target is invalid")
}
fields := map[string]any{}
for destination, source := range target.ColumnMappings {
if value, ok := row[source]; ok {
fields[destination] = value
}
}
keyValues := make([]string, 0, len(target.UpsertKeys))
for _, key := range target.UpsertKeys {
value, ok := fields[key]
if !ok {
value, ok = row[key]
}
text := firstString(map[string]any{"value": value}, "value")
if !ok || text == "" {
return validationError("SCUM row is missing declared upsert key " + key)
}
keyValues = append(keyValues, text)
}
upsertKey := strings.Join(keyValues, "\x00")
id := scumProjectionID(string(table), serverID, upsertKey)
value, err := svc.store.SCUMDataRows().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMDataRow{ID: id, ServerInstanceID: serverID, TargetTable: table, UpsertKey: upsertKey, CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.Fields = domain.CopyGameClientBridgePayload(fields)
value.Payload = domain.CopyGameClientBridgePayload(row)
value.PluginID, value.QueryKey, value.Freshness, value.UpdatedAt = pluginID, queryKey, freshness, svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMDataRows().Create(value)
}
return svc.store.SCUMDataRows().Update(value)
}
func validSCUMDataSet(value domain.SCUMDataSet) bool {
switch value {
case domain.SCUMDataSetUsers, domain.SCUMDataSetSquads, domain.SCUMDataSetMembers, domain.SCUMDataSetVehicles, domain.SCUMDataSetFlags, domain.SCUMDataSetActivity, domain.SCUMDataSetGiftEvents, domain.SCUMDataSetMapPoints:
return true
default:
return false
}
}
func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
@@ -641,27 +558,8 @@ func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition)
func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error {
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
target, err := svc.scumRowTarget(result.PluginID, result.QueryKey)
if err != nil {
return err
}
if target.TargetTable != "" {
values, err := svc.store.SCUMDataRows().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID, TargetTable: domain.SCUMDataSet(target.TargetTable)})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness, value.UpdatedAt = freshness, svc.now()
if err := svc.store.SCUMDataRows().Update(value); err != nil {
return err
}
}
}
return nil
}
switch result.QueryKey {
case "scum.player.profile":
lower := strings.ToLower(result.QueryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
@@ -675,7 +573,8 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
}
}
}
case "scum.squads", "scum.squad-members":
}
if strings.Contains(lower, "squad") {
values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
@@ -689,7 +588,8 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
}
}
}
case "scum.vehicles":
}
if strings.Contains(lower, "vehicle") {
values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
@@ -703,7 +603,8 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
}
}
}
case "scum.flags":
}
if strings.Contains(lower, "flag") {
values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
-26
View File
@@ -108,29 +108,3 @@ func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *t
t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0])
}
}
func TestSCUMObservationUsesDeclaredRowTargetInsteadOfQueryKeyName(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
plugin, err := svc.store.GamePlugins().Get("server.scum")
if err != nil {
t.Fatalf("get plugin: %v", err)
}
plugin.GameClientBridge.QueryTemplates = append(plugin.GameClientBridge.QueryTemplates, domain.GameClientBridgeQueryTemplateDeclaration{Key: "v57.catalog.people", RowTarget: &domain.SCUMRowTargetDeclaration{TargetTable: string(domain.SCUMDataSetUsers), UpsertKeys: []string{"profileId"}, ColumnMappings: map[string]string{"profileId": "user_profile_id", "name": "display_name"}}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin: %v", err)
}
_, err = svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "v57.catalog.people", Sequence: 1, ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"user_profile_id": "profile-1", "display_name": "Moon", "unmapped": "kept"}}})
if err != nil {
t.Fatalf("apply declared data row: %v", err)
}
rows, err := svc.store.SCUMDataRows().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", TargetTable: domain.SCUMDataSetUsers})
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if rows[0].Fields["profileId"] != "profile-1" || rows[0].Payload["unmapped"] != "kept" {
t.Fatalf("unexpected declared row: %+v", rows[0])
}
if states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1"}); err != nil || len(states) != 0 {
t.Fatalf("query key leaked into legacy projection dispatch: states=%+v err=%v", states, err)
}
}