237 lines
7.7 KiB
Go
237 lines
7.7 KiB
Go
package repo
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
|
)
|
|
|
|
// The immutable side of the registry.
|
|
//
|
|
// §3: "Immutable versions. Editing publishes a new version. Running
|
|
// conversations pin the version they started with." Two halves, and the second
|
|
// is the one that costs something to get right.
|
|
//
|
|
// The first half is a snapshot on publish, which is this file's Snapshot.
|
|
//
|
|
// The second half is why the snapshot is worth taking. Every run records the
|
|
// agent version it ran under, and until now that number pointed at a definition
|
|
// that had since been edited — so "which agent answered this?" was
|
|
// unanswerable, and worse, a confirmation approved against version 3 would be
|
|
// carried out by version 4's tool list. A person approves what they were shown.
|
|
// Resolving that number back to the definition it named is what makes the
|
|
// approval mean the thing they approved.
|
|
|
|
// VersionKind distinguishes the two definition types.
|
|
//
|
|
// One table for both, because agents and skills version identically and two
|
|
// tables with the same columns and the same rules are two places to fix the
|
|
// next rule.
|
|
type VersionKind string
|
|
|
|
const (
|
|
KindAgent VersionKind = "agent"
|
|
KindSkill VersionKind = "skill"
|
|
)
|
|
|
|
// VersionsRepo reads and appends published versions.
|
|
type VersionsRepo struct {
|
|
db Querier
|
|
}
|
|
|
|
// NewVersionsRepo builds a repository over a pool or transaction.
|
|
func NewVersionsRepo(db Querier) *VersionsRepo { return &VersionsRepo{db: db} }
|
|
|
|
// Version is one published snapshot.
|
|
type Version struct {
|
|
Kind VersionKind `json:"kind"`
|
|
DefinitionID string `json:"definitionId"`
|
|
Version int `json:"version"`
|
|
Markdown string `json:"markdown"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Pages []string `json:"pages"`
|
|
PublishedAt string `json:"publishedAt"`
|
|
}
|
|
|
|
// SnapshotInput is what a publish records.
|
|
type SnapshotInput struct {
|
|
Kind VersionKind
|
|
DefinitionID string
|
|
Version int
|
|
Markdown string
|
|
Name string
|
|
Description string
|
|
Pages []string
|
|
}
|
|
|
|
// Snapshot records a published version.
|
|
//
|
|
// Idempotent by construction: republishing the same version number with the
|
|
// same content is a no-op rather than an error, because the honest reading of
|
|
// "publish version 3 again" is that version 3 already exists and says this.
|
|
//
|
|
// Republishing the same number with DIFFERENT content is refused, and that
|
|
// refusal is the whole point of the table. It is the moment somebody would
|
|
// otherwise have rewritten what a person approved, and it fails loudly with the
|
|
// version number in the message rather than silently taking the newer text.
|
|
func (r *VersionsRepo) Snapshot(ctx context.Context, ident authctx.Identity, in SnapshotInput) error {
|
|
if strings.TrimSpace(ident.OrgID) == "" {
|
|
return domain.Internal(errors.New("a version needs an organization"))
|
|
}
|
|
if in.Version < 1 {
|
|
return domain.Validation("a published version must be at least 1", nil)
|
|
}
|
|
if strings.TrimSpace(in.Markdown) == "" {
|
|
return domain.Validation("a published version needs a definition", nil)
|
|
}
|
|
|
|
pages := in.Pages
|
|
if pages == nil {
|
|
pages = []string{}
|
|
}
|
|
|
|
var existing string
|
|
err := r.db.QueryRow(ctx, `
|
|
INSERT INTO definition_versions
|
|
(kind, org_id, definition_id, version, markdown, name, description, pages, published_by)
|
|
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8::text[], $9)
|
|
ON CONFLICT (org_id, kind, definition_id, version) DO NOTHING
|
|
RETURNING markdown`,
|
|
string(in.Kind), ident.OrgID, in.DefinitionID, in.Version,
|
|
in.Markdown, in.Name, in.Description, pages, nullUUID(ident.UserID),
|
|
).Scan(&existing)
|
|
|
|
if err == nil {
|
|
return nil // inserted
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return translate(err)
|
|
}
|
|
|
|
// The conflict path: this version already exists. Whether that is fine
|
|
// depends entirely on whether it says the same thing.
|
|
var stored string
|
|
if err := r.db.QueryRow(ctx, `
|
|
SELECT markdown FROM definition_versions
|
|
WHERE org_id = $1::uuid AND kind = $2 AND definition_id = $3 AND version = $4`,
|
|
ident.OrgID, string(in.Kind), in.DefinitionID, in.Version,
|
|
).Scan(&stored); err != nil {
|
|
return translate(err)
|
|
}
|
|
if stored == in.Markdown {
|
|
return nil
|
|
}
|
|
return domain.Conflict(fmt.Sprintf(
|
|
"version %d of %q is already published and says something different; "+
|
|
"publish a new version rather than changing this one",
|
|
in.Version, in.DefinitionID))
|
|
}
|
|
|
|
// Load returns one published version.
|
|
//
|
|
// Tenant-scoped in the query, so a version from another organization is absent
|
|
// rather than forbidden — the same rule every other row in this service follows,
|
|
// and for the same reason: a distinguishable refusal is a way to enumerate.
|
|
func (r *VersionsRepo) Load(ctx context.Context, ident authctx.Identity,
|
|
kind VersionKind, definitionID string, version int) (*Version, error) {
|
|
|
|
if strings.TrimSpace(ident.OrgID) == "" {
|
|
return nil, domain.NotFound("version", definitionID)
|
|
}
|
|
|
|
var v Version
|
|
err := r.db.QueryRow(ctx, `
|
|
SELECT kind, definition_id, version, markdown, name, description, pages,
|
|
to_char(published_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
|
FROM definition_versions
|
|
WHERE org_id = $1::uuid AND kind = $2 AND definition_id = $3 AND version = $4`,
|
|
ident.OrgID, string(kind), definitionID, version,
|
|
).Scan(&v.Kind, &v.DefinitionID, &v.Version, &v.Markdown, &v.Name,
|
|
&v.Description, &v.Pages, &v.PublishedAt)
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, domain.NotFound("version", fmt.Sprintf("%s v%d", definitionID, version))
|
|
}
|
|
if err != nil {
|
|
return nil, translate(err)
|
|
}
|
|
return &v, nil
|
|
}
|
|
|
|
// History lists a definition's published versions, newest first.
|
|
func (r *VersionsRepo) History(ctx context.Context, ident authctx.Identity,
|
|
kind VersionKind, definitionID string, limit int) ([]Version, error) {
|
|
|
|
if strings.TrimSpace(ident.OrgID) == "" {
|
|
return []Version{}, nil
|
|
}
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
rows, err := r.db.Query(ctx, `
|
|
SELECT kind, definition_id, version, markdown, name, description, pages,
|
|
to_char(published_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
|
FROM definition_versions
|
|
WHERE org_id = $1::uuid AND kind = $2 AND definition_id = $3
|
|
ORDER BY version DESC
|
|
LIMIT $4`,
|
|
ident.OrgID, string(kind), definitionID, limit)
|
|
if err != nil {
|
|
return nil, translate(err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Version{}
|
|
for rows.Next() {
|
|
var v Version
|
|
if err := rows.Scan(&v.Kind, &v.DefinitionID, &v.Version, &v.Markdown,
|
|
&v.Name, &v.Description, &v.Pages, &v.PublishedAt); err != nil {
|
|
return nil, translate(err)
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// LatestVersion is the highest published version number, or 0 for none.
|
|
//
|
|
// Used to decide what a new publish should be numbered. Reading the CURRENT
|
|
// definition's version would be wrong: a draft can carry any number its author
|
|
// typed, and the next published version has to follow what was actually
|
|
// published rather than what somebody wrote in the frontmatter.
|
|
func (r *VersionsRepo) LatestVersion(ctx context.Context, ident authctx.Identity,
|
|
kind VersionKind, definitionID string) (int, error) {
|
|
|
|
if strings.TrimSpace(ident.OrgID) == "" {
|
|
return 0, nil
|
|
}
|
|
var latest *int
|
|
if err := r.db.QueryRow(ctx, `
|
|
SELECT max(version) FROM definition_versions
|
|
WHERE org_id = $1::uuid AND kind = $2 AND definition_id = $3`,
|
|
ident.OrgID, string(kind), definitionID,
|
|
).Scan(&latest); err != nil {
|
|
return 0, translate(err)
|
|
}
|
|
if latest == nil {
|
|
return 0, nil
|
|
}
|
|
return *latest, nil
|
|
}
|
|
|
|
// nullUUID keeps an empty principal id out of a uuid column.
|
|
func nullUUID(s string) any {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|