Remove plugin data query projection

This commit is contained in:
npc0-hue
2026-08-21 15:36:49 +08:00
parent fc86b24699
commit 8aeb488179
20 changed files with 91 additions and 703 deletions
+2
View File
@@ -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` | | 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` | | 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. 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 ## Implemented Query Filters
-25
View File
@@ -44,19 +44,6 @@ type GameClientBridgeQueryTemplateDeclaration struct {
MaxRows int MaxRows int
TimeoutSeconds int TimeoutSeconds int
PollIntervalSeconds 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 { type GameClientBridgeLogProjectionStepDeclaration struct {
@@ -407,12 +394,6 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...) value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) 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 = &copy
}
}
value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...) value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...)
for index := range value.LogProjections { for index := range value.LogProjections {
value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index]) value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index])
@@ -438,12 +419,6 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
return value return value
} }
func CopyPluginDataRowTargetDeclaration(value PluginDataRowTargetDeclaration) PluginDataRowTargetDeclaration {
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
value.ColumnMappings = CopyStringMap(value.ColumnMappings)
return value
}
func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration { func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration {
value.StreamKeys = CopyStringSlice(value.StreamKeys) value.StreamKeys = CopyStringSlice(value.StreamKeys)
value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...) value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...)
+2 -3
View File
@@ -4,7 +4,7 @@ import "testing"
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
manifest := GameClientBridgeManifest{ 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{{ LogProjections: []GameClientBridgeLogProjectionDeclaration{{
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?<slot>\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"}, 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 := CopyGameClientBridgeManifest(manifest)
manifestCopy.QueryTemplates[0].Key = "mutated" manifestCopy.QueryTemplates[0].Key = "mutated"
manifestCopy.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated"
manifestCopy.LogProjections[0].StreamKeys[0] = "mutated" manifestCopy.LogProjections[0].StreamKeys[0] = "mutated"
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated" manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated" manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated"
manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated" manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated"
manifestCopy.DataPacks[0].DataRefs[0] = "mutated" manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
manifestCopy.Pages[0].QueryTemplateKeys[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) t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
} }
+3 -2
View File
@@ -2,8 +2,9 @@ package domain
import "time" import "time"
// PluginDataRecord is an opaque plugin-owned platform record. Platform scopes // PluginDataRecord is an opaque plugin-owned, server-scoped platform record.
// it but does not interpret the collection name or payload fields. // 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 { type PluginDataRecord struct {
ID string ID string
PluginID string PluginID string
+2 -19
View File
@@ -291,14 +291,6 @@ type GameClientBridgeQueryTemplateDeclarationBody struct {
MaxRows int `json:"maxRows"` MaxRows int `json:"maxRows"`
TimeoutSeconds int `json:"timeoutSeconds"` TimeoutSeconds int `json:"timeoutSeconds"`
PollIntervalSeconds int `json:"pollIntervalSeconds"` 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"`
} }
type GameClientBridgeLogProjectionStepDeclarationBody struct { type GameClientBridgeLogProjectionStepDeclarationBody struct {
@@ -1145,12 +1137,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
} }
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates)) queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
for index, template := range body.QueryTemplates { for index, template := range body.QueryTemplates {
var rowTarget *domain.PluginDataRowTargetDeclaration 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}
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}
} }
logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections)) logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections))
for index, projection := range body.LogProjections { for index, projection := range body.LogProjections {
@@ -1602,11 +1589,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
} }
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates)) queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
for index, template := range value.QueryTemplates { for index, template := range value.QueryTemplates {
var rowTarget *PluginDataRowTargetDeclarationBody 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}
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}
} }
logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections)) logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections))
for index, projection := range value.LogProjections { for index, projection := range value.LogProjections {
+2 -8
View File
@@ -137,7 +137,6 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{ QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", 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, 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{{ LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
@@ -151,14 +150,9 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
} }
domainManifest := body.ToDomain() 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) 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" domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" { if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" {
t.Fatal("log projection target aliases request DTO data") 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 { if err := json.Unmarshal(encoded, &projection); err != nil {
t.Fatalf("decode safe query template projection: %v", err) 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) { if len(projection) != len(expectedFields) {
t.Fatalf("query template projection contains unexpected fields: %s", encoded) t.Fatalf("query template projection contains unexpected fields: %s", encoded)
} }
-6
View File
@@ -40,9 +40,6 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
if err := svc.sweepExpiredJobs(claim.RunEndpointID, stamp); err != nil { if err := svc.sweepExpiredJobs(claim.RunEndpointID, stamp); err != nil {
return domain.RunJobClaimResult{}, err 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 { if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs {
return emptyJobClaim(claim.RunEndpointID, stamp), nil 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 { if err := svc.validateDistributionBuildResult(job); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
if err := svc.projectPluginDataJobResult(job); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.updateScheduledJob(job); err != nil { if err := svc.updateScheduledJob(job); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
-126
View File
@@ -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
}
+42
View File
@@ -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
}
-92
View File
@@ -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))
}
+21 -164
View File
@@ -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) svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ 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 {
Collection: "users", t.Fatalf("seed plugin data: %v", err)
UpsertKeys: []string{"userId"},
ColumnMappings: map[string]string{
"userId": "user_id",
"displayName": "display_name",
},
} }
if err := svc.store.GamePlugins().Update(plugin); err != nil { 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{
t.Fatalf("update plugin row target: %v", err) "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-no-projection-1", "input.templateKey": "players.by-id",
}
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",
}}) }})
if err != nil || queued.Status != "queued" { if err != nil || queued.Status != "queued" {
t.Fatalf("queue declared query=%+v err=%v", queued, err) t.Fatalf("queue declared query=%+v err=%v", queued, err)
} }
helloRequest := validRunControlHello() helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) 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) hello, err := svc.RegisterRunHello(helloRequest)
if err != nil { if err != nil {
t.Fatalf("register Run: %v", err) t.Fatalf("register Run: %v", err)
@@ -132,170 +124,35 @@ func TestDeclaredSQLiteQueryProjectsRowsIntoPluginCollection(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("complete query job: %v", err) t.Fatalf("complete query job: %v", err)
} }
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users"}) items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
if err != nil || len(items) != 2 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" { if err != nil || len(items) != 1 || items[0].Key != "steam-keep" || items[0].Value["format"] != "plugin-local" {
t.Fatalf("projected plugin rows=%+v err=%v", items, err) 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) { func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t) svc, plugin, endpoint, _, _ := 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)
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3 plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
if err := svc.store.GamePlugins().Update(plugin); err != nil { 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 := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) 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) hello, err := svc.RegisterRunHello(helloRequest)
if err != nil { if err != nil {
t.Fatalf("register Run: %v", err) 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}}) 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 { if err != nil || claim.HasJob {
t.Fatalf("automatic query claim=%+v err=%v", claim, err) t.Fatalf("automatic projection query was scheduled: %+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)
} }
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID}) jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID})
if err != nil || len(jobs) != 1 { if err != nil || len(jobs) != 0 {
t.Fatalf("automatic query jobs=%+v err=%v", jobs, err) t.Fatalf("automatic projection query persisted 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)
} }
} }
-1
View File
@@ -1846,7 +1846,6 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
SQLRef: "sql/players.by-id.sql", SQLRef: "sql/players.by-id.sql",
MaxRows: 25, MaxRows: 25,
TimeoutSeconds: 20, TimeoutSeconds: 20,
RowTarget: &domain.PluginDataRowTargetDeclaration{Collection: "players", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}},
}, },
}, },
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100},
+1 -25
View File
@@ -572,33 +572,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
if template.PollIntervalSeconds < 0 || template.PollIntervalSeconds > 86400 { if template.PollIntervalSeconds < 0 || template.PollIntervalSeconds > 86400 {
violations = append(violations, prefix+".pollIntervalSeconds is invalid") violations = append(violations, prefix+".pollIntervalSeconds is invalid")
} }
projectsRows := template.SQLRef != "" || template.RowTarget != nil if template.SQLRef != "" && !safeRelativeSQLRef(template.SQLRef) {
if projectsRows {
if !safeRelativeSQLRef(template.SQLRef) {
violations = append(violations, prefix+".sqlRef must reference a package-relative SQL asset") 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")
}
}
}
}
transport, exists := transports[template.TransportKey] transport, exists := transports[template.TransportKey]
if !exists { if !exists {
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile") violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
+2 -3
View File
@@ -201,9 +201,8 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(
{name: "poll interval bound", expected: "pollIntervalSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) { {name: "poll interval bound", expected: "pollIntervalSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 86401 value.Manifest.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 86401
}}, }},
{name: "write mode", expected: "writeMode must be merge or replace", mutate: func(value *domain.GamePluginManifestRegistration) { {name: "unsafe SQL asset", expected: "sqlRef must reference", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.QueryTemplates[0].SQLRef = "sql/player-lookup.sql" value.Manifest.GameClientBridge.QueryTemplates[0].SQLRef = "/etc/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: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) { {name: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing" value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing"
-1
View File
@@ -51,7 +51,6 @@ export interface GameClientBridgeQueryTemplateDeclarationResponse {
sqlRef?: string; sqlRef?: string;
maxRows: number; maxRows: number;
timeoutSeconds: number; timeoutSeconds: number;
rowTarget?: { collection: string; upsertKeys: string[]; columnMappings: Record<string, string> };
} }
export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; } export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; }
@@ -273,29 +273,6 @@
"resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json",
"sqlRef": "sql/scum-db-v57/users.sql", "sqlRef": "sql/scum-db-v57/users.sql",
"pollIntervalSeconds": 3, "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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -310,25 +287,6 @@
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json",
"sqlRef": "sql/scum-db-v57/squads.sql", "sqlRef": "sql/scum-db-v57/squads.sql",
"pollIntervalSeconds": 1800, "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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -343,23 +301,6 @@
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json",
"sqlRef": "sql/scum-db-v57/squad-members.sql", "sqlRef": "sql/scum-db-v57/squad-members.sql",
"pollIntervalSeconds": 1800, "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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -374,24 +315,6 @@
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json",
"sqlRef": "sql/scum-db-v57/vehicles.sql", "sqlRef": "sql/scum-db-v57/vehicles.sql",
"pollIntervalSeconds": 3, "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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -406,28 +329,6 @@
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json",
"sqlRef": "sql/scum-db-v57/flags.sql", "sqlRef": "sql/scum-db-v57/flags.sql",
"pollIntervalSeconds": 1800, "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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -442,27 +343,6 @@
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json",
"sqlRef": "sql/scum-db-v57/map-points.sql", "sqlRef": "sql/scum-db-v57/map-points.sql",
"pollIntervalSeconds": 3, "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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -477,25 +357,6 @@
"resultSchemaRef": "schemas/bridge/queries/scum-tasks.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-tasks.result.schema.json",
"sqlRef": "sql/scum-db-v57/tasks.sql", "sqlRef": "sql/scum-db-v57/tasks.sql",
"pollIntervalSeconds": 1800, "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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -510,28 +371,6 @@
"resultSchemaRef": "schemas/bridge/queries/scum-events.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-events.result.schema.json",
"sqlRef": "sql/scum-db-v57/events.sql", "sqlRef": "sql/scum-db-v57/events.sql",
"pollIntervalSeconds": 1800, "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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -546,20 +385,6 @@
"resultSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.result.schema.json",
"sqlRef": "sql/scum-db-v57/native-timed-gifts.sql", "sqlRef": "sql/scum-db-v57/native-timed-gifts.sql",
"pollIntervalSeconds": 1800, "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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
} }
@@ -335,23 +335,11 @@
"parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" }, "sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
"rowTarget": { "$ref": "#/$defs/pluginDataRowTarget" },
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, "maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 },
"pollIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 } "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": { "gameClientBridgeLogProjection": {
"type": "object", "type": "object",
"required": ["key", "streamKeys", "steps", "correlationFields", "maxInterveningLines", "target"], "required": ["key", "streamKeys", "steps", "correlationFields", "maxInterveningLines", "target"],
+1 -12
View File
@@ -685,7 +685,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
parameterSchemaRef?: string; parameterSchemaRef?: string;
resultSchemaRef?: string; resultSchemaRef?: string;
sqlRef?: string; sqlRef?: string;
rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record<string, string>; writeMode?: string };
maxRows?: number; maxRows?: number;
timeoutSeconds?: number; timeoutSeconds?: number;
pollIntervalSeconds?: 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`); errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
} }
} }
const projectsRows = queryTemplate.sqlRef !== undefined || queryTemplate.rowTarget !== undefined; if (queryTemplate.sqlRef !== undefined && !isSafeRelativeSqlRef(queryTemplate.sqlRef)) errors.push(`${location}.sqlRef: query templates require a package-relative SQL asset`);
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 (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) { 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`); errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
} }
-8
View File
@@ -249,14 +249,6 @@ export interface GameClientBridgeQueryTemplateDeclaration {
maxRows: number; maxRows: number;
timeoutSeconds: number; timeoutSeconds: number;
pollIntervalSeconds?: number; pollIntervalSeconds?: number;
rowTarget?: PluginDataRowTargetDeclaration;
}
export interface PluginDataRowTargetDeclaration {
collection: string;
upsertKeys: string[];
columnMappings: Record<string, string>;
writeMode: "merge" | "replace";
} }
export interface GameClientBridgeLogProjectionStepDeclaration { export interface GameClientBridgeLogProjectionStepDeclaration {
@@ -637,7 +637,6 @@ describe("plugin manifest validation", () => {
resultSchemaRef: string; resultSchemaRef: string;
sqlRef: string; sqlRef: string;
pollIntervalSeconds: number; pollIntervalSeconds: number;
rowTarget: { collection: string; upsertKeys: string[]; writeMode: "merge" | "replace"; columnMappings: Record<string, string> };
maxRows: number; maxRows: number;
timeoutSeconds: number; timeoutSeconds: number;
}>; }>;
@@ -673,11 +672,7 @@ describe("plugin manifest validation", () => {
expect(template.transportKey).toBe("scum-database"); expect(template.transportKey).toBe("scum-database");
expect(template.targetKey).toBe("scum-database"); expect(template.targetKey).toBe("scum-database");
expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/); 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.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(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/); 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")); 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(parameters).toMatchObject({ type: "object", additionalProperties: false });
expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] }); expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] });
expect(result.properties.rows.maxItems).toBeLessThanOrEqual(template.maxRows); 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(Object.keys(result.properties.rows.items.properties).sort()).toEqual([...expectedColumns].sort());
expect([...result.properties.rows.items.required].sort()).toEqual([...expectedColumns].sort()); expect([...result.properties.rows.items.required].sort()).toEqual([...expectedColumns].sort());
for (const column of expectedColumns) { for (const column of expectedColumns) {
expect(sql).toMatch(new RegExp(`\\bAS\\s+${column}\\b`, "i")); 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 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"); 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); expect(userSQL).toMatch(/FROM user account\s+LEFT JOIN user_profile profile/i);