Feat v0.9.4 package adoption roles (#78)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m55s
CI/CD / test-sqlite (push) Successful in 3m7s
CI/CD / build-and-deploy (push) Successful in 1m19s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #78.
This commit is contained in:
2026-04-03 16:23:43 +00:00
committed by xcaliber
parent 0661e1d768
commit 6b9ce92103
19 changed files with 2268 additions and 48 deletions

View File

@@ -208,6 +208,23 @@ type TeamStore interface {
// RemoveAllUserRoles deletes all additional roles for a member (cleanup on removal).
RemoveAllUserRoles(ctx context.Context, teamID, userID string) error
// ── v0.9.4 — Team Role Catalog ──
// AddRoleToCatalog registers a known role for the team. Idempotent.
AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error
// ListRoleCatalog returns all known roles for a team.
ListRoleCatalog(ctx context.Context, teamID string) ([]TeamRoleCatalogEntry, error)
// RemoveRoleCatalogBySource removes all catalog entries sourced from a package.
RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error
}
// TeamRoleCatalogEntry represents a known role sourced from an adopted package.
type TeamRoleCatalogEntry struct {
Role string `json:"role"`
SourcePackageID string `json:"source_package_id,omitempty"`
}
// =========================================

View File

@@ -93,6 +93,14 @@ type PackageStore interface {
// DeleteTeamSettings removes team-scoped overrides, reverting to global defaults.
DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error
// ── v0.9.4 — Package Adoption ───────────────
// ListAdoptable returns global, adoptable, enabled packages.
ListAdoptable(ctx context.Context) ([]PackageRegistration, error)
// GetByAdoptedFrom returns a team's adopted copy of a source package, or nil.
GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*PackageRegistration, error)
}
// PackageRegistration is a row from the packages table.
@@ -114,6 +122,8 @@ type PackageRegistration struct {
SchemaVersion int `json:"schema_version" db:"schema_version"`
PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"`
Source string `json:"source" db:"source"`
Adoptable bool `json:"adoptable" db:"adoptable"`
AdoptedFrom *string `json:"adopted_from,omitempty" db:"adopted_from"`
InstalledAt string `json:"installed_at" db:"installed_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}

View File

@@ -107,15 +107,15 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
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)
schema_version, package_settings, source, adoptable, adopted_from)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
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,
pkg.Source, pkg.Adoptable, nullStrPtr(pkg.AdoptedFrom),
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
}
@@ -173,7 +173,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
var result []store.UserPackage
for rows.Next() {
var up store.UserPackage
var teamID, installedBy sql.NullString
var teamID, installedBy, adoptedFrom sql.NullString
var manifestJSON []byte
var pkgSettings []byte
var userEnabled sql.NullBool
@@ -185,13 +185,15 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
&teamID, &installedBy,
&manifestJSON, &up.Enabled, &up.Status,
&up.SchemaVersion, &pkgSettings,
&up.Source, &up.InstalledAt, &up.UpdatedAt,
&up.Source, &up.Adoptable, &adoptedFrom,
&up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy)
up.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal(manifestJSON, &up.Manifest)
up.PackageSettings = json.RawMessage(pkgSettings)
if userEnabled.Valid {
@@ -250,11 +252,12 @@ 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`
p.source, p.adoptable, p.adopted_from,
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 teamID, installedBy, adoptedFrom sql.NullString
var manifestJSON []byte
var pkgSettings []byte
err := DB.QueryRowContext(ctx, query, args...).Scan(
@@ -263,7 +266,8 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
&teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Status,
&pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
&pkg.Source, &pkg.Adoptable, &adoptedFrom,
&pkg.InstalledAt, &pkg.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -273,6 +277,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
}
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal(manifestJSON, &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
return &pkg, nil
@@ -288,7 +293,7 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
var result []store.PackageRegistration
for rows.Next() {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var teamID, installedBy, adoptedFrom sql.NullString
var manifestJSON []byte
var pkgSettings []byte
if err := rows.Scan(
@@ -297,12 +302,14 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
&teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Status,
&pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
&pkg.Source, &pkg.Adoptable, &adoptedFrom,
&pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil {
return nil, err
}
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal(manifestJSON, &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
result = append(result, pkg)
@@ -397,6 +404,23 @@ func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID str
return err
}
// ── v0.9.4 — Package Adoption ───────────────
func (s *PackageStore) ListAdoptable(ctx context.Context) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `
SELECT `+pkgCols+`
FROM packages p
WHERE p.adoptable = true AND p.scope = 'global' AND p.enabled = true
ORDER BY p.title`)
}
func (s *PackageStore) GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*store.PackageRegistration, error) {
return s.scanOne(ctx, `
SELECT `+pkgCols+`
FROM packages p
WHERE p.adopted_from = $1 AND p.team_id = $2`, sourceID, teamID)
}
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
func nullStrPtr(s *string) sql.NullString {
if s == nil {

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"armature/models"
"armature/store"
)
type TeamStore struct{}
@@ -393,3 +394,40 @@ func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID strin
teamID, userID)
return err
}
// ── v0.9.4 — Team Role Catalog ──
func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO team_role_catalog (id, team_id, role, source_package_id)
VALUES (gen_random_uuid(), $1, $2, $3)
ON CONFLICT (team_id, role) DO NOTHING`,
teamID, role, sourcePkgID)
return err
}
func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) {
rows, err := DB.QueryContext(ctx, `
SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog
WHERE team_id = $1 ORDER BY role`, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.TeamRoleCatalogEntry
for rows.Next() {
var e store.TeamRoleCatalogEntry
if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil {
return nil, err
}
result = append(result, e)
}
return result, rows.Err()
}
func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM team_role_catalog WHERE team_id = $1 AND source_package_id = $2`,
teamID, sourcePkgID)
return err
}

