feat(backend): add projects and pr_enrichment tables to SQLite store

Migration 0002 adds two tables off the canonical CDC path:

- projects: durable registry of managed repos (the twin of the old YAML
  config). Soft-deletable via archived_at so a session's project_id always
  resolves; ListProjects returns active rows only, GetProject resolves any.
- pr_enrichment: per-session cache of rich SCM facts (CI summary, review
  decision, mergeability, pending comments, CI log tail) that do not live
  in the canonical lifecycle. 1:1 with a session, cascades on session delete.

Both are written outside the LCM write path: no revision bump, no
change_log/outbox event. Store methods mirror the reaction_trackers adapter
pattern with storage-local row structs.
This commit is contained in:
Pritom14 2026-05-30 21:53:14 +05:30
parent f5bc4c7b8c
commit 23b8fe43cf
10 changed files with 672 additions and 0 deletions

View File

@ -34,6 +34,31 @@ type Outbox struct {
CreatedAt time.Time
}
type PrEnrichment struct {
SessionID string
CiSummary string
ReviewDecision string
Mergeability string
PendingComments string
CiLogTail string
LastFetchedAt time.Time
}
type Project struct {
ID string
Path string
RepoOwner string
RepoName string
RepoPlatform string
RepoOriginUrl string
DefaultBranch string
DisplayName string
SessionPrefix string
Source string
RegisteredAt time.Time
ArchivedAt sql.NullTime
}
type ReactionTracker struct {
SessionID string
ReactionKey string

View File

@ -0,0 +1,76 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: pr_enrichment.sql
package gen
import (
"context"
"time"
)
const deletePREnrichment = `-- name: DeletePREnrichment :exec
DELETE FROM pr_enrichment WHERE session_id = ?
`
func (q *Queries) DeletePREnrichment(ctx context.Context, sessionID string) error {
_, err := q.db.ExecContext(ctx, deletePREnrichment, sessionID)
return err
}
const getPREnrichment = `-- name: GetPREnrichment :one
SELECT session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at
FROM pr_enrichment
WHERE session_id = ?
`
func (q *Queries) GetPREnrichment(ctx context.Context, sessionID string) (PrEnrichment, error) {
row := q.db.QueryRowContext(ctx, getPREnrichment, sessionID)
var i PrEnrichment
err := row.Scan(
&i.SessionID,
&i.CiSummary,
&i.ReviewDecision,
&i.Mergeability,
&i.PendingComments,
&i.CiLogTail,
&i.LastFetchedAt,
)
return i, err
}
const upsertPREnrichment = `-- name: UpsertPREnrichment :exec
INSERT INTO pr_enrichment (session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (session_id) DO UPDATE SET
ci_summary = excluded.ci_summary,
review_decision = excluded.review_decision,
mergeability = excluded.mergeability,
pending_comments = excluded.pending_comments,
ci_log_tail = excluded.ci_log_tail,
last_fetched_at = excluded.last_fetched_at
`
type UpsertPREnrichmentParams struct {
SessionID string
CiSummary string
ReviewDecision string
Mergeability string
PendingComments string
CiLogTail string
LastFetchedAt time.Time
}
func (q *Queries) UpsertPREnrichment(ctx context.Context, arg UpsertPREnrichmentParams) error {
_, err := q.db.ExecContext(ctx, upsertPREnrichment,
arg.SessionID,
arg.CiSummary,
arg.ReviewDecision,
arg.Mergeability,
arg.PendingComments,
arg.CiLogTail,
arg.LastFetchedAt,
)
return err
}

View File

@ -0,0 +1,154 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: projects.sql
package gen
import (
"context"
"database/sql"
"time"
)
const archiveProject = `-- name: ArchiveProject :exec
UPDATE projects SET archived_at = ? WHERE id = ?
`
type ArchiveProjectParams struct {
ArchivedAt sql.NullTime
ID string
}
func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error {
_, err := q.db.ExecContext(ctx, archiveProject, arg.ArchivedAt, arg.ID)
return err
}
const deleteProject = `-- name: DeleteProject :exec
DELETE FROM projects WHERE id = ?
`
func (q *Queries) DeleteProject(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteProject, id)
return err
}
const getProject = `-- name: GetProject :one
SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at
FROM projects
WHERE id = ?
`
func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) {
row := q.db.QueryRowContext(ctx, getProject, id)
var i Project
err := row.Scan(
&i.ID,
&i.Path,
&i.RepoOwner,
&i.RepoName,
&i.RepoPlatform,
&i.RepoOriginUrl,
&i.DefaultBranch,
&i.DisplayName,
&i.SessionPrefix,
&i.Source,
&i.RegisteredAt,
&i.ArchivedAt,
)
return i, err
}
const listProjects = `-- name: ListProjects :many
SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at
FROM projects
WHERE archived_at IS NULL
ORDER BY id
`
func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, listProjects)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.Path,
&i.RepoOwner,
&i.RepoName,
&i.RepoPlatform,
&i.RepoOriginUrl,
&i.DefaultBranch,
&i.DisplayName,
&i.SessionPrefix,
&i.Source,
&i.RegisteredAt,
&i.ArchivedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertProject = `-- name: UpsertProject :exec
INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
path = excluded.path,
repo_owner = excluded.repo_owner,
repo_name = excluded.repo_name,
repo_platform = excluded.repo_platform,
repo_origin_url = excluded.repo_origin_url,
default_branch = excluded.default_branch,
display_name = excluded.display_name,
session_prefix = excluded.session_prefix,
source = excluded.source,
registered_at = excluded.registered_at,
archived_at = excluded.archived_at
`
type UpsertProjectParams struct {
ID string
Path string
RepoOwner string
RepoName string
RepoPlatform string
RepoOriginUrl string
DefaultBranch string
DisplayName string
SessionPrefix string
Source string
RegisteredAt time.Time
ArchivedAt sql.NullTime
}
func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) error {
_, err := q.db.ExecContext(ctx, upsertProject,
arg.ID,
arg.Path,
arg.RepoOwner,
arg.RepoName,
arg.RepoPlatform,
arg.RepoOriginUrl,
arg.DefaultBranch,
arg.DisplayName,
arg.SessionPrefix,
arg.Source,
arg.RegisteredAt,
arg.ArchivedAt,
)
return err
}

