From 8aeb488179b9448f43394738fe37c392e04fb6f8 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Fri, 21 Aug 2026 15:36:49 +0800 Subject: [PATCH] Remove plugin data query projection --- platform/api/routes.md | 2 + platform/domain/game_client_bridge.go | 25 --- platform/domain/game_client_bridge_test.go | 5 +- platform/domain/plugin_data.go | 5 +- platform/dto/resources.go | 45 ++--- platform/dto/resources_test.go | 10 +- platform/service/job_channel.go | 6 - platform/service/plugin_data_projection.go | 126 ------------ platform/service/plugin_data_rows.go | 42 ++++ platform/service/plugin_data_schedule.go | 92 --------- platform/service/plugin_data_test.go | 185 ++---------------- platform/service/resources_test.go | 1 - platform/validator/resources.go | 28 +-- platform/validator/resources_test.go | 5 +- platform_web/api/types.ts | 1 - .../examples/scum-server-plugin/manifest.json | 175 ----------------- .../game-plugin.manifest.schema.json | 12 -- plugins/scripts/validate-manifest.ts | 13 +- plugins/sdk/index.ts | 8 - plugins/tests/manifest-validation.test.ts | 8 - 20 files changed, 91 insertions(+), 703 deletions(-) delete mode 100644 platform/service/plugin_data_projection.go create mode 100644 platform/service/plugin_data_rows.go delete mode 100644 platform/service/plugin_data_schedule.go diff --git a/platform/api/routes.md b/platform/api/routes.md index a122fd6..9aabdd8 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -24,6 +24,8 @@ All routes use JSON request and response bodies. Collection routes support `GET` | Artifacts | `GET /api/v1/artifacts`, `POST /api/v1/artifacts` | `GET /api/v1/artifacts/{id}`, `POST /api/v1/artifacts/{id}/download`, `GET /api/v1/artifacts/{id}/content` | `ArtifactCreateRequest`, `ArtifactResponse`, `ArtifactListResponse`, `ArtifactDownloadReferenceResponse`, `ArtifactContentRequest` | | Log streams | `GET /api/v1/log-streams`, `POST /api/v1/log-streams` | `GET /api/v1/log-streams/{id}` | `LogStreamCreateRequest`, `LogStreamResponse`, `LogStreamListResponse` | +Plugin-owned data is an independent, server-scoped plugin store. It is not a projection of the game-server database and never aliases platform user/auth storage. + Client Manager lifecycle routes are grouped under the server instance and return only the safe installation projection: `GET /api/v1/server-instances/{id}/client-managers`, `GET .../{profileKey}`, and typed `POST` routes for `deploy`, `control`, `update`, `retry`, `revoke-session`, and confirmed `uninstall`. Component-only `POST /api/v1/client-managers/register` and `/heartbeat` use the separate signed component identity/session contract. Run-only input/chunk routes are fenced by the active Run job lease. None of these DTOs return raw component keys, bearer sessions, secret refs/values, host paths, PIDs, sockets, or endpoint addresses. ## Implemented Query Filters diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index b92e944..797f451 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -44,19 +44,6 @@ type GameClientBridgeQueryTemplateDeclaration struct { MaxRows int TimeoutSeconds int PollIntervalSeconds int - RowTarget *PluginDataRowTargetDeclaration -} - -const ( - PluginDataRowWriteModeMerge = "merge" - PluginDataRowWriteModeReplace = "replace" -) - -type PluginDataRowTargetDeclaration struct { - Collection string - UpsertKeys []string - ColumnMappings map[string]string - WriteMode string } type GameClientBridgeLogProjectionStepDeclaration struct { @@ -407,12 +394,6 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...) value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) - for index := range value.QueryTemplates { - if value.QueryTemplates[index].RowTarget != nil { - copy := CopyPluginDataRowTargetDeclaration(*value.QueryTemplates[index].RowTarget) - value.QueryTemplates[index].RowTarget = © - } - } value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...) for index := range value.LogProjections { value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index]) @@ -438,12 +419,6 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid return value } -func CopyPluginDataRowTargetDeclaration(value PluginDataRowTargetDeclaration) PluginDataRowTargetDeclaration { - value.UpsertKeys = CopyStringSlice(value.UpsertKeys) - value.ColumnMappings = CopyStringMap(value.ColumnMappings) - return value -} - func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration { value.StreamKeys = CopyStringSlice(value.StreamKeys) value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...) diff --git a/platform/domain/game_client_bridge_test.go b/platform/domain/game_client_bridge_test.go index b0e085e..1dffc81 100644 --- a/platform/domain/game_client_bridge_test.go +++ b/platform/domain/game_client_bridge_test.go @@ -4,7 +4,7 @@ import "testing" func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { manifest := GameClientBridgeManifest{ - QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, RowTarget: &PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: PluginDataRowWriteModeMerge}}}, + QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql"}}, LogProjections: []GameClientBridgeLogProjectionDeclaration{{ Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, @@ -15,14 +15,13 @@ func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) } manifestCopy := CopyGameClientBridgeManifest(manifest) manifestCopy.QueryTemplates[0].Key = "mutated" - manifestCopy.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated" manifestCopy.LogProjections[0].StreamKeys[0] = "mutated" manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated" manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated" manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated" manifestCopy.DataPacks[0].DataRefs[0] = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" - if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { + if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy) } diff --git a/platform/domain/plugin_data.go b/platform/domain/plugin_data.go index 49453b8..8fd9ee1 100644 --- a/platform/domain/plugin_data.go +++ b/platform/domain/plugin_data.go @@ -2,8 +2,9 @@ package domain import "time" -// PluginDataRecord is an opaque plugin-owned platform record. Platform scopes -// it but does not interpret the collection name or payload fields. +// PluginDataRecord is an opaque plugin-owned, server-scoped platform record. +// It is separate from platform users/auth sessions and from game-server data; +// Platform scopes it but does not interpret collection names or payload fields. type PluginDataRecord struct { ID string PluginID string diff --git a/platform/dto/resources.go b/platform/dto/resources.go index c4ae523..77a25b2 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -279,26 +279,18 @@ type GameClientBridgeSnapshotDeclarationBody struct { } type GameClientBridgeQueryTemplateDeclarationBody struct { - Key string `json:"key"` - Title string `json:"title"` - Permission string `json:"permission"` - Engine string `json:"engine"` - TransportKey string `json:"transportKey"` - TargetKey string `json:"targetKey"` - ParameterSchemaRef string `json:"parameterSchemaRef"` - ResultSchemaRef string `json:"resultSchemaRef"` - SQLRef string `json:"sqlRef,omitempty"` - MaxRows int `json:"maxRows"` - TimeoutSeconds int `json:"timeoutSeconds"` - PollIntervalSeconds int `json:"pollIntervalSeconds"` - RowTarget *PluginDataRowTargetDeclarationBody `json:"rowTarget,omitempty"` -} - -type PluginDataRowTargetDeclarationBody struct { - Collection string `json:"collection"` - UpsertKeys []string `json:"upsertKeys"` - ColumnMappings map[string]string `json:"columnMappings"` - WriteMode string `json:"writeMode"` + Key string `json:"key"` + Title string `json:"title"` + Permission string `json:"permission"` + Engine string `json:"engine"` + TransportKey string `json:"transportKey"` + TargetKey string `json:"targetKey"` + ParameterSchemaRef string `json:"parameterSchemaRef"` + ResultSchemaRef string `json:"resultSchemaRef"` + SQLRef string `json:"sqlRef,omitempty"` + MaxRows int `json:"maxRows"` + TimeoutSeconds int `json:"timeoutSeconds"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` } type GameClientBridgeLogProjectionStepDeclarationBody struct { @@ -1145,12 +1137,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif } queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates)) for index, template := range body.QueryTemplates { - var rowTarget *domain.PluginDataRowTargetDeclaration - if template.RowTarget != nil { - value := domain.PluginDataRowTargetDeclaration{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings), WriteMode: template.RowTarget.WriteMode} - rowTarget = &value - } - queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, RowTarget: rowTarget} + queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds} } logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections)) for index, projection := range body.LogProjections { @@ -1602,11 +1589,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G } queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates)) for index, template := range value.QueryTemplates { - var rowTarget *PluginDataRowTargetDeclarationBody - if template.RowTarget != nil { - rowTarget = &PluginDataRowTargetDeclarationBody{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings), WriteMode: template.RowTarget.WriteMode} - } - queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, RowTarget: rowTarget} + queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds} } logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections)) for index, projection := range value.LogProjections { diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index d8c95b8..039bb86 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -137,7 +137,6 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{ Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, PollIntervalSeconds: 3, - RowTarget: &PluginDataRowTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: "merge"}, }}, LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{ Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, @@ -151,14 +150,9 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { } domainManifest := body.ToDomain() - if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || domainManifest.QueryTemplates[0].RowTarget.WriteMode != "merge" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { + if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest) } - domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated" - if body.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" { - t.Fatal("query template row target aliases request DTO data") - } - domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "user_id" domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated" if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" { t.Fatal("log projection target aliases request DTO data") @@ -193,7 +187,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { if err := json.Unmarshal(encoded, &projection); err != nil { t.Fatalf("decode safe query template projection: %v", err) } - expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "pollIntervalSeconds", "rowTarget"} + expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "pollIntervalSeconds"} if len(projection) != len(expectedFields) { t.Fatalf("query template projection contains unexpected fields: %s", encoded) } diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 95b7f4f..9dc75e2 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -40,9 +40,6 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai if err := svc.sweepExpiredJobs(claim.RunEndpointID, stamp); err != nil { return domain.RunJobClaimResult{}, err } - if err := svc.scheduleDuePluginQueries(claim.RunEndpointID, claim.Capabilities, stamp); err != nil { - return domain.RunJobClaimResult{}, err - } if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs { return emptyJobClaim(claim.RunEndpointID, stamp), nil } @@ -263,9 +260,6 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo if err := svc.validateDistributionBuildResult(job); err != nil { return domain.RunJobResultResult{}, err } - if err := svc.projectPluginDataJobResult(job); err != nil { - return domain.RunJobResultResult{}, err - } if err := svc.updateScheduledJob(job); err != nil { return domain.RunJobResultResult{}, err } diff --git a/platform/service/plugin_data_projection.go b/platform/service/plugin_data_projection.go deleted file mode 100644 index 1fbd0ef..0000000 --- a/platform/service/plugin_data_projection.go +++ /dev/null @@ -1,126 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -type pluginDataQueryResult struct { - Rows []map[string]any `json:"rows"` -} - -func (svc *CoreService) projectPluginDataJobResult(job domain.Job) error { - if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.State != domain.JobStateSucceeded { - return nil - } - templateKey := strings.TrimSpace(job.ExecutionInput.Inputs["templateKey"]) - if templateKey == "" { - return nil - } - instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) - if err != nil { - return err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return err - } - var template domain.GameClientBridgeQueryTemplateDeclaration - for _, candidate := range plugin.GameClientBridge.QueryTemplates { - if candidate.Key == templateKey { - template = candidate - break - } - } - if template.RowTarget == nil { - return nil - } - var result pluginDataQueryResult - if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &result); err != nil { - return validationError("declared query result is not valid JSON") - } - mutationsByKey := make(map[string]domain.PluginDataMutation, len(result.Rows)) - for _, row := range result.Rows { - value := make(map[string]any, len(template.RowTarget.ColumnMappings)) - for destination, source := range template.RowTarget.ColumnMappings { - value[destination] = row[source] - } - key, err := pluginDataRowKey(value, template.RowTarget.UpsertKeys) - if err != nil { - return err - } - if template.RowTarget.WriteMode == domain.PluginDataRowWriteModeMerge { - existing, getErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, template.RowTarget.Collection, key)) - if getErr == nil { - value = mergePluginDataValues(existing.Value, value) - } else if !errors.Is(getErr, repo.ErrNotFound) { - return getErr - } - } - mutationsByKey[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value} - } - if template.RowTarget.WriteMode == domain.PluginDataRowWriteModeReplace { - existing, listErr := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection}) - if listErr != nil { - return listErr - } - for _, record := range existing { - if _, present := mutationsByKey[record.Key]; !present { - mutationsByKey[record.Key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationDelete, Key: record.Key} - } - } - } - if len(mutationsByKey) == 0 { - return nil - } - keys := make([]string, 0, len(mutationsByKey)) - for key := range mutationsByKey { - keys = append(keys, key) - } - sort.Strings(keys) - mutations := make([]domain.PluginDataMutation, 0, len(keys)) - for _, key := range keys { - mutations = append(mutations, mutationsByKey[key]) - } - _, err = svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection, Mutations: mutations}) - return err -} - -func mergePluginDataValues(existing, incoming map[string]any) map[string]any { - merged := domain.CopyGameClientBridgePayload(existing) - if merged == nil { - merged = make(map[string]any, len(incoming)) - } - for key, value := range incoming { - merged[key] = value - } - return merged -} - -func pluginDataRowKey(value map[string]any, keys []string) (string, error) { - parts := make([]string, len(keys)) - for index, key := range keys { - item, exists := value[key] - if !exists || item == nil || strings.TrimSpace(fmt.Sprint(item)) == "" { - return "", validationError("declared query row is missing an upsert key") - } - encoded, err := json.Marshal(item) - if err != nil { - return "", validationError("declared query row upsert key is invalid") - } - parts[index] = string(encoded) - } - if len(parts) == 1 { - var key string - if err := json.Unmarshal([]byte(parts[0]), &key); err == nil { - return key, nil - } - } - return strings.Join(parts, "\x1f"), nil -} diff --git a/platform/service/plugin_data_rows.go b/platform/service/plugin_data_rows.go new file mode 100644 index 0000000..16d597c --- /dev/null +++ b/platform/service/plugin_data_rows.go @@ -0,0 +1,42 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "browser.local/platform/domain" +) + +func mergePluginDataValues(existing, incoming map[string]any) map[string]any { + merged := domain.CopyGameClientBridgePayload(existing) + if merged == nil { + merged = make(map[string]any, len(incoming)) + } + for key, value := range incoming { + merged[key] = value + } + return merged +} + +func pluginDataRowKey(value map[string]any, keys []string) (string, error) { + parts := make([]string, len(keys)) + for index, key := range keys { + item, exists := value[key] + if !exists || item == nil || strings.TrimSpace(fmt.Sprint(item)) == "" { + return "", validationError("declared plugin data row is missing an upsert key") + } + encoded, err := json.Marshal(item) + if err != nil { + return "", validationError("declared plugin data row upsert key is invalid") + } + parts[index] = string(encoded) + } + if len(parts) == 1 { + var key string + if err := json.Unmarshal([]byte(parts[0]), &key); err == nil { + return key, nil + } + } + return strings.Join(parts, "\x1f"), nil +} diff --git a/platform/service/plugin_data_schedule.go b/platform/service/plugin_data_schedule.go deleted file mode 100644 index 4fb11ff..0000000 --- a/platform/service/plugin_data_schedule.go +++ /dev/null @@ -1,92 +0,0 @@ -package service - -import ( - "fmt" - "strconv" - "strings" - "time" - - "browser.local/platform/domain" -) - -func (svc *CoreService) scheduleDuePluginQueries(runEndpointID string, capabilities []string, stamp time.Time) error { - if !containsString(capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) { - return nil - } - instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: runEndpointID}) - if err != nil { - return err - } - jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: runEndpointID}) - if err != nil { - return err - } - for _, instance := range instances { - if instance.State == domain.ServerInstanceStateDeleted { - continue - } - plugin, getErr := svc.store.GamePlugins().Get(instance.PluginID) - if getErr != nil { - return getErr - } - for _, template := range plugin.GameClientBridge.QueryTemplates { - if template.PollIntervalSeconds <= 0 || template.RowTarget == nil || strings.TrimSpace(template.SQLRef) == "" { - continue - } - if !pluginQueryTemplateDue(jobs, instance.ID, template.Key, time.Duration(template.PollIntervalSeconds)*time.Second, stamp) { - continue - } - bucket := stamp.Unix() / int64(template.PollIntervalSeconds) - idempotencyKey := fmt.Sprintf("plugin-query:%s:%s:%d", instance.ID, template.Key, bucket) - job := domain.Job{ - ID: jobIDFromParts("job-plugin-query", instance.ID, idempotencyKey), - ServerInstanceID: instance.ID, - RunEndpointID: runEndpointID, - Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, - TargetKey: template.TargetKey, - InputRef: "input://plugin-query/" + template.Key, - IdempotencyKey: idempotencyKey, - Progress: domain.JobProgress{Percent: 0, Message: "declared automatic plugin query queued"}, - RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, - ExecutionInput: domain.JobExecutionInput{ - WorkspaceScope: svc.runtimeProfileScope(instance.ID), - RemoteAdapterKey: template.TransportKey, - RemoteAdapterKind: string(domain.RemoteAdapterDatabase), - TimeoutSeconds: template.TimeoutSeconds, - Inputs: map[string]string{ - "templateKey": template.Key, - "sqlRef": template.SQLRef, - "maxRows": strconv.Itoa(template.MaxRows), - "limit": strconv.Itoa(template.MaxRows), - }, - }, - } - created, createErr := svc.CreateJob(job) - if createErr != nil { - return createErr - } - jobs = append(jobs, created) - } - } - return nil -} - -func pluginQueryTemplateDue(jobs []domain.Job, serverInstanceID, templateKey string, interval time.Duration, stamp time.Time) bool { - var latest time.Time - for _, job := range jobs { - if job.ServerInstanceID != serverInstanceID || job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.ExecutionInput.Inputs["templateKey"] != templateKey { - continue - } - if !isTerminalJobState(job.State) { - return false - } - attemptedAt := job.TerminalAt - if attemptedAt.IsZero() { - attemptedAt = job.UpdatedAt - } - if attemptedAt.After(latest) { - latest = attemptedAt - } - } - return latest.IsZero() || !stamp.Before(latest.Add(interval)) -} diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go index 0374721..6bf4126 100644 --- a/platform/service/plugin_data_test.go +++ b/platform/service/plugin_data_test.go @@ -98,28 +98,20 @@ func TestPluginDataTransactionValidationFailureDoesNotPartiallyApply(t *testing. } } -func TestDeclaredSQLiteQueryProjectsRowsIntoPluginCollection(t *testing.T) { +func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T) { svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) - plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ - Collection: "users", - UpsertKeys: []string{"userId"}, - ColumnMappings: map[string]string{ - "userId": "user_id", - "displayName": "display_name", - }, + if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Key: "steam-keep", Value: map[string]any{"steamId": "steam-keep", "format": "plugin-local"}}); err != nil { + t.Fatalf("seed plugin data: %v", err) } - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("update plugin row target: %v", err) - } - queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-project-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ - "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-project-1", "input.templateKey": "players.by-id", + queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-no-projection-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ + "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-no-projection-1", "input.templateKey": "players.by-id", }}) if err != nil || queued.Status != "queued" { t.Fatalf("queue declared query=%+v err=%v", queued, err) } helloRequest := validRunControlHello() helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) - helloRequest.CapabilityReport.Fingerprint = "cap-plugin-data-query" + helloRequest.CapabilityReport.Fingerprint = "cap-plugin-data-query-independent" hello, err := svc.RegisterRunHello(helloRequest) if err != nil { t.Fatalf("register Run: %v", err) @@ -132,170 +124,35 @@ func TestDeclaredSQLiteQueryProjectsRowsIntoPluginCollection(t *testing.T) { if err != nil { t.Fatalf("complete query job: %v", err) } - items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users"}) - if err != nil || len(items) != 2 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" { - t.Fatalf("projected plugin rows=%+v err=%v", items, err) + items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"}) + if err != nil || len(items) != 1 || items[0].Key != "steam-keep" || items[0].Value["format"] != "plugin-local" { + t.Fatalf("declared query mutated plugin data=%+v err=%v", items, err) + } + current, err := svc.GetCurrentUser(session) + if err != nil || current.ID != "user-query-owner" || current.Email != "query-owner@example.test" { + t.Fatalf("declared query mutated platform user session/user=%+v err=%v", current, err) } } -func TestDeclaredSQLiteQueryProjectionRejectsInvalidBatchAtomically(t *testing.T) { - svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t) - plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ - Collection: "members", - UpsertKeys: []string{"accountId"}, - ColumnMappings: map[string]string{ - "accountId": "account_id", - "displayName": "display_name", - }, - } - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("update plugin row target: %v", err) - } - job := domain.Job{ - ServerInstanceID: instance.ID, - Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, - State: domain.JobStateSucceeded, - ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": plugin.GameClientBridge.QueryTemplates[0].Key}}, - ExecutionResult: domain.JobExecutionResult{Content: `{"rows":[{"account_id":"one","display_name":"Ada","ignored":"value"},{"display_name":"Missing key"}]}`}, - } - if err := svc.projectPluginDataJobResult(job); err == nil { - t.Fatal("expected missing upsert key error") - } - items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "members"}) - if err != nil || len(items) != 0 { - t.Fatalf("invalid projection batch partially applied values=%+v err=%v", items, err) - } -} - -func TestDeclaredSQLiteQueryProjectionMergesPresenceAndReplacesCompleteSnapshots(t *testing.T) { - svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t) - template := &plugin.GameClientBridge.QueryTemplates[0] - template.RowTarget = &domain.PluginDataRowTargetDeclaration{ - Collection: "users", UpsertKeys: []string{"steamId"}, WriteMode: domain.PluginDataRowWriteModeMerge, - ColumnMappings: map[string]string{"steamId": "steam_id", "displayName": "display_name", "x": "x"}, - } - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("update merge target: %v", err) - } - if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "online": true, "lastLoginAt": "2026-07-03T12:00:00Z"}}); err != nil { - t.Fatalf("seed stdout user: %v", err) - } - job := domain.Job{ServerInstanceID: instance.ID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, State: domain.JobStateSucceeded, ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": template.Key}}, ExecutionResult: domain.JobExecutionResult{Content: `{"rows":[{"steam_id":"steam-1","display_name":"Ada","x":12.5}]}`}} - if err := svc.projectPluginDataJobResult(job); err != nil { - t.Fatalf("merge query projection: %v", err) - } - users, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users"}) - if err != nil || len(users) != 1 || users[0].Value["online"] != true || users[0].Value["displayName"] != "Ada" { - t.Fatalf("merged users=%+v err=%v", users, err) - } - - plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ - Collection: "vehicles", UpsertKeys: []string{"vehicleId"}, WriteMode: domain.PluginDataRowWriteModeReplace, - ColumnMappings: map[string]string{"vehicleId": "vehicle_id", "x": "x"}, - } - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("update replace target: %v", err) - } - for _, id := range []string{"keep", "gone"} { - if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles", Key: id, Value: map[string]any{"vehicleId": id}}); err != nil { - t.Fatalf("seed vehicle %s: %v", id, err) - } - } - job.ExecutionResult.Content = `{"rows":[{"vehicle_id":"keep","x":7}]}` - if err := svc.projectPluginDataJobResult(job); err != nil { - t.Fatalf("replace query projection: %v", err) - } - vehicles, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles"}) - if err != nil || len(vehicles) != 1 || vehicles[0].Key != "keep" { - t.Fatalf("replaced vehicles=%+v err=%v", vehicles, err) - } - job.ExecutionResult.Content = `{"rows":[]}` - if err := svc.projectPluginDataJobResult(job); err != nil { - t.Fatalf("empty replace query projection: %v", err) - } - vehicles, err = svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles"}) - if err != nil || len(vehicles) != 0 { - t.Fatalf("empty replace did not clear vehicles=%+v err=%v", vehicles, err) - } -} - -func TestRunPollSchedulesDueDeclaredPluginQueryWithoutBrowserSession(t *testing.T) { - svc, plugin, endpoint, _, instance := createSQLiteQueryBridgeFixture(t) +func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) { + svc, plugin, endpoint, _, _ := createSQLiteQueryBridgeFixture(t) plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3 if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("enable automatic query: %v", err) + t.Fatalf("enable query refresh hint: %v", err) } helloRequest := validRunControlHello() helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) - helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler" + helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-no-scheduler" hello, err := svc.RegisterRunHello(helloRequest) if err != nil { t.Fatalf("register Run: %v", err) } claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil { - t.Fatalf("automatic query claim=%+v err=%v", claim, err) - } - if claim.Job.ServerInstanceID != instance.ID || claim.Job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["limit"] != "25" { - t.Fatalf("unexpected automatic query assignment: %+v", claim.Job) + if err != nil || claim.HasJob { + t.Fatalf("automatic projection query was scheduled: %+v err=%v", claim, err) } jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID}) - if err != nil || len(jobs) != 1 { - t.Fatalf("automatic query jobs=%+v err=%v", jobs, err) - } - second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1, RunningJobs: 1}}) - if err != nil || second.HasJob { - t.Fatalf("overlapping automatic query was not suppressed: %+v err=%v", second, err) - } - jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID}) - if err != nil || len(jobs) != 1 { - t.Fatalf("overlap created duplicate jobs=%+v err=%v", jobs, err) - } -} - -func TestDeclaredSQLiteQueryProjectionFailureKeepsJobRetryable(t *testing.T) { - svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) - plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ - Collection: "members", - UpsertKeys: []string{"accountId"}, - ColumnMappings: map[string]string{ - "accountId": "account_id", - "displayName": "display_name", - }, - } - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("update plugin row target: %v", err) - } - queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-invalid-projection", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ - "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-invalid-projection", "input.templateKey": "players.by-id", - }}) - if err != nil || queued.Status != "queued" { - t.Fatalf("queue declared query=%+v err=%v", queued, err) - } - helloRequest := validRunControlHello() - helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) - helloRequest.CapabilityReport.Fingerprint = "cap-plugin-data-invalid-query" - hello, err := svc.RegisterRunHello(helloRequest) - if err != nil { - t.Fatalf("register Run: %v", err) - } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil { - t.Fatalf("claim query job=%+v err=%v", claim, err) - } - result := domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"display_name":"Missing key"}]}`}} - if _, err := svc.CompleteRunJob(result); err == nil { - t.Fatal("expected projection failure") - } - job, err := svc.store.Jobs().Get(claim.Job.JobID) - if err != nil || isTerminalJobState(job.State) { - t.Fatalf("projection failure persisted terminal job=%+v err=%v", job, err) - } - if _, err := svc.CompleteRunJob(result); err == nil { - t.Fatal("expected projection retry to re-run and fail") - } - items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "members"}) - if err != nil || len(items) != 0 { - t.Fatalf("invalid retry projected records=%+v err=%v", items, err) + if err != nil || len(jobs) != 0 { + t.Fatalf("automatic projection query persisted jobs=%+v err=%v", jobs, err) } } diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 62bbf1c..b3bd400 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -1846,7 +1846,6 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug SQLRef: "sql/players.by-id.sql", MaxRows: 25, TimeoutSeconds: 20, - RowTarget: &domain.PluginDataRowTargetDeclaration{Collection: "players", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}}, }, }, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}, diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 14c79dc..1ec7dac 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -572,32 +572,8 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if template.PollIntervalSeconds < 0 || template.PollIntervalSeconds > 86400 { violations = append(violations, prefix+".pollIntervalSeconds is invalid") } - projectsRows := template.SQLRef != "" || template.RowTarget != nil - if projectsRows { - if !safeRelativeSQLRef(template.SQLRef) { - violations = append(violations, prefix+".sqlRef must reference a package-relative SQL asset") - } - if template.RowTarget == nil { - violations = append(violations, prefix+".rowTarget is required for projected queries") - } else { - target := template.RowTarget - if !clientManagerIdentifierPattern.MatchString(target.Collection) || len(target.UpsertKeys) == 0 || len(target.ColumnMappings) == 0 { - violations = append(violations, prefix+".rowTarget must declare a collection, upsert keys, and column mappings") - } - if target.WriteMode != "" && target.WriteMode != domain.PluginDataRowWriteModeMerge && target.WriteMode != domain.PluginDataRowWriteModeReplace { - violations = append(violations, prefix+".rowTarget.writeMode must be merge or replace") - } - for _, key := range target.UpsertKeys { - if !clientManagerIdentifierPattern.MatchString(key) { - violations = append(violations, prefix+".rowTarget upsert key is invalid") - } - } - for destination, source := range target.ColumnMappings { - if !clientManagerIdentifierPattern.MatchString(destination) || !clientManagerIdentifierPattern.MatchString(source) { - violations = append(violations, prefix+".rowTarget column mapping is invalid") - } - } - } + if template.SQLRef != "" && !safeRelativeSQLRef(template.SQLRef) { + violations = append(violations, prefix+".sqlRef must reference a package-relative SQL asset") } transport, exists := transports[template.TransportKey] if !exists { diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index d002b8d..b2e3602 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -201,9 +201,8 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog( {name: "poll interval bound", expected: "pollIntervalSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) { value.Manifest.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 86401 }}, - {name: "write mode", expected: "writeMode must be merge or replace", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.QueryTemplates[0].SQLRef = "sql/player-lookup.sql" - value.Manifest.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: "append"} + {name: "unsafe SQL asset", expected: "sqlRef must reference", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.QueryTemplates[0].SQLRef = "/etc/player-lookup.sql" }}, {name: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) { value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing" diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 9c6e0e7..220fdeb 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -51,7 +51,6 @@ export interface GameClientBridgeQueryTemplateDeclarationResponse { sqlRef?: string; maxRows: number; timeoutSeconds: number; - rowTarget?: { collection: string; upsertKeys: string[]; columnMappings: Record }; } export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index d947cc8..9906da3 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -273,29 +273,6 @@ "resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json", "sqlRef": "sql/scum-db-v57/users.sql", "pollIntervalSeconds": 3, - "rowTarget": { - "collection": "scum_users", - "upsertKeys": [ - "steamId" - ], - "writeMode": "merge", - "columnMappings": { - "userProfileId": "userProfileId", - "steamId": "steamId", - "gamePlayerId": "gamePlayerId", - "displayName": "displayName", - "squadId": "squadId", - "squadName": "squadName", - "famePoints": "famePoints", - "normalBalance": "normalBalance", - "goldBalance": "goldBalance", - "x": "x", - "y": "y", - "z": "z", - "lastLoginTime": "lastLoginTime", - "lastSaveTime": "lastSaveTime" - } - }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -310,25 +287,6 @@ "resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json", "sqlRef": "sql/scum-db-v57/squads.sql", "pollIntervalSeconds": 1800, - "rowTarget": { - "collection": "scum_squads", - "upsertKeys": [ - "squadId" - ], - "writeMode": "replace", - "columnMappings": { - "squadId": "squadId", - "name": "name", - "leaderProfileId": "leaderProfileId", - "leaderPlayerId": "leaderPlayerId", - "memberCount": "memberCount", - "score": "score", - "memberLimit": "memberLimit", - "message": "message", - "info": "info", - "lastMemberLoginTime": "lastMemberLoginTime" - } - }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -343,23 +301,6 @@ "resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json", "sqlRef": "sql/scum-db-v57/squad-members.sql", "pollIntervalSeconds": 1800, - "rowTarget": { - "collection": "scum_squad_members", - "upsertKeys": [ - "squadId", - "steamId" - ], - "writeMode": "replace", - "columnMappings": { - "squadId": "squadId", - "userProfileId": "userProfileId", - "gamePlayerId": "gamePlayerId", - "steamId": "steamId", - "displayName": "displayName", - "rank": "rank", - "isLeader": "isLeader" - } - }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -374,24 +315,6 @@ "resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json", "sqlRef": "sql/scum-db-v57/vehicles.sql", "pollIntervalSeconds": 3, - "rowTarget": { - "collection": "scum_vehicles", - "upsertKeys": [ - "vehicleId" - ], - "writeMode": "replace", - "columnMappings": { - "vehicleId": "vehicleId", - "entityId": "entityId", - "className": "className", - "label": "label", - "x": "x", - "y": "y", - "z": "z", - "lastAccessTime": "lastAccessTime", - "isFunctional": "isFunctional" - } - }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -406,28 +329,6 @@ "resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json", "sqlRef": "sql/scum-db-v57/flags.sql", "pollIntervalSeconds": 1800, - "rowTarget": { - "collection": "scum_flags", - "upsertKeys": [ - "flagId" - ], - "writeMode": "replace", - "columnMappings": { - "flagId": "flagId", - "entityId": "entityId", - "baseId": "baseId", - "ownerProfileId": "ownerProfileId", - "ownerPlayerId": "ownerPlayerId", - "ownerSquadId": "ownerSquadId", - "ownerSquadName": "ownerSquadName", - "overtakerProfileId": "overtakerProfileId", - "overtakeEndTime": "overtakeEndTime", - "ownershipConfidence": "ownershipConfidence", - "x": "x", - "y": "y", - "z": "z" - } - }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -442,27 +343,6 @@ "resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json", "sqlRef": "sql/scum-db-v57/map-points.sql", "pollIntervalSeconds": 3, - "rowTarget": { - "collection": "scum_map_points", - "upsertKeys": [ - "subjectType", - "subjectId" - ], - "writeMode": "replace", - "columnMappings": { - "subjectType": "subjectType", - "subjectId": "subjectId", - "userProfileId": "userProfileId", - "gamePlayerId": "gamePlayerId", - "vehicleId": "vehicleId", - "entityId": "entityId", - "baseId": "baseId", - "x": "x", - "y": "y", - "z": "z", - "observedAt": "observedAt" - } - }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -477,25 +357,6 @@ "resultSchemaRef": "schemas/bridge/queries/scum-tasks.result.schema.json", "sqlRef": "sql/scum-db-v57/tasks.sql", "pollIntervalSeconds": 1800, - "rowTarget": { - "collection": "scum_tasks", - "upsertKeys": [ - "taskRecordId" - ], - "writeMode": "replace", - "columnMappings": { - "taskRecordId": "taskRecordId", - "taskKind": "taskKind", - "userProfileId": "userProfileId", - "mapId": "mapId", - "trackingDataSetId": "trackingDataSetId", - "dataAssetPath": "dataAssetPath", - "sequenceIndex": "sequenceIndex", - "isTracked": "isTracked", - "state": "state", - "completionDeadline": "completionDeadline" - } - }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -510,28 +371,6 @@ "resultSchemaRef": "schemas/bridge/queries/scum-events.result.schema.json", "sqlRef": "sql/scum-db-v57/events.sql", "pollIntervalSeconds": 1800, - "rowTarget": { - "collection": "scum_native_event_rounds", - "upsertKeys": [ - "eventRecordId" - ], - "writeMode": "replace", - "columnMappings": { - "eventRecordId": "eventRecordId", - "eventId": "eventId", - "roundId": "roundId", - "userProfileId": "userProfileId", - "startTime": "startTime", - "endTime": "endTime", - "state": "state", - "score": "score", - "enemyKills": "enemyKills", - "teamKills": "teamKills", - "deaths": "deaths", - "assists": "assists", - "headshots": "headshots" - } - }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -546,20 +385,6 @@ "resultSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.result.schema.json", "sqlRef": "sql/scum-db-v57/native-timed-gifts.sql", "pollIntervalSeconds": 1800, - "rowTarget": { - "collection": "scum_timed_gift_events", - "upsertKeys": [ - "timedGiftId" - ], - "writeMode": "replace", - "columnMappings": { - "timedGiftId": "timedGiftId", - "userProfileId": "userProfileId", - "mapId": "mapId", - "spawnTime": "spawnTime", - "spawnAt": "spawnAt" - } - }, "maxRows": 500, "timeoutSeconds": 15 } diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 21753dc..8ff2de5 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -335,23 +335,11 @@ "parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "sqlRef": { "$ref": "#/$defs/relativeSqlRef" }, - "rowTarget": { "$ref": "#/$defs/pluginDataRowTarget" }, "maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }, "pollIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 } } }, - "pluginDataRowTarget": { - "type": "object", - "required": ["collection", "upsertKeys", "columnMappings", "writeMode"], - "additionalProperties": false, - "properties": { - "collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" }, - "upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 }, - "columnMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } }, - "writeMode": { "enum": ["merge", "replace"] } - } - }, "gameClientBridgeLogProjection": { "type": "object", "required": ["key", "streamKeys", "steps", "correlationFields", "maxInterveningLines", "target"], diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 07f5ba3..7b56b11 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -685,7 +685,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { parameterSchemaRef?: string; resultSchemaRef?: string; sqlRef?: string; - rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record; writeMode?: string }; maxRows?: number; timeoutSeconds?: number; pollIntervalSeconds?: number; @@ -817,17 +816,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`); } } - const projectsRows = queryTemplate.sqlRef !== undefined || queryTemplate.rowTarget !== undefined; - if (projectsRows) { - if (!queryTemplate.sqlRef || !isSafeRelativeSqlRef(queryTemplate.sqlRef)) errors.push(`${location}.sqlRef: projected queries require a package-relative SQL asset`); - const target = queryTemplate.rowTarget; - if (!target || !/^[A-Za-z][A-Za-z0-9._-]{0,119}$/.test(target.collection ?? "")) errors.push(`${location}.rowTarget.collection: projected queries require a safe collection`); - if (!Array.isArray(target?.upsertKeys) || target.upsertKeys.length === 0 || !target.upsertKeys.every((key) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(key))) errors.push(`${location}.rowTarget.upsertKeys: projected queries require safe upsert keys`); - const mappings = target?.columnMappings; - if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([destination, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(destination) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) errors.push(`${location}.rowTarget.columnMappings: projected queries require safe field mappings`); - if (mappings && Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => key in mappings)) errors.push(`${location}.rowTarget.upsertKeys: every upsert key must be declared in columnMappings`); - if (!new Set(["merge", "replace"]).has(target?.writeMode ?? "")) errors.push(`${location}.rowTarget.writeMode: projected queries require merge or replace`); - } + if (queryTemplate.sqlRef !== undefined && !isSafeRelativeSqlRef(queryTemplate.sqlRef)) errors.push(`${location}.sqlRef: query templates require a package-relative SQL asset`); if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) { errors.push(`${location}.maxRows: must be an integer between 1 and 500`); } diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 2a5fc2e..211817d 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -249,14 +249,6 @@ export interface GameClientBridgeQueryTemplateDeclaration { maxRows: number; timeoutSeconds: number; pollIntervalSeconds?: number; - rowTarget?: PluginDataRowTargetDeclaration; -} - -export interface PluginDataRowTargetDeclaration { - collection: string; - upsertKeys: string[]; - columnMappings: Record; - writeMode: "merge" | "replace"; } export interface GameClientBridgeLogProjectionStepDeclaration { diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index de57c57..5eaeb22 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -637,7 +637,6 @@ describe("plugin manifest validation", () => { resultSchemaRef: string; sqlRef: string; pollIntervalSeconds: number; - rowTarget: { collection: string; upsertKeys: string[]; writeMode: "merge" | "replace"; columnMappings: Record }; maxRows: number; timeoutSeconds: number; }>; @@ -673,11 +672,7 @@ describe("plugin manifest validation", () => { expect(template.transportKey).toBe("scum-database"); expect(template.targetKey).toBe("scum-database"); expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/); - expect(template.rowTarget.collection).toMatch(/^scum_/); expect(template.pollIntervalSeconds).toBe(fastTemplates.has(key) ? 3 : 1800); - expect(template.rowTarget.writeMode).toBe(key === "scum.player.profile" ? "merge" : "replace"); - expect(template.rowTarget.upsertKeys.length).toBeGreaterThan(0); - expect(template.rowTarget.upsertKeys.every((upsertKey) => upsertKey in template.rowTarget.columnMappings)).toBe(true); expect(fs.existsSync(path.join(pluginDir, template.sqlRef))).toBe(true); expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/); const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8")); @@ -687,15 +682,12 @@ describe("plugin manifest validation", () => { expect(parameters).toMatchObject({ type: "object", additionalProperties: false }); expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] }); expect(result.properties.rows.maxItems).toBeLessThanOrEqual(template.maxRows); - expect(template.rowTarget.columnMappings).toEqual(Object.fromEntries(expectedColumns.map((column) => [column, column]))); expect(Object.keys(result.properties.rows.items.properties).sort()).toEqual([...expectedColumns].sort()); expect([...result.properties.rows.items.required].sort()).toEqual([...expectedColumns].sort()); for (const column of expectedColumns) { expect(sql).toMatch(new RegExp(`\\bAS\\s+${column}\\b`, "i")); } } - expect(templatesByKey.get("scum.player.profile")?.rowTarget.upsertKeys).toEqual(["steamId"]); - expect(templatesByKey.get("scum.squad-members")?.rowTarget.upsertKeys).toEqual(["squadId", "steamId"]); const userSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.player.profile")!.sqlRef), "utf8"); const positionSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.positions")!.sqlRef), "utf8"); expect(userSQL).toMatch(/FROM user account\s+LEFT JOIN user_profile profile/i);