598 lines
22 KiB
Go
598 lines
22 KiB
Go
package canvas
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/db"
|
|
)
|
|
|
|
// Repository stores the canvas-owned metadata that is not part of a plugin
|
|
// instance. It intentionally uses a separate table name so databases that
|
|
// still contain the superseded declarative canvases table are not read as if
|
|
// they used the current plugin-backed lifecycle schema.
|
|
type Repository struct {
|
|
db *sqlx.DB
|
|
ro *sqlx.DB
|
|
}
|
|
|
|
// SchemaSQL is exported for schema replay and backup tests. The statements
|
|
// use the common SQLite/PostgreSQL subset and are safe to run repeatedly.
|
|
const SchemaSQL = `
|
|
CREATE TABLE IF NOT EXISTS canvas_lifecycle_metadata (
|
|
id TEXT PRIMARY KEY,
|
|
plugin_instance_id TEXT NOT NULL UNIQUE,
|
|
workspace_id TEXT NOT NULL,
|
|
task_id TEXT NOT NULL DEFAULT '',
|
|
origin_task_id TEXT NOT NULL DEFAULT '',
|
|
title TEXT NOT NULL,
|
|
created_by_session_id TEXT NOT NULL DEFAULT '',
|
|
promoted_by_user_id TEXT NOT NULL DEFAULT '',
|
|
promoted_at TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_canvas_lifecycle_workspace
|
|
ON canvas_lifecycle_metadata(workspace_id, updated_at, id);
|
|
CREATE INDEX IF NOT EXISTS idx_canvas_lifecycle_task
|
|
ON canvas_lifecycle_metadata(task_id, updated_at, id);
|
|
CREATE TABLE IF NOT EXISTS canvas_creation_authority (
|
|
canvas_id TEXT PRIMARY KEY,
|
|
owner_user_id TEXT NOT NULL,
|
|
creating_session_id TEXT NOT NULL,
|
|
policy_version INTEGER NOT NULL,
|
|
consumed_at TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_canvas_creation_authority_owner
|
|
ON canvas_creation_authority(owner_user_id, consumed_at);
|
|
CREATE TABLE IF NOT EXISTS canvas_lifecycle_admission (
|
|
id INTEGER PRIMARY KEY,
|
|
version INTEGER NOT NULL
|
|
);
|
|
INSERT INTO canvas_lifecycle_admission (id, version)
|
|
VALUES (1, 1) ON CONFLICT (id) DO NOTHING;
|
|
CREATE TABLE IF NOT EXISTS canvas_install_receipts (
|
|
preparation_id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
canvas_id TEXT NOT NULL UNIQUE,
|
|
workspace_id TEXT NOT NULL,
|
|
package_id TEXT NOT NULL,
|
|
package_version TEXT NOT NULL,
|
|
package_digest TEXT NOT NULL,
|
|
source_id TEXT NOT NULL DEFAULT '',
|
|
repository_url TEXT NOT NULL DEFAULT '',
|
|
origin_kind TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_canvas_install_receipts_workspace
|
|
ON canvas_install_receipts(workspace_id, created_at, preparation_id);
|
|
`
|
|
|
|
func (r *Repository) GetInstallReceipt(ctx context.Context, preparationID, userID string) (InstallReceipt, error) {
|
|
if strings.TrimSpace(preparationID) == "" || strings.TrimSpace(userID) == "" {
|
|
return InstallReceipt{}, ErrInstallReceiptNotFound
|
|
}
|
|
var row struct {
|
|
PreparationID string `db:"preparation_id"`
|
|
UserID string `db:"user_id"`
|
|
CanvasID string `db:"canvas_id"`
|
|
WorkspaceID string `db:"workspace_id"`
|
|
PackageID string `db:"package_id"`
|
|
PackageVersion string `db:"package_version"`
|
|
PackageDigest string `db:"package_digest"`
|
|
SourceID string `db:"source_id"`
|
|
RepositoryURL string `db:"repository_url"`
|
|
OriginKind string `db:"origin_kind"`
|
|
CreatedAt string `db:"created_at"`
|
|
}
|
|
err := r.ro.GetContext(ctx, &row, r.ro.Rebind(`SELECT preparation_id, user_id, canvas_id, workspace_id, package_id, package_version, package_digest, source_id, repository_url, origin_kind, created_at FROM canvas_install_receipts WHERE preparation_id = ? AND user_id = ?`), preparationID, userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return InstallReceipt{}, ErrInstallReceiptNotFound
|
|
}
|
|
if err != nil {
|
|
return InstallReceipt{}, err
|
|
}
|
|
createdAt, err := time.Parse(time.RFC3339Nano, row.CreatedAt)
|
|
if err != nil {
|
|
return InstallReceipt{}, err
|
|
}
|
|
return InstallReceipt{PreparationID: row.PreparationID, UserID: row.UserID, CanvasID: row.CanvasID, WorkspaceID: row.WorkspaceID, PackageID: row.PackageID, Version: row.PackageVersion, Digest: row.PackageDigest, SourceID: row.SourceID, RepositoryURL: row.RepositoryURL, OriginKind: row.OriginKind, CreatedAt: createdAt}, nil
|
|
}
|
|
|
|
func (r *Repository) CreateInstallReceiptTx(ctx context.Context, tx *sqlx.Tx, receipt InstallReceipt) error {
|
|
if strings.TrimSpace(receipt.PreparationID) == "" || strings.TrimSpace(receipt.UserID) == "" || strings.TrimSpace(receipt.CanvasID) == "" || strings.TrimSpace(receipt.WorkspaceID) == "" || strings.TrimSpace(receipt.PackageID) == "" || strings.TrimSpace(receipt.Version) == "" || strings.TrimSpace(receipt.Digest) == "" || strings.TrimSpace(receipt.OriginKind) == "" {
|
|
return ErrInstallInvalid
|
|
}
|
|
createdAt := receipt.CreatedAt.UTC()
|
|
if createdAt.IsZero() {
|
|
createdAt = time.Now().UTC()
|
|
}
|
|
_, err := tx.ExecContext(ctx, tx.Rebind(`INSERT INTO canvas_install_receipts (preparation_id, user_id, canvas_id, workspace_id, package_id, package_version, package_digest, source_id, repository_url, origin_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), receipt.PreparationID, receipt.UserID, receipt.CanvasID, receipt.WorkspaceID, receipt.PackageID, receipt.Version, receipt.Digest, receipt.SourceID, receipt.RepositoryURL, receipt.OriginKind, createdAt.Format(time.RFC3339Nano))
|
|
return err
|
|
}
|
|
|
|
func (r *Repository) CreateInstallReceipt(ctx context.Context, receipt InstallReceipt) error {
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if err := r.CreateInstallReceiptTx(ctx, tx, receipt); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (r *Repository) DeleteInstallReceiptTx(ctx context.Context, tx *sqlx.Tx, canvasID string) error {
|
|
if strings.TrimSpace(canvasID) == "" {
|
|
return ErrInvalidCanvas
|
|
}
|
|
_, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM canvas_install_receipts WHERE canvas_id = ?`), canvasID)
|
|
return err
|
|
}
|
|
|
|
// DeleteInstallReceipt removes the durable retry receipt for a canvas after
|
|
// the canvas authority and plugin instance have been removed.
|
|
func (r *Repository) DeleteInstallReceipt(ctx context.Context, canvasID string) error {
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if err := r.DeleteInstallReceiptTx(ctx, tx, canvasID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// NewRepository constructs the canvas metadata repository on an existing
|
|
// application database pool. Plugin instance schema initialization remains
|
|
// owned by internal/plugins/instances.
|
|
func NewRepository(pool *db.Pool) (*Repository, error) {
|
|
if pool == nil || pool.Writer() == nil || pool.Reader() == nil {
|
|
return nil, errors.New("canvas: database pool is required")
|
|
}
|
|
return NewRepositoryWithDB(pool.Writer(), pool.Reader())
|
|
}
|
|
|
|
// NewRepositoryWithDB is useful for tests and for callers that already own
|
|
// separate read and write sqlx handles.
|
|
func NewRepositoryWithDB(writer, reader *sqlx.DB) (*Repository, error) {
|
|
if writer == nil || reader == nil {
|
|
return nil, errors.New("canvas: database connections are required")
|
|
}
|
|
repo := &Repository{db: writer, ro: reader}
|
|
if err := repo.initSchema(); err != nil {
|
|
return nil, fmt.Errorf("canvas schema: %w", err)
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
// NewStore is a naming-compatible convenience for callers that use Store for
|
|
// database-backed domain repositories.
|
|
func NewStore(pool *db.Pool) (*Repository, error) {
|
|
return NewRepository(pool)
|
|
}
|
|
|
|
func (r *Repository) initSchema() error {
|
|
for _, statement := range strings.Split(SchemaSQL, ";") {
|
|
if strings.TrimSpace(statement) == "" {
|
|
continue
|
|
}
|
|
if _, err := r.db.Exec(statement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Create persists metadata after the plugin instance admission has succeeded.
|
|
func (r *Repository) Create(ctx context.Context, metadata CanvasMetadata) error {
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if err := r.CreateTx(ctx, tx, metadata); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// CreateTx persists metadata in an existing lifecycle transaction. Canvas
|
|
// creation uses this with the plugin-instance insert so a crash cannot leave
|
|
// quota-consuming instance authority without its one-to-one canvas row.
|
|
func (r *Repository) CreateTx(ctx context.Context, tx *sqlx.Tx, metadata CanvasMetadata) error {
|
|
if err := validateMetadata(metadata); err != nil {
|
|
return err
|
|
}
|
|
createdAt := metadata.CreatedAt.UTC()
|
|
if metadata.CreatedAt.IsZero() {
|
|
createdAt = time.Now().UTC()
|
|
}
|
|
updatedAt := metadata.UpdatedAt.UTC()
|
|
if metadata.UpdatedAt.IsZero() {
|
|
updatedAt = createdAt
|
|
}
|
|
// The singleton update serializes admission across service instances and
|
|
// processes on PostgreSQL, and obtains SQLite's write lock before counts.
|
|
if _, err := tx.ExecContext(ctx, tx.Rebind(
|
|
`UPDATE canvas_lifecycle_admission SET version = version WHERE id = 1`)); err != nil {
|
|
return err
|
|
}
|
|
if err := checkAdmission(ctx, tx, metadata); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.ExecContext(ctx, tx.Rebind(`
|
|
INSERT INTO canvas_lifecycle_metadata
|
|
(id, plugin_instance_id, workspace_id, task_id, origin_task_id, title,
|
|
created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
metadata.ID,
|
|
metadata.PluginInstanceID,
|
|
metadata.WorkspaceID,
|
|
metadata.TaskID,
|
|
metadata.OriginTaskID,
|
|
metadata.Title,
|
|
metadata.CreatedBySessionID,
|
|
metadata.PromotedByUserID,
|
|
formatOptionalTime(metadata.PromotedAt),
|
|
createdAt.Format(time.RFC3339Nano),
|
|
updatedAt.Format(time.RFC3339Nano),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if metadata.CreationOwnerUserID != "" {
|
|
if metadata.TaskID == "" || metadata.CreatedBySessionID == "" {
|
|
return ErrInvalidCanvas
|
|
}
|
|
if _, err := tx.ExecContext(ctx, tx.Rebind(
|
|
`INSERT INTO canvas_creation_authority (canvas_id, owner_user_id, creating_session_id, policy_version, consumed_at) VALUES (?, ?, ?, ?, '')`,
|
|
), metadata.ID, metadata.CreationOwnerUserID, metadata.CreatedBySessionID, CreationAuthorityPolicyVersion); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetCreationAuthority returns the recorded first-publication authority. A
|
|
// missing row is expected for legacy drafts and imported instances.
|
|
func (r *Repository) GetCreationAuthority(ctx context.Context, canvasID string) (CreationAuthority, error) {
|
|
var row struct {
|
|
CanvasID string `db:"canvas_id"`
|
|
OwnerUserID string `db:"owner_user_id"`
|
|
CreatingSessionID string `db:"creating_session_id"`
|
|
TaskID string `db:"task_id"`
|
|
PolicyVersion int `db:"policy_version"`
|
|
ConsumedAt string `db:"consumed_at"`
|
|
}
|
|
err := r.ro.GetContext(ctx, &row, r.ro.Rebind(
|
|
`SELECT a.canvas_id, a.owner_user_id, a.creating_session_id, m.task_id, a.policy_version, a.consumed_at
|
|
FROM canvas_creation_authority a
|
|
JOIN canvas_lifecycle_metadata m ON m.id = a.canvas_id
|
|
WHERE a.canvas_id = ?`,
|
|
), canvasID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return CreationAuthority{}, ErrCreationAuthorityNotFound
|
|
}
|
|
if err != nil {
|
|
return CreationAuthority{}, err
|
|
}
|
|
consumedAt, err := parseOptionalTime(row.ConsumedAt)
|
|
if err != nil {
|
|
return CreationAuthority{}, fmt.Errorf("canvas: parse creation authority consumed_at: %w", err)
|
|
}
|
|
result := CreationAuthority{
|
|
CanvasID: row.CanvasID, OwnerUserID: row.OwnerUserID,
|
|
CreatingSessionID: row.CreatingSessionID, TaskID: row.TaskID,
|
|
PolicyVersion: row.PolicyVersion,
|
|
}
|
|
if consumedAt != nil {
|
|
result.ConsumedAt = *consumedAt
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ConsumeCreationAuthorityTx atomically consumes the single-use authority
|
|
// while rechecking its owner, session, task, policy, task scope, and current
|
|
// workspace ownership.
|
|
func (r *Repository) ConsumeCreationAuthorityTx(ctx context.Context, tx *sqlx.Tx, authority CreationAuthority, ownerUserID, sessionID, taskID string, allowUnownedWorkspace bool) error {
|
|
if authority.CanvasID == "" || !creationAuthorityPolicyVersionSupported(authority.PolicyVersion) || ownerUserID == "" || sessionID == "" || taskID == "" {
|
|
return ErrStaleCanvasPublish
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
result, err := tx.ExecContext(ctx, tx.Rebind(
|
|
`UPDATE canvas_creation_authority
|
|
SET consumed_at = ?
|
|
WHERE canvas_creation_authority.canvas_id = ? AND owner_user_id = ? AND creating_session_id = ?
|
|
AND policy_version = ? AND consumed_at = ''
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM canvas_lifecycle_metadata m
|
|
JOIN workspaces w ON w.id = m.workspace_id
|
|
WHERE m.id = canvas_creation_authority.canvas_id
|
|
AND m.task_id = ?
|
|
AND (w.owner_id = ? OR (? AND COALESCE(w.owner_id, '') = ''))
|
|
)`,
|
|
), now, authority.CanvasID, ownerUserID, sessionID, authority.PolicyVersion, taskID, ownerUserID, allowUnownedWorkspace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, _ := result.RowsAffected(); affected == 0 {
|
|
return ErrStaleCanvasPublish
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkAdmission(ctx context.Context, tx *sqlx.Tx, metadata CanvasMetadata) error {
|
|
var count int
|
|
if metadata.TaskID != "" {
|
|
if err := tx.GetContext(ctx, &count, tx.Rebind(
|
|
`SELECT COUNT(*) FROM canvas_lifecycle_metadata WHERE task_id = ?`), metadata.TaskID); err != nil {
|
|
return err
|
|
}
|
|
if count >= MaxTaskCanvases {
|
|
return ErrTaskCanvasLimit
|
|
}
|
|
}
|
|
if err := tx.GetContext(ctx, &count, tx.Rebind(
|
|
`SELECT COUNT(*) FROM canvas_lifecycle_metadata WHERE workspace_id = ?`), metadata.WorkspaceID); err != nil {
|
|
return err
|
|
}
|
|
if count >= MaxWorkspaceCanvases {
|
|
return ErrWorkspaceCanvasLimit
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Get returns one canvas-owned metadata record.
|
|
func (r *Repository) Get(ctx context.Context, id string) (CanvasMetadata, error) {
|
|
if strings.TrimSpace(id) == "" {
|
|
return CanvasMetadata{}, ErrCanvasNotFound
|
|
}
|
|
var row metadataRow
|
|
err := r.ro.GetContext(ctx, &row, r.ro.Rebind(`
|
|
SELECT id, plugin_instance_id, workspace_id, task_id, origin_task_id, title,
|
|
created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at
|
|
FROM canvas_lifecycle_metadata WHERE id = ?`), id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return CanvasMetadata{}, ErrCanvasNotFound
|
|
}
|
|
if err != nil {
|
|
return CanvasMetadata{}, err
|
|
}
|
|
return row.metadata()
|
|
}
|
|
|
|
// ListByWorkspace returns metadata in stable creation order. Lifecycle status
|
|
// filtering is performed by Service because status belongs to the plugin
|
|
// instance store.
|
|
func (r *Repository) ListByWorkspace(ctx context.Context, workspaceID string) ([]CanvasMetadata, error) {
|
|
if strings.TrimSpace(workspaceID) == "" {
|
|
return nil, ErrInvalidCanvas
|
|
}
|
|
return r.list(ctx, `
|
|
SELECT id, plugin_instance_id, workspace_id, task_id, origin_task_id, title,
|
|
created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at
|
|
FROM canvas_lifecycle_metadata WHERE workspace_id = ? ORDER BY created_at, id`, workspaceID)
|
|
}
|
|
|
|
// ListByTask returns unpromoted task metadata. Promotion clears task_id, so a
|
|
// promoted canvas is naturally preserved by task cleanup.
|
|
func (r *Repository) ListByTask(ctx context.Context, taskID string) ([]CanvasMetadata, error) {
|
|
if strings.TrimSpace(taskID) == "" {
|
|
return nil, ErrInvalidCanvas
|
|
}
|
|
return r.list(ctx, `
|
|
SELECT id, plugin_instance_id, workspace_id, task_id, origin_task_id, title,
|
|
created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at
|
|
FROM canvas_lifecycle_metadata WHERE task_id = ? ORDER BY created_at, id`, taskID)
|
|
}
|
|
|
|
// ListAll returns every canvas metadata row for startup lifecycle
|
|
// reconciliation. Status and release authority remain owned by the instance
|
|
// store.
|
|
func (r *Repository) ListAll(ctx context.Context) ([]CanvasMetadata, error) {
|
|
return r.list(ctx, `
|
|
SELECT id, plugin_instance_id, workspace_id, task_id, origin_task_id, title,
|
|
created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at
|
|
FROM canvas_lifecycle_metadata ORDER BY created_at, id`)
|
|
}
|
|
|
|
// ClearOriginTask removes provenance that points at a task after that task
|
|
// has been deleted. Promoted canvases have no current task_id and remain in
|
|
// the workspace.
|
|
func (r *Repository) ClearOriginTask(ctx context.Context, taskID string) error {
|
|
if strings.TrimSpace(taskID) == "" {
|
|
return ErrInvalidCanvas
|
|
}
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(
|
|
`UPDATE canvas_lifecycle_metadata SET origin_task_id = '', updated_at = ? WHERE origin_task_id = ? AND task_id = ''`),
|
|
time.Now().UTC().Format(time.RFC3339Nano), taskID)
|
|
return err
|
|
}
|
|
|
|
// Delete removes only the canvas-owned metadata. Service.Remove deletes the
|
|
// plugin instance first so its artifact cleanup inventory is durable before
|
|
// this authority record disappears.
|
|
func (r *Repository) Delete(ctx context.Context, id string) error {
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if err := r.DeleteTx(ctx, tx, id); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// DeleteTx removes metadata in an existing lifecycle transaction.
|
|
func (r *Repository) DeleteTx(ctx context.Context, tx *sqlx.Tx, id string) error {
|
|
if _, err := tx.ExecContext(ctx, tx.Rebind(
|
|
`DELETE FROM canvas_creation_authority WHERE canvas_id = ?`), id); err != nil {
|
|
return err
|
|
}
|
|
result, err := tx.ExecContext(ctx, tx.Rebind(
|
|
`DELETE FROM canvas_lifecycle_metadata WHERE id = ?`), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrCanvasNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Rename changes only the mutable canvas title. A removed row cannot be recreated.
|
|
func (r *Repository) Rename(ctx context.Context, id, title string, updatedAt time.Time) error {
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`UPDATE canvas_lifecycle_metadata SET title = ?, updated_at = ? WHERE id = ?`), title, updatedAt.UTC().Format(time.RFC3339Nano), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrCanvasNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Promote records the human-owned provenance and clears the current task
|
|
// relationship. The instance scope/grants are changed by the plugin
|
|
// instance store immediately before this metadata transaction.
|
|
func (r *Repository) Promote(ctx context.Context, id, userID string, promotedAt time.Time) error {
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if err := r.PromoteTx(ctx, tx, id, userID, promotedAt); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// PromoteTx records workspace provenance in an existing lifecycle
|
|
// transaction. The canvas service uses this with the instance scope and grant
|
|
// mutation so promotion commits both authorities together.
|
|
func (r *Repository) PromoteTx(ctx context.Context, tx *sqlx.Tx, id, userID string, promotedAt time.Time) error {
|
|
if strings.TrimSpace(id) == "" || strings.TrimSpace(userID) == "" {
|
|
return ErrInvalidCanvas
|
|
}
|
|
if promotedAt.IsZero() {
|
|
promotedAt = time.Now().UTC()
|
|
}
|
|
result, err := tx.ExecContext(ctx, tx.Rebind(
|
|
`UPDATE canvas_lifecycle_metadata SET task_id = '', promoted_by_user_id = ?, promoted_at = ?, updated_at = ? WHERE id = ? AND task_id <> ''`,
|
|
), userID, promotedAt.UTC().Format(time.RFC3339Nano), promotedAt.UTC().Format(time.RFC3339Nano), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, _ := result.RowsAffected(); affected == 0 {
|
|
return ErrInvalidCanvas
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) list(ctx context.Context, query string, args ...any) ([]CanvasMetadata, error) {
|
|
rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(query), args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
metadata := make([]CanvasMetadata, 0)
|
|
for rows.Next() {
|
|
var row metadataRow
|
|
if err := rows.StructScan(&row); err != nil {
|
|
return nil, err
|
|
}
|
|
item, err := row.metadata()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
metadata = append(metadata, item)
|
|
}
|
|
return metadata, rows.Err()
|
|
}
|
|
|
|
func validateMetadata(metadata CanvasMetadata) error {
|
|
if strings.TrimSpace(metadata.ID) == "" || strings.TrimSpace(metadata.PluginInstanceID) == "" || strings.TrimSpace(metadata.WorkspaceID) == "" || strings.TrimSpace(metadata.Title) == "" {
|
|
return ErrInvalidCanvas
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type metadataRow struct {
|
|
ID string `db:"id"`
|
|
PluginInstanceID string `db:"plugin_instance_id"`
|
|
WorkspaceID string `db:"workspace_id"`
|
|
TaskID string `db:"task_id"`
|
|
OriginTaskID string `db:"origin_task_id"`
|
|
Title string `db:"title"`
|
|
CreatedBySessionID string `db:"created_by_session_id"`
|
|
PromotedByUserID string `db:"promoted_by_user_id"`
|
|
PromotedAt string `db:"promoted_at"`
|
|
CreatedAt string `db:"created_at"`
|
|
UpdatedAt string `db:"updated_at"`
|
|
}
|
|
|
|
func (r metadataRow) metadata() (CanvasMetadata, error) {
|
|
createdAt, err := time.Parse(time.RFC3339Nano, r.CreatedAt)
|
|
if err != nil {
|
|
return CanvasMetadata{}, fmt.Errorf("canvas: parse created_at: %w", err)
|
|
}
|
|
updatedAt, err := time.Parse(time.RFC3339Nano, r.UpdatedAt)
|
|
if err != nil {
|
|
return CanvasMetadata{}, fmt.Errorf("canvas: parse updated_at: %w", err)
|
|
}
|
|
promotedAt, err := parseOptionalTime(r.PromotedAt)
|
|
if err != nil {
|
|
return CanvasMetadata{}, fmt.Errorf("canvas: parse promoted_at: %w", err)
|
|
}
|
|
return CanvasMetadata{
|
|
ID: r.ID,
|
|
PluginInstanceID: r.PluginInstanceID,
|
|
WorkspaceID: r.WorkspaceID,
|
|
TaskID: r.TaskID,
|
|
OriginTaskID: r.OriginTaskID,
|
|
Title: r.Title,
|
|
CreatedBySessionID: r.CreatedBySessionID,
|
|
PromotedByUserID: r.PromotedByUserID,
|
|
PromotedAt: promotedAt,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func formatOptionalTime(value *time.Time) string {
|
|
if value == nil || value.IsZero() {
|
|
return ""
|
|
}
|
|
return value.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
|
|
func parseOptionalTime(value string) (*time.Time, error) {
|
|
if value == "" {
|
|
return nil, nil
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339Nano, value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &parsed, nil
|
|
}
|