View File

@@ -109,18 +109,22 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
if pkg.Status == "" {
pkg.Status = "active"
}
adoptableInt := 0
if pkg.Adoptable {
adoptableInt = 1
}
_, err := DB.ExecContext(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,
schema_version, package_settings, source, adoptable, adopted_from,
installed_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
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.Status,
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
pkg.Source,
pkg.Source, adoptableInt, nullStrPtr(pkg.AdoptedFrom),
now.Format(timeFmt), now.Format(timeFmt),
)
if err != nil {
@@ -184,11 +188,12 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
var result []store.UserPackage
for rows.Next() {
var up store.UserPackage
var teamID, installedBy sql.NullString
var teamID, installedBy, adoptedFrom sql.NullString
var manifestJSON string
var pkgSettings string
var enabledInt int
var isSystemInt int
var adoptableInt int
var userEnabled sql.NullBool
var userSettings []byte
@@ -198,15 +203,18 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
&teamID, &installedBy,
&manifestJSON, &enabledInt, &up.Status,
&up.SchemaVersion, &pkgSettings,
&up.Source, &up.InstalledAt, &up.UpdatedAt,
&up.Source, &adoptableInt, &adoptedFrom,
&up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
up.IsSystem = isSystemInt != 0
up.Enabled = enabledInt != 0
up.Adoptable = adoptableInt != 0
up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy)
up.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
up.PackageSettings = json.RawMessage(pkgSettings)
if userEnabled.Valid {
@@ -263,22 +271,25 @@ 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`
p.source, p.adoptable, p.adopted_from,
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 teamID, installedBy, adoptedFrom sql.NullString
var manifestJSON string
var pkgSettings string
var enabledInt int
var isSystemInt int
var adoptableInt 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.Status,
&pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
&pkg.Source, &adoptableInt, &adoptedFrom,
&pkg.InstalledAt, &pkg.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -288,8 +299,10 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
}
pkg.IsSystem = isSystemInt != 0
pkg.Enabled = enabledInt != 0
pkg.Adoptable = adoptableInt != 0
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
return &pkg, nil
@@ -305,25 +318,29 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
var result []store.PackageRegistration
for rows.Next() {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var teamID, installedBy, adoptedFrom sql.NullString
var manifestJSON string
var pkgSettings string
var enabledInt int
var isSystemInt int
var adoptableInt 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.Status,
&pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
&pkg.Source, &adoptableInt, &adoptedFrom,
&pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil {
return nil, err
}
pkg.IsSystem = isSystemInt != 0
pkg.Enabled = enabledInt != 0
pkg.Adoptable = adoptableInt != 0
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
result = append(result, pkg)
@@ -418,6 +435,23 @@ func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID str
return err
}
// ── v0.9.4 — Package Adoption ───────────────
func (s *PackageStore) ListAdoptable(ctx context.Context) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `
SELECT `+pkgCols+`
FROM packages p
WHERE p.adoptable = 1 AND p.scope = 'global' AND p.enabled = 1
ORDER BY p.title`)
}
func (s *PackageStore) GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*store.PackageRegistration, error) {
return s.scanOne(ctx, `
SELECT `+pkgCols+`
FROM packages p
WHERE p.adopted_from = ? AND p.team_id = ?`, sourceID, teamID)
}
func nullStrPtr(s *string) sql.NullString {
if s == nil {
return sql.NullString{}

View File

@@ -8,6 +8,8 @@ import (
"armature/models"
"armature/store"
"github.com/google/uuid"
)
type TeamStore struct{}
@@ -399,3 +401,40 @@ func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID strin
teamID, userID)
return err
}
// ── v0.9.4 — Team Role Catalog ──
func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO team_role_catalog (id, team_id, role, source_package_id)
VALUES (?, ?, ?, ?)
ON CONFLICT (team_id, role) DO NOTHING`,
uuid.New().String(), teamID, role, sourcePkgID)
return err
}
func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) {
rows, err := DB.QueryContext(ctx, `
SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog
WHERE team_id = ? ORDER BY role`, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.TeamRoleCatalogEntry
for rows.Next() {
var e store.TeamRoleCatalogEntry
if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil {
return nil, err
}
result = append(result, e)
}
return result, rows.Err()
}
func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM team_role_catalog WHERE team_id = ? AND source_package_id = ?`,
teamID, sourcePkgID)
return err
}