View File

@ -9,11 +9,16 @@ import (
)
type Querier interface {
ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error
DeletePREnrichment(ctx context.Context, sessionID string) error
DeleteProject(ctx context.Context, id string) error
DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error
DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error)
DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error
GetConsumerOffset(ctx context.Context, consumer string) (int64, error)
GetMetadata(ctx context.Context, sessionID string) ([]GetMetadataRow, error)
GetPREnrichment(ctx context.Context, sessionID string) (PrEnrichment, error)
GetProject(ctx context.Context, id string) (Project, error)
GetSession(ctx context.Context, id string) (Session, error)
GetSessionRevision(ctx context.Context, id string) (int64, error)
// Appends a canonical-write record and returns its monotonic seq so the same
@ -24,6 +29,7 @@ type Querier interface {
// the row is persisted at revision 1.
InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error)
ListAllSessions(ctx context.Context) ([]Session, error)
ListProjects(ctx context.Context) ([]Project, error)
ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error)
ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error)
ListUnsentOutbox(ctx context.Context, limit int64) ([]ListUnsentOutboxRow, error)
@ -36,6 +42,8 @@ type Querier interface {
UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error)
UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error
UpsertMetadata(ctx context.Context, arg UpsertMetadataParams) error
UpsertPREnrichment(ctx context.Context, arg UpsertPREnrichmentParams) error
UpsertProject(ctx context.Context, arg UpsertProjectParams) error
UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error
}

View File

