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 }