Changeset 0.28.7 (#193)
This commit is contained in:
@@ -39,7 +39,6 @@ type Stores struct {
|
||||
GlobalConfig GlobalConfigStore
|
||||
Usage UsageStore
|
||||
Pricing PricingStore
|
||||
Extensions ExtensionStore
|
||||
Files FileStore
|
||||
KnowledgeBases KnowledgeBaseStore
|
||||
Groups GroupStore
|
||||
@@ -53,7 +52,7 @@ type Stores struct {
|
||||
CapOverrides CapabilityOverrideStore
|
||||
RoutingPolicies RoutingPolicyStore
|
||||
Sessions SessionStore
|
||||
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
|
||||
Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions)
|
||||
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
|
||||
Tasks TaskStore // v0.27.1: Task scheduling + run history
|
||||
}
|
||||
@@ -402,29 +401,6 @@ type PricingStore interface {
|
||||
Delete(ctx context.Context, providerConfigID, modelID string) error
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// EXTENSION STORE
|
||||
// =========================================
|
||||
|
||||
type ExtensionStore interface {
|
||||
// Admin CRUD
|
||||
Create(ctx context.Context, ext *models.Extension) error
|
||||
GetByID(ctx context.Context, id string) (*models.Extension, error)
|
||||
GetByExtID(ctx context.Context, extID string) (*models.Extension, error)
|
||||
Update(ctx context.Context, id string, ext *models.Extension) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Listing
|
||||
ListAll(ctx context.Context) ([]models.Extension, error)
|
||||
ListEnabled(ctx context.Context) ([]models.Extension, error)
|
||||
ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error)
|
||||
|
||||
// User settings
|
||||
GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error)
|
||||
SetUserSettings(ctx context.Context, s *models.ExtensionUserSettings) error
|
||||
DeleteUserSettings(ctx context.Context, extID, userID string) error
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// FILE STORE
|
||||
// =========================================
|
||||
|
||||
101
server/store/package_iface.go
Normal file
101
server/store/package_iface.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// PackageStore manages the unified package registry (surfaces + extensions).
|
||||
// Replaces SurfaceRegistryStore and ExtensionStore (v0.28.7).
|
||||
type PackageStore interface {
|
||||
// ── Lifecycle (from SurfaceRegistryStore) ────────────
|
||||
|
||||
// Seed upserts a package at startup. Does NOT overwrite the enabled
|
||||
// flag if the row already exists (admin toggle survives restarts).
|
||||
// Called by the page engine for core surfaces and by
|
||||
// SeedBuiltinPackages() for builtin extensions.
|
||||
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
|
||||
|
||||
// List returns all registered packages, ordered by source then title.
|
||||
List(ctx context.Context) ([]PackageRegistration, error)
|
||||
|
||||
// Get returns a single package by ID (slug).
|
||||
Get(ctx context.Context, id string) (*PackageRegistration, error)
|
||||
|
||||
// SetEnabled toggles a package's enabled state.
|
||||
SetEnabled(ctx context.Context, id string, enabled bool) error
|
||||
|
||||
// Delete removes a non-core package. Core packages cannot be deleted.
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// ListEnabled returns IDs of all enabled packages.
|
||||
// Used by the page engine for nav rendering, not exposed via HTTP.
|
||||
ListEnabled(ctx context.Context) ([]string, error)
|
||||
|
||||
// ── Extended lifecycle ───────────────────────────────
|
||||
|
||||
// Create inserts a new package with all fields. Used by the install
|
||||
// handler and by SeedBuiltinPackages() for first-time seeding.
|
||||
Create(ctx context.Context, pkg *PackageRegistration) error
|
||||
|
||||
// Update modifies mutable fields on an existing package.
|
||||
Update(ctx context.Context, id string, pkg *PackageRegistration) error
|
||||
|
||||
// ListByType returns packages filtered by type (surface, extension, full).
|
||||
ListByType(ctx context.Context, pkgType string) ([]PackageRegistration, error)
|
||||
|
||||
// ListEnabledByType returns enabled packages of a given type.
|
||||
ListEnabledByType(ctx context.Context, pkgType string) ([]PackageRegistration, error)
|
||||
|
||||
// ── Extension runtime (from ExtensionStore) ─────────
|
||||
|
||||
// ListForUser returns enabled extension/full packages with per-user
|
||||
// settings overlay from package_user_settings. System packages are
|
||||
// always included (users can't disable them).
|
||||
ListForUser(ctx context.Context, userID string) ([]UserPackage, error)
|
||||
|
||||
// GetUserSettings returns per-user settings for a specific package.
|
||||
GetUserSettings(ctx context.Context, pkgID, userID string) (*PackageUserSettings, error)
|
||||
|
||||
// SetUserSettings upserts per-user settings for a package.
|
||||
SetUserSettings(ctx context.Context, s *PackageUserSettings) error
|
||||
|
||||
// DeleteUserSettings removes per-user settings, reverting to defaults.
|
||||
DeleteUserSettings(ctx context.Context, pkgID, userID string) error
|
||||
}
|
||||
|
||||
// PackageRegistration is a row from the packages table.
|
||||
type PackageRegistration struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Type string `json:"type" db:"type"`
|
||||
Version string `json:"version" db:"version"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Author string `json:"author" db:"author"`
|
||||
Tier string `json:"tier" db:"tier"`
|
||||
IsSystem bool `json:"is_system" db:"is_system"`
|
||||
Scope string `json:"scope" db:"scope"`
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"`
|
||||
Manifest map[string]any `json:"manifest" db:"manifest"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
Source string `json:"source" db:"source"`
|
||||
InstalledAt string `json:"installed_at" db:"installed_at"`
|
||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// UserPackage combines package info with per-user settings for API responses.
|
||||
// Returned by ListForUser for the /extensions endpoint.
|
||||
type UserPackage struct {
|
||||
PackageRegistration
|
||||
UserEnabled *bool `json:"user_enabled,omitempty"`
|
||||
UserSettings *json.RawMessage `json:"user_settings,omitempty"`
|
||||
}
|
||||
|
||||
// PackageUserSettings stores per-user overrides for a package.
|
||||
type PackageUserSettings struct {
|
||||
PackageID string `json:"package_id" db:"package_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Settings json.RawMessage `json:"settings" db:"settings"`
|
||||
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
type ExtensionStore struct{}
|
||||
|
||||
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
|
||||
|
||||
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO extensions (ext_id, name, version, tier, description, author,
|
||||
manifest, is_system, is_enabled, scope, team_id, installed_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
|
||||
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
|
||||
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
|
||||
).Scan(&ext.ID, &ext.CreatedAt, &ext.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
|
||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = $1`, id)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
|
||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = $1`, extID)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE extensions SET
|
||||
name = $2, version = $3, description = $4, author = $5,
|
||||
manifest = $6, is_system = $7, is_enabled = $8,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, ext.Name, ext.Version, ext.Description, ext.Author,
|
||||
ext.Manifest, ext.IsSystem, ext.IsEnabled,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
|
||||
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
|
||||
}
|
||||
|
||||
// ListEnabled returns all globally enabled extensions regardless of user.
|
||||
// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup.
|
||||
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
|
||||
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = true ORDER BY name`)
|
||||
}
|
||||
|
||||
// ListForUser returns all enabled extensions with user-specific overrides merged in.
|
||||
// System extensions are always included (users can't disable them).
|
||||
// Non-system extensions respect per-user enabled toggle.
|
||||
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
|
||||
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
|
||||
e.created_at, e.updated_at,
|
||||
eus.is_enabled AS user_enabled,
|
||||
eus.settings AS user_settings
|
||||
FROM extensions e
|
||||
LEFT JOIN extension_user_settings eus
|
||||
ON eus.extension_id = e.id AND eus.user_id = $1
|
||||
WHERE e.is_enabled = true
|
||||
AND (e.is_system = true OR COALESCE(eus.is_enabled, true) = true)
|
||||
ORDER BY e.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.UserExtension
|
||||
for rows.Next() {
|
||||
var ue models.UserExtension
|
||||
var teamID, installedBy sql.NullString
|
||||
var userEnabled sql.NullBool
|
||||
var userSettings []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
|
||||
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
|
||||
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
|
||||
&ue.CreatedAt, &ue.UpdatedAt,
|
||||
&userEnabled, &userSettings,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ue.TeamID = NullableStringPtr(teamID)
|
||||
ue.InstalledBy = NullableStringPtr(installedBy)
|
||||
if userEnabled.Valid {
|
||||
ue.UserEnabled = &userEnabled.Bool
|
||||
}
|
||||
if userSettings != nil {
|
||||
raw := json.RawMessage(userSettings)
|
||||
ue.UserSettings = &raw
|
||||
}
|
||||
result = append(result, ue)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]models.UserExtension, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetUserSettings returns per-user settings for a specific extension.
|
||||
// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions.
|
||||
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
|
||||
var eus models.ExtensionUserSettings
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT extension_id, user_id, settings, is_enabled
|
||||
FROM extension_user_settings
|
||||
WHERE extension_id = $1 AND user_id = $2`,
|
||||
extID, userID,
|
||||
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &eus, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (extension_id, user_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
||||
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUserSettings removes per-user settings, reverting to defaults.
|
||||
// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config.
|
||||
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
DELETE FROM extension_user_settings WHERE extension_id = $1 AND user_id = $2`,
|
||||
extID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────
|
||||
|
||||
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
|
||||
var ext models.Extension
|
||||
var teamID, installedBy sql.NullString
|
||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
||||
&ext.CreatedAt, &ext.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext.TeamID = NullableStringPtr(teamID)
|
||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
||||
return &ext, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.Extension
|
||||
for rows.Next() {
|
||||
var ext models.Extension
|
||||
var teamID, installedBy sql.NullString
|
||||
if err := rows.Scan(
|
||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
||||
&ext.CreatedAt, &ext.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext.TeamID = NullableStringPtr(teamID)
|
||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
||||
result = append(result, ext)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]models.Extension, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
281
server/store/postgres/packages.go
Normal file
281
server/store/postgres/packages.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type PackageStore struct{}
|
||||
|
||||
func NewPackageStore() *PackageStore { return &PackageStore{} }
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────
|
||||
|
||||
// Seed upserts a package at startup. Does NOT overwrite the enabled flag.
|
||||
func (s *PackageStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
|
||||
manifestJSON := ToJSON(manifest)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO packages (id, title, source, manifest, enabled, installed_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = $2,
|
||||
manifest = $4,
|
||||
source = $3,
|
||||
updated_at = NOW()`,
|
||||
id, title, source, manifestJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PackageStore) List(ctx context.Context) ([]store.PackageRegistration, error) {
|
||||
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p ORDER BY p.source, p.title`)
|
||||
}
|
||||
|
||||
func (s *PackageStore) Get(ctx context.Context, id string) (*store.PackageRegistration, error) {
|
||||
return s.scanOne(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.id = $1`, id)
|
||||
}
|
||||
|
||||
func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`UPDATE packages SET enabled = $2, updated_at = NOW() WHERE id = $1`,
|
||||
id, enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) Delete(ctx context.Context, id string) error {
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM packages WHERE id = $1 AND source != 'core'`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT id FROM packages WHERE enabled = true ORDER BY title`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ── Extended lifecycle ───────────────────────────
|
||||
|
||||
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
|
||||
manifestJSON := ToJSON(pkg.Manifest)
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||
is_system, scope, team_id, installed_by, manifest, enabled, source)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
RETURNING installed_at, updated_at`,
|
||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||
manifestJSON, pkg.Enabled, pkg.Source,
|
||||
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *PackageStore) Update(ctx context.Context, id string, pkg *store.PackageRegistration) error {
|
||||
manifestJSON := ToJSON(pkg.Manifest)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE packages SET
|
||||
title = $2, type = $3, version = $4, description = $5, author = $6,
|
||||
tier = $7, manifest = $8, is_system = $9, enabled = $10,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
id, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||
pkg.Tier, manifestJSON, pkg.IsSystem, pkg.Enabled,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PackageStore) ListByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
|
||||
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.type = $1 ORDER BY p.title`, pkgType)
|
||||
}
|
||||
|
||||
func (s *PackageStore) ListEnabledByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
|
||||
return s.scanMany(ctx,
|
||||
`SELECT `+pkgCols+` FROM packages p WHERE p.type = $1 AND p.enabled = true ORDER BY p.title`,
|
||||
pkgType)
|
||||
}
|
||||
|
||||
// ── Extension runtime ────────────────────────────
|
||||
|
||||
// ListForUser returns enabled extension/full packages with user settings overlay.
|
||||
func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.UserPackage, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT `+pkgCols+`,
|
||||
pus.is_enabled AS user_enabled,
|
||||
pus.settings AS user_settings
|
||||
FROM packages p
|
||||
LEFT JOIN package_user_settings pus
|
||||
ON pus.package_id = p.id AND pus.user_id = $1
|
||||
WHERE p.enabled = true
|
||||
AND p.type IN ('extension', 'full')
|
||||
AND (p.is_system = true OR COALESCE(pus.is_enabled, true) = true)
|
||||
ORDER BY p.title`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.UserPackage
|
||||
for rows.Next() {
|
||||
var up store.UserPackage
|
||||
var teamID, installedBy sql.NullString
|
||||
var manifestJSON []byte
|
||||
var userEnabled sql.NullBool
|
||||
var userSettings []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
||||
&up.Author, &up.Tier, &up.IsSystem, &up.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &up.Enabled, &up.Source,
|
||||
&up.InstalledAt, &up.UpdatedAt,
|
||||
&userEnabled, &userSettings,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
up.TeamID = NullableStringPtr(teamID)
|
||||
up.InstalledBy = NullableStringPtr(installedBy)
|
||||
json.Unmarshal(manifestJSON, &up.Manifest)
|
||||
if userEnabled.Valid {
|
||||
up.UserEnabled = &userEnabled.Bool
|
||||
}
|
||||
if userSettings != nil {
|
||||
raw := json.RawMessage(userSettings)
|
||||
up.UserSettings = &raw
|
||||
}
|
||||
result = append(result, up)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]store.UserPackage, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PackageStore) GetUserSettings(ctx context.Context, pkgID, userID string) (*store.PackageUserSettings, error) {
|
||||
var pus store.PackageUserSettings
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT package_id, user_id, settings, is_enabled
|
||||
FROM package_user_settings
|
||||
WHERE package_id = $1 AND user_id = $2`,
|
||||
pkgID, userID,
|
||||
).Scan(&pus.PackageID, &pus.UserID, &pus.Settings, &pus.IsEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pus, nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) SetUserSettings(ctx context.Context, pus *store.PackageUserSettings) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO package_user_settings (package_id, user_id, settings, is_enabled)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (package_id, user_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
||||
pus.PackageID, pus.UserID, pus.Settings, pus.IsEnabled,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
DELETE FROM package_user_settings WHERE package_id = $1 AND user_id = $2`,
|
||||
pkgID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────
|
||||
|
||||
// pkgCols is the explicit column list for packages scans. Avoids SELECT *
|
||||
// so column additions don't silently break positional Scan().
|
||||
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||
p.manifest, p.enabled, p.source, p.installed_at, p.updated_at`
|
||||
|
||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||
var pkg store.PackageRegistration
|
||||
var teamID, installedBy sql.NullString
|
||||
var manifestJSON []byte
|
||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &pkg.Enabled, &pkg.Source,
|
||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg.TeamID = NullableStringPtr(teamID)
|
||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]store.PackageRegistration, error) {
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.PackageRegistration
|
||||
for rows.Next() {
|
||||
var pkg store.PackageRegistration
|
||||
var teamID, installedBy sql.NullString
|
||||
var manifestJSON []byte
|
||||
if err := rows.Scan(
|
||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &pkg.Enabled, &pkg.Source,
|
||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg.TeamID = NullableStringPtr(teamID)
|
||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||
result = append(result, pkg)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
|
||||
func nullStrPtr(s *string) sql.NullString {
|
||||
if s == nil {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: *s, Valid: true}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
GlobalConfig: NewGlobalConfigStore(),
|
||||
Usage: NewUsageStore(),
|
||||
Pricing: NewPricingStore(),
|
||||
Extensions: NewExtensionStore(),
|
||||
Files: NewFileStore(),
|
||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||
Groups: NewGroupStore(),
|
||||
@@ -40,7 +39,7 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
CapOverrides: NewCapOverrideStore(db),
|
||||
RoutingPolicies: NewRoutingPolicyStore(db),
|
||||
Sessions: NewSessionStore(),
|
||||
Surfaces: NewSurfaceRegistryStore(),
|
||||
Packages: NewPackageStore(),
|
||||
Workflows: NewWorkflowStore(),
|
||||
Tasks: NewTaskStore(),
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type SurfaceRegistryStore struct{}
|
||||
|
||||
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
|
||||
|
||||
// Seed upserts a core surface. Called at startup by the page engine
|
||||
// to ensure core surfaces exist in the registry. Does NOT overwrite
|
||||
// the enabled state if the row already exists (admin toggle survives restart).
|
||||
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
|
||||
manifestJSON := ToJSON(manifest)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = $2,
|
||||
manifest = $4,
|
||||
source = $3,
|
||||
updated_at = NOW()`,
|
||||
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
|
||||
id, title, source, manifestJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
||||
FROM surface_registry ORDER BY source, title`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var surfaces []store.SurfaceRegistration
|
||||
for rows.Next() {
|
||||
var sr store.SurfaceRegistration
|
||||
var manifestJSON []byte
|
||||
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
json.Unmarshal(manifestJSON, &sr.Manifest)
|
||||
surfaces = append(surfaces, sr)
|
||||
}
|
||||
return surfaces, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
|
||||
var sr store.SurfaceRegistration
|
||||
var manifestJSON []byte
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
||||
FROM surface_registry WHERE id = $1`, id).
|
||||
Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(manifestJSON, &sr.Manifest)
|
||||
return &sr, nil
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`UPDATE surface_registry SET enabled = $2, updated_at = NOW() WHERE id = $1`,
|
||||
id, enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
|
||||
// Only allow deleting extension surfaces, not core
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM surface_registry WHERE id = $1 AND source = 'extension'`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT id FROM surface_registry WHERE enabled = true ORDER BY title`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type ExtensionStore struct{}
|
||||
|
||||
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
|
||||
|
||||
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
|
||||
ext.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
ext.CreatedAt = now
|
||||
ext.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO extensions (id, ext_id, name, version, tier, description, author,
|
||||
manifest, is_system, is_enabled, scope, team_id, installed_by, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
ext.ID, ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
|
||||
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
|
||||
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
|
||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = ?`, id)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
|
||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = ?`, extID)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE extensions SET
|
||||
name = ?, version = ?, description = ?, author = ?,
|
||||
manifest = ?, is_system = ?, is_enabled = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
ext.Name, ext.Version, ext.Description, ext.Author,
|
||||
ext.Manifest, ext.IsSystem, ext.IsEnabled, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
|
||||
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
|
||||
}
|
||||
|
||||
// ListEnabled returns all globally enabled extensions regardless of user.
|
||||
// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup.
|
||||
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
|
||||
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = 1 ORDER BY name`)
|
||||
}
|
||||
|
||||
// ListForUser returns all enabled extensions with user-specific overrides merged in.
|
||||
// System extensions are always included (users can't disable them).
|
||||
// Non-system extensions respect per-user enabled toggle.
|
||||
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
|
||||
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
|
||||
e.created_at, e.updated_at,
|
||||
eus.is_enabled AS user_enabled,
|
||||
eus.settings AS user_settings
|
||||
FROM extensions e
|
||||
LEFT JOIN extension_user_settings eus
|
||||
ON eus.extension_id = e.id AND eus.user_id = ?
|
||||
WHERE e.is_enabled = 1
|
||||
AND (e.is_system = 1 OR COALESCE(eus.is_enabled, 1) = 1)
|
||||
ORDER BY e.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.UserExtension
|
||||
for rows.Next() {
|
||||
var ue models.UserExtension
|
||||
var teamID, installedBy sql.NullString
|
||||
var userEnabled sql.NullBool
|
||||
var userSettings []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
|
||||
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
|
||||
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
|
||||
st(&ue.CreatedAt), st(&ue.UpdatedAt),
|
||||
&userEnabled, &userSettings,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ue.TeamID = NullableStringPtr(teamID)
|
||||
ue.InstalledBy = NullableStringPtr(installedBy)
|
||||
if userEnabled.Valid {
|
||||
ue.UserEnabled = &userEnabled.Bool
|
||||
}
|
||||
if userSettings != nil {
|
||||
raw := json.RawMessage(userSettings)
|
||||
ue.UserSettings = &raw
|
||||
}
|
||||
result = append(result, ue)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]models.UserExtension, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetUserSettings returns per-user settings for a specific extension.
|
||||
// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions.
|
||||
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
|
||||
var eus models.ExtensionUserSettings
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT extension_id, user_id, settings, is_enabled
|
||||
FROM extension_user_settings
|
||||
WHERE extension_id = ? AND user_id = ?`,
|
||||
extID, userID,
|
||||
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &eus, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (extension_id, user_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
||||
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUserSettings removes per-user settings, reverting to defaults.
|
||||
// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config.
|
||||
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
DELETE FROM extension_user_settings WHERE extension_id = ? AND user_id = ?`,
|
||||
extID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────
|
||||
|
||||
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
|
||||
var ext models.Extension
|
||||
var teamID, installedBy sql.NullString
|
||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
||||
st(&ext.CreatedAt), st(&ext.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext.TeamID = NullableStringPtr(teamID)
|
||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
||||
return &ext, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.Extension
|
||||
for rows.Next() {
|
||||
var ext models.Extension
|
||||
var teamID, installedBy sql.NullString
|
||||
if err := rows.Scan(
|
||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
||||
st(&ext.CreatedAt), st(&ext.UpdatedAt),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext.TeamID = NullableStringPtr(teamID)
|
||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
||||
result = append(result, ext)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]models.Extension, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
301
server/store/sqlite/packages.go
Normal file
301
server/store/sqlite/packages.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type PackageStore struct{}
|
||||
|
||||
func NewPackageStore() *PackageStore { return &PackageStore{} }
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────
|
||||
|
||||
func (s *PackageStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
|
||||
manifestJSON := ToJSON(manifest)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO packages (id, title, source, manifest, enabled, installed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
manifest = excluded.manifest,
|
||||
source = excluded.source,
|
||||
updated_at = datetime('now')`,
|
||||
id, title, source, manifestJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PackageStore) List(ctx context.Context) ([]store.PackageRegistration, error) {
|
||||
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p ORDER BY p.source, p.title`)
|
||||
}
|
||||
|
||||
func (s *PackageStore) Get(ctx context.Context, id string) (*store.PackageRegistration, error) {
|
||||
return s.scanOne(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.id = ?`, id)
|
||||
}
|
||||
|
||||
func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
enabledInt := 0
|
||||
if enabled {
|
||||
enabledInt = 1
|
||||
}
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`UPDATE packages SET enabled = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
enabledInt, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) Delete(ctx context.Context, id string) error {
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM packages WHERE id = ? AND source != 'core'`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT id FROM packages WHERE enabled = 1 ORDER BY title`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ── Extended lifecycle ───────────────────────────
|
||||
|
||||
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
|
||||
now := time.Now().UTC()
|
||||
manifestJSON := ToJSON(pkg.Manifest)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||
is_system, scope, team_id, installed_by, manifest, enabled, source,
|
||||
installed_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||
manifestJSON, pkg.Enabled, pkg.Source,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkg.InstalledAt = now.Format(timeFmt)
|
||||
pkg.UpdatedAt = now.Format(timeFmt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) Update(ctx context.Context, id string, pkg *store.PackageRegistration) error {
|
||||
manifestJSON := ToJSON(pkg.Manifest)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE packages SET
|
||||
title = ?, type = ?, version = ?, description = ?, author = ?,
|
||||
tier = ?, manifest = ?, is_system = ?, enabled = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||
pkg.Tier, manifestJSON, pkg.IsSystem, pkg.Enabled, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PackageStore) ListByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
|
||||
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.type = ? ORDER BY p.title`, pkgType)
|
||||
}
|
||||
|
||||
func (s *PackageStore) ListEnabledByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
|
||||
return s.scanMany(ctx,
|
||||
`SELECT `+pkgCols+` FROM packages p WHERE p.type = ? AND p.enabled = 1 ORDER BY p.title`,
|
||||
pkgType)
|
||||
}
|
||||
|
||||
// ── Extension runtime ────────────────────────────
|
||||
|
||||
func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.UserPackage, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT `+pkgCols+`,
|
||||
pus.is_enabled AS user_enabled,
|
||||
pus.settings AS user_settings
|
||||
FROM packages p
|
||||
LEFT JOIN package_user_settings pus
|
||||
ON pus.package_id = p.id AND pus.user_id = ?
|
||||
WHERE p.enabled = 1
|
||||
AND p.type IN ('extension', 'full')
|
||||
AND (p.is_system = 1 OR COALESCE(pus.is_enabled, 1) = 1)
|
||||
ORDER BY p.title`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.UserPackage
|
||||
for rows.Next() {
|
||||
var up store.UserPackage
|
||||
var teamID, installedBy sql.NullString
|
||||
var manifestJSON string
|
||||
var enabledInt int
|
||||
var isSystemInt int
|
||||
var userEnabled sql.NullBool
|
||||
var userSettings []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
||||
&up.Author, &up.Tier, &isSystemInt, &up.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &enabledInt, &up.Source,
|
||||
&up.InstalledAt, &up.UpdatedAt,
|
||||
&userEnabled, &userSettings,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
up.IsSystem = isSystemInt != 0
|
||||
up.Enabled = enabledInt != 0
|
||||
up.TeamID = NullableStringPtr(teamID)
|
||||
up.InstalledBy = NullableStringPtr(installedBy)
|
||||
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
|
||||
if userEnabled.Valid {
|
||||
up.UserEnabled = &userEnabled.Bool
|
||||
}
|
||||
if userSettings != nil {
|
||||
raw := json.RawMessage(userSettings)
|
||||
up.UserSettings = &raw
|
||||
}
|
||||
result = append(result, up)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]store.UserPackage, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PackageStore) GetUserSettings(ctx context.Context, pkgID, userID string) (*store.PackageUserSettings, error) {
|
||||
var pus store.PackageUserSettings
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT package_id, user_id, settings, is_enabled
|
||||
FROM package_user_settings
|
||||
WHERE package_id = ? AND user_id = ?`,
|
||||
pkgID, userID,
|
||||
).Scan(&pus.PackageID, &pus.UserID, &pus.Settings, &pus.IsEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pus, nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) SetUserSettings(ctx context.Context, pus *store.PackageUserSettings) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO package_user_settings (package_id, user_id, settings, is_enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (package_id, user_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
||||
pus.PackageID, pus.UserID, pus.Settings, pus.IsEnabled,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
DELETE FROM package_user_settings WHERE package_id = ? AND user_id = ?`,
|
||||
pkgID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────
|
||||
|
||||
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||
p.manifest, p.enabled, p.source, p.installed_at, p.updated_at`
|
||||
|
||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||
var pkg store.PackageRegistration
|
||||
var teamID, installedBy sql.NullString
|
||||
var manifestJSON string
|
||||
var enabledInt int
|
||||
var isSystemInt int
|
||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &enabledInt, &pkg.Source,
|
||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg.IsSystem = isSystemInt != 0
|
||||
pkg.Enabled = enabledInt != 0
|
||||
pkg.TeamID = NullableStringPtr(teamID)
|
||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]store.PackageRegistration, error) {
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.PackageRegistration
|
||||
for rows.Next() {
|
||||
var pkg store.PackageRegistration
|
||||
var teamID, installedBy sql.NullString
|
||||
var manifestJSON string
|
||||
var enabledInt int
|
||||
var isSystemInt int
|
||||
if err := rows.Scan(
|
||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &enabledInt, &pkg.Source,
|
||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg.IsSystem = isSystemInt != 0
|
||||
pkg.Enabled = enabledInt != 0
|
||||
pkg.TeamID = NullableStringPtr(teamID)
|
||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||
result = append(result, pkg)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func nullStrPtr(s *string) sql.NullString {
|
||||
if s == nil {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: *s, Valid: true}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
GlobalConfig: NewGlobalConfigStore(),
|
||||
Usage: NewUsageStore(),
|
||||
Pricing: NewPricingStore(),
|
||||
Extensions: NewExtensionStore(),
|
||||
Files: NewFileStore(),
|
||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||
Groups: NewGroupStore(),
|
||||
@@ -40,7 +39,7 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
CapOverrides: NewCapOverrideStore(),
|
||||
RoutingPolicies: NewRoutingPolicyStore(),
|
||||
Sessions: NewSessionStore(),
|
||||
Surfaces: NewSurfaceRegistryStore(),
|
||||
Packages: NewPackageStore(),
|
||||
Workflows: NewWorkflowStore(),
|
||||
Tasks: NewTaskStore(),
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type SurfaceRegistryStore struct{}
|
||||
|
||||
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
|
||||
|
||||
// Seed upserts a core surface. Called at startup by the page engine
|
||||
// to ensure core surfaces exist in the registry. Does NOT overwrite
|
||||
// the enabled state if the row already exists (admin toggle survives restart).
|
||||
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
|
||||
manifestJSON := ToJSON(manifest)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
manifest = excluded.manifest,
|
||||
source = excluded.source,
|
||||
updated_at = datetime('now')`,
|
||||
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
|
||||
id, title, source, manifestJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
||||
FROM surface_registry ORDER BY source, title`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var surfaces []store.SurfaceRegistration
|
||||
for rows.Next() {
|
||||
var sr store.SurfaceRegistration
|
||||
var manifestJSON string
|
||||
var enabledInt int
|
||||
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &enabledInt, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
sr.Enabled = enabledInt != 0
|
||||
json.Unmarshal([]byte(manifestJSON), &sr.Manifest)
|
||||
surfaces = append(surfaces, sr)
|
||||
}
|
||||
return surfaces, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
|
||||
var sr store.SurfaceRegistration
|
||||
var manifestJSON string
|
||||
var enabledInt int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
||||
FROM surface_registry WHERE id = ?`, id).
|
||||
Scan(&sr.ID, &sr.Title, &manifestJSON, &enabledInt, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sr.Enabled = enabledInt != 0
|
||||
json.Unmarshal([]byte(manifestJSON), &sr.Manifest)
|
||||
return &sr, nil
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
enabledInt := 0
|
||||
if enabled {
|
||||
enabledInt = 1
|
||||
}
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`UPDATE surface_registry SET enabled = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
enabledInt, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
|
||||
// Only allow deleting extension surfaces, not core
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM surface_registry WHERE id = ? AND source = 'extension'`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT id FROM surface_registry WHERE enabled = 1 ORDER BY title`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
// SurfaceRegistryStore — CRUD for the surface_registry table.
|
||||
// Core surfaces are seeded at startup. Extension surfaces are managed via admin API.
|
||||
type SurfaceRegistryStore interface {
|
||||
// Seed upserts a core surface (called at startup by page engine).
|
||||
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
|
||||
|
||||
// List returns all registered surfaces, ordered by title.
|
||||
List(ctx context.Context) ([]SurfaceRegistration, error)
|
||||
|
||||
// Get returns a single surface by ID.
|
||||
Get(ctx context.Context, id string) (*SurfaceRegistration, error)
|
||||
|
||||
// SetEnabled toggles a surface's enabled state.
|
||||
SetEnabled(ctx context.Context, id string, enabled bool) error
|
||||
|
||||
// Delete removes an extension surface. Core surfaces cannot be deleted.
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// ListEnabled returns IDs of all enabled surfaces.
|
||||
ListEnabled(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// SurfaceRegistration is a row from surface_registry.
|
||||
type SurfaceRegistration struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Manifest map[string]any `json:"manifest" db:"manifest"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
Source string `json:"source" db:"source"`
|
||||
InstalledAt string `json:"installed_at" db:"installed_at"`
|
||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
Reference in New Issue
Block a user