Move SCUM trajectories to plugin SQL storage

This commit is contained in:
npc0-hue
2026-08-28 17:50:45 +08:00
parent bb187f6007
commit aafbec4c96
21 changed files with 611 additions and 194 deletions
@@ -199,7 +199,6 @@ func TestCompanionProductionSourceHasNoForbiddenAdapterPaths(t *testing.T) {
t.Fatal("resolve companion source directory")
}
forbidden := map[string]*regexp.Regexp{
"raw SQL or direct database access": regexp.MustCompile(`(?im)"(?:database/sql|github\.com/(?:mattn/go-sqlite3|go-sql-driver/mysql)|gorm\.io/gorm)"|\b(?:sql|db|database)\.(?:Open|Exec(?:Context)?|Query(?:Context)?|Prepare(?:Context)?)\s*\(`),
"unrestricted RCON or command execution": regexp.MustCompile(`(?i)\b(?:send|execute|run|dispatch)[a-z0-9_]*(?:rcon|rawcommand|command)\s*\(`),
"desktop automation or screen capture": regexp.MustCompile(`(?i)\b(?:tesseract|gosseract|screenshot|robotgo|autogui|keybd_event|mouse_event|sendinput)\b`),
"direct socket transport": regexp.MustCompile(`\bnet\.(?:Dial|DialTimeout)\s*\(`),
@@ -10,8 +10,9 @@ import (
)
// SafeAdapter is intentionally narrow: it receives typed values only and has
// no direct transport, host-path, credential, or shell access. Protected SQL,
// RCON, and management-program text is forwarded to Run by Platform, not here.
// no direct transport, host-path, credential, or shell access. Game SQLite,
// RCON, and management-program text stay behind declared typed ports; plugin-owned
// durable writes use the dedicated SCUMSQLStore instead of browser page payloads.
type SafeAdapter interface {
ReadConfiguration(context.Context) (map[string]any, error)
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
@@ -2,4 +2,8 @@ module browser.local/plugins/scum-server-plugin/companion
go 1.25.1
require gopkg.in/yaml.v3 v3.0.1
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/go-sql-driver/mysql v1.10.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -1,3 +1,7 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -0,0 +1,366 @@
package companion
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
const PlatformMySQLDSNEnvironment = "PLATFORM_MYSQL_DSN"
var openSQL = sql.Open
type SCUMSQLStore struct{ db *sql.DB }
type TrajectorySample struct {
ServerInstanceID string
SubjectType string
SubjectID string
SteamID string
UserProfileID string
GamePlayerID string
VehicleID string
EntityID string
BaseID string
DisplayName string
Label string
ClassName string
WorldX float64
WorldY float64
WorldZ *float64
ObservedAt time.Time
SampledAt time.Time
Source string
}
func NewSCUMSQLStore(db *sql.DB) (*SCUMSQLStore, error) {
if db == nil {
return nil, fmt.Errorf("platform SQL handle is required")
}
return &SCUMSQLStore{db: db}, nil
}
func OpenSCUMSQLStoreFromEnv(envName string) (*SCUMSQLStore, error) {
name := strings.TrimSpace(envName)
if name == "" {
name = PlatformMySQLDSNEnvironment
}
dsn := strings.TrimSpace(os.Getenv(name))
if dsn == "" {
return nil, fmt.Errorf("%s is required for SCUM plugin SQL storage", name)
}
db, err := openSQL("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("open SCUM plugin SQL storage: %w", err)
}
return NewSCUMSQLStore(db)
}
func (store *SCUMSQLStore) EnsureSchema(ctx context.Context) error {
if store == nil || store.db == nil {
return fmt.Errorf("SCUM plugin SQL store is not configured")
}
for _, statement := range scumSQLStoreMigrations {
if _, err := store.db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("apply SCUM plugin SQL migration: %w", err)
}
}
return nil
}
func (store *SCUMSQLStore) StorePositionRows(ctx context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
samples, err := TrajectorySamplesFromPositionRows(serverInstanceID, rows, sampledAt)
if err != nil {
return 0, err
}
return store.StoreTrajectorySamples(ctx, samples)
}
func (store *SCUMSQLStore) StoreVehicleRows(ctx context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
samples, err := TrajectorySamplesFromVehicleRows(serverInstanceID, rows, sampledAt)
if err != nil {
return 0, err
}
return store.StoreTrajectorySamples(ctx, samples)
}
func (store *SCUMSQLStore) StoreTrajectorySamples(ctx context.Context, samples []TrajectorySample) (int, error) {
if store == nil || store.db == nil {
return 0, fmt.Errorf("SCUM plugin SQL store is not configured")
}
normalized := make([]TrajectorySample, 0, len(samples))
for _, sample := range samples {
value, err := normalizeTrajectorySample(sample)
if err != nil {
return 0, err
}
normalized = append(normalized, value)
}
if len(normalized) == 0 {
return 0, nil
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin SCUM trajectory write: %w", err)
}
defer tx.Rollback()
stamp := time.Now().UTC()
for _, sample := range normalized {
args := scumTrajectoryInsertArgs(sample, stamp)
if _, err := tx.ExecContext(ctx, scumTrajectoryInsertSQL, args...); err != nil {
return 0, fmt.Errorf("write SCUM trajectory sample: %w", err)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit SCUM trajectory samples: %w", err)
}
return len(normalized), nil
}
func TrajectorySamplesFromPositionRows(serverInstanceID string, rows []map[string]any, sampledAt time.Time) ([]TrajectorySample, error) {
result := make([]TrajectorySample, 0, len(rows))
for _, row := range rows {
subjectType := textFromRow(row["subjectType"])
if subjectType != "player" && subjectType != "vehicle" {
continue
}
x, xOK := numberFromRow(row["x"])
y, yOK := numberFromRow(row["y"])
if !xOK || !yOK {
continue
}
z := optionalNumberFromRow(row["z"])
subjectID := textFromRow(row["subjectId"])
sample := TrajectorySample{
ServerInstanceID: serverInstanceID,
SubjectType: subjectType,
SubjectID: subjectID,
UserProfileID: textFromRow(row["userProfileId"]),
GamePlayerID: textFromRow(row["gamePlayerId"]),
VehicleID: textFromRow(row["vehicleId"]),
EntityID: textFromRow(row["entityId"]),
BaseID: textFromRow(row["baseId"]),
WorldX: x,
WorldY: y,
WorldZ: z,
ObservedAt: timestampFromRow(row["observedAt"], sampledAt),
SampledAt: sampledAt,
Source: "plugin.sql.scum.positions",
}
if sample.SubjectType == "player" {
sample.SteamID = sample.SubjectID
}
result = append(result, sample)
}
return result, nil
}
func TrajectorySamplesFromVehicleRows(serverInstanceID string, rows []map[string]any, sampledAt time.Time) ([]TrajectorySample, error) {
result := make([]TrajectorySample, 0, len(rows))
for _, row := range rows {
x, xOK := numberFromRow(row["x"])
y, yOK := numberFromRow(row["y"])
if !xOK || !yOK {
continue
}
vehicleID := textFromRow(row["vehicleId"])
result = append(result, TrajectorySample{
ServerInstanceID: serverInstanceID,
SubjectType: "vehicle",
SubjectID: vehicleID,
VehicleID: vehicleID,
EntityID: textFromRow(row["entityId"]),
Label: textFromRow(row["label"]),
ClassName: textFromRow(row["className"]),
WorldX: x,
WorldY: y,
WorldZ: optionalNumberFromRow(row["z"]),
ObservedAt: timestampFromRow(row["lastAccessTime"], sampledAt),
SampledAt: sampledAt,
Source: "plugin.sql.scum.vehicles",
})
}
return result, nil
}
func normalizeTrajectorySample(sample TrajectorySample) (TrajectorySample, error) {
sample.ServerInstanceID = strings.TrimSpace(sample.ServerInstanceID)
sample.SubjectType = strings.TrimSpace(sample.SubjectType)
sample.SubjectID = strings.TrimSpace(sample.SubjectID)
sample.SteamID = strings.TrimSpace(sample.SteamID)
sample.UserProfileID = strings.TrimSpace(sample.UserProfileID)
sample.GamePlayerID = strings.TrimSpace(sample.GamePlayerID)
sample.VehicleID = strings.TrimSpace(sample.VehicleID)
sample.EntityID = strings.TrimSpace(sample.EntityID)
sample.BaseID = strings.TrimSpace(sample.BaseID)
sample.DisplayName = strings.TrimSpace(sample.DisplayName)
sample.Label = strings.TrimSpace(sample.Label)
sample.ClassName = strings.TrimSpace(sample.ClassName)
sample.Source = strings.TrimSpace(sample.Source)
if sample.ServerInstanceID == "" || sample.SubjectID == "" || sample.SampledAt.IsZero() || !validTrajectorySubjectType(sample.SubjectType) || !finite(sample.WorldX) || !finite(sample.WorldY) || sample.WorldZ != nil && !finite(*sample.WorldZ) {
return TrajectorySample{}, fmt.Errorf("SCUM trajectory sample is invalid")
}
if sample.ObservedAt.IsZero() {
sample.ObservedAt = sample.SampledAt
}
if sample.Source == "" {
sample.Source = "plugin.sql.scum"
}
if !boundedTrajectoryTexts(sample) {
return TrajectorySample{}, fmt.Errorf("SCUM trajectory sample text is too long")
}
sample.ObservedAt = sample.ObservedAt.UTC()
sample.SampledAt = sample.SampledAt.UTC()
return sample, nil
}
func scumTrajectoryInsertArgs(sample TrajectorySample, stamp time.Time) []any {
return []any{
scumTrajectoryRecordKey(sample), sample.ServerInstanceID, sample.SubjectType, sample.SubjectID,
nullText(sample.SteamID), nullText(sample.UserProfileID), nullText(sample.GamePlayerID), nullText(sample.VehicleID), nullText(sample.EntityID), nullText(sample.BaseID),
nullText(sample.DisplayName), nullText(sample.Label), nullText(sample.ClassName), sample.WorldX, sample.WorldY, nullFloat(sample.WorldZ), sample.ObservedAt, sample.SampledAt, sample.Source, stamp, stamp,
}
}
func scumTrajectoryRecordKey(sample TrajectorySample) string {
digest := sha256.Sum256([]byte(strings.Join([]string{sample.ServerInstanceID, sample.SubjectType, sample.SubjectID, sample.SampledAt.UTC().Format(time.RFC3339Nano)}, "\x00")))
return hex.EncodeToString(digest[:])
}
func validTrajectorySubjectType(value string) bool { return value == "player" || value == "vehicle" }
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
func nullText(value string) any {
if value == "" {
return nil
}
return value
}
func nullFloat(value *float64) any {
if value == nil {
return nil
}
return *value
}
func boundedTrajectoryTexts(sample TrajectorySample) bool {
return len(sample.ServerInstanceID) <= 96 && len(sample.SubjectType) <= 16 && len(sample.SubjectID) <= 128 && len(sample.SteamID) <= 32 && len(sample.UserProfileID) <= 96 && len(sample.GamePlayerID) <= 96 && len(sample.VehicleID) <= 96 && len(sample.EntityID) <= 96 && len(sample.BaseID) <= 96 && len(sample.DisplayName) <= 120 && len(sample.Label) <= 120 && len(sample.ClassName) <= 160 && len(sample.Source) <= 80
}
func textFromRow(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func numberFromRow(value any) (float64, bool) {
switch typed := value.(type) {
case nil:
return 0, false
case float64:
return typed, finite(typed)
case float32:
value := float64(typed)
return value, finite(value)
case int:
return float64(typed), true
case int64:
return float64(typed), true
case int32:
return float64(typed), true
case json.Number:
value, err := typed.Float64()
return value, err == nil && finite(value)
case string:
value, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return value, err == nil && finite(value)
default:
return 0, false
}
}
func optionalNumberFromRow(value any) *float64 {
number, ok := numberFromRow(value)
if !ok {
return nil
}
return &number
}
func timestampFromRow(value any, fallback time.Time) time.Time {
text := textFromRow(value)
if text == "" {
return fallback
}
parsed, err := time.Parse(time.RFC3339Nano, text)
if err != nil {
return fallback
}
return parsed
}
var scumSQLStoreMigrations = []string{`
CREATE TABLE IF NOT EXISTS scum_trajectories (
record_key CHAR(64) PRIMARY KEY,
server_instance_id VARCHAR(96) NOT NULL,
subject_type VARCHAR(16) NOT NULL,
subject_id VARCHAR(128) NOT NULL,
steam_id VARCHAR(32) NULL,
user_profile_id VARCHAR(96) NULL,
game_player_id VARCHAR(96) NULL,
vehicle_id VARCHAR(96) NULL,
entity_id VARCHAR(96) NULL,
base_id VARCHAR(96) NULL,
display_name VARCHAR(120) NULL,
label VARCHAR(120) NULL,
class_name VARCHAR(160) NULL,
world_x DOUBLE NOT NULL,
world_y DOUBLE NOT NULL,
world_z DOUBLE NULL,
observed_at DATETIME(6) NOT NULL,
sampled_at DATETIME(6) NOT NULL,
source VARCHAR(80) NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY scum_trajectories_sample_uq (server_instance_id, subject_type, subject_id, sampled_at),
KEY scum_trajectories_subject_idx (server_instance_id, subject_type, subject_id, observed_at),
KEY scum_trajectories_sampled_idx (server_instance_id, sampled_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`}
const scumTrajectoryInsertSQL = `
INSERT INTO scum_trajectories (
record_key, server_instance_id, subject_type, subject_id, steam_id, user_profile_id, game_player_id, vehicle_id, entity_id, base_id,
display_name, label, class_name, world_x, world_y, world_z, observed_at, sampled_at, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
steam_id = VALUES(steam_id),
user_profile_id = VALUES(user_profile_id),
game_player_id = VALUES(game_player_id),
vehicle_id = VALUES(vehicle_id),
entity_id = VALUES(entity_id),
base_id = VALUES(base_id),
display_name = VALUES(display_name),
label = VALUES(label),
class_name = VALUES(class_name),
world_x = VALUES(world_x),
world_y = VALUES(world_y),
world_z = VALUES(world_z),
observed_at = VALUES(observed_at),
source = VALUES(source),
updated_at = VALUES(updated_at)`
@@ -0,0 +1,170 @@
package companion
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"strings"
"sync"
"testing"
"time"
)
const recordingSQLDriverName = "scum_companion_recording_sql"
var (
recordingSQLDriverOnce sync.Once
activeSQLRecorder *sqlRecorder
)
type sqlRecorder struct {
mu sync.Mutex
dsn string
statements []string
args [][]driver.NamedValue
commits int
}
func (recorder *sqlRecorder) append(query string, args []driver.NamedValue) {
recorder.mu.Lock()
defer recorder.mu.Unlock()
recorder.statements = append(recorder.statements, query)
recorder.args = append(recorder.args, append([]driver.NamedValue(nil), args...))
}
func (recorder *sqlRecorder) committed() {
recorder.mu.Lock()
defer recorder.mu.Unlock()
recorder.commits++
}
type recordingSQLDriver struct{}
type recordingSQLConn struct{ recorder *sqlRecorder }
type recordingSQLTx struct{ recorder *sqlRecorder }
func (recordingSQLDriver) Open(dsn string) (driver.Conn, error) {
if activeSQLRecorder == nil {
return nil, errors.New("recording SQL recorder is not configured")
}
activeSQLRecorder.dsn = dsn
return &recordingSQLConn{recorder: activeSQLRecorder}, nil
}
func (conn *recordingSQLConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (conn *recordingSQLConn) Close() error { return nil }
func (conn *recordingSQLConn) Begin() (driver.Tx, error) {
return &recordingSQLTx{recorder: conn.recorder}, nil
}
func (conn *recordingSQLConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
return &recordingSQLTx{recorder: conn.recorder}, nil
}
func (conn *recordingSQLConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
conn.recorder.append(query, args)
return driver.RowsAffected(1), nil
}
func (tx *recordingSQLTx) Commit() error { tx.recorder.committed(); return nil }
func (tx *recordingSQLTx) Rollback() error { return nil }
func TestSCUMSQLStoreWritesTrajectorySamplesWithoutCoordinateConversion(t *testing.T) {
db, recorder := newRecordingSQLDB(t, "direct-db")
store, err := NewSCUMSQLStore(db)
if err != nil {
t.Fatalf("create SCUM SQL store: %v", err)
}
if err := store.EnsureSchema(context.Background()); err != nil {
t.Fatalf("ensure schema: %v", err)
}
z := 7.75
sampledAt := time.Date(2026, 8, 28, 7, 30, 0, 123456000, time.UTC)
written, err := store.StoreTrajectorySamples(context.Background(), []TrajectorySample{{
ServerInstanceID: "server-1", SubjectType: "player", SubjectID: "76561198000000001", SteamID: "76561198000000001", UserProfileID: "profile-1", GamePlayerID: "player-1",
WorldX: 123.5, WorldY: -456.25, WorldZ: &z, ObservedAt: sampledAt.Add(-time.Second), SampledAt: sampledAt, Source: "plugin.sql.scum.positions",
}})
if err != nil || written != 1 {
t.Fatalf("store trajectory sample: written=%d err=%v", written, err)
}
if recorder.commits != 1 {
t.Fatalf("trajectory write did not commit once: %d", recorder.commits)
}
insertIndex := findStatement(recorder.statements, "INSERT INTO scum_trajectories")
if insertIndex < 0 {
t.Fatalf("missing trajectory insert statement: %v", recorder.statements)
}
insert := recorder.statements[insertIndex]
if !strings.Contains(insert, "world_x") || !strings.Contains(insert, "world_y") || !strings.Contains(insert, "world_z") || strings.Contains(insert, "map_x") || strings.Contains(insert, "pixel") {
t.Fatalf("trajectory SQL does not use raw world coordinate columns: %s", insert)
}
args := recorder.args[insertIndex]
if args[13].Value != 123.5 || args[14].Value != -456.25 || args[15].Value != 7.75 {
t.Fatalf("coordinates were changed before storage: %+v", args[13:16])
}
}
func TestTrajectorySamplesFromSCUMRowsKeepWorldCoordinates(t *testing.T) {
sampledAt := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC)
positionSamples, err := TrajectorySamplesFromPositionRows("server-1", []map[string]any{
{"subjectType": "player", "subjectId": "76561198000000001", "userProfileId": "profile-1", "gamePlayerId": "player-1", "x": 10.25, "y": -20.5, "z": 3.75, "observedAt": "2026-08-28T07:59:00Z"},
{"subjectType": "base", "subjectId": "base-1", "x": 1, "y": 2, "z": 0},
}, sampledAt)
if err != nil || len(positionSamples) != 1 {
t.Fatalf("position samples=%+v err=%v", positionSamples, err)
}
if positionSamples[0].WorldX != 10.25 || positionSamples[0].WorldY != -20.5 || positionSamples[0].WorldZ == nil || *positionSamples[0].WorldZ != 3.75 || positionSamples[0].Source != "plugin.sql.scum.positions" {
t.Fatalf("position coordinates were not preserved: %+v", positionSamples[0])
}
vehicleSamples, err := TrajectorySamplesFromVehicleRows("server-1", []map[string]any{{"vehicleId": "vehicle-1", "entityId": "entity-1", "className": "BPC_Laika_C", "label": "Laika", "x": "400.5", "y": 200, "z": 0, "lastAccessTime": "2026-08-28T07:58:00Z"}}, sampledAt)
if err != nil || len(vehicleSamples) != 1 {
t.Fatalf("vehicle samples=%+v err=%v", vehicleSamples, err)
}
if vehicleSamples[0].SubjectType != "vehicle" || vehicleSamples[0].WorldX != 400.5 || vehicleSamples[0].WorldY != 200 || vehicleSamples[0].ClassName != "BPC_Laika_C" || vehicleSamples[0].Source != "plugin.sql.scum.vehicles" {
t.Fatalf("vehicle coordinates were not preserved: %+v", vehicleSamples[0])
}
}
func TestOpenSCUMSQLStoreFromEnvUsesSharedPlatformDSNName(t *testing.T) {
recorder := &sqlRecorder{}
activeSQLRecorder = recorder
previousOpenSQL := openSQL
t.Cleanup(func() { openSQL = previousOpenSQL })
openSQL = func(driverName, dsn string) (*sql.DB, error) {
if driverName != "mysql" {
t.Fatalf("unexpected SQL driver: %s", driverName)
}
return sql.Open(recordingSQLDriverName, dsn)
}
t.Setenv(PlatformMySQLDSNEnvironment, "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true")
store, err := OpenSCUMSQLStoreFromEnv(PlatformMySQLDSNEnvironment)
if err != nil {
t.Fatalf("open store from shared env: %v", err)
}
if err := store.EnsureSchema(context.Background()); err != nil {
t.Fatalf("ensure schema from shared env: %v", err)
}
store.db.Close()
if recorder.dsn != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" {
t.Fatalf("store did not use the shared platform DSN environment: %q", recorder.dsn)
}
}
func newRecordingSQLDB(t *testing.T, dsn string) (*sql.DB, *sqlRecorder) {
t.Helper()
recordingSQLDriverOnce.Do(func() { sql.Register(recordingSQLDriverName, recordingSQLDriver{}) })
recorder := &sqlRecorder{}
activeSQLRecorder = recorder
db, err := sql.Open(recordingSQLDriverName, dsn)
if err != nil {
t.Fatalf("open recording SQL db: %v", err)
}
return db, recorder
}
func findStatement(statements []string, prefix string) int {
for index, statement := range statements {
if strings.Contains(statement, prefix) {
return index
}
}
return -1
}