first commit
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
type StoreSnapshot struct {
|
||||
Users []domain.User `json:"users"`
|
||||
AIProviders []domain.AIProvider `json:"aiProviders"`
|
||||
GamePlugins []domain.GamePlugin `json:"gamePlugins"`
|
||||
ServerInstances []domain.ServerInstance `json:"serverInstances"`
|
||||
RunEndpoints []domain.RunEndpoint `json:"runEndpoints"`
|
||||
Jobs []domain.Job `json:"jobs"`
|
||||
Artifacts []domain.Artifact `json:"artifacts"`
|
||||
LogStreams []domain.LogStream `json:"logStreams"`
|
||||
AuditEvents []domain.AuditEvent `json:"auditEvents"`
|
||||
}
|
||||
|
||||
type FileStore struct {
|
||||
*MemoryStore
|
||||
path string
|
||||
persistMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFileStore(path string) (*FileStore, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("metadata path is required")
|
||||
}
|
||||
store := &FileStore{
|
||||
MemoryStore: NewMemoryStore(),
|
||||
path: path,
|
||||
}
|
||||
if err := store.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (store *FileStore) MetadataPath() string {
|
||||
return store.path
|
||||
}
|
||||
|
||||
func (store *FileStore) Users() UserRepository {
|
||||
return &persistentRepository[domain.User, domain.UserFilter]{repository: store.MemoryStore.users, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) AIProviders() AIProviderRepository {
|
||||
return &persistentRepository[domain.AIProvider, domain.AIProviderFilter]{repository: store.MemoryStore.aiProviders, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) GamePlugins() GamePluginRepository {
|
||||
return &persistentRepository[domain.GamePlugin, domain.GamePluginFilter]{repository: store.MemoryStore.gamePlugins, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) ServerInstances() ServerInstanceRepository {
|
||||
return &persistentRepository[domain.ServerInstance, domain.ServerInstanceFilter]{repository: store.MemoryStore.serverInstances, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) RunEndpoints() RunEndpointRepository {
|
||||
return &persistentRepository[domain.RunEndpoint, domain.RunEndpointFilter]{repository: store.MemoryStore.runEndpoints, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) Jobs() JobRepository {
|
||||
return &persistentJobRepository{
|
||||
persistentRepository: &persistentRepository[domain.Job, domain.JobFilter]{repository: store.MemoryStore.jobs, persist: store.persist},
|
||||
repository: store.MemoryStore.jobs,
|
||||
}
|
||||
}
|
||||
|
||||
func (store *FileStore) Artifacts() ArtifactRepository {
|
||||
return &persistentRepository[domain.Artifact, domain.ArtifactFilter]{repository: store.MemoryStore.artifacts, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) LogStreams() LogStreamRepository {
|
||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) AuditEvents() AuditEventRepository {
|
||||
return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) load() error {
|
||||
data, err := os.ReadFile(store.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read metadata snapshot: %w", err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil
|
||||
}
|
||||
var snapshot StoreSnapshot
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
return fmt.Errorf("decode metadata snapshot: %w", err)
|
||||
}
|
||||
store.loadSnapshot(snapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *FileStore) persist() error {
|
||||
store.persistMu.Lock()
|
||||
defer store.persistMu.Unlock()
|
||||
|
||||
snapshot := store.snapshot()
|
||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode metadata snapshot: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil {
|
||||
return fmt.Errorf("create metadata directory: %w", err)
|
||||
}
|
||||
tmpPath := store.path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write metadata snapshot: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, store.path); err != nil {
|
||||
return fmt.Errorf("replace metadata snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *FileStore) snapshot() StoreSnapshot {
|
||||
return StoreSnapshot{
|
||||
Users: snapshotRepository(store.MemoryStore.users),
|
||||
AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
|
||||
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
|
||||
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
|
||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
|
||||
Artifacts: snapshotRepository(store.MemoryStore.artifacts),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.users, snapshot.Users)
|
||||
loadRepository(store.MemoryStore.aiProviders, snapshot.AIProviders)
|
||||
loadRepository(store.MemoryStore.gamePlugins, snapshot.GamePlugins)
|
||||
loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances)
|
||||
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
|
||||
loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs)
|
||||
loadRepository(store.MemoryStore.artifacts, snapshot.Artifacts)
|
||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
||||
}
|
||||
|
||||
type mutableRepository[T any, F any] interface {
|
||||
Create(T) error
|
||||
Get(string) (T, error)
|
||||
List(F) ([]T, error)
|
||||
Update(T) error
|
||||
}
|
||||
|
||||
type persistentRepository[T any, F any] struct {
|
||||
repository mutableRepository[T, F]
|
||||
persist func() error
|
||||
}
|
||||
|
||||
func (repository *persistentRepository[T, F]) Create(value T) error {
|
||||
if err := repository.repository.Create(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.persist()
|
||||
}
|
||||
|
||||
func (repository *persistentRepository[T, F]) Get(id string) (T, error) {
|
||||
return repository.repository.Get(id)
|
||||
}
|
||||
|
||||
func (repository *persistentRepository[T, F]) List(filter F) ([]T, error) {
|
||||
return repository.repository.List(filter)
|
||||
}
|
||||
|
||||
func (repository *persistentRepository[T, F]) Update(value T) error {
|
||||
if err := repository.repository.Update(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.persist()
|
||||
}
|
||||
|
||||
type persistentJobRepository struct {
|
||||
*persistentRepository[domain.Job, domain.JobFilter]
|
||||
repository JobRepository
|
||||
}
|
||||
|
||||
func (repository *persistentJobRepository) GetByIdempotency(runEndpointID string, idempotencyKey string) (domain.Job, error) {
|
||||
return repository.repository.GetByIdempotency(runEndpointID, idempotencyKey)
|
||||
}
|
||||
|
||||
func snapshotRepository[T any, F any](repository *memoryRepository[T, F]) []T {
|
||||
repository.mu.RLock()
|
||||
defer repository.mu.RUnlock()
|
||||
|
||||
ids := make([]string, 0, len(repository.byID))
|
||||
for id := range repository.byID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
values := make([]T, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
values = append(values, repository.copyOf(repository.byID[id]))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func loadRepository[T any, F any](repository *memoryRepository[T, F], values []T) {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
|
||||
repository.byID = map[string]T{}
|
||||
for _, value := range values {
|
||||
repository.byID[repository.idOf(value)] = repository.copyOf(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const mysqlSnapshotID = "current"
|
||||
|
||||
type MySQLStore struct {
|
||||
*MemoryStore
|
||||
db *sql.DB
|
||||
persistMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewMySQLStore(dsn string) (*MySQLStore, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return nil, fmt.Errorf("PLATFORM_MYSQL_DSN is required when PLATFORM_STORAGE_BACKEND=mysql")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mysql metadata store: %w", err)
|
||||
}
|
||||
store := &MySQLStore{
|
||||
MemoryStore: NewMemoryStore(),
|
||||
db: db,
|
||||
}
|
||||
if err := store.initialize(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := store.load(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) Close() error {
|
||||
return store.db.Close()
|
||||
}
|
||||
|
||||
func (store *MySQLStore) Users() UserRepository {
|
||||
return &persistentRepository[domain.User, domain.UserFilter]{repository: store.MemoryStore.users, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) AIProviders() AIProviderRepository {
|
||||
return &persistentRepository[domain.AIProvider, domain.AIProviderFilter]{repository: store.MemoryStore.aiProviders, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) GamePlugins() GamePluginRepository {
|
||||
return &persistentRepository[domain.GamePlugin, domain.GamePluginFilter]{repository: store.MemoryStore.gamePlugins, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) ServerInstances() ServerInstanceRepository {
|
||||
return &persistentRepository[domain.ServerInstance, domain.ServerInstanceFilter]{repository: store.MemoryStore.serverInstances, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) RunEndpoints() RunEndpointRepository {
|
||||
return &persistentRepository[domain.RunEndpoint, domain.RunEndpointFilter]{repository: store.MemoryStore.runEndpoints, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) Jobs() JobRepository {
|
||||
return &persistentJobRepository{
|
||||
persistentRepository: &persistentRepository[domain.Job, domain.JobFilter]{repository: store.MemoryStore.jobs, persist: store.persist},
|
||||
repository: store.MemoryStore.jobs,
|
||||
}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) Artifacts() ArtifactRepository {
|
||||
return &persistentRepository[domain.Artifact, domain.ArtifactFilter]{repository: store.MemoryStore.artifacts, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) LogStreams() LogStreamRepository {
|
||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) AuditEvents() AuditEventRepository {
|
||||
return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) initialize() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := store.db.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("connect mysql metadata store: %w", err)
|
||||
}
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS platform_metadata_snapshots (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
snapshot_json JSON NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create mysql metadata snapshot table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) load() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var payload []byte
|
||||
err := store.db.QueryRowContext(ctx, "SELECT snapshot_json FROM platform_metadata_snapshots WHERE id = ?", mysqlSnapshotID).Scan(&payload)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read mysql metadata snapshot: %w", err)
|
||||
}
|
||||
var snapshot StoreSnapshot
|
||||
if err := json.Unmarshal(payload, &snapshot); err != nil {
|
||||
return fmt.Errorf("decode mysql metadata snapshot: %w", err)
|
||||
}
|
||||
store.loadSnapshot(snapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) persist() error {
|
||||
store.persistMu.Lock()
|
||||
defer store.persistMu.Unlock()
|
||||
|
||||
snapshot := store.snapshot()
|
||||
payload, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode mysql metadata snapshot: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO platform_metadata_snapshots (id, snapshot_json)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysqlSnapshotID, string(payload), string(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("write mysql metadata snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) snapshot() StoreSnapshot {
|
||||
return StoreSnapshot{
|
||||
Users: snapshotRepository(store.MemoryStore.users),
|
||||
AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
|
||||
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
|
||||
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
|
||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
|
||||
Artifacts: snapshotRepository(store.MemoryStore.artifacts),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.users, snapshot.Users)
|
||||
loadRepository(store.MemoryStore.aiProviders, snapshot.AIProviders)
|
||||
loadRepository(store.MemoryStore.gamePlugins, snapshot.GamePlugins)
|
||||
loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances)
|
||||
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
|
||||
loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs)
|
||||
loadRepository(store.MemoryStore.artifacts, snapshot.Artifacts)
|
||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicate = errors.New("resource already exists")
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(domain.User) error
|
||||
Get(id string) (domain.User, error)
|
||||
List(domain.UserFilter) ([]domain.User, error)
|
||||
Update(domain.User) error
|
||||
}
|
||||
|
||||
type AIProviderRepository interface {
|
||||
Create(domain.AIProvider) error
|
||||
Get(id string) (domain.AIProvider, error)
|
||||
List(domain.AIProviderFilter) ([]domain.AIProvider, error)
|
||||
Update(domain.AIProvider) error
|
||||
}
|
||||
|
||||
type GamePluginRepository interface {
|
||||
Create(domain.GamePlugin) error
|
||||
Get(id string) (domain.GamePlugin, error)
|
||||
List(domain.GamePluginFilter) ([]domain.GamePlugin, error)
|
||||
Update(domain.GamePlugin) error
|
||||
}
|
||||
|
||||
type ServerInstanceRepository interface {
|
||||
Create(domain.ServerInstance) error
|
||||
Get(id string) (domain.ServerInstance, error)
|
||||
List(domain.ServerInstanceFilter) ([]domain.ServerInstance, error)
|
||||
Update(domain.ServerInstance) error
|
||||
}
|
||||
|
||||
type RunEndpointRepository interface {
|
||||
Create(domain.RunEndpoint) error
|
||||
Get(id string) (domain.RunEndpoint, error)
|
||||
List(domain.RunEndpointFilter) ([]domain.RunEndpoint, error)
|
||||
Update(domain.RunEndpoint) error
|
||||
}
|
||||
|
||||
type JobRepository interface {
|
||||
Create(domain.Job) error
|
||||
Get(id string) (domain.Job, error)
|
||||
GetByIdempotency(runEndpointID string, idempotencyKey string) (domain.Job, error)
|
||||
List(domain.JobFilter) ([]domain.Job, error)
|
||||
Update(domain.Job) error
|
||||
}
|
||||
|
||||
type ArtifactRepository interface {
|
||||
Create(domain.Artifact) error
|
||||
Get(id string) (domain.Artifact, error)
|
||||
List(domain.ArtifactFilter) ([]domain.Artifact, error)
|
||||
Update(domain.Artifact) error
|
||||
}
|
||||
|
||||
type LogStreamRepository interface {
|
||||
Create(domain.LogStream) error
|
||||
Get(id string) (domain.LogStream, error)
|
||||
List(domain.LogStreamFilter) ([]domain.LogStream, error)
|
||||
Update(domain.LogStream) error
|
||||
}
|
||||
|
||||
type AuditEventRepository interface {
|
||||
Create(domain.AuditEvent) error
|
||||
Get(id string) (domain.AuditEvent, error)
|
||||
List(domain.AuditEventFilter) ([]domain.AuditEvent, error)
|
||||
Update(domain.AuditEvent) error
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Users() UserRepository
|
||||
AIProviders() AIProviderRepository
|
||||
GamePlugins() GamePluginRepository
|
||||
ServerInstances() ServerInstanceRepository
|
||||
RunEndpoints() RunEndpointRepository
|
||||
Jobs() JobRepository
|
||||
Artifacts() ArtifactRepository
|
||||
LogStreams() LogStreamRepository
|
||||
AuditEvents() AuditEventRepository
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
users *memoryRepository[domain.User, domain.UserFilter]
|
||||
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
|
||||
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
|
||||
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
|
||||
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
|
||||
jobs *memoryJobRepository
|
||||
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
|
||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
users: newMemoryRepository(
|
||||
func(user domain.User) string { return user.ID },
|
||||
domain.CopyUser,
|
||||
matchUser,
|
||||
),
|
||||
aiProviders: newMemoryRepository(
|
||||
func(provider domain.AIProvider) string { return provider.ID },
|
||||
domain.CopyAIProvider,
|
||||
matchAIProvider,
|
||||
),
|
||||
gamePlugins: newMemoryRepository(
|
||||
func(plugin domain.GamePlugin) string { return plugin.ID },
|
||||
domain.CopyGamePlugin,
|
||||
matchGamePlugin,
|
||||
),
|
||||
serverInstances: newMemoryRepository(
|
||||
func(instance domain.ServerInstance) string { return instance.ID },
|
||||
domain.CopyServerInstance,
|
||||
matchServerInstance,
|
||||
),
|
||||
runEndpoints: newMemoryRepository(
|
||||
func(endpoint domain.RunEndpoint) string { return endpoint.ID },
|
||||
domain.CopyRunEndpoint,
|
||||
matchRunEndpoint,
|
||||
),
|
||||
jobs: newMemoryJobRepository(),
|
||||
artifacts: newMemoryRepository(
|
||||
func(artifact domain.Artifact) string { return artifact.ID },
|
||||
domain.CopyArtifact,
|
||||
matchArtifact,
|
||||
),
|
||||
logStreams: newMemoryRepository(
|
||||
func(stream domain.LogStream) string { return stream.ID },
|
||||
domain.CopyLogStream,
|
||||
matchLogStream,
|
||||
),
|
||||
auditEvents: newMemoryRepository(
|
||||
func(event domain.AuditEvent) string { return event.ID },
|
||||
domain.CopyAuditEvent,
|
||||
matchAuditEvent,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *MemoryStore) Users() UserRepository { return store.users }
|
||||
func (store *MemoryStore) AIProviders() AIProviderRepository { return store.aiProviders }
|
||||
func (store *MemoryStore) GamePlugins() GamePluginRepository { return store.gamePlugins }
|
||||
func (store *MemoryStore) ServerInstances() ServerInstanceRepository { return store.serverInstances }
|
||||
func (store *MemoryStore) RunEndpoints() RunEndpointRepository { return store.runEndpoints }
|
||||
func (store *MemoryStore) Jobs() JobRepository { return store.jobs }
|
||||
func (store *MemoryStore) Artifacts() ArtifactRepository { return store.artifacts }
|
||||
func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
|
||||
func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents }
|
||||
|
||||
type memoryRepository[T any, F any] struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]T
|
||||
idOf func(T) string
|
||||
copyOf func(T) T
|
||||
match func(T, F) bool
|
||||
}
|
||||
|
||||
func newMemoryRepository[T any, F any](idOf func(T) string, copyOf func(T) T, match func(T, F) bool) *memoryRepository[T, F] {
|
||||
return &memoryRepository[T, F]{
|
||||
byID: map[string]T{},
|
||||
idOf: idOf,
|
||||
copyOf: copyOf,
|
||||
match: match,
|
||||
}
|
||||
}
|
||||
|
||||
func (repository *memoryRepository[T, F]) Create(value T) error {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
|
||||
id := repository.idOf(value)
|
||||
if _, exists := repository.byID[id]; exists {
|
||||
return ErrDuplicate
|
||||
}
|
||||
repository.byID[id] = repository.copyOf(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repository *memoryRepository[T, F]) Get(id string) (T, error) {
|
||||
repository.mu.RLock()
|
||||
defer repository.mu.RUnlock()
|
||||
|
||||
value, exists := repository.byID[id]
|
||||
if !exists {
|
||||
var zero T
|
||||
return zero, ErrNotFound
|
||||
}
|
||||
return repository.copyOf(value), nil
|
||||
}
|
||||
|
||||
func (repository *memoryRepository[T, F]) List(filter F) ([]T, error) {
|
||||
repository.mu.RLock()
|
||||
defer repository.mu.RUnlock()
|
||||
|
||||
ids := make([]string, 0, len(repository.byID))
|
||||
for id := range repository.byID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
values := make([]T, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
value := repository.byID[id]
|
||||
if repository.match(value, filter) {
|
||||
values = append(values, repository.copyOf(value))
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (repository *memoryRepository[T, F]) Update(value T) error {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
|
||||
id := repository.idOf(value)
|
||||
if _, exists := repository.byID[id]; !exists {
|
||||
return ErrNotFound
|
||||
}
|
||||
repository.byID[id] = repository.copyOf(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type memoryJobRepository struct {
|
||||
*memoryRepository[domain.Job, domain.JobFilter]
|
||||
}
|
||||
|
||||
func newMemoryJobRepository() *memoryJobRepository {
|
||||
return &memoryJobRepository{
|
||||
memoryRepository: newMemoryRepository(
|
||||
func(job domain.Job) string { return job.ID },
|
||||
domain.CopyJob,
|
||||
matchJob,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (repository *memoryJobRepository) GetByIdempotency(runEndpointID string, idempotencyKey string) (domain.Job, error) {
|
||||
repository.mu.RLock()
|
||||
defer repository.mu.RUnlock()
|
||||
|
||||
for _, job := range repository.byID {
|
||||
if job.RunEndpointID == runEndpointID && job.IdempotencyKey == idempotencyKey {
|
||||
return domain.CopyJob(job), nil
|
||||
}
|
||||
}
|
||||
return domain.Job{}, ErrNotFound
|
||||
}
|
||||
|
||||
func matchUser(user domain.User, filter domain.UserFilter) bool {
|
||||
return filter.Status == "" || user.Status == filter.Status
|
||||
}
|
||||
|
||||
func matchAIProvider(provider domain.AIProvider, filter domain.AIProviderFilter) bool {
|
||||
return (filter.Kind == "" || provider.Kind == filter.Kind) &&
|
||||
(filter.Status == "" || provider.Status == filter.Status)
|
||||
}
|
||||
|
||||
func matchGamePlugin(plugin domain.GamePlugin, filter domain.GamePluginFilter) bool {
|
||||
return (filter.ServerType == "" || plugin.ServerType == filter.ServerType) &&
|
||||
(filter.Status == "" || plugin.Status == filter.Status)
|
||||
}
|
||||
|
||||
func matchServerInstance(instance domain.ServerInstance, filter domain.ServerInstanceFilter) bool {
|
||||
return (filter.PluginID == "" || instance.PluginID == filter.PluginID) &&
|
||||
(filter.RunEndpointID == "" || instance.RunEndpointID == filter.RunEndpointID) &&
|
||||
(filter.State == "" || instance.State == filter.State) &&
|
||||
(filter.VisibleToUserID == "" || instance.OwnerUserID == filter.VisibleToUserID || containsString(instance.AdminUserIDs, filter.VisibleToUserID))
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchRunEndpoint(endpoint domain.RunEndpoint, filter domain.RunEndpointFilter) bool {
|
||||
return filter.Status == "" || endpoint.Status == filter.Status
|
||||
}
|
||||
|
||||
func matchJob(job domain.Job, filter domain.JobFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.RunEndpointID == "" || job.RunEndpointID == filter.RunEndpointID) &&
|
||||
(filter.State == "" || job.State == filter.State)
|
||||
}
|
||||
|
||||
func matchArtifact(artifact domain.Artifact, filter domain.ArtifactFilter) bool {
|
||||
return (filter.OwnerKind == "" || artifact.OwnerKind == filter.OwnerKind) &&
|
||||
(filter.OwnerID == "" || artifact.OwnerID == filter.OwnerID) &&
|
||||
(filter.State == "" || artifact.State == filter.State)
|
||||
}
|
||||
|
||||
func matchLogStream(stream domain.LogStream, filter domain.LogStreamFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || stream.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
|
||||
}
|
||||
|
||||
func matchAuditEvent(event domain.AuditEvent, filter domain.AuditEventFilter) bool {
|
||||
return (filter.ActorID == "" || event.ActorID == filter.ActorID) &&
|
||||
(filter.ResourceKind == "" || event.ResourceKind == filter.ResourceKind) &&
|
||||
(filter.ResourceID == "" || event.ResourceID == filter.ResourceID) &&
|
||||
(filter.Result == "" || event.Result == filter.Result)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestMemoryRepositoryRejectsDuplicateAndCopiesValues(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
user := domain.User{
|
||||
ID: "user-1",
|
||||
DisplayName: "Mary",
|
||||
Status: domain.UserStatusActive,
|
||||
Roles: []string{"admin"},
|
||||
}
|
||||
|
||||
if err := store.Users().Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := store.Users().Create(user); !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("expected duplicate error, got %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Users().Get(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get user: %v", err)
|
||||
}
|
||||
got.Roles[0] = "mutated"
|
||||
|
||||
again, err := store.Users().Get(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get user again: %v", err)
|
||||
}
|
||||
if again.Roles[0] != "admin" {
|
||||
t.Fatalf("expected stored roles to be isolated, got %+v", again.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepositoryListFiltersAndSorts(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
users := []domain.User{
|
||||
{ID: "user-c", DisplayName: "C", Status: domain.UserStatusDisabled},
|
||||
{ID: "user-b", DisplayName: "B", Status: domain.UserStatusActive},
|
||||
{ID: "user-a", DisplayName: "A", Status: domain.UserStatusActive},
|
||||
}
|
||||
for _, user := range users {
|
||||
if err := store.Users().Create(user); err != nil {
|
||||
t.Fatalf("create user %s: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
active, err := store.Users().List(domain.UserFilter{Status: domain.UserStatusActive})
|
||||
if err != nil {
|
||||
t.Fatalf("list users: %v", err)
|
||||
}
|
||||
if len(active) != 2 || active[0].ID != "user-a" || active[1].ID != "user-b" {
|
||||
t.Fatalf("expected sorted active users, got %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryJobRepositoryFindsIdempotencyKey(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
job := domain.Job{
|
||||
ID: "job-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: "process.start",
|
||||
IdempotencyKey: "idem-1",
|
||||
State: domain.JobStateQueued,
|
||||
}
|
||||
|
||||
if err := store.Jobs().Create(job); err != nil {
|
||||
t.Fatalf("create job: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Jobs().GetByIdempotency("run-local", "idem-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get by idempotency: %v", err)
|
||||
}
|
||||
if got.ID != job.ID {
|
||||
t.Fatalf("expected job %q, got %q", job.ID, got.ID)
|
||||
}
|
||||
|
||||
_, err = store.Jobs().GetByIdempotency("run-local", "missing")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected not found for missing idempotency key, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStorePersistsAndReloadsResources(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||
store, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create file store: %v", err)
|
||||
}
|
||||
user := domain.User{
|
||||
ID: "user-1",
|
||||
DisplayName: "Durable User",
|
||||
Status: domain.UserStatusActive,
|
||||
Roles: []string{"platform-admin"},
|
||||
}
|
||||
if err := store.Users().Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: "job-1",
|
||||
RunEndpointID: "run-local",
|
||||
ServerInstanceID: "server-1",
|
||||
Capability: "process.start",
|
||||
IdempotencyKey: "idem-1",
|
||||
State: domain.JobStateQueued,
|
||||
}
|
||||
if err := store.Jobs().Create(job); err != nil {
|
||||
t.Fatalf("create job: %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload file store: %v", err)
|
||||
}
|
||||
got, err := reloaded.Users().Get("user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get reloaded user: %v", err)
|
||||
}
|
||||
if got.DisplayName != user.DisplayName || got.Roles[0] != "platform-admin" {
|
||||
t.Fatalf("unexpected reloaded user: %+v", got)
|
||||
}
|
||||
gotJob, err := reloaded.Jobs().GetByIdempotency("run-local", "idem-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get reloaded job by idempotency: %v", err)
|
||||
}
|
||||
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" {
|
||||
t.Fatalf("unexpected reloaded job: %+v", gotJob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLStoreRequiresDSN(t *testing.T) {
|
||||
_, err := NewMySQLStore("")
|
||||
if err == nil || !strings.Contains(err.Error(), "PLATFORM_MYSQL_DSN") {
|
||||
t.Fatalf("expected missing MySQL DSN error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user