Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
530 lines
18 KiB
Go
530 lines
18 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
|
|
userprojectbiz "github.com/tutti-os/tutti/services/tuttid/biz/userproject"
|
|
)
|
|
|
|
func (s *SQLiteStore) ListUserProjects(ctx context.Context) ([]userprojectbiz.Project, error) {
|
|
if s == nil || s.writeDB == nil {
|
|
return nil, errors.New("workspace database is not initialized")
|
|
}
|
|
|
|
rows, err := s.readDB.QueryContext(ctx, `
|
|
SELECT id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms, pinned_at_unix_ms, sort_order
|
|
FROM user_projects
|
|
ORDER BY sort_order ASC, id ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list user projects: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []userprojectbiz.Project
|
|
for rows.Next() {
|
|
var project userprojectbiz.Project
|
|
if err := rows.Scan(
|
|
&project.ID,
|
|
&project.Path,
|
|
&project.Label,
|
|
&project.CreatedAtUnixMS,
|
|
&project.UpdatedAtUnixMS,
|
|
&project.LastUsedAtUnixMS,
|
|
&project.PinnedAtUnixMS,
|
|
&project.SortOrder,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan user project: %w", err)
|
|
}
|
|
project.SectionKey = userprojectbiz.SectionKeyFromPath(project.Path)
|
|
result = append(result, project)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate user projects: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) DeleteUserProject(ctx context.Context, id string) error {
|
|
if s == nil || s.writeDB == nil {
|
|
return errors.New("workspace database is not initialized")
|
|
}
|
|
tx, err := s.writeDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin delete user project: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
_, err = tx.ExecContext(ctx, `
|
|
DELETE FROM user_projects
|
|
WHERE id = ?
|
|
`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete user project: %w", err)
|
|
}
|
|
if err := compactUserProjectOrder(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit delete user project: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteUserProjectByPath removes a user project by its stored path rather
|
|
// than by a recomputed id. The path column remains the durable lookup key, with
|
|
// a platform-aware identity fallback so Windows slash/case variants resolve
|
|
// to the same stored row.
|
|
func (s *SQLiteStore) DeleteUserProjectByPath(ctx context.Context, path string) error {
|
|
if s == nil || s.writeDB == nil {
|
|
return errors.New("workspace database is not initialized")
|
|
}
|
|
tx, err := s.writeDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin delete user project by path: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
storedPath, found, err := findUserProjectPathTx(ctx, tx, path)
|
|
if err != nil {
|
|
return fmt.Errorf("find user project by path before delete: %w", err)
|
|
}
|
|
if !found {
|
|
if err := compactUserProjectOrder(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit delete user project by path: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
_, err = tx.ExecContext(ctx, `
|
|
DELETE FROM user_projects
|
|
WHERE path = ?
|
|
`, storedPath)
|
|
if err != nil {
|
|
return fmt.Errorf("delete user project by path: %w", err)
|
|
}
|
|
if err := compactUserProjectOrder(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit delete user project by path: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TryFinalizeUserProjectRemovalByPath atomically checks whether any live,
|
|
// unpinned root sessions still belong to the project and, only when none do,
|
|
// rehomes every remaining project session row to Chats before deleting the
|
|
// project metadata. Remaining rows are pinned live trees or recoverable
|
|
// tombstones; rehoming tombstones also makes a later restore land in Chats.
|
|
//
|
|
// The check and mutation share the writer transaction. Session creation also
|
|
// classifies rail membership through this database, so callers can delete the
|
|
// returned roots through Agent Host and retry without a check/delete race.
|
|
func (s *SQLiteStore) TryFinalizeUserProjectRemovalByPath(ctx context.Context, path string) (UserProjectRemovalPlan, error) {
|
|
if s == nil || s.writeDB == nil {
|
|
return UserProjectRemovalPlan{}, errors.New("workspace database is not initialized")
|
|
}
|
|
tx, err := s.writeDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("begin finalize user project removal: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
storedPath, found, err := findUserProjectPathTx(ctx, tx, path)
|
|
if err != nil {
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("find user project before finalize removal: %w", err)
|
|
}
|
|
if !found {
|
|
return UserProjectRemovalPlan{Finalized: true}, nil
|
|
}
|
|
sectionKey := agentactivitybiz.RailSectionKeyForProject(storedPath)
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT workspace_id, agent_session_id
|
|
FROM workspace_agent_sessions
|
|
WHERE session_kind = 'root'
|
|
AND rail_section_key = ?
|
|
AND pinned_at_unix_ms = 0
|
|
AND deleted_at_unix_ms = 0
|
|
ORDER BY workspace_id ASC, agent_session_id ASC
|
|
`, sectionKey)
|
|
if err != nil {
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("list unpinned project sessions before removal: %w", err)
|
|
}
|
|
pending := make(map[string][]string)
|
|
for rows.Next() {
|
|
var workspaceID, sessionID string
|
|
if err := rows.Scan(&workspaceID, &sessionID); err != nil {
|
|
_ = rows.Close()
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("scan unpinned project session before removal: %w", err)
|
|
}
|
|
workspaceID = strings.TrimSpace(workspaceID)
|
|
sessionID = strings.TrimSpace(sessionID)
|
|
if workspaceID != "" && sessionID != "" {
|
|
pending[workspaceID] = append(pending[workspaceID], sessionID)
|
|
}
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("close unpinned project sessions before removal: %w", err)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("iterate unpinned project sessions before removal: %w", err)
|
|
}
|
|
if len(pending) > 0 {
|
|
return UserProjectRemovalPlan{SessionIDsByWorkspace: pending}, nil
|
|
}
|
|
|
|
rehomed, err := tx.ExecContext(ctx, `
|
|
UPDATE workspace_agent_sessions
|
|
SET rail_section_kind = ?, rail_project_path = '', rail_section_key = ?
|
|
WHERE rail_section_key = ?
|
|
`, agentactivitybiz.RailSectionKindConversations, agentactivitybiz.RailSectionKeyConversations, sectionKey)
|
|
if err != nil {
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("rehome retained project sessions to Chats: %w", err)
|
|
}
|
|
rehomedCount, err := rehomed.RowsAffected()
|
|
if err != nil {
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("count rehomed project sessions: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM user_projects WHERE path = ?`, storedPath); err != nil {
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("delete finalized user project by path: %w", err)
|
|
}
|
|
if err := compactUserProjectOrder(ctx, tx); err != nil {
|
|
return UserProjectRemovalPlan{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return UserProjectRemovalPlan{}, fmt.Errorf("commit finalized user project removal: %w", err)
|
|
}
|
|
return UserProjectRemovalPlan{Finalized: true, RehomedSessions: int(rehomedCount)}, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) PutUserProject(ctx context.Context, project userprojectbiz.Project) (userprojectbiz.Project, error) {
|
|
if s == nil || s.writeDB == nil {
|
|
return userprojectbiz.Project{}, errors.New("workspace database is not initialized")
|
|
}
|
|
|
|
now := unixMs(time.Now().UTC())
|
|
if project.CreatedAtUnixMS <= 0 {
|
|
project.CreatedAtUnixMS = now
|
|
}
|
|
if project.LastUsedAtUnixMS <= 0 {
|
|
project.LastUsedAtUnixMS = now
|
|
}
|
|
project.UpdatedAtUnixMS = project.LastUsedAtUnixMS
|
|
tx, err := s.writeDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return userprojectbiz.Project{}, fmt.Errorf("begin put user project: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
existingPath, found, err := findUserProjectPathTx(ctx, tx, project.Path)
|
|
if err != nil {
|
|
return userprojectbiz.Project{}, fmt.Errorf("find user project before put: %w", err)
|
|
}
|
|
if found {
|
|
// Preserve the first stored spelling while treating Windows path
|
|
// variants as one project identity. The UNIQUE(path) constraint then
|
|
// remains the final write guard without a schema change.
|
|
project.Path = existingPath
|
|
}
|
|
if !found {
|
|
var firstUnpinnedOrder int
|
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_projects WHERE pinned_at_unix_ms > 0`).Scan(&firstUnpinnedOrder); err != nil {
|
|
return userprojectbiz.Project{}, fmt.Errorf("find first unpinned user project order: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE user_projects SET sort_order = sort_order + 1 WHERE sort_order >= ?`, firstUnpinnedOrder); err != nil {
|
|
return userprojectbiz.Project{}, fmt.Errorf("shift user projects before insert: %w", err)
|
|
}
|
|
project.SortOrder = firstUnpinnedOrder
|
|
project.PinnedAtUnixMS = 0
|
|
}
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO user_projects (
|
|
id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms, pinned_at_unix_ms, sort_order
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(path) DO UPDATE SET
|
|
label = excluded.label,
|
|
updated_at_unix_ms = excluded.updated_at_unix_ms,
|
|
last_used_at_unix_ms = excluded.last_used_at_unix_ms
|
|
`, project.ID, project.Path, project.Label, project.CreatedAtUnixMS, project.UpdatedAtUnixMS, project.LastUsedAtUnixMS, project.PinnedAtUnixMS, project.SortOrder)
|
|
if err != nil {
|
|
return userprojectbiz.Project{}, fmt.Errorf("put user project: %w", err)
|
|
}
|
|
|
|
row := tx.QueryRowContext(ctx, `
|
|
SELECT id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms, pinned_at_unix_ms, sort_order
|
|
FROM user_projects
|
|
WHERE path = ?
|
|
`, project.Path)
|
|
var stored userprojectbiz.Project
|
|
if err := row.Scan(
|
|
&stored.ID,
|
|
&stored.Path,
|
|
&stored.Label,
|
|
&stored.CreatedAtUnixMS,
|
|
&stored.UpdatedAtUnixMS,
|
|
&stored.LastUsedAtUnixMS,
|
|
&stored.PinnedAtUnixMS,
|
|
&stored.SortOrder,
|
|
); err != nil {
|
|
return userprojectbiz.Project{}, fmt.Errorf("get user project after put: %w", err)
|
|
}
|
|
stored.SectionKey = userprojectbiz.SectionKeyFromPath(stored.Path)
|
|
if _, err := s.agentStore().RepairImportedProjectRailSectionsTx(ctx, tx, stored.Path); err != nil {
|
|
return userprojectbiz.Project{}, fmt.Errorf("repair imported project rail after put: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return userprojectbiz.Project{}, fmt.Errorf("commit put user project: %w", err)
|
|
}
|
|
return stored, nil
|
|
}
|
|
|
|
// findUserProjectPathTx first uses the UNIQUE column directly, then applies
|
|
// the platform path identity rules for a Windows slash/case variant. The
|
|
// fallback scan is intentionally limited to project registration/deletion,
|
|
// which are low-frequency mutations; rail reads keep their indexed section
|
|
// key queries.
|
|
func findUserProjectPathTx(ctx context.Context, tx *sql.Tx, path string) (string, bool, error) {
|
|
row := tx.QueryRowContext(ctx, `SELECT path FROM user_projects WHERE path = ?`, path)
|
|
var storedPath string
|
|
if err := row.Scan(&storedPath); err == nil {
|
|
return storedPath, true, nil
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
return "", false, fmt.Errorf("find exact user project path: %w", err)
|
|
}
|
|
|
|
rows, err := tx.QueryContext(ctx, `SELECT path FROM user_projects`)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("list user project paths for identity match: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
if err := rows.Scan(&storedPath); err != nil {
|
|
return "", false, fmt.Errorf("scan user project path for identity match: %w", err)
|
|
}
|
|
if agentactivitybiz.AreProjectPathsEqual(path, storedPath) {
|
|
return storedPath, true, nil
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return "", false, fmt.Errorf("iterate user project paths for identity match: %w", err)
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) TouchUserProject(ctx context.Context, id string, atUnixMS int64) error {
|
|
if s == nil || s.writeDB == nil {
|
|
return errors.New("workspace database is not initialized")
|
|
}
|
|
if atUnixMS <= 0 {
|
|
atUnixMS = unixMs(time.Now().UTC())
|
|
}
|
|
_, err := s.writeDB.ExecContext(ctx, `
|
|
UPDATE user_projects
|
|
SET last_used_at_unix_ms = ?, updated_at_unix_ms = ?
|
|
WHERE id = ?
|
|
`, atUnixMS, atUnixMS, id)
|
|
if err != nil {
|
|
return fmt.Errorf("touch user project: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) MoveUserProject(ctx context.Context, projectID string, beforeProjectID *string) ([]userprojectbiz.Project, error) {
|
|
if s == nil || s.writeDB == nil {
|
|
return nil, errors.New("workspace database is not initialized")
|
|
}
|
|
projectID = strings.TrimSpace(projectID)
|
|
if projectID == "" {
|
|
return nil, ErrUserProjectNotFound
|
|
}
|
|
var beforeID *string
|
|
if beforeProjectID != nil {
|
|
normalized := strings.TrimSpace(*beforeProjectID)
|
|
if normalized == "" {
|
|
return nil, ErrUserProjectNotFound
|
|
}
|
|
beforeID = &normalized
|
|
}
|
|
|
|
tx, err := s.writeDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin move user project: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
projects, err := listUserProjectsTx(ctx, tx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fromIndex := -1
|
|
beforeIndex := -1
|
|
for index, project := range projects {
|
|
if project.ID == projectID {
|
|
fromIndex = index
|
|
}
|
|
if beforeID != nil && project.ID == *beforeID {
|
|
beforeIndex = index
|
|
}
|
|
}
|
|
if fromIndex < 0 || (beforeID != nil && beforeIndex < 0) {
|
|
return nil, ErrUserProjectNotFound
|
|
}
|
|
if beforeID != nil && (projects[fromIndex].PinnedAtUnixMS > 0) != (projects[beforeIndex].PinnedAtUnixMS > 0) {
|
|
return nil, ErrUserProjectPartitionMismatch
|
|
}
|
|
if beforeID == nil || *beforeID != projectID {
|
|
moving := projects[fromIndex]
|
|
projects = append(projects[:fromIndex], projects[fromIndex+1:]...)
|
|
insertIndex := len(projects)
|
|
if beforeID != nil {
|
|
for index, project := range projects {
|
|
if project.ID == *beforeID {
|
|
insertIndex = index
|
|
break
|
|
}
|
|
}
|
|
} else if moving.PinnedAtUnixMS > 0 {
|
|
insertIndex = 0
|
|
for insertIndex < len(projects) && projects[insertIndex].PinnedAtUnixMS > 0 {
|
|
insertIndex++
|
|
}
|
|
}
|
|
projects = append(projects, userprojectbiz.Project{})
|
|
copy(projects[insertIndex+1:], projects[insertIndex:])
|
|
projects[insertIndex] = moving
|
|
}
|
|
if err := rewriteUserProjectOrder(ctx, tx, projects); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit move user project: %w", err)
|
|
}
|
|
return projects, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) PinUserProject(ctx context.Context, projectID string, pinned bool) ([]userprojectbiz.Project, bool, error) {
|
|
if s == nil || s.writeDB == nil {
|
|
return nil, false, errors.New("workspace database is not initialized")
|
|
}
|
|
projectID = strings.TrimSpace(projectID)
|
|
if projectID == "" {
|
|
return nil, false, ErrUserProjectNotFound
|
|
}
|
|
|
|
tx, err := s.writeDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("begin pin user project: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
projects, err := listUserProjectsTx(ctx, tx)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
fromIndex := -1
|
|
for index := range projects {
|
|
if projects[index].ID == projectID {
|
|
fromIndex = index
|
|
break
|
|
}
|
|
}
|
|
if fromIndex < 0 {
|
|
return nil, false, ErrUserProjectNotFound
|
|
}
|
|
if (projects[fromIndex].PinnedAtUnixMS > 0) == pinned {
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, false, fmt.Errorf("commit idempotent pin user project: %w", err)
|
|
}
|
|
return projects, false, nil
|
|
}
|
|
|
|
now := unixMs(time.Now().UTC())
|
|
pinnedAtUnixMS := int64(0)
|
|
if pinned {
|
|
pinnedAtUnixMS = now
|
|
}
|
|
moving := projects[fromIndex]
|
|
moving.PinnedAtUnixMS = pinnedAtUnixMS
|
|
moving.UpdatedAtUnixMS = now
|
|
projects = append(projects[:fromIndex], projects[fromIndex+1:]...)
|
|
insertIndex := 0
|
|
if !pinned {
|
|
for insertIndex < len(projects) && projects[insertIndex].PinnedAtUnixMS > 0 {
|
|
insertIndex++
|
|
}
|
|
}
|
|
projects = append(projects, userprojectbiz.Project{})
|
|
copy(projects[insertIndex+1:], projects[insertIndex:])
|
|
projects[insertIndex] = moving
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE user_projects
|
|
SET pinned_at_unix_ms = ?, updated_at_unix_ms = ?
|
|
WHERE id = ?
|
|
`, pinnedAtUnixMS, now, projectID); err != nil {
|
|
return nil, false, fmt.Errorf("update user project pinned state: %w", err)
|
|
}
|
|
if err := rewriteUserProjectOrder(ctx, tx, projects); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, false, fmt.Errorf("commit pin user project: %w", err)
|
|
}
|
|
return projects, true, nil
|
|
}
|
|
|
|
func rewriteUserProjectOrder(ctx context.Context, tx *sql.Tx, projects []userprojectbiz.Project) error {
|
|
for index := range projects {
|
|
projects[index].SortOrder = index
|
|
if _, err := tx.ExecContext(ctx, `UPDATE user_projects SET sort_order = ? WHERE id = ?`, index, projects[index].ID); err != nil {
|
|
return fmt.Errorf("rewrite user project order: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func compactUserProjectOrder(ctx context.Context, tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
WITH ordered AS (
|
|
SELECT id, ROW_NUMBER() OVER (ORDER BY sort_order ASC, id ASC) - 1 AS next_sort_order
|
|
FROM user_projects
|
|
)
|
|
UPDATE user_projects
|
|
SET sort_order = (SELECT next_sort_order FROM ordered WHERE ordered.id = user_projects.id)
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("compact user project order: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func listUserProjectsTx(ctx context.Context, tx *sql.Tx) ([]userprojectbiz.Project, error) {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms, pinned_at_unix_ms, sort_order
|
|
FROM user_projects
|
|
ORDER BY sort_order ASC, id ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list user projects in transaction: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
projects := make([]userprojectbiz.Project, 0)
|
|
for rows.Next() {
|
|
var project userprojectbiz.Project
|
|
if err := rows.Scan(&project.ID, &project.Path, &project.Label, &project.CreatedAtUnixMS, &project.UpdatedAtUnixMS, &project.LastUsedAtUnixMS, &project.PinnedAtUnixMS, &project.SortOrder); err != nil {
|
|
return nil, fmt.Errorf("scan user project in transaction: %w", err)
|
|
}
|
|
project.SectionKey = userprojectbiz.SectionKeyFromPath(project.Path)
|
|
projects = append(projects, project)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate user projects in transaction: %w", err)
|
|
}
|
|
return projects, nil
|
|
}
|