@ -0,0 +1,50 @@
-- +goose Up
-- +goose StatementBegin
-- projects is the durable registry of repos AO manages, the SQLite twin of the
-- old YAML config (global config.yaml + per-repo agent-orchestrator.yaml). id is
-- the {basename}_{sha256(path:originUrl)[:10]} key the session layer references
-- via sessions.project_id. The relationship is app-enforced, NOT a hard FK:
-- SQLite cannot ALTER ADD a FK without a table rebuild, and an existing-session
-- backfill may land sessions before their project row.
CREATE TABLE projects (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
repo_owner TEXT NOT NULL DEFAULT '',
repo_name TEXT NOT NULL DEFAULT '',
repo_platform TEXT NOT NULL DEFAULT '',
repo_origin_url TEXT NOT NULL DEFAULT '',
default_branch TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
session_prefix TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
registered_at TIMESTAMP NOT NULL,
-- soft delete: NULL = active. Archiving keeps the row so a session's
-- project_id always resolves (there is no FK to enforce it), avoiding
-- dangling references; active-only reads filter archived_at IS NULL.
archived_at TIMESTAMP
);
-- pr_enrichment is the SCM observer's per-session cache of the rich PR facts that
-- do NOT live in the canonical lifecycle (which keeps only pr_state/reason/number/
-- url). It is 1:1 with a session (a PR is always tied to a session by its branch),
-- written by the SCM observer OFF the canonical CDC path (no revision bump, no
-- change_log/outbox event), and cascades away with its session.
CREATE TABLE pr_enrichment (
session_id TEXT PRIMARY KEY REFERENCES sessions (id) ON DELETE CASCADE,
ci_summary TEXT NOT NULL DEFAULT '',
review_decision TEXT NOT NULL DEFAULT '',
mergeability TEXT NOT NULL DEFAULT '',
pending_comments TEXT NOT NULL DEFAULT '',
ci_log_tail TEXT NOT NULL DEFAULT '',
last_fetched_at TIMESTAMP NOT NULL
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE pr_enrichment;
DROP TABLE projects;
-- +goose StatementEnd

View File

@ -0,0 +1,128 @@
package sqlite
import (
"context"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestProjectUpsertGetListDelete(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
if _, ok, err := s.GetProject(ctx, "p1"); err != nil || ok {
t.Fatalf("get missing: ok=%v err=%v", ok, err)
}
p := ProjectRow{
ID: "p1", Path: "/repo", RepoOwner: "acme", RepoName: "widget",
RepoPlatform: "github", RepoOriginURL: "git@github.com:acme/widget.git",
DefaultBranch: "main", DisplayName: "Widget", SessionPrefix: "wid",
Source: "local", RegisteredAt: now,
}
if err := s.UpsertProject(ctx, p); err != nil {
t.Fatalf("upsert: %v", err)
}
got, ok, err := s.GetProject(ctx, "p1")
if err != nil || !ok {
t.Fatalf("get: ok=%v err=%v", ok, err)
}
if got != p {
t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, p)
}
// Upsert again with a changed field updates in place (no duplicate).
p.DisplayName = "Widget 2"
if err := s.UpsertProject(ctx, p); err != nil {
t.Fatalf("re-upsert: %v", err)
}
list, err := s.ListProjects(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 1 || list[0].DisplayName != "Widget 2" {
t.Fatalf("list after re-upsert = %+v", list)
}
if err := s.DeleteProject(ctx, "p1"); err != nil {
t.Fatalf("delete: %v", err)
}
if _, ok, _ := s.GetProject(ctx, "p1"); ok {
t.Fatal("project should be gone after delete")
}
}
func TestArchiveProjectHidesFromListButGetResolves(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
if err := s.UpsertProject(ctx, ProjectRow{ID: "p1", Path: "/repo", RegisteredAt: now}); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := s.ArchiveProject(ctx, "p1", now); err != nil {
t.Fatalf("archive: %v", err)
}
// Active-only list hides it.
list, err := s.ListProjects(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 0 {
t.Fatalf("archived project should not appear in ListProjects, got %+v", list)
}
// Get still resolves it (a session's project_id must not dangle) and reports
// the archived marker.
got, ok, err := s.GetProject(ctx, "p1")
if err != nil || !ok {
t.Fatalf("get archived: ok=%v err=%v", ok, err)
}
if got.ArchivedAt.IsZero() {
t.Fatal("archived project should carry a non-zero ArchivedAt")
}
}
func TestPREnrichmentUpsertGetDelete(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
// pr_enrichment FKs sessions(id); seed the session first.
if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil {
t.Fatalf("seed session: %v", err)
}
if _, ok, err := s.GetPREnrichment(ctx, "s1"); err != nil || ok {
t.Fatalf("get missing: ok=%v err=%v", ok, err)
}
e := PREnrichmentRow{
SessionID: "s1", CISummary: "3 passing, 1 failing", ReviewDecision: "changes_requested",
Mergeability: "blocked", PendingComments: `[{"path":"a.go"}]`, CILogTail: "FAIL TestX",
LastFetchedAt: now,
}
if err := s.UpsertPREnrichment(ctx, e); err != nil {
t.Fatalf("upsert: %v", err)
}
got, ok, err := s.GetPREnrichment(ctx, "s1")
if err != nil || !ok {
t.Fatalf("get: ok=%v err=%v", ok, err)
}
if got != e {
t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, e)
}
if err := s.DeletePREnrichment(ctx, "s1"); err != nil {
t.Fatalf("delete: %v", err)
}
if _, ok, _ := s.GetPREnrichment(ctx, "s1"); ok {
t.Fatal("enrichment should be gone after delete")
}
}

View File

@ -0,0 +1,66 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen"
)
// PREnrichmentRow is the SCM observer's cache of the rich PR facts that do not
// live in the canonical lifecycle (which keeps only pr_state/reason/number/url).
// It is 1:1 with a session and written OFF the canonical CDC path: upserting it
// never bumps revision and never emits a change_log/outbox event. pending_comments
// and ci_log_tail are opaque blobs the SCM observer serializes.
type PREnrichmentRow struct {
SessionID string
CISummary string
ReviewDecision string
Mergeability string
PendingComments string
CILogTail string
LastFetchedAt time.Time
}
// UpsertPREnrichment inserts or replaces the cached PR facts for one session.
func (s *Store) UpsertPREnrichment(ctx context.Context, r PREnrichmentRow) error {
return s.q.UpsertPREnrichment(ctx, gen.UpsertPREnrichmentParams{
SessionID: r.SessionID,
CiSummary: r.CISummary,
ReviewDecision: r.ReviewDecision,
Mergeability: r.Mergeability,
PendingComments: r.PendingComments,
CiLogTail: r.CILogTail,
LastFetchedAt: r.LastFetchedAt,
})
}
// GetPREnrichment returns the cached PR facts for one session. ok is false when
// no row exists (the SCM observer has not yet fetched, or the session has no PR).
func (s *Store) GetPREnrichment(ctx context.Context, sessionID string) (PREnrichmentRow, bool, error) {
e, err := s.q.GetPREnrichment(ctx, sessionID)
if errors.Is(err, sql.ErrNoRows) {
return PREnrichmentRow{}, false, nil
}
if err != nil {
return PREnrichmentRow{}, false, fmt.Errorf("get pr enrichment: %w", err)
}
return PREnrichmentRow{
SessionID: e.SessionID,
CISummary: e.CiSummary,
ReviewDecision: e.ReviewDecision,
Mergeability: e.Mergeability,
PendingComments: e.PendingComments,
CILogTail: e.CiLogTail,
LastFetchedAt: e.LastFetchedAt,
}, true, nil
}
// DeletePREnrichment drops the cached PR facts for one session. Normally
// unnecessary (the FK cascades on session delete), exposed for explicit eviction.
func (s *Store) DeletePREnrichment(ctx context.Context, sessionID string) error {
return s.q.DeletePREnrichment(ctx, sessionID)
}

View File

@ -0,0 +1,115 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen"
)
// ProjectRow is one registered repo, the durable twin of the old YAML config
// entry. It is the unit the registration path upserts and cross-project readers
// list. Off the canonical CDC path: writing a project never emits a change_log
// or outbox event.
type ProjectRow struct {
ID string
Path string
RepoOwner string
RepoName string
RepoPlatform string
RepoOriginURL string
DefaultBranch string
DisplayName string
SessionPrefix string
Source string
RegisteredAt time.Time
// ArchivedAt is the soft-delete marker; zero means active. GetProject returns
// it regardless of state (so a session can resolve its archived project);
// ListProjects returns only rows where it is zero.
ArchivedAt time.Time
}
// UpsertProject inserts or updates one registered project.
func (s *Store) UpsertProject(ctx context.Context, r ProjectRow) error {
return s.q.UpsertProject(ctx, gen.UpsertProjectParams{
ID: r.ID,
Path: r.Path,
RepoOwner: r.RepoOwner,
RepoName: r.RepoName,
RepoPlatform: r.RepoPlatform,
RepoOriginUrl: r.RepoOriginURL,
DefaultBranch: r.DefaultBranch,
DisplayName: r.DisplayName,
SessionPrefix: r.SessionPrefix,
Source: r.Source,
RegisteredAt: r.RegisteredAt,
ArchivedAt: nullTime(r.ArchivedAt),
})
}
// ArchiveProject soft-deletes one project, keeping the row so a session's
// project_id still resolves. Active-only reads (ListProjects) then hide it.
func (s *Store) ArchiveProject(ctx context.Context, id string, t time.Time) error {
return s.q.ArchiveProject(ctx, gen.ArchiveProjectParams{
ArchivedAt: nullTime(t),
ID: id,
})
}
// GetProject returns one project by id. ok is false when no row exists.
func (s *Store) GetProject(ctx context.Context, id string) (ProjectRow, bool, error) {
p, err := s.q.GetProject(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return ProjectRow{}, false, nil
}
if err != nil {
return ProjectRow{}, false, fmt.Errorf("get project: %w", err)
}
return projectRowFromGen(p), true, nil
}
// ListProjects returns every registered project, ordered by id.
func (s *Store) ListProjects(ctx context.Context) ([]ProjectRow, error) {
rows, err := s.q.ListProjects(ctx)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
out := make([]ProjectRow, 0, len(rows))
for _, p := range rows {
out = append(out, projectRowFromGen(p))
}
return out, nil
}
// DeleteProject removes one project by id.
func (s *Store) DeleteProject(ctx context.Context, id string) error {
return s.q.DeleteProject(ctx, id)
}
func projectRowFromGen(p gen.Project) ProjectRow {
return ProjectRow{
ID: p.ID,
Path: p.Path,
RepoOwner: p.RepoOwner,
RepoName: p.RepoName,
RepoPlatform: p.RepoPlatform,
RepoOriginURL: p.RepoOriginUrl,
DefaultBranch: p.DefaultBranch,
DisplayName: p.DisplayName,
SessionPrefix: p.SessionPrefix,
Source: p.Source,
RegisteredAt: p.RegisteredAt,
ArchivedAt: p.ArchivedAt.Time,
}
}
// nullTime maps a zero time.Time to a NULL column, else a valid timestamp.
func nullTime(t time.Time) sql.NullTime {
if t.IsZero() {
return sql.NullTime{}
}
return sql.NullTime{Time: t, Valid: true}
}

View File

@ -0,0 +1,18 @@
-- name: UpsertPREnrichment :exec
INSERT INTO pr_enrichment (session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (session_id) DO UPDATE SET
ci_summary = excluded.ci_summary,
review_decision = excluded.review_decision,
mergeability = excluded.mergeability,
pending_comments = excluded.pending_comments,
ci_log_tail = excluded.ci_log_tail,
last_fetched_at = excluded.last_fetched_at;
-- name: GetPREnrichment :one
SELECT session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at
FROM pr_enrichment
WHERE session_id = ?;
-- name: DeletePREnrichment :exec
DELETE FROM pr_enrichment WHERE session_id = ?;

View File

@ -0,0 +1,32 @@
-- name: UpsertProject :exec
INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
path = excluded.path,
repo_owner = excluded.repo_owner,
repo_name = excluded.repo_name,
repo_platform = excluded.repo_platform,
repo_origin_url = excluded.repo_origin_url,
default_branch = excluded.default_branch,
display_name = excluded.display_name,
session_prefix = excluded.session_prefix,
source = excluded.source,
registered_at = excluded.registered_at,
archived_at = excluded.archived_at;
-- name: GetProject :one
SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at
FROM projects
WHERE id = ?;
-- name: ListProjects :many
SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at
FROM projects
WHERE archived_at IS NULL
ORDER BY id;
-- name: ArchiveProject :exec
UPDATE projects SET archived_at = ? WHERE id = ?;
-- name: DeleteProject :exec
DELETE FROM projects WHERE id = ?;