Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
415 lines
13 KiB
Go
415 lines
13 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
|
|
"switchboard-core/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) SetStatus(ctx context.Context, id string, status string) error {
|
|
result, err := DB.ExecContext(ctx,
|
|
`UPDATE packages SET status = $2, updated_at = NOW() WHERE id = $1`,
|
|
id, status)
|
|
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)
|
|
if pkg.Status == "" {
|
|
pkg.Status = "active"
|
|
}
|
|
return DB.QueryRowContext(ctx, `
|
|
INSERT INTO packages (id, title, type, version, description, author, tier,
|
|
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
|
schema_version, package_settings, source)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
|
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.Status,
|
|
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
|
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)
|
|
AND (
|
|
p.scope = 'global'
|
|
OR (p.scope = 'personal' AND p.installed_by = $1)
|
|
OR (p.scope = 'team' AND p.team_id IN (
|
|
SELECT team_id FROM team_members WHERE user_id = $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 []byte
|
|
var pkgSettings []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.Status,
|
|
&up.SchemaVersion, &pkgSettings,
|
|
&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)
|
|
up.PackageSettings = json.RawMessage(pkgSettings)
|
|
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.status,
|
|
p.schema_version, p.package_settings,
|
|
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
|
|
var pkgSettings []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.Status,
|
|
&pkg.SchemaVersion, &pkgSettings,
|
|
&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)
|
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
|
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
|
|
var pkgSettings []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.Status,
|
|
&pkg.SchemaVersion, &pkgSettings,
|
|
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
pkg.TeamID = NullableStringPtr(teamID)
|
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
|
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
|
result = append(result, pkg)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// ── Scoped visibility (v0.30.0) ──────────────────
|
|
|
|
func (s *PackageStore) ListVisiblePackages(ctx context.Context, userID string) ([]store.PackageRegistration, error) {
|
|
return s.scanMany(ctx, `
|
|
SELECT `+pkgCols+`
|
|
FROM packages p
|
|
WHERE p.enabled = true
|
|
AND (
|
|
p.scope = 'global'
|
|
OR (p.scope = 'personal' AND p.installed_by = $1)
|
|
OR (p.scope = 'team' AND p.team_id IN (
|
|
SELECT team_id FROM team_members WHERE user_id = $1
|
|
))
|
|
)
|
|
ORDER BY p.source, p.title`, userID)
|
|
}
|
|
|
|
// ── Package lifecycle (v0.30.0) ──────────────────
|
|
|
|
func (s *PackageStore) SetSchemaVersion(ctx context.Context, id string, version int) error {
|
|
result, err := DB.ExecContext(ctx,
|
|
`UPDATE packages SET schema_version = $2, updated_at = NOW() WHERE id = $1`,
|
|
id, version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, _ := result.RowsAffected()
|
|
if affected == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PackageStore) GetPackageSettings(ctx context.Context, id string) (json.RawMessage, error) {
|
|
var settings []byte
|
|
err := DB.QueryRowContext(ctx,
|
|
`SELECT package_settings FROM packages WHERE id = $1`, id).Scan(&settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.RawMessage(settings), nil
|
|
}
|
|
|
|
func (s *PackageStore) SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error {
|
|
result, err := DB.ExecContext(ctx,
|
|
`UPDATE packages SET package_settings = $2, updated_at = NOW() WHERE id = $1`,
|
|
id, []byte(settings))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, _ := result.RowsAffected()
|
|
if affected == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Team-level settings (v0.2.0) ─────────────────
|
|
|
|
func (s *PackageStore) GetTeamSettings(ctx context.Context, pkgID, teamID string) (json.RawMessage, error) {
|
|
var settings []byte
|
|
err := DB.QueryRowContext(ctx,
|
|
`SELECT settings FROM package_team_settings WHERE package_id = $1 AND team_id = $2`,
|
|
pkgID, teamID).Scan(&settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.RawMessage(settings), nil
|
|
}
|
|
|
|
func (s *PackageStore) SetTeamSettings(ctx context.Context, pkgID, teamID string, settings json.RawMessage) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
INSERT INTO package_team_settings (package_id, team_id, settings, updated_at)
|
|
VALUES ($1, $2, $3, NOW())
|
|
ON CONFLICT (package_id, team_id)
|
|
DO UPDATE SET settings = EXCLUDED.settings, updated_at = NOW()`,
|
|
pkgID, teamID, []byte(settings))
|
|
return err
|
|
}
|
|
|
|
func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error {
|
|
_, err := DB.ExecContext(ctx,
|
|
`DELETE FROM package_team_settings WHERE package_id = $1 AND team_id = $2`,
|
|
pkgID, teamID)
|
|
return 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}
|
|
}
|
|
|
|
// defaultJSON returns the raw message or '{}' if nil/empty.
|
|
func defaultJSON(raw json.RawMessage) []byte {
|
|
if len(raw) == 0 {
|
|
return []byte("{}")
|
|
}
|
|
return []byte(raw)
|
|
}
|