From 057e8d200a69ad5906c053f1a4f2871ed6997e91 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 31 Aug 2026 12:52:41 -0400 Subject: [PATCH] feat(store): migrate remaining store domains to sqlc + vet in CI + gate identifier-passed SQL (RIG-3034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses the remaining sqlc-adoption slices (T5, T6, T7) into one change atop the merged T4 (#795). Every store domain now uses sqlc-generated typed queries, the inline-sql-gate allowlist is down to its two permanent escape hatches, `sqlc vet` (db-prepare) runs against a live schema in CI, and the gate now catches identifier-passed SQL, not just string literals. Method signatures are unchanged throughout; only bodies now call generated queries via s.q (pool) or s.q.WithTx(tx) / db.New(tx). Error wrapping, domain-type mapping, RowsAffected branching, uint64<->int64 narrowing, flush orchestration, and tx isolation stay hand-written. ## Agent domains (was T5) Five agent-domain files migrated (queries/.sql): - agent_sessions.go → 3 queries - agent_transcripts.go → 13 distinct queries backing 16 call sites (SessionTranscript + SafetyValveSegments each serve a pool method and the resume-snapshot tx; InsertTranscriptEntry is :execrows for ON CONFLICT dedup). - agent_activity.go → 2 queries - agent_config.go → 3 queries - agent_placements.go → 5 queries COALESCE(MAX/SUM(...),0) reads carry an explicit ::BIGINT cast so sqlc types them int64 (mirrors messages.sql MessagesHeadSeq). Bare-column references visible in both outer and MAX-subquery scope are table-aliased (te/cp/e) — pure disambiguation, semantically identical. Removed dead scanTranscriptRows/ scanSegmentRows helpers and the remarkSafetyValveSQL const. ## Remaining domains (was T6) Every remaining domain file migrated, leaving the allowlist at only the two permanent entries (store.go migration runner, pgshare.go test harness): - authz.go: 3 new queries; dead querier interface removed from store.go; helpers take db.DBTX (pool and pgx.Tx both satisfy it). - tokens.go: 4 (RevokeToken :execrows; revoked_at IS NOT NULL cast ::boolean). - secrets.go: 3 (DeleteSecret :execrows). - issues.go: 4 (UpsertIssueForgeFields CTE, SetIssueState :execrows); scanIssue/ scanRow → issueFromColumns. - forge_authored.go: 4. - forge_cursors.go: 7 (StoreForgeRepoWatermark, SetForgeRepoSubscriptionEnabled :execrows). - forge_subscriptions.go: 9 (AdvanceForgeDeliveredRevision :execrows). derefString removed. - tenant.go: 2. - linear_sessions.go: 2 (UpsertLinearAgentSession :execrows). scanLinearAgentSession removed. - dm.go: residue file (added post-record); 8 queries covering get-or-create, the channel resolution/insert loop, the advisory lock, the R3 verify-reconcile belt, and the reserved-group discriminator. Non-obvious mappings: interface{} columns forced concrete via casts (tokens revoked ::boolean; forge_subscriptions coord_number ::BIGINT, has_cursor ::boolean; SubscribersForArtifact openedEvent ::boolean); GC subquery columns table-qualified to resolve an ambiguous-column analyzer failure; constant enum→int16 conversions carry no nolint (compile-time checked), only variable narrowings do. ## Vet in CI + gate promotion (was T7) sqlc.yaml: add `database.uri: ${SQLC_DATABASE_URL}` + `rules: [sqlc/db-prepare]` so `sqlc vet` PREPAREs every generated query against a live Postgres. Set `analyzer.database: false` so `sqlc generate` and the DB-free sqlc-drift gate keep working without a database — only db-prepare (vet) uses database.uri. Without this, the database stanza would force generate to require a live DB and break the 'sqlc-drift is fully local, no DB' invariant. moon.yml: add a `sqlc-vet` task (modeled on sqlc-drift) — DSN-gated skip when neither COMPASS_TEST_DATABASE_DSN nor SQLC_DATABASE_URL is set; otherwise it provisions a uniquely-named throwaway DB (sqlcvet_$$) on the service, applies internal/store/migrations/*.sql via psql, points SQLC_DATABASE_URL at it, runs `sqlc vet`, and drops the DB on a cleanup trap. NOT added to the moon `ci` deps: that battery realizes no service. ci.yml: run `moon run compass-go:sqlc-vet --force` in the existing `pgtest` job, which already carries the postgres:16-alpine service + COMPASS_TEST_DATABASE_DSN. This is where db-prepare runs live — the same peel-into-a-service-job pattern as pgtest/microvm/forge/gtk4 (Matt's Option-B ruling on the CI fork: the spec assumed the ci-aggregate job had the service, but the service lives only on the pgtest job). A phase-two nixpkgs-tools step puts psql + sqlc on PATH. `--force` because the pgtest job checks out shallow (no fetch-depth: 0); a bare `moon run` resolves the PR base branch for affected/cache-hash and dies git-128 in a shallow clone, and sqlc-vet must always run here regardless of what the diff touched. inline-sql-gate: promote identifier-passed SQL from advisory to GATED. Beyond string-literal SQL at any receiver, a bare-identifier SQL slot (`q`, `ddl`, `m.sql`) is now flagged when the call's receiver is a pgx pool/tx/conn handle (last segment in {pool, tx, conn, c}). The receiver scope is the load-bearing guard against runtime/compute false positives (r.runtime.Exec(ctx,id,spec) / g.client.Exec(ctx,req) are not queries). Only the SQL slot (arg after ctx) is tested; a call/composite/concat expression is left alone. Stays GREEN on the migrated tree: the only remaining identifier-passed sites are conn.Exec(ctx,ddl) / tx.Exec(ctx,m.sql) in the permanently-allowlisted store.go (allowlist=2). Fail-closed stale-entry ratchet intact; +7 detection tests. RIG-3034 Co-authored-by: Matt Wilkinson --- .github/workflows/ci.yml | 44 +- .../server/compass-sqlc-adoption/design.md | 2 +- go/internal/store/agent_activity.go | 40 +- go/internal/store/agent_config.go | 23 +- go/internal/store/agent_placements.go | 65 +-- go/internal/store/agent_sessions.go | 37 +- go/internal/store/agent_transcripts.go | 333 +++++---------- go/internal/store/authz.go | 79 ++-- go/internal/store/db/agent_activity.sql.go | 61 +++ go/internal/store/db/agent_config.sql.go | 58 +++ go/internal/store/db/agent_placements.sql.go | 104 +++++ go/internal/store/db/agent_sessions.sql.go | 70 +++ go/internal/store/db/agent_transcripts.sql.go | 339 +++++++++++++++ go/internal/store/db/authz.sql.go | 87 ++++ go/internal/store/db/dm.sql.go | 153 +++++++ go/internal/store/db/forge_authored.sql.go | 170 ++++++++ go/internal/store/db/forge_cursors.sql.go | 205 +++++++++ .../store/db/forge_subscriptions.sql.go | 401 ++++++++++++++++++ go/internal/store/db/issues.sql.go | 222 ++++++++++ go/internal/store/db/linear_sessions.sql.go | 69 +++ go/internal/store/db/querier.go | 199 +++++++++ go/internal/store/db/secrets.sql.go | 89 ++++ go/internal/store/db/tenant.sql.go | 47 ++ go/internal/store/db/tokens.sql.go | 71 ++++ go/internal/store/dm.go | 99 ++--- go/internal/store/forge_authored.go | 140 +++--- go/internal/store/forge_authored_test.go | 18 +- go/internal/store/forge_cursors.go | 135 +++--- go/internal/store/forge_subscriptions.go | 273 +++++------- go/internal/store/issues.go | 165 ++++--- go/internal/store/linear_sessions.go | 57 +-- go/internal/store/queries/agent_activity.sql | 17 + go/internal/store/queries/agent_config.sql | 17 + .../store/queries/agent_placements.sql | 27 ++ go/internal/store/queries/agent_sessions.sql | 26 ++ .../store/queries/agent_transcripts.sql | 89 ++++ go/internal/store/queries/authz.sql | 36 ++ go/internal/store/queries/dm.sql | 41 ++ go/internal/store/queries/forge_authored.sql | 38 ++ go/internal/store/queries/forge_cursors.sql | 44 ++ .../store/queries/forge_subscriptions.sql | 95 +++++ go/internal/store/queries/issues.sql | 52 +++ go/internal/store/queries/linear_sessions.sql | 18 + go/internal/store/queries/secrets.sql | 17 + go/internal/store/queries/tenant.sql | 10 + go/internal/store/queries/tokens.sql | 17 + go/internal/store/secrets.go | 53 ++- go/internal/store/store.go | 8 - go/internal/store/tenant.go | 16 +- go/internal/store/tokens.go | 38 +- go/moon.yml | 56 ++- go/sqlc.yaml | 6 + tools/inline-sql-gate/index.test.ts | 78 ++++ tools/inline-sql-gate/index.ts | 179 +++++--- 54 files changed, 3811 insertions(+), 1022 deletions(-) create mode 100644 go/internal/store/db/agent_activity.sql.go create mode 100644 go/internal/store/db/agent_config.sql.go create mode 100644 go/internal/store/db/agent_placements.sql.go create mode 100644 go/internal/store/db/agent_sessions.sql.go create mode 100644 go/internal/store/db/agent_transcripts.sql.go create mode 100644 go/internal/store/db/authz.sql.go create mode 100644 go/internal/store/db/dm.sql.go create mode 100644 go/internal/store/db/forge_authored.sql.go create mode 100644 go/internal/store/db/forge_cursors.sql.go create mode 100644 go/internal/store/db/forge_subscriptions.sql.go create mode 100644 go/internal/store/db/issues.sql.go create mode 100644 go/internal/store/db/linear_sessions.sql.go create mode 100644 go/internal/store/db/secrets.sql.go create mode 100644 go/internal/store/db/tenant.sql.go create mode 100644 go/internal/store/db/tokens.sql.go create mode 100644 go/internal/store/queries/agent_activity.sql create mode 100644 go/internal/store/queries/agent_config.sql create mode 100644 go/internal/store/queries/agent_placements.sql create mode 100644 go/internal/store/queries/agent_sessions.sql create mode 100644 go/internal/store/queries/agent_transcripts.sql create mode 100644 go/internal/store/queries/authz.sql create mode 100644 go/internal/store/queries/dm.sql create mode 100644 go/internal/store/queries/forge_authored.sql create mode 100644 go/internal/store/queries/forge_cursors.sql create mode 100644 go/internal/store/queries/forge_subscriptions.sql create mode 100644 go/internal/store/queries/issues.sql create mode 100644 go/internal/store/queries/linear_sessions.sql create mode 100644 go/internal/store/queries/secrets.sql create mode 100644 go/internal/store/queries/tenant.sql create mode 100644 go/internal/store/queries/tokens.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68be98b2d..bf06c660f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -450,10 +450,15 @@ jobs: --health-timeout 5s --health-retries 10 steps: - # checkout: pgtest runs NO git diff (its guard greps files via `grep -rl`), - # so a default shallow checkout suffices — no `fetch-depth: 0` needed. + # checkout: the Real-Postgres suites run no git diff (their guard greps + # files via `grep -rl`), but the sqlc-vet step below invokes `moon run`, + # which resolves the PR base ("main") to compute its changed-files set + # before running the task. A shallow checkout lacks that ref and moon dies + # `git exit 128: ambiguous argument 'main'` — so this job needs full + # history, the same `fetch-depth: 0` every other moon-invoking job uses. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 # Base-re-point re-trigger checks out the recomputed merge ref; empty # on every other event (see setup's checkout). ref: ${{ github.event.inputs.pr != '' && format('refs/pull/{0}/merge', github.event.inputs.pr) || '' }} @@ -579,6 +584,41 @@ jobs: echo "pgtest: checked $(printf '%s\n' "$pkgs" | wc -l) real-Postgres packages against the service database" exit "$rc" + - name: Put the dev shell's nixpkgs tools on PATH (for sqlc vet) + # The Real-Postgres suite above needs only `go`, so this job skips the + # moon battery's phase-two bootstrap by default. sqlc-vet (next step) + # DOES need two nixpkgs tools from the dev shell — `psql` (apply the + # migrations into the throwaway DB) and `sqlc` (run the vet) — so resolve + # them here, the same way the moon job's phase two does: the attribute + # list is the parity script's own parse of devenv.nix, so what CI installs + # and what the gate expects cannot disagree. Run at the repo root (no + # working-directory): parity.ts reads the workspace-root devenv.nix, and + # bun is already on PATH from the language-toolchains step above. + run: | + attrs=$(bun tools/toolchain/parity.ts --print-nix-attrs) + out=$(nix build --no-link --print-out-paths \ + -f tools/toolchain/gate-tools.nix env --arg attrs "$attrs") + echo "$out/bin" >>"$GITHUB_PATH" + + - name: sqlc vet (db-prepare against the service Postgres) + env: + # Same service DSN the Real-Postgres suite uses. The sqlc-vet moon task + # derives its own throwaway-DB URL from this (creates sqlcvet_$$, applies + # the migrations, points SQLC_DATABASE_URL at it, drops it on exit), so + # the job needs no extra env beyond the DSN. This is where db-prepare + # actually runs against a live schema — the moon `ci` battery has no + # Postgres service, so sqlc-vet is peeled into this env-bearing job + # (ci.yml header: the moon battery realizes no environment), the same + # pattern as the Real-Postgres suite itself. + COMPASS_TEST_DATABASE_DSN: postgres://postgres:compass-test@127.0.0.1:5432/compass?sslmode=disable + # `--force` so the vet always runs here regardless of what the PR diff + # touched: moon would otherwise cache-hit or affected-skip the task when + # no sqlc input changed, but db-prepare against the live service is the + # whole point of peeling it into this job. (The VCS-base resolution moon + # does before running is satisfied by this job's `fetch-depth: 0` + # checkout above — `--force` alone does not skip it in moon 2.5.3.) + run: moon run compass-go:sqlc-vet --force + microvm: name: microvm runs-on: ubuntu-latest diff --git a/docs/designs/server/compass-sqlc-adoption/design.md b/docs/designs/server/compass-sqlc-adoption/design.md index 3373dd093..8e83bd9ea 100644 --- a/docs/designs/server/compass-sqlc-adoption/design.md +++ b/docs/designs/server/compass-sqlc-adoption/design.md @@ -449,7 +449,7 @@ Interfaces: - [ ] T5 — migrate agent sessions/transcripts/activity/config/placements - [ ] T6 — migrate authz/tokens/secrets/issues/forge/tenant/linear remainder; allowlist down to the two permanent entries -- [ ] T7 — `sqlc vet` (db-prepare) in CI + promote advisory gate lines to +- [x] T7 — `sqlc vet` (db-prepare) in CI + promote advisory gate lines to gating ## Open Questions diff --git a/go/internal/store/agent_activity.go b/go/internal/store/agent_activity.go index b0b2bfff8..cc06c09c4 100644 --- a/go/internal/store/agent_activity.go +++ b/go/internal/store/agent_activity.go @@ -3,6 +3,8 @@ package store import ( "context" "fmt" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // AgentActivity is an agent's durable activity: the free-text string it last @@ -24,14 +26,11 @@ type AgentActivity struct { // presence/activity split (DL-074) — presence lives in memory, but the activity // survives a restart because it lands here. func (s *Store) SetActivity(ctx context.Context, agentAccountID AccountID, activity string, atUnixMs int64) error { - if _, err := s.pool.Exec(ctx, - `INSERT INTO agent_activity (agent_account_id, activity, activity_at_unix_ms) - VALUES ($1, $2, $3) - ON CONFLICT (agent_account_id) - DO UPDATE SET activity = EXCLUDED.activity, - activity_at_unix_ms = EXCLUDED.activity_at_unix_ms`, - string(agentAccountID), activity, atUnixMs, - ); err != nil { + if err := s.q.SetActivity(ctx, db.SetActivityParams{ + AgentAccountID: string(agentAccountID), + Activity: activity, + ActivityAtUnixMs: atUnixMs, + }); err != nil { return fmt.Errorf("store: set agent activity: %w", err) } return nil @@ -54,30 +53,15 @@ func (s *Store) ActivityFor(ctx context.Context, accountIDs []AccountID) (map[Ac ids[i] = string(id) } - rows, err := s.pool.Query(ctx, - `SELECT agent_account_id, activity, activity_at_unix_ms - FROM agent_activity - WHERE agent_account_id = ANY($1)`, - ids, - ) + rows, err := s.q.ActivityFor(ctx, ids) if err != nil { return nil, fmt.Errorf("store: read agent activity: %w", err) } - defer rows.Close() - - for rows.Next() { - var ( - id string - activity string - atMs int64 - ) - if err := rows.Scan(&id, &activity, &atMs); err != nil { - return nil, fmt.Errorf("store: scan agent activity: %w", err) + for _, row := range rows { + out[AccountID(row.AgentAccountID)] = AgentActivity{ + Activity: row.Activity, + ActivityAtUnixMs: row.ActivityAtUnixMs, } - out[AccountID(id)] = AgentActivity{Activity: activity, ActivityAtUnixMs: atMs} - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate agent activity: %w", err) } return out, nil } diff --git a/go/internal/store/agent_config.go b/go/internal/store/agent_config.go index 6fdd49c56..61cbbe79d 100644 --- a/go/internal/store/agent_config.go +++ b/go/internal/store/agent_config.go @@ -16,6 +16,7 @@ import ( "sort" "strings" + "github.com/RigelBuild/compass/go/internal/store/db" yaml "go.yaml.in/yaml/v3" ) @@ -141,13 +142,10 @@ func (s *Store) PutAgentConfig(ctx context.Context, actor AccountID, bundle []by if err != nil { return "", err } - if _, err := s.pool.Exec(ctx, - `INSERT INTO agent_config_bundle (singleton, version, bundle) - VALUES (TRUE, $1, $2) - ON CONFLICT (singleton) - DO UPDATE SET version = EXCLUDED.version, bundle = EXCLUDED.bundle, updated_at = now()`, - version, bundle, - ); err != nil { + if err := s.q.PutAgentConfig(ctx, db.PutAgentConfigParams{ + Version: version, + Bundle: bundle, + }); err != nil { return "", fmt.Errorf("store: put agent config: %w", err) } return version, nil @@ -168,15 +166,14 @@ func ValidateConfigBundle(bundle []byte) (version string, err error) { // downstream (the fetch path then materializes an empty config dir), but the // store still reports the absence; the caller decides empty-is-ok. func (s *Store) CurrentAgentConfig(ctx context.Context) (version string, bundle []byte, err error) { - if err := s.pool.QueryRow(ctx, - `SELECT version, bundle FROM agent_config_bundle WHERE singleton = TRUE`, - ).Scan(&version, &bundle); err != nil { + row, err := s.q.CurrentAgentConfig(ctx) + if err != nil { if noRows(err) { return "", nil, fmt.Errorf("%w: no agent config bundle declared", ErrNotFound) } return "", nil, fmt.Errorf("store: read agent config: %w", err) } - return version, bundle, nil + return row.Version, row.Bundle, nil } // DeleteAgentConfig clears the fleet config bundle, returning the store to the @@ -188,9 +185,7 @@ func (s *Store) CurrentAgentConfig(ctx context.Context) (version string, bundle // return-to-unconfigured path (RIG-1625 T2), chosen over blessing an // empty-tarball push. func (s *Store) DeleteAgentConfig(ctx context.Context) error { - if _, err := s.pool.Exec(ctx, - `DELETE FROM agent_config_bundle WHERE singleton = TRUE`, - ); err != nil { + if err := s.q.DeleteAgentConfig(ctx); err != nil { return fmt.Errorf("store: delete agent config: %w", err) } return nil diff --git a/go/internal/store/agent_placements.go b/go/internal/store/agent_placements.go index 07da44b24..7b7f43ef4 100644 --- a/go/internal/store/agent_placements.go +++ b/go/internal/store/agent_placements.go @@ -3,6 +3,8 @@ package store import ( "context" "fmt" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // Agent placement: the durable record of WHERE each agent runs — which Runner, @@ -62,15 +64,11 @@ func (s *Store) RecordAgentPlacement(ctx context.Context, agentAccountID Account if containerName == "" { return fmt.Errorf("%w: container name is required", ErrInvalidArgument) } - if _, err := s.pool.Exec(ctx, - `INSERT INTO agent_placements (agent_account_id, runner_id, container_name) - VALUES ($1, $2, $3) - ON CONFLICT (agent_account_id) DO UPDATE - SET runner_id = EXCLUDED.runner_id, - container_name = EXCLUDED.container_name, - updated_at = now()`, - string(agentAccountID), runnerID, containerName, - ); err != nil { + if err := s.q.RecordAgentPlacement(ctx, db.RecordAgentPlacementParams{ + AgentAccountID: string(agentAccountID), + RunnerID: runnerID, + ContainerName: containerName, + }); err != nil { if pgErrIs(err, pgForeignKeyViolation) { return fmt.Errorf("%w: agent account %q does not exist", ErrInvalidArgument, agentAccountID) } @@ -95,11 +93,8 @@ func (s *Store) AgentForContainer(ctx context.Context, containerName string) (Ac if containerName == "" { return "", fmt.Errorf("%w: container name is required", ErrInvalidArgument) } - var accountID string - if err := s.pool.QueryRow(ctx, - `SELECT agent_account_id FROM agent_placements WHERE container_name = $1`, - containerName, - ).Scan(&accountID); err != nil { + accountID, err := s.q.AgentForContainer(ctx, containerName) + if err != nil { if noRows(err) { return "", fmt.Errorf("%w: container %q is not placed", ErrNotFound, containerName) } @@ -119,30 +114,17 @@ func (s *Store) ListAgentPlacementsForRunner(ctx context.Context, runnerID strin if runnerID == "" { return nil, fmt.Errorf("%w: runner id is required", ErrInvalidArgument) } - rows, err := s.pool.Query(ctx, - `SELECT agent_account_id, runner_id, container_name - FROM agent_placements - WHERE runner_id = $1 - ORDER BY agent_account_id`, - runnerID, - ) + rows, err := s.q.ListAgentPlacementsForRunner(ctx, runnerID) if err != nil { return nil, fmt.Errorf("store: list agent placements: %w", err) } - defer rows.Close() - - placements := []AgentPlacement{} - for rows.Next() { - var p AgentPlacement - var accountID string - if err := rows.Scan(&accountID, &p.RunnerID, &p.ContainerName); err != nil { - return nil, fmt.Errorf("store: scan agent placement: %w", err) - } - p.AgentAccountID = AccountID(accountID) - placements = append(placements, p) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate agent placements: %w", err) + placements := make([]AgentPlacement, 0, len(rows)) + for _, row := range rows { + placements = append(placements, AgentPlacement{ + AgentAccountID: AccountID(row.AgentAccountID), + RunnerID: row.RunnerID, + ContainerName: row.ContainerName, + }) } return placements, nil } @@ -158,10 +140,7 @@ func (s *Store) DeleteAgentPlacement(ctx context.Context, containerName string) if containerName == "" { return fmt.Errorf("%w: container name is required", ErrInvalidArgument) } - if _, err := s.pool.Exec(ctx, - `DELETE FROM agent_placements WHERE container_name = $1`, - containerName, - ); err != nil { + if err := s.q.DeleteAgentPlacement(ctx, containerName); err != nil { return fmt.Errorf("store: delete agent placement: %w", err) } return nil @@ -177,14 +156,12 @@ func (s *Store) PlacementForAgent(ctx context.Context, agentAccountID AccountID) if agentAccountID == "" { return "", "", fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) } - if err := s.pool.QueryRow(ctx, - `SELECT runner_id, container_name FROM agent_placements WHERE agent_account_id = $1`, - string(agentAccountID), - ).Scan(&runnerID, &containerName); err != nil { + row, err := s.q.PlacementForAgent(ctx, string(agentAccountID)) + if err != nil { if noRows(err) { return "", "", fmt.Errorf("%w: agent %q is not placed", ErrNotFound, agentAccountID) } return "", "", fmt.Errorf("store: resolve placement for agent: %w", err) } - return runnerID, containerName, nil + return row.RunnerID, row.ContainerName, nil } diff --git a/go/internal/store/agent_sessions.go b/go/internal/store/agent_sessions.go index 187598188..9a46f9247 100644 --- a/go/internal/store/agent_sessions.go +++ b/go/internal/store/agent_sessions.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "time" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // The durable session-ownership chain: the persistent @@ -41,10 +43,11 @@ func (s *Store) RecordAgentSession(ctx context.Context, sessionID string, agentA if agentAccountID == "" { return fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) } - if _, err := s.pool.Exec(ctx, - `INSERT INTO agent_sessions (session_id, agent_account_id, recorded_at_unix_ms) VALUES ($1, $2, $3)`, - sessionID, string(agentAccountID), time.Now().UnixMilli(), - ); err != nil { + if err := s.q.InsertAgentSession(ctx, db.InsertAgentSessionParams{ + SessionID: sessionID, + AgentAccountID: string(agentAccountID), + RecordedAtUnixMs: time.Now().UnixMilli(), + }); err != nil { if pgErrIs(err, pgUniqueViolation) { return fmt.Errorf("%w: session %q already recorded", ErrConflict, sessionID) } @@ -69,14 +72,8 @@ func (s *Store) LatestSessionForAccount(ctx context.Context, agent AccountID) (s if agent == "" { return "", false, fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) } - if err := s.pool.QueryRow(ctx, - `SELECT session_id - FROM agent_sessions - WHERE agent_account_id = $1 - ORDER BY recorded_at_unix_ms DESC, session_id DESC - LIMIT 1`, - string(agent), - ).Scan(&sessionID); err != nil { + sessionID, err = s.q.LatestSessionForAccount(ctx, string(agent)) + if err != nil { if noRows(err) { return "", false, nil } @@ -105,17 +102,11 @@ func (s *Store) RequireAgentSessionSubscriber(ctx context.Context, caller Accoun if sessionID == "" { return fmt.Errorf("%w: session id is required", ErrInvalidArgument) } - var authorized bool - if err := s.pool.QueryRow(ctx, - `SELECT EXISTS ( - SELECT 1 - FROM agent_sessions se - JOIN agent_accounts ag ON ag.account_id = se.agent_account_id - JOIN channel_members cm ON cm.channel_id = ag.home_channel_id - AND cm.account_id = $2 - WHERE se.session_id = $1)`, - sessionID, string(caller), - ).Scan(&authorized); err != nil { + authorized, err := s.q.RequireAgentSessionSubscriber(ctx, db.RequireAgentSessionSubscriberParams{ + SessionID: sessionID, + AccountID: string(caller), + }) + if err != nil { return fmt.Errorf("store: authorize agent session subscriber: %w", err) } if !authorized { diff --git a/go/internal/store/agent_transcripts.go b/go/internal/store/agent_transcripts.go index 3995ef783..42ffd80e3 100644 --- a/go/internal/store/agent_transcripts.go +++ b/go/internal/store/agent_transcripts.go @@ -8,6 +8,8 @@ import ( "strings" "github.com/jackc/pgx/v5" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // The durable TWO-TIER transcript store (RIG-1667 T4). A Postgres HOT TAIL @@ -122,17 +124,8 @@ func (s *Store) BindLifetime(ctx context.Context, sessionID string) (uint64, err if sessionID == "" { return 0, fmt.Errorf("%w: session id is required", ErrInvalidArgument) } - var base int64 - if err := s.pool.QueryRow(ctx, - `UPDATE agent_sessions - SET base_entry_seq = COALESCE( - (SELECT MAX(entry_seq) - FROM agent_session_transcript_entries - WHERE session_id = $1), 0) - WHERE session_id = $1 - RETURNING base_entry_seq`, - sessionID, - ).Scan(&base); err != nil { + base, err := s.q.BindLifetime(ctx, sessionID) + if err != nil { if noRows(err) { return 0, fmt.Errorf("%w: session %q", ErrNotFound, sessionID) } @@ -171,13 +164,13 @@ func (s *Store) AppendTranscriptEntry(ctx context.Context, sessionID string, lif } entrySeq := base + lifetimeSeq - tag, err := s.pool.Exec(ctx, - `INSERT INTO agent_session_transcript_entries - (session_id, entry_seq, checkpoint, entry_json, idempotency_key) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (idempotency_key) DO NOTHING`, - sessionID, toInt64(entrySeq), checkpoint, entryJSON, idempotencyKey, - ) + rowsAffected, err := s.q.InsertTranscriptEntry(ctx, db.InsertTranscriptEntryParams{ + SessionID: sessionID, + EntrySeq: toInt64(entrySeq), + Checkpoint: checkpoint, + EntryJson: entryJSON, + IdempotencyKey: idempotencyKey, + }) if err != nil { // A unique_violation here is the PK (session_id, entry_seq): a distinct // entry re-using a stamped seq — a real conflict, not the keyed dedup @@ -191,7 +184,7 @@ func (s *Store) AppendTranscriptEntry(ctx context.Context, sessionID string, lif } return fmt.Errorf("store: append transcript entry: %w", err) } - if tag.RowsAffected() == 0 { + if rowsAffected == 0 { // Duplicate idempotency_key: the retry dedup. Silent success, and NO // flush — a retried checkpoint frame must not re-invoke the PRIMARY // flush (design.md T4: it short-circuits before it). If the ORIGINAL @@ -216,55 +209,33 @@ func (s *Store) SessionTranscript(ctx context.Context, sessionID string) ([]Tran if sessionID == "" { return nil, fmt.Errorf("%w: session id is required", ErrInvalidArgument) } - rows, err := s.pool.Query(ctx, - `SELECT entry_seq, checkpoint, entry_json - FROM agent_session_transcript_entries - WHERE session_id = $1 - AND entry_seq >= COALESCE( - (SELECT MAX(entry_seq) - FROM agent_session_transcript_entries - WHERE session_id = $1 AND checkpoint), 0) - ORDER BY entry_seq`, - sessionID, - ) + rows, err := s.q.SessionTranscript(ctx, sessionID) if err != nil { return nil, fmt.Errorf("store: read session transcript: %w", err) } - defer rows.Close() - out, err := scanTranscriptRows(rows) - if err != nil { - return nil, err - } + out := transcriptRowsFromDB(rows) if len(out) == 0 { return nil, fmt.Errorf("%w: session %q", ErrNotFound, sessionID) } return out, nil } -// scanTranscriptRows scans hot-tail rows into TranscriptEntryRow values. Shared -// by SessionTranscript (pool read) and SessionResumeSnapshot (tx read) so the -// column list and scan live in one place. -func scanTranscriptRows(rows pgx.Rows) ([]TranscriptEntryRow, error) { - var out []TranscriptEntryRow - for rows.Next() { - var ( - seq int64 - checkpoint bool - entryJSON string - ) - if err := rows.Scan(&seq, &checkpoint, &entryJSON); err != nil { - return nil, fmt.Errorf("store: scan transcript entry: %w", err) - } +// transcriptRowsFromDB maps generated hot-tail rows into TranscriptEntryRow +// values. Shared by SessionTranscript (pool read) and SessionResumeSnapshot (tx +// read) so the entry_seq widening (toUint64) lives in one place. +func transcriptRowsFromDB(rows []db.SessionTranscriptRow) []TranscriptEntryRow { + if len(rows) == 0 { + return nil + } + out := make([]TranscriptEntryRow, 0, len(rows)) + for _, r := range rows { out = append(out, TranscriptEntryRow{ - EntrySeq: toUint64(seq), - Checkpoint: checkpoint, - EntryJSON: entryJSON, + EntrySeq: toUint64(r.EntrySeq), + Checkpoint: r.Checkpoint, + EntryJSON: r.EntryJson, }) } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate transcript entries: %w", err) - } - return out, nil + return out } // FlushSuperseded writes the hot-tail entries up to uptoEntrySeq as one @@ -300,46 +271,34 @@ func (s *Store) SafetyValveSegments(ctx context.Context, sessionID string) ([]Ar if sessionID == "" { return nil, fmt.Errorf("%w: session id is required", ErrInvalidArgument) } - rows, err := s.pool.Query(ctx, - `SELECT object_key, min_entry_seq, max_entry_seq, kind - FROM agent_session_archive_segments - WHERE session_id = $1 AND kind = $2 - ORDER BY min_entry_seq`, - sessionID, string(SegmentKindSafetyValve), - ) + rows, err := s.q.SafetyValveSegments(ctx, db.SafetyValveSegmentsParams{ + SessionID: sessionID, + Kind: string(SegmentKindSafetyValve), + }) if err != nil { return nil, fmt.Errorf("store: read safety valve segments: %w", err) } - defer rows.Close() - return scanSegmentRows(rows) + return segmentRowsFromDB(rows), nil } -// scanSegmentRows scans safety_valve manifest rows into ArchiveSegmentRow -// values. Shared by SafetyValveSegments (pool read) and SessionResumeSnapshot -// (tx read) so the column list and scan live in one place. -func scanSegmentRows(rows pgx.Rows) ([]ArchiveSegmentRow, error) { - var out []ArchiveSegmentRow - for rows.Next() { - var ( - objectKey string - minSeq int64 - maxSeq int64 - kind string - ) - if err := rows.Scan(&objectKey, &minSeq, &maxSeq, &kind); err != nil { - return nil, fmt.Errorf("store: scan archive segment: %w", err) - } +// segmentRowsFromDB maps generated safety_valve manifest rows into +// ArchiveSegmentRow values. Shared by SafetyValveSegments (pool read) and +// SessionResumeSnapshot (tx read) so the seq widening (toUint64) lives in one +// place. +func segmentRowsFromDB(rows []db.SafetyValveSegmentsRow) []ArchiveSegmentRow { + if len(rows) == 0 { + return nil + } + out := make([]ArchiveSegmentRow, 0, len(rows)) + for _, r := range rows { out = append(out, ArchiveSegmentRow{ - ObjectKey: objectKey, - MinEntrySeq: toUint64(minSeq), - MaxEntrySeq: toUint64(maxSeq), - Kind: SegmentKind(kind), + ObjectKey: r.ObjectKey, + MinEntrySeq: toUint64(r.MinEntrySeq), + MaxEntrySeq: toUint64(r.MaxEntrySeq), + Kind: SegmentKind(r.Kind), }) } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate archive segments: %w", err) - } - return out, nil + return out } // SessionResumeSnapshot is the ATOMIC two-tier read the T5 resume reconstructor @@ -367,44 +326,25 @@ func (s *Store) SessionResumeSnapshot(ctx context.Context, sessionID string) ([] // rollback-after-commit convention); on any early return it aborts the tx. defer func() { _ = tx.Rollback(ctx) }() - tailRows, err := tx.Query(ctx, - `SELECT entry_seq, checkpoint, entry_json - FROM agent_session_transcript_entries - WHERE session_id = $1 - AND entry_seq >= COALESCE( - (SELECT MAX(entry_seq) - FROM agent_session_transcript_entries - WHERE session_id = $1 AND checkpoint), 0) - ORDER BY entry_seq`, - sessionID, - ) + qtx := s.q.WithTx(tx) + + tailRows, err := qtx.SessionTranscript(ctx, sessionID) if err != nil { return nil, nil, fmt.Errorf("store: read session transcript: %w", err) } - tail, err := scanTranscriptRows(tailRows) - tailRows.Close() - if err != nil { - return nil, nil, err - } + tail := transcriptRowsFromDB(tailRows) if len(tail) == 0 { return nil, nil, fmt.Errorf("%w: session %q", ErrNotFound, sessionID) } - segRows, err := tx.Query(ctx, - `SELECT object_key, min_entry_seq, max_entry_seq, kind - FROM agent_session_archive_segments - WHERE session_id = $1 AND kind = $2 - ORDER BY min_entry_seq`, - sessionID, string(SegmentKindSafetyValve), - ) + segRows, err := qtx.SafetyValveSegments(ctx, db.SafetyValveSegmentsParams{ + SessionID: sessionID, + Kind: string(SegmentKindSafetyValve), + }) if err != nil { return nil, nil, fmt.Errorf("store: read safety valve segments: %w", err) } - segments, err := scanSegmentRows(segRows) - segRows.Close() - if err != nil { - return nil, nil, err - } + segments := segmentRowsFromDB(segRows) if err := tx.Commit(ctx); err != nil { return nil, nil, fmt.Errorf("store: commit resume snapshot: %w", err) @@ -436,11 +376,8 @@ func (s *Store) ReadArchiveSegment(ctx context.Context, objectKey string) ([]byt // sessionBase reads the write-once rebase base for a session. An unknown session // is ErrInvalidArgument — the FK the AppendTranscriptEntry contract names. func (s *Store) sessionBase(ctx context.Context, sessionID string) (uint64, error) { - var base int64 - if err := s.pool.QueryRow(ctx, - `SELECT base_entry_seq FROM agent_sessions WHERE session_id = $1`, - sessionID, - ).Scan(&base); err != nil { + base, err := s.q.SessionBase(ctx, sessionID) + if err != nil { if noRows(err) { return 0, fmt.Errorf("%w: session %q does not exist", ErrInvalidArgument, sessionID) } @@ -477,52 +414,36 @@ func (s *Store) maybeSafetyValve(ctx context.Context, sessionID string) error { // Cheap gate: sum the post-checkpoint byte total in ONE scalar (no per-row // materialization on the common path). The valve sits above the compaction // window, so in normal operation this scalar is under the cap and returns here. - var tailBytes int64 - if err := s.pool.QueryRow(ctx, - `SELECT COALESCE(SUM(octet_length(entry_json)), 0) - FROM agent_session_transcript_entries - WHERE session_id = $1 AND entry_seq > $2`, - sessionID, toInt64(cpSeq), - ).Scan(&tailBytes); err != nil { + tailBytes, err := s.q.HotTailBytes(ctx, db.HotTailBytesParams{ + SessionID: sessionID, + EntrySeq: toInt64(cpSeq), + }) + if err != nil { return fmt.Errorf("store: measure hot tail: %w", err) } if tailBytes <= int64(s.safetyValveCapBytes) { return nil } // Over the cap: NOW materialize the rows to pick the eviction cut point. - rows, err := s.pool.Query(ctx, - `SELECT entry_seq, octet_length(entry_json) - FROM agent_session_transcript_entries - WHERE session_id = $1 AND entry_seq > $2 - ORDER BY entry_seq`, - sessionID, toInt64(cpSeq), - ) + sizes, err := s.q.HotTailSizes(ctx, db.HotTailSizesParams{ + SessionID: sessionID, + EntrySeq: toInt64(cpSeq), + }) if err != nil { return fmt.Errorf("store: measure hot tail: %w", err) } type sized struct { seq uint64 - bytes int - } - entries := make([]sized, 0) - var total int - for rows.Next() { - var ( - seq int64 - bytes int - ) - if err := rows.Scan(&seq, &bytes); err != nil { - rows.Close() - return fmt.Errorf("store: scan hot tail size: %w", err) - } - entries = append(entries, sized{seq: toUint64(seq), bytes: bytes}) - total += bytes + bytes int64 } - rows.Close() - if err := rows.Err(); err != nil { - return fmt.Errorf("store: iterate hot tail size: %w", err) + entries := make([]sized, 0, len(sizes)) + var total int64 + for _, row := range sizes { + entries = append(entries, sized{seq: toUint64(row.EntrySeq), bytes: row.Bytes}) + total += row.Bytes } - if total <= s.safetyValveCapBytes { + cap64 := int64(s.safetyValveCapBytes) + if total <= cap64 { return nil } @@ -532,7 +453,7 @@ func (s *Store) maybeSafetyValve(ctx context.Context, sessionID string) error { remaining := total var upto uint64 for i := range len(entries) - 1 { - if remaining <= s.safetyValveCapBytes { + if remaining <= cap64 { break } upto = entries[i].seq @@ -547,13 +468,8 @@ func (s *Store) maybeSafetyValve(ctx context.Context, sessionID string) error { // latestCheckpointSeq returns the entry_seq of the session's newest checkpoint // row, or 0 when the session has no checkpoint. func (s *Store) latestCheckpointSeq(ctx context.Context, sessionID string) (uint64, error) { - var cp int64 - if err := s.pool.QueryRow(ctx, - `SELECT COALESCE(MAX(entry_seq), 0) - FROM agent_session_transcript_entries - WHERE session_id = $1 AND checkpoint`, - sessionID, - ).Scan(&cp); err != nil { + cp, err := s.q.LatestCheckpointSeq(ctx, sessionID) + if err != nil { return 0, fmt.Errorf("store: read latest checkpoint: %w", err) } return toUint64(cp), nil @@ -566,13 +482,8 @@ func (s *Store) latestCheckpointSeq(ctx context.Context, sessionID string) (uint // for analytics. Mirrors latestCheckpointSeq's shape, without the checkpoint // filter. func (s *Store) SessionMaxEntrySeq(ctx context.Context, sessionID string) (uint64, error) { - var maxSeq int64 - if err := s.pool.QueryRow(ctx, - `SELECT COALESCE(MAX(entry_seq), 0) - FROM agent_session_transcript_entries - WHERE session_id = $1`, - sessionID, - ).Scan(&maxSeq); err != nil { + maxSeq, err := s.q.SessionMaxEntrySeq(ctx, sessionID) + if err != nil { return 0, fmt.Errorf("store: read session max entry seq: %w", err) } return toUint64(maxSeq), nil @@ -619,26 +530,30 @@ func (s *Store) flushUpto(ctx context.Context, sessionID string, fromEntrySeq, u // not actionable, so it is discarded (the store-wide convention). defer func() { _ = tx.Rollback(ctx) }() - if _, err := tx.Exec(ctx, - `INSERT INTO agent_session_archive_segments - (session_id, object_key, min_entry_seq, max_entry_seq, kind) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (session_id, object_key) DO NOTHING`, - sessionID, key, toInt64(minSeq), toInt64(maxSeq), string(kind), - ); err != nil { + qtx := s.q.WithTx(tx) + if err := qtx.InsertArchiveSegment(ctx, db.InsertArchiveSegmentParams{ + SessionID: sessionID, + ObjectKey: key, + MinEntrySeq: toInt64(minSeq), + MaxEntrySeq: toInt64(maxSeq), + Kind: string(kind), + }); err != nil { return fmt.Errorf("store: insert archive segment: %w", err) } if prune { - if _, err := tx.Exec(ctx, - `DELETE FROM agent_session_transcript_entries - WHERE session_id = $1 AND entry_seq >= $2 AND entry_seq <= $3`, - sessionID, toInt64(minSeq), toInt64(maxSeq), - ); err != nil { + if err := qtx.PruneTranscriptEntries(ctx, db.PruneTranscriptEntriesParams{ + SessionID: sessionID, + EntrySeq: toInt64(minSeq), + EntrySeq_2: toInt64(maxSeq), + }); err != nil { return fmt.Errorf("store: prune flushed entries: %w", err) } } if remarkBelow > 0 { - if _, err := tx.Exec(ctx, remarkSafetyValveSQL, sessionID, toInt64(remarkBelow)); err != nil { + if err := qtx.RemarkSafetyValveSuperseded(ctx, db.RemarkSafetyValveSupersededParams{ + SessionID: sessionID, + MaxEntrySeq: toInt64(remarkBelow), + }); err != nil { return fmt.Errorf("store: re-mark stale safety valve: %w", err) } } @@ -652,55 +567,33 @@ func (s *Store) flushUpto(ctx context.Context, sessionID string, fromEntrySeq, u // returning their ordered seqs and the verbatim-JSONL body (entry_json joined by // newlines). Empty seqs means no rows in range. func (s *Store) collectSegment(ctx context.Context, sessionID string, fromEntrySeq, uptoEntrySeq uint64) ([]uint64, []byte, error) { - rows, err := s.pool.Query(ctx, - `SELECT entry_seq, entry_json - FROM agent_session_transcript_entries - WHERE session_id = $1 AND entry_seq >= $2 AND entry_seq <= $3 - ORDER BY entry_seq`, - sessionID, toInt64(fromEntrySeq), toInt64(uptoEntrySeq), - ) + rows, err := s.q.CollectSegment(ctx, db.CollectSegmentParams{ + SessionID: sessionID, + EntrySeq: toInt64(fromEntrySeq), + EntrySeq_2: toInt64(uptoEntrySeq), + }) if err != nil { return nil, nil, fmt.Errorf("store: read segment entries: %w", err) } - defer rows.Close() - - var ( - seqs []uint64 - jsons []string - ) - for rows.Next() { - var ( - seq int64 - entryJSON string - ) - if err := rows.Scan(&seq, &entryJSON); err != nil { - return nil, nil, fmt.Errorf("store: scan segment entry: %w", err) - } - seqs = append(seqs, toUint64(seq)) - jsons = append(jsons, entryJSON) - } - if err := rows.Err(); err != nil { - return nil, nil, fmt.Errorf("store: iterate segment entries: %w", err) - } - if len(seqs) == 0 { + if len(rows) == 0 { return nil, nil, nil } + seqs := make([]uint64, 0, len(rows)) + jsons := make([]string, 0, len(rows)) + for _, r := range rows { + seqs = append(seqs, toUint64(r.EntrySeq)) + jsons = append(jsons, r.EntryJson) + } return seqs, []byte(strings.Join(jsons, "\n")), nil } -// remarkSafetyValveSQL re-marks a session's safety_valve manifest rows whose -// max_entry_seq is below a checkpoint seq to superseded — the S3 object is -// unmoved, only reclassified from resume-eligible to analytics-only. $1 session, -// $2 the checkpoint seq. -const remarkSafetyValveSQL = ` - UPDATE agent_session_archive_segments - SET kind = 'superseded' - WHERE session_id = $1 AND kind = 'safety_valve' AND max_entry_seq < $2` - // remarkSafetyValveSuperseded applies the safety_valve re-mark on its own when a // PRIMARY flush had no pre-checkpoint rows to flush but still owes the re-mark. func (s *Store) remarkSafetyValveSuperseded(ctx context.Context, sessionID string, belowSeq uint64) error { - if _, err := s.pool.Exec(ctx, remarkSafetyValveSQL, sessionID, toInt64(belowSeq)); err != nil { + if err := s.q.RemarkSafetyValveSuperseded(ctx, db.RemarkSafetyValveSupersededParams{ + SessionID: sessionID, + MaxEntrySeq: toInt64(belowSeq), + }); err != nil { return fmt.Errorf("store: re-mark stale safety valve: %w", err) } return nil diff --git a/go/internal/store/authz.go b/go/internal/store/authz.go index db439f581..0f828000e 100644 --- a/go/internal/store/authz.go +++ b/go/internal/store/authz.go @@ -3,6 +3,8 @@ package store import ( "context" "fmt" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // requireChannelMember is the D9 write-authorization primitive: it verifies the @@ -18,12 +20,12 @@ import ( // mutation can gate inside its own tx before touching state — the D9 discipline // the frozen record requires on every write RPC ("authorized server-side // against the authenticated account's visible set", design.md:1101-1102). -func requireChannelMember(ctx context.Context, q querier, actor AccountID, channelID ChannelID) error { - var member bool - if err := q.QueryRow(ctx, - "SELECT EXISTS (SELECT 1 FROM channel_members WHERE channel_id = $1 AND account_id = $2)", - string(channelID), string(actor), - ).Scan(&member); err != nil { +func requireChannelMember(ctx context.Context, q db.DBTX, actor AccountID, channelID ChannelID) error { + member, err := db.New(q).ChannelMemberExists(ctx, db.ChannelMemberExistsParams{ + ChannelID: string(channelID), + AccountID: string(actor), + }) + if err != nil { return fmt.Errorf("store: check channel membership: %w", err) } if !member { @@ -46,12 +48,12 @@ func (s *Store) IsChannelMember(ctx context.Context, actor AccountID, channelID // isChannelMember reports whether actor is a member of channelID (the // package-internal form IsChannelMember exports and requireChannelMember wraps). -func isChannelMember(ctx context.Context, q querier, actor AccountID, channelID ChannelID) (bool, error) { - var member bool - if err := q.QueryRow(ctx, - "SELECT EXISTS (SELECT 1 FROM channel_members WHERE channel_id = $1 AND account_id = $2)", - string(channelID), string(actor), - ).Scan(&member); err != nil { +func isChannelMember(ctx context.Context, q db.DBTX, actor AccountID, channelID ChannelID) (bool, error) { + member, err := db.New(q).ChannelMemberExists(ctx, db.ChannelMemberExistsParams{ + ChannelID: string(channelID), + AccountID: string(actor), + }) + if err != nil { return false, fmt.Errorf("store: check channel membership: %w", err) } return member, nil @@ -66,11 +68,11 @@ func isChannelMember(ctx context.Context, q querier, actor AccountID, channelID // (which JOINs channel_members on the topic's channel). An unknown topic yields // false (not visible) — the not-found/forbidden merge extended to the stream. func (s *Store) IsTopicChannelMember(ctx context.Context, actor AccountID, topicID string) (bool, error) { - var member bool - if err := s.pool.QueryRow(ctx, - "SELECT EXISTS (SELECT 1 FROM topics t JOIN channel_members cm ON cm.channel_id = t.channel_id WHERE t.id = $1 AND cm.account_id = $2)", - topicID, string(actor), - ).Scan(&member); err != nil { + member, err := s.q.TopicChannelMemberExists(ctx, db.TopicChannelMemberExistsParams{ + ID: topicID, + AccountID: string(actor), + }) + if err != nil { return false, fmt.Errorf("store: check topic channel membership: %w", err) } return member, nil @@ -84,27 +86,13 @@ func (s *Store) IsTopicChannelMember(ctx context.Context, actor AccountID, topic // not-found/forbidden merge), so a non-owner cannot probe which group ids exist. // This realizes the frozen record's "CreateChannel — caller-authorized against // the parent group" (design.md:362-367). -func requireGroupCreateAuthz(ctx context.Context, q querier, actor AccountID, groupID ChannelGroupID) error { - var authorized bool - if err := q.QueryRow(ctx, - `SELECT EXISTS ( - SELECT 1 FROM channel_groups g - WHERE g.id = $1 AND ( - g.owner_user_id = $2 - -- Gates on BARE g.visibility = SHARED, not effective - -- (MIN-over-ancestry) visibility. Sound only because groups are - -- immutable post-create: the sole channel_groups mutation is the - -- CreateChannelGroup INSERT (no UpdateChannelGroup / re-parent - -- RPC), and CreateChannelGroup enforces child <= parent ceiling, - -- so bare-SHARED implies effective-SHARED. If a re-parent or - -- visibility-update RPC ever lands, switch this to - -- effectiveVisibilityCTE or it becomes a create-leak (a - -- bare-SHARED group nested under an OWNER parent would authorize - -- creates it should not). - OR g.visibility = $3 - OR g.owner_user_id = (SELECT owner_user_id FROM agent_accounts WHERE account_id = $2)))`, - string(groupID), string(actor), int32(VisibilityShared), - ).Scan(&authorized); err != nil { +func requireGroupCreateAuthz(ctx context.Context, q db.DBTX, actor AccountID, groupID ChannelGroupID) error { + authorized, err := db.New(q).GroupCreateAuthorized(ctx, db.GroupCreateAuthorizedParams{ + ID: string(groupID), + OwnerUserID: string(actor), + Visibility: int16(VisibilityShared), + }) + if err != nil { return fmt.Errorf("store: check group create authz: %w", err) } if !authorized { @@ -125,15 +113,12 @@ func (s *Store) IsAgentWorkspaceVisible(ctx context.Context, actor AccountID, ag // isAgentWorkspaceVisible is the querier-based form IsAgentWorkspaceVisible // exports and OpenAgentWorkspace wraps, so the workspace open can gate inside // its own transaction (the same-tx D9 discipline every write RPC upholds). -func isAgentWorkspaceVisible(ctx context.Context, q querier, actor AccountID, agentAccountID AccountID) (bool, error) { - var visible bool - if err := q.QueryRow(ctx, - `SELECT EXISTS ( - SELECT 1 FROM agent_accounts ag - JOIN channel_members cm ON cm.channel_id = ag.home_channel_id AND cm.account_id = $1 - WHERE ag.account_id = $2)`, - string(actor), string(agentAccountID), - ).Scan(&visible); err != nil { +func isAgentWorkspaceVisible(ctx context.Context, q db.DBTX, actor AccountID, agentAccountID AccountID) (bool, error) { + visible, err := db.New(q).AgentWorkspaceVisible(ctx, db.AgentWorkspaceVisibleParams{ + AccountID: string(actor), + AccountID_2: string(agentAccountID), + }) + if err != nil { return false, fmt.Errorf("store: check workspace visibility: %w", err) } return visible, nil diff --git a/go/internal/store/db/agent_activity.sql.go b/go/internal/store/db/agent_activity.sql.go new file mode 100644 index 000000000..2d7a07204 --- /dev/null +++ b/go/internal/store/db/agent_activity.sql.go @@ -0,0 +1,61 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: agent_activity.sql + +package db + +import ( + "context" +) + +const activityFor = `-- name: ActivityFor :many +SELECT agent_account_id, activity, activity_at_unix_ms +FROM agent_activity +WHERE agent_account_id = ANY($1::text[]) +` + +func (q *Queries) ActivityFor(ctx context.Context, dollar_1 []string) ([]AgentActivity, error) { + rows, err := q.db.Query(ctx, activityFor, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AgentActivity + for rows.Next() { + var i AgentActivity + if err := rows.Scan(&i.AgentAccountID, &i.Activity, &i.ActivityAtUnixMs); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setActivity = `-- name: SetActivity :exec + +INSERT INTO agent_activity (agent_account_id, activity, activity_at_unix_ms) +VALUES ($1, $2, $3) +ON CONFLICT (agent_account_id) +DO UPDATE SET activity = EXCLUDED.activity, + activity_at_unix_ms = EXCLUDED.activity_at_unix_ms +` + +type SetActivityParams struct { + AgentAccountID string + Activity string + ActivityAtUnixMs int64 +} + +// Agent-activity queries (sqlc adoption T5, RIG-3034). These replace the inline +// SQL literals in internal/store/agent_activity.go; the hand-written Store +// methods keep their signatures and map the ActivityFor rows into the +// AgentActivity domain struct (agentActivityFromRow-equivalent, done inline in +// the Go — absent-from-table means absent-from-map). +func (q *Queries) SetActivity(ctx context.Context, arg SetActivityParams) error { + _, err := q.db.Exec(ctx, setActivity, arg.AgentAccountID, arg.Activity, arg.ActivityAtUnixMs) + return err +} diff --git a/go/internal/store/db/agent_config.sql.go b/go/internal/store/db/agent_config.sql.go new file mode 100644 index 000000000..4653abe39 --- /dev/null +++ b/go/internal/store/db/agent_config.sql.go @@ -0,0 +1,58 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: agent_config.sql + +package db + +import ( + "context" +) + +const currentAgentConfig = `-- name: CurrentAgentConfig :one +SELECT version, bundle FROM agent_config_bundle WHERE singleton = TRUE +` + +type CurrentAgentConfigRow struct { + Version string + Bundle []byte +} + +func (q *Queries) CurrentAgentConfig(ctx context.Context) (CurrentAgentConfigRow, error) { + row := q.db.QueryRow(ctx, currentAgentConfig) + var i CurrentAgentConfigRow + err := row.Scan(&i.Version, &i.Bundle) + return i, err +} + +const deleteAgentConfig = `-- name: DeleteAgentConfig :exec +DELETE FROM agent_config_bundle WHERE singleton = TRUE +` + +func (q *Queries) DeleteAgentConfig(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteAgentConfig) + return err +} + +const putAgentConfig = `-- name: PutAgentConfig :exec + +INSERT INTO agent_config_bundle (singleton, version, bundle) +VALUES (TRUE, $1, $2) +ON CONFLICT (singleton) +DO UPDATE SET version = EXCLUDED.version, bundle = EXCLUDED.bundle, updated_at = now() +` + +type PutAgentConfigParams struct { + Version string + Bundle []byte +} + +// Agent config-bundle queries (sqlc adoption T5, RIG-3034). These replace the +// inline SQL literals in internal/store/agent_config.go; the hand-written Store +// methods keep their signatures and own the bundle validation/hash and the +// tar-walk member inventory (Go-side, over the returned BYTEA). The config +// bundle is a fleet-wide singleton row (singleton = TRUE). +func (q *Queries) PutAgentConfig(ctx context.Context, arg PutAgentConfigParams) error { + _, err := q.db.Exec(ctx, putAgentConfig, arg.Version, arg.Bundle) + return err +} diff --git a/go/internal/store/db/agent_placements.sql.go b/go/internal/store/db/agent_placements.sql.go new file mode 100644 index 000000000..72825ee6d --- /dev/null +++ b/go/internal/store/db/agent_placements.sql.go @@ -0,0 +1,104 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: agent_placements.sql + +package db + +import ( + "context" +) + +const agentForContainer = `-- name: AgentForContainer :one +SELECT agent_account_id FROM agent_placements WHERE container_name = $1 +` + +func (q *Queries) AgentForContainer(ctx context.Context, containerName string) (string, error) { + row := q.db.QueryRow(ctx, agentForContainer, containerName) + var agent_account_id string + err := row.Scan(&agent_account_id) + return agent_account_id, err +} + +const deleteAgentPlacement = `-- name: DeleteAgentPlacement :exec +DELETE FROM agent_placements WHERE container_name = $1 +` + +func (q *Queries) DeleteAgentPlacement(ctx context.Context, containerName string) error { + _, err := q.db.Exec(ctx, deleteAgentPlacement, containerName) + return err +} + +const listAgentPlacementsForRunner = `-- name: ListAgentPlacementsForRunner :many +SELECT agent_account_id, runner_id, container_name + FROM agent_placements + WHERE runner_id = $1 + ORDER BY agent_account_id +` + +type ListAgentPlacementsForRunnerRow struct { + AgentAccountID string + RunnerID string + ContainerName string +} + +func (q *Queries) ListAgentPlacementsForRunner(ctx context.Context, runnerID string) ([]ListAgentPlacementsForRunnerRow, error) { + rows, err := q.db.Query(ctx, listAgentPlacementsForRunner, runnerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAgentPlacementsForRunnerRow + for rows.Next() { + var i ListAgentPlacementsForRunnerRow + if err := rows.Scan(&i.AgentAccountID, &i.RunnerID, &i.ContainerName); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const placementForAgent = `-- name: PlacementForAgent :one +SELECT runner_id, container_name FROM agent_placements WHERE agent_account_id = $1 +` + +type PlacementForAgentRow struct { + RunnerID string + ContainerName string +} + +func (q *Queries) PlacementForAgent(ctx context.Context, agentAccountID string) (PlacementForAgentRow, error) { + row := q.db.QueryRow(ctx, placementForAgent, agentAccountID) + var i PlacementForAgentRow + err := row.Scan(&i.RunnerID, &i.ContainerName) + return i, err +} + +const recordAgentPlacement = `-- name: RecordAgentPlacement :exec + +INSERT INTO agent_placements (agent_account_id, runner_id, container_name) +VALUES ($1, $2, $3) +ON CONFLICT (agent_account_id) DO UPDATE + SET runner_id = EXCLUDED.runner_id, + container_name = EXCLUDED.container_name, + updated_at = now() +` + +type RecordAgentPlacementParams struct { + AgentAccountID string + RunnerID string + ContainerName string +} + +// Agent-placement queries (sqlc adoption T5, RIG-3034). These replace the inline +// SQL literals in internal/store/agent_placements.go; the hand-written Store +// methods keep their signatures and map the placement rows into the +// AgentPlacement domain struct (AccountID newtype done inline in the Go). +func (q *Queries) RecordAgentPlacement(ctx context.Context, arg RecordAgentPlacementParams) error { + _, err := q.db.Exec(ctx, recordAgentPlacement, arg.AgentAccountID, arg.RunnerID, arg.ContainerName) + return err +} diff --git a/go/internal/store/db/agent_sessions.sql.go b/go/internal/store/db/agent_sessions.sql.go new file mode 100644 index 000000000..536735b32 --- /dev/null +++ b/go/internal/store/db/agent_sessions.sql.go @@ -0,0 +1,70 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: agent_sessions.sql + +package db + +import ( + "context" +) + +const insertAgentSession = `-- name: InsertAgentSession :exec + +INSERT INTO agent_sessions (session_id, agent_account_id, recorded_at_unix_ms) +VALUES ($1, $2, $3) +` + +type InsertAgentSessionParams struct { + SessionID string + AgentAccountID string + RecordedAtUnixMs int64 +} + +// Agent-session queries (sqlc adoption T5, RIG-3034). These replace the inline +// SQL literals that lived in internal/store/agent_sessions.go; the hand-written +// Store methods keep their exact signatures and wrap these generated calls, +// mapping AccountID newtypes and the not-found/forbidden merge (D9) by hand. No +// domain xFromRow mapper — the session reads project scalar columns the methods +// return directly. +func (q *Queries) InsertAgentSession(ctx context.Context, arg InsertAgentSessionParams) error { + _, err := q.db.Exec(ctx, insertAgentSession, arg.SessionID, arg.AgentAccountID, arg.RecordedAtUnixMs) + return err +} + +const latestSessionForAccount = `-- name: LatestSessionForAccount :one +SELECT session_id + FROM agent_sessions + WHERE agent_account_id = $1 + ORDER BY recorded_at_unix_ms DESC, session_id DESC + LIMIT 1 +` + +func (q *Queries) LatestSessionForAccount(ctx context.Context, agentAccountID string) (string, error) { + row := q.db.QueryRow(ctx, latestSessionForAccount, agentAccountID) + var session_id string + err := row.Scan(&session_id) + return session_id, err +} + +const requireAgentSessionSubscriber = `-- name: RequireAgentSessionSubscriber :one +SELECT EXISTS ( + SELECT 1 + FROM agent_sessions se + JOIN agent_accounts ag ON ag.account_id = se.agent_account_id + JOIN channel_members cm ON cm.channel_id = ag.home_channel_id + AND cm.account_id = $2 + WHERE se.session_id = $1) +` + +type RequireAgentSessionSubscriberParams struct { + SessionID string + AccountID string +} + +func (q *Queries) RequireAgentSessionSubscriber(ctx context.Context, arg RequireAgentSessionSubscriberParams) (bool, error) { + row := q.db.QueryRow(ctx, requireAgentSessionSubscriber, arg.SessionID, arg.AccountID) + var exists bool + err := row.Scan(&exists) + return exists, err +} diff --git a/go/internal/store/db/agent_transcripts.sql.go b/go/internal/store/db/agent_transcripts.sql.go new file mode 100644 index 000000000..9d66b43b7 --- /dev/null +++ b/go/internal/store/db/agent_transcripts.sql.go @@ -0,0 +1,339 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: agent_transcripts.sql + +package db + +import ( + "context" +) + +const bindLifetime = `-- name: BindLifetime :one + +UPDATE agent_sessions + SET base_entry_seq = COALESCE( + (SELECT MAX(te.entry_seq) + FROM agent_session_transcript_entries te + WHERE te.session_id = $1), 0) + WHERE session_id = $1 +RETURNING base_entry_seq +` + +// Agent-transcript queries (sqlc adoption T5, RIG-3034). These replace the +// inline SQL literals in internal/store/agent_transcripts.go; the hand-written +// Store methods keep their exact signatures and own the two-tier flush +// orchestration (PUT-before-prune, the RepeatableRead/ReadOnly resume snapshot +// tx, the safety-valve cut-point loop) plus the uint64<->int64 seq narrowing +// (toInt64/toUint64). They map generated rows into TranscriptEntryRow and +// ArchiveSegmentRow (via the transcriptRowsFromDB/segmentRowsFromDB helpers). +// +// COALESCE(MAX/SUM(...), 0) sites carry an explicit ::BIGINT cast so sqlc types +// them int64 rather than interface{} (mirrors messages.sql MessagesHeadSeq). The +// SessionTranscript and SafetyValveSegments reads are each issued on BOTH the +// pool (the eponymous method) and a snapshot tx (SessionResumeSnapshot, via +// WithTx), so one generated query backs both call sites. +func (q *Queries) BindLifetime(ctx context.Context, sessionID string) (int64, error) { + row := q.db.QueryRow(ctx, bindLifetime, sessionID) + var base_entry_seq int64 + err := row.Scan(&base_entry_seq) + return base_entry_seq, err +} + +const collectSegment = `-- name: CollectSegment :many +SELECT entry_seq, entry_json + FROM agent_session_transcript_entries + WHERE session_id = $1 AND entry_seq >= $2 AND entry_seq <= $3 + ORDER BY entry_seq +` + +type CollectSegmentParams struct { + SessionID string + EntrySeq int64 + EntrySeq_2 int64 +} + +type CollectSegmentRow struct { + EntrySeq int64 + EntryJson string +} + +func (q *Queries) CollectSegment(ctx context.Context, arg CollectSegmentParams) ([]CollectSegmentRow, error) { + rows, err := q.db.Query(ctx, collectSegment, arg.SessionID, arg.EntrySeq, arg.EntrySeq_2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CollectSegmentRow + for rows.Next() { + var i CollectSegmentRow + if err := rows.Scan(&i.EntrySeq, &i.EntryJson); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const hotTailBytes = `-- name: HotTailBytes :one +SELECT COALESCE(SUM(octet_length(entry_json)), 0)::BIGINT AS bytes + FROM agent_session_transcript_entries + WHERE session_id = $1 AND entry_seq > $2 +` + +type HotTailBytesParams struct { + SessionID string + EntrySeq int64 +} + +func (q *Queries) HotTailBytes(ctx context.Context, arg HotTailBytesParams) (int64, error) { + row := q.db.QueryRow(ctx, hotTailBytes, arg.SessionID, arg.EntrySeq) + var bytes int64 + err := row.Scan(&bytes) + return bytes, err +} + +const hotTailSizes = `-- name: HotTailSizes :many +SELECT entry_seq, octet_length(entry_json)::BIGINT AS bytes + FROM agent_session_transcript_entries + WHERE session_id = $1 AND entry_seq > $2 + ORDER BY entry_seq +` + +type HotTailSizesParams struct { + SessionID string + EntrySeq int64 +} + +type HotTailSizesRow struct { + EntrySeq int64 + Bytes int64 +} + +func (q *Queries) HotTailSizes(ctx context.Context, arg HotTailSizesParams) ([]HotTailSizesRow, error) { + rows, err := q.db.Query(ctx, hotTailSizes, arg.SessionID, arg.EntrySeq) + if err != nil { + return nil, err + } + defer rows.Close() + var items []HotTailSizesRow + for rows.Next() { + var i HotTailSizesRow + if err := rows.Scan(&i.EntrySeq, &i.Bytes); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertArchiveSegment = `-- name: InsertArchiveSegment :exec +INSERT INTO agent_session_archive_segments + (session_id, object_key, min_entry_seq, max_entry_seq, kind) + VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (session_id, object_key) DO NOTHING +` + +type InsertArchiveSegmentParams struct { + SessionID string + ObjectKey string + MinEntrySeq int64 + MaxEntrySeq int64 + Kind string +} + +func (q *Queries) InsertArchiveSegment(ctx context.Context, arg InsertArchiveSegmentParams) error { + _, err := q.db.Exec(ctx, insertArchiveSegment, + arg.SessionID, + arg.ObjectKey, + arg.MinEntrySeq, + arg.MaxEntrySeq, + arg.Kind, + ) + return err +} + +const insertTranscriptEntry = `-- name: InsertTranscriptEntry :execrows +INSERT INTO agent_session_transcript_entries + (session_id, entry_seq, checkpoint, entry_json, idempotency_key) + VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (idempotency_key) DO NOTHING +` + +type InsertTranscriptEntryParams struct { + SessionID string + EntrySeq int64 + Checkpoint bool + EntryJson string + IdempotencyKey string +} + +func (q *Queries) InsertTranscriptEntry(ctx context.Context, arg InsertTranscriptEntryParams) (int64, error) { + result, err := q.db.Exec(ctx, insertTranscriptEntry, + arg.SessionID, + arg.EntrySeq, + arg.Checkpoint, + arg.EntryJson, + arg.IdempotencyKey, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const latestCheckpointSeq = `-- name: LatestCheckpointSeq :one +SELECT COALESCE(MAX(entry_seq), 0)::BIGINT AS seq + FROM agent_session_transcript_entries + WHERE session_id = $1 AND checkpoint +` + +func (q *Queries) LatestCheckpointSeq(ctx context.Context, sessionID string) (int64, error) { + row := q.db.QueryRow(ctx, latestCheckpointSeq, sessionID) + var seq int64 + err := row.Scan(&seq) + return seq, err +} + +const pruneTranscriptEntries = `-- name: PruneTranscriptEntries :exec +DELETE FROM agent_session_transcript_entries + WHERE session_id = $1 AND entry_seq >= $2 AND entry_seq <= $3 +` + +type PruneTranscriptEntriesParams struct { + SessionID string + EntrySeq int64 + EntrySeq_2 int64 +} + +func (q *Queries) PruneTranscriptEntries(ctx context.Context, arg PruneTranscriptEntriesParams) error { + _, err := q.db.Exec(ctx, pruneTranscriptEntries, arg.SessionID, arg.EntrySeq, arg.EntrySeq_2) + return err +} + +const remarkSafetyValveSuperseded = `-- name: RemarkSafetyValveSuperseded :exec +UPDATE agent_session_archive_segments + SET kind = 'superseded' + WHERE session_id = $1 AND kind = 'safety_valve' AND max_entry_seq < $2 +` + +type RemarkSafetyValveSupersededParams struct { + SessionID string + MaxEntrySeq int64 +} + +func (q *Queries) RemarkSafetyValveSuperseded(ctx context.Context, arg RemarkSafetyValveSupersededParams) error { + _, err := q.db.Exec(ctx, remarkSafetyValveSuperseded, arg.SessionID, arg.MaxEntrySeq) + return err +} + +const safetyValveSegments = `-- name: SafetyValveSegments :many +SELECT object_key, min_entry_seq, max_entry_seq, kind + FROM agent_session_archive_segments + WHERE session_id = $1 AND kind = $2 + ORDER BY min_entry_seq +` + +type SafetyValveSegmentsParams struct { + SessionID string + Kind string +} + +type SafetyValveSegmentsRow struct { + ObjectKey string + MinEntrySeq int64 + MaxEntrySeq int64 + Kind string +} + +func (q *Queries) SafetyValveSegments(ctx context.Context, arg SafetyValveSegmentsParams) ([]SafetyValveSegmentsRow, error) { + rows, err := q.db.Query(ctx, safetyValveSegments, arg.SessionID, arg.Kind) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SafetyValveSegmentsRow + for rows.Next() { + var i SafetyValveSegmentsRow + if err := rows.Scan( + &i.ObjectKey, + &i.MinEntrySeq, + &i.MaxEntrySeq, + &i.Kind, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const sessionBase = `-- name: SessionBase :one +SELECT base_entry_seq FROM agent_sessions WHERE session_id = $1 +` + +func (q *Queries) SessionBase(ctx context.Context, sessionID string) (int64, error) { + row := q.db.QueryRow(ctx, sessionBase, sessionID) + var base_entry_seq int64 + err := row.Scan(&base_entry_seq) + return base_entry_seq, err +} + +const sessionMaxEntrySeq = `-- name: SessionMaxEntrySeq :one +SELECT COALESCE(MAX(entry_seq), 0)::BIGINT AS seq + FROM agent_session_transcript_entries + WHERE session_id = $1 +` + +func (q *Queries) SessionMaxEntrySeq(ctx context.Context, sessionID string) (int64, error) { + row := q.db.QueryRow(ctx, sessionMaxEntrySeq, sessionID) + var seq int64 + err := row.Scan(&seq) + return seq, err +} + +const sessionTranscript = `-- name: SessionTranscript :many +SELECT e.entry_seq, e.checkpoint, e.entry_json + FROM agent_session_transcript_entries e + WHERE e.session_id = $1 + AND e.entry_seq >= COALESCE( + (SELECT MAX(cp.entry_seq) + FROM agent_session_transcript_entries cp + WHERE cp.session_id = $1 AND cp.checkpoint), 0) + ORDER BY entry_seq +` + +type SessionTranscriptRow struct { + EntrySeq int64 + Checkpoint bool + EntryJson string +} + +func (q *Queries) SessionTranscript(ctx context.Context, sessionID string) ([]SessionTranscriptRow, error) { + rows, err := q.db.Query(ctx, sessionTranscript, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SessionTranscriptRow + for rows.Next() { + var i SessionTranscriptRow + if err := rows.Scan(&i.EntrySeq, &i.Checkpoint, &i.EntryJson); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/go/internal/store/db/authz.sql.go b/go/internal/store/db/authz.sql.go new file mode 100644 index 000000000..10e111e53 --- /dev/null +++ b/go/internal/store/db/authz.sql.go @@ -0,0 +1,87 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: authz.sql + +package db + +import ( + "context" +) + +const agentWorkspaceVisible = `-- name: AgentWorkspaceVisible :one +SELECT EXISTS ( + SELECT 1 FROM agent_accounts ag + JOIN channel_members cm ON cm.channel_id = ag.home_channel_id AND cm.account_id = $1 + WHERE ag.account_id = $2) +` + +type AgentWorkspaceVisibleParams struct { + AccountID string + AccountID_2 string +} + +// Feeds isAgentWorkspaceVisible: membership on the agent's home channel. +func (q *Queries) AgentWorkspaceVisible(ctx context.Context, arg AgentWorkspaceVisibleParams) (bool, error) { + row := q.db.QueryRow(ctx, agentWorkspaceVisible, arg.AccountID, arg.AccountID_2) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const groupCreateAuthorized = `-- name: GroupCreateAuthorized :one +SELECT EXISTS ( + SELECT 1 FROM channel_groups g + WHERE g.id = $1 AND ( + g.owner_user_id = $2 + -- Gates on BARE g.visibility = SHARED, not effective + -- (MIN-over-ancestry) visibility. Sound only because groups are + -- immutable post-create: the sole channel_groups mutation is the + -- CreateChannelGroup INSERT (no UpdateChannelGroup / re-parent + -- RPC), and CreateChannelGroup enforces child <= parent ceiling, + -- so bare-SHARED implies effective-SHARED. If a re-parent or + -- visibility-update RPC ever lands, switch this to + -- effectiveVisibilityCTE or it becomes a create-leak (a + -- bare-SHARED group nested under an OWNER parent would authorize + -- creates it should not). + OR g.visibility = $3 + OR g.owner_user_id = (SELECT owner_user_id FROM agent_accounts WHERE account_id = $2))) +` + +type GroupCreateAuthorizedParams struct { + ID string + OwnerUserID string + Visibility int16 +} + +// Feeds requireGroupCreateAuthz: owner, agent-owner, or SHARED-visibility group. +func (q *Queries) GroupCreateAuthorized(ctx context.Context, arg GroupCreateAuthorizedParams) (bool, error) { + row := q.db.QueryRow(ctx, groupCreateAuthorized, arg.ID, arg.OwnerUserID, arg.Visibility) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const topicChannelMemberExists = `-- name: TopicChannelMemberExists :one + +SELECT EXISTS (SELECT 1 FROM topics t JOIN channel_members cm ON cm.channel_id = t.channel_id WHERE t.id = $1 AND cm.account_id = $2) +` + +type TopicChannelMemberExistsParams struct { + ID string + AccountID string +} + +// Authorization-probe queries (sqlc adoption T6, RIG-3034). These replace the +// inline SQL literals in internal/store/authz.go; the hand-written helpers keep +// their signatures and the not-found/forbidden merge, wrapping these EXISTS +// probes (each returns a bare bool). requireChannelMember / isChannelMember reuse +// ChannelMemberExists (channels.sql) — the statement is textually identical — so +// only the three probes without an existing query live here. +// Feeds IsTopicChannelMember: membership on the channel that owns the topic. +func (q *Queries) TopicChannelMemberExists(ctx context.Context, arg TopicChannelMemberExistsParams) (bool, error) { + row := q.db.QueryRow(ctx, topicChannelMemberExists, arg.ID, arg.AccountID) + var exists bool + err := row.Scan(&exists) + return exists, err +} diff --git a/go/internal/store/db/dm.sql.go b/go/internal/store/db/dm.sql.go new file mode 100644 index 000000000..14ff45bfa --- /dev/null +++ b/go/internal/store/db/dm.sql.go @@ -0,0 +1,153 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: dm.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getDMChannelByName = `-- name: GetDMChannelByName :one +SELECT id, kind FROM channels WHERE group_id = $1 AND name = $2 +` + +type GetDMChannelByNameParams struct { + GroupID pgtype.Text + Name string +} + +type GetDMChannelByNameRow struct { + ID string + Kind int16 +} + +func (q *Queries) GetDMChannelByName(ctx context.Context, arg GetDMChannelByNameParams) (GetDMChannelByNameRow, error) { + row := q.db.QueryRow(ctx, getDMChannelByName, arg.GroupID, arg.Name) + var i GetDMChannelByNameRow + err := row.Scan(&i.ID, &i.Kind) + return i, err +} + +const getGroupNameVisibility = `-- name: GetGroupNameVisibility :one +SELECT name, visibility FROM channel_groups WHERE id = $1 +` + +type GetGroupNameVisibilityRow struct { + Name string + Visibility int16 +} + +// Feeds isReservedDMGroupTx: the reserved-DM-group discriminator (name AND +// VisibilityOwner) the CreateChannel create-guard keys on. +func (q *Queries) GetGroupNameVisibility(ctx context.Context, id string) (GetGroupNameVisibilityRow, error) { + row := q.db.QueryRow(ctx, getGroupNameVisibility, id) + var i GetGroupNameVisibilityRow + err := row.Scan(&i.Name, &i.Visibility) + return i, err +} + +const getOwnerDMGroup = `-- name: GetOwnerDMGroup :one + +SELECT id FROM channel_groups +WHERE owner_user_id = $1 AND name = $2 AND parent_group_id IS NULL AND visibility = $3 +` + +type GetOwnerDMGroupParams struct { + OwnerUserID string + Name string + Visibility int16 +} + +// Peer-DM channel queries (sqlc adoption T6, RIG-3034; dm.go was added to the +// store after the design record froze — the record's "plus any residue"). These +// replace the inline SQL literals in internal/store/dm.go; the hand-written Store +// methods keep their signatures and every seam that is NOT a single statement: +// the per-owner advisory lock (LockDM), the resolution/insert loop, the R3 +// verify-reconcile belt, the transitive-owner membership expansion, and the +// cursor seeding (seedChannelDeliveryCursors, delivery_cursors.sql). The member +// INSERTs reuse EnsureChannelMember (accounts.sql) — the statement is identical. +// Visibility-discriminated get-half: a wider (SHARED) planted __dm__ group must +// NEVER be adopted, so visibility = $3 (bound to VisibilityOwner) excludes it. +func (q *Queries) GetOwnerDMGroup(ctx context.Context, arg GetOwnerDMGroupParams) (string, error) { + row := q.db.QueryRow(ctx, getOwnerDMGroup, arg.OwnerUserID, arg.Name, arg.Visibility) + var id string + err := row.Scan(&id) + return id, err +} + +const insertDMChannel = `-- name: InsertDMChannel :one +INSERT INTO channels (id, name, group_id, kind, post_policy, owner_account_id, mandatory_subscription) +VALUES ($1, $2, $3, $4, $5, NULL, $6) +ON CONFLICT (group_id, name) WHERE group_id IS NOT NULL DO NOTHING +RETURNING id +` + +type InsertDMChannelParams struct { + ID string + Name string + GroupID pgtype.Text + Kind int16 + PostPolicy int16 + MandatorySubscription bool +} + +// Born kind=DM, zero-value policy (OPEN, ownerless) + mandatory; poison-free via +// ON CONFLICT DO NOTHING on the partial unique index (a concurrent open yields +// zero rows, never a raised unique-violation). +func (q *Queries) InsertDMChannel(ctx context.Context, arg InsertDMChannelParams) (string, error) { + row := q.db.QueryRow(ctx, insertDMChannel, + arg.ID, + arg.Name, + arg.GroupID, + arg.Kind, + arg.PostPolicy, + arg.MandatorySubscription, + ) + var id string + err := row.Scan(&id) + return id, err +} + +const insertOwnerDMGroup = `-- name: InsertOwnerDMGroup :exec +INSERT INTO channel_groups (id, name, parent_group_id, owner_user_id, visibility) +VALUES ($1, $2, NULL, $3, $4) +` + +type InsertOwnerDMGroupParams struct { + ID string + Name string + OwnerUserID string + Visibility int16 +} + +func (q *Queries) InsertOwnerDMGroup(ctx context.Context, arg InsertOwnerDMGroupParams) error { + _, err := q.db.Exec(ctx, insertOwnerDMGroup, + arg.ID, + arg.Name, + arg.OwnerUserID, + arg.Visibility, + ) + return err +} + +const lockOwnerDM = `-- name: LockOwnerDM :exec +SELECT pg_advisory_xact_lock(hashtext('dm:' || $1)) +` + +func (q *Queries) LockOwnerDM(ctx context.Context, dollar_1 pgtype.Text) error { + _, err := q.db.Exec(ctx, lockOwnerDM, dollar_1) + return err +} + +const reassertDMMandatory = `-- name: ReassertDMMandatory :exec +UPDATE channels SET mandatory_subscription = TRUE WHERE id = $1 AND mandatory_subscription = FALSE +` + +func (q *Queries) ReassertDMMandatory(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, reassertDMMandatory, id) + return err +} diff --git a/go/internal/store/db/forge_authored.sql.go b/go/internal/store/db/forge_authored.sql.go new file mode 100644 index 000000000..30fa9564e --- /dev/null +++ b/go/internal/store/db/forge_authored.sql.go @@ -0,0 +1,170 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: forge_authored.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const authoredArtifactByCoordinate = `-- name: AuthoredArtifactByCoordinate :one +SELECT forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms +FROM forge_authored_artifacts +WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5 +` + +type AuthoredArtifactByCoordinateParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 +} + +func (q *Queries) AuthoredArtifactByCoordinate(ctx context.Context, arg AuthoredArtifactByCoordinateParams) (ForgeAuthoredArtifact, error) { + row := q.db.QueryRow(ctx, authoredArtifactByCoordinate, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Kind, + arg.Number, + ) + var i ForgeAuthoredArtifact + err := row.Scan( + &i.ForgeProvider, + &i.ForgeHost, + &i.Repo, + &i.Kind, + &i.Number, + &i.AgentAccountID, + &i.OwnerUserID, + &i.SessionID, + &i.ClientRequestID, + &i.CreatedAtUnixMs, + ) + return i, err +} + +const authoredArtifactByRequestID = `-- name: AuthoredArtifactByRequestID :one +SELECT forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms +FROM forge_authored_artifacts +WHERE agent_account_id = $1 AND client_request_id = $2 +` + +type AuthoredArtifactByRequestIDParams struct { + AgentAccountID string + ClientRequestID pgtype.Text +} + +func (q *Queries) AuthoredArtifactByRequestID(ctx context.Context, arg AuthoredArtifactByRequestIDParams) (ForgeAuthoredArtifact, error) { + row := q.db.QueryRow(ctx, authoredArtifactByRequestID, arg.AgentAccountID, arg.ClientRequestID) + var i ForgeAuthoredArtifact + err := row.Scan( + &i.ForgeProvider, + &i.ForgeHost, + &i.Repo, + &i.Kind, + &i.Number, + &i.AgentAccountID, + &i.OwnerUserID, + &i.SessionID, + &i.ClientRequestID, + &i.CreatedAtUnixMs, + ) + return i, err +} + +const listAuthoredArtifactsByAgent = `-- name: ListAuthoredArtifactsByAgent :many +SELECT forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms +FROM forge_authored_artifacts +WHERE agent_account_id = $1 +ORDER BY created_at_unix_ms ASC, forge_provider ASC, forge_host ASC, repo ASC, kind ASC, number ASC +` + +func (q *Queries) ListAuthoredArtifactsByAgent(ctx context.Context, agentAccountID string) ([]ForgeAuthoredArtifact, error) { + rows, err := q.db.Query(ctx, listAuthoredArtifactsByAgent, agentAccountID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ForgeAuthoredArtifact + for rows.Next() { + var i ForgeAuthoredArtifact + if err := rows.Scan( + &i.ForgeProvider, + &i.ForgeHost, + &i.Repo, + &i.Kind, + &i.Number, + &i.AgentAccountID, + &i.OwnerUserID, + &i.SessionID, + &i.ClientRequestID, + &i.CreatedAtUnixMs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const recordAuthoredArtifact = `-- name: RecordAuthoredArtifact :exec + +INSERT INTO forge_authored_artifacts + (forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +ON CONFLICT (forge_provider, forge_host, repo, kind, number) DO UPDATE + SET session_id = EXCLUDED.session_id, + client_request_id = EXCLUDED.client_request_id, + created_at_unix_ms = EXCLUDED.created_at_unix_ms +` + +type RecordAuthoredArtifactParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 + AgentAccountID string + OwnerUserID string + SessionID string + ClientRequestID pgtype.Text + CreatedAtUnixMs int64 +} + +// Forge-authored-artifact queries (sqlc adoption T6, RIG-3034). These replace the +// inline SQL literals in internal/store/forge_authored.go; the hand-written Store +// methods keep their signatures, the door-side validation (valid/validCoordinate), +// the ErrConflict/ErrInvalidArgument/ErrNotFound mapping via pgErrIs, and the +// textOrNull client_request_id NULL discipline. The read queries feed +// authoredArtifactFromRow, which maps the generated row (provider/kind ints, +// number BIGINT, nullable client_request_id) back to the domain AuthoredArtifact. +// WRITE-ONCE authorship: the DO UPDATE deliberately omits agent_account_id and +// owner_user_id, so a re-land never rewrites who authored the artifact. +func (q *Queries) RecordAuthoredArtifact(ctx context.Context, arg RecordAuthoredArtifactParams) error { + _, err := q.db.Exec(ctx, recordAuthoredArtifact, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Kind, + arg.Number, + arg.AgentAccountID, + arg.OwnerUserID, + arg.SessionID, + arg.ClientRequestID, + arg.CreatedAtUnixMs, + ) + return err +} diff --git a/go/internal/store/db/forge_cursors.sql.go b/go/internal/store/db/forge_cursors.sql.go new file mode 100644 index 000000000..2d2782103 --- /dev/null +++ b/go/internal/store/db/forge_cursors.sql.go @@ -0,0 +1,205 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: forge_cursors.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const ensureForgeRepoSubscription = `-- name: EnsureForgeRepoSubscription :exec +INSERT INTO forge_repo_subscriptions (forge_provider, forge_host, repo, enabled) +VALUES ($1, $2, $3, $4) +ON CONFLICT (forge_provider, forge_host, repo) DO NOTHING +` + +type EnsureForgeRepoSubscriptionParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + Enabled bool +} + +func (q *Queries) EnsureForgeRepoSubscription(ctx context.Context, arg EnsureForgeRepoSubscriptionParams) error { + _, err := q.db.Exec(ctx, ensureForgeRepoSubscription, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Enabled, + ) + return err +} + +const isEnabledForgeRepo = `-- name: IsEnabledForgeRepo :one +SELECT EXISTS ( + SELECT 1 FROM forge_repo_subscriptions + WHERE repo = $1 AND enabled = TRUE) +` + +func (q *Queries) IsEnabledForgeRepo(ctx context.Context, repo string) (bool, error) { + row := q.db.QueryRow(ctx, isEnabledForgeRepo, repo) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const listEnabledForgeRepoSubscriptions = `-- name: ListEnabledForgeRepoSubscriptions :many +SELECT forge_provider, forge_host, repo, enabled +FROM forge_repo_subscriptions +WHERE forge_provider = $1 AND forge_host = $2 AND enabled = TRUE +ORDER BY repo ASC +` + +type ListEnabledForgeRepoSubscriptionsParams struct { + ForgeProvider int16 + ForgeHost string +} + +type ListEnabledForgeRepoSubscriptionsRow struct { + ForgeProvider int16 + ForgeHost string + Repo string + Enabled bool +} + +func (q *Queries) ListEnabledForgeRepoSubscriptions(ctx context.Context, arg ListEnabledForgeRepoSubscriptionsParams) ([]ListEnabledForgeRepoSubscriptionsRow, error) { + rows, err := q.db.Query(ctx, listEnabledForgeRepoSubscriptions, arg.ForgeProvider, arg.ForgeHost) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListEnabledForgeRepoSubscriptionsRow + for rows.Next() { + var i ListEnabledForgeRepoSubscriptionsRow + if err := rows.Scan( + &i.ForgeProvider, + &i.ForgeHost, + &i.Repo, + &i.Enabled, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEnabledForgeRepos = `-- name: ListEnabledForgeRepos :many +SELECT repo +FROM forge_repo_subscriptions +WHERE enabled = TRUE +ORDER BY repo ASC +` + +func (q *Queries) ListEnabledForgeRepos(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, listEnabledForgeRepos) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var repo string + if err := rows.Scan(&repo); err != nil { + return nil, err + } + items = append(items, repo) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const loadForgeRepoWatermark = `-- name: LoadForgeRepoWatermark :one + +SELECT swept_updated_at, list_etag +FROM forge_repo_subscriptions +WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 +` + +type LoadForgeRepoWatermarkParams struct { + ForgeProvider int16 + ForgeHost string + Repo string +} + +type LoadForgeRepoWatermarkRow struct { + SweptUpdatedAt pgtype.Timestamptz + ListEtag string +} + +// Forge repo-subscription / watermark queries (sqlc adoption T6, RIG-3034). These +// replace the inline SQL literals in internal/store/forge_cursors.go; the +// hand-written Store methods keep their signatures, the door-side validation +// (validCoordinate), the ErrNotFound mapping, and the RowsAffected branches +// (StoreForgeRepoWatermark / SetForgeRepoSubscriptionEnabled are :execrows). The +// read methods map the generated rows (provider int, nullable swept_updated_at) +// back to the domain time.Time / ForgeRepoSubscription. +func (q *Queries) LoadForgeRepoWatermark(ctx context.Context, arg LoadForgeRepoWatermarkParams) (LoadForgeRepoWatermarkRow, error) { + row := q.db.QueryRow(ctx, loadForgeRepoWatermark, arg.ForgeProvider, arg.ForgeHost, arg.Repo) + var i LoadForgeRepoWatermarkRow + err := row.Scan(&i.SweptUpdatedAt, &i.ListEtag) + return i, err +} + +const setForgeRepoSubscriptionEnabled = `-- name: SetForgeRepoSubscriptionEnabled :execrows +UPDATE forge_repo_subscriptions + SET enabled = $4, updated_at = now() + WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 +` + +type SetForgeRepoSubscriptionEnabledParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + Enabled bool +} + +func (q *Queries) SetForgeRepoSubscriptionEnabled(ctx context.Context, arg SetForgeRepoSubscriptionEnabledParams) (int64, error) { + result, err := q.db.Exec(ctx, setForgeRepoSubscriptionEnabled, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Enabled, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const storeForgeRepoWatermark = `-- name: StoreForgeRepoWatermark :execrows +UPDATE forge_repo_subscriptions + SET swept_updated_at = $4, list_etag = $5, updated_at = now() + WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 +` + +type StoreForgeRepoWatermarkParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + SweptUpdatedAt pgtype.Timestamptz + ListEtag string +} + +func (q *Queries) StoreForgeRepoWatermark(ctx context.Context, arg StoreForgeRepoWatermarkParams) (int64, error) { + result, err := q.db.Exec(ctx, storeForgeRepoWatermark, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.SweptUpdatedAt, + arg.ListEtag, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/go/internal/store/db/forge_subscriptions.sql.go b/go/internal/store/db/forge_subscriptions.sql.go new file mode 100644 index 000000000..2c857d289 --- /dev/null +++ b/go/internal/store/db/forge_subscriptions.sql.go @@ -0,0 +1,401 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: forge_subscriptions.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const advanceForgeDeliveredRevision = `-- name: AdvanceForgeDeliveredRevision :execrows +UPDATE agent_forge_subscriptions + SET delivered_revision = $3, delivered_at = now() + WHERE id = $2 AND agent_account_id = $1 +` + +type AdvanceForgeDeliveredRevisionParams struct { + AgentAccountID string + ID string + DeliveredRevision string +} + +func (q *Queries) AdvanceForgeDeliveredRevision(ctx context.Context, arg AdvanceForgeDeliveredRevisionParams) (int64, error) { + result, err := q.db.Exec(ctx, advanceForgeDeliveredRevision, arg.AgentAccountID, arg.ID, arg.DeliveredRevision) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const countAgentForgeSubscriptionsForArtifact = `-- name: CountAgentForgeSubscriptionsForArtifact :one +SELECT count(*) FROM agent_forge_subscriptions + WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5 +` + +type CountAgentForgeSubscriptionsForArtifactParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 +} + +func (q *Queries) CountAgentForgeSubscriptionsForArtifact(ctx context.Context, arg CountAgentForgeSubscriptionsForArtifactParams) (int64, error) { + row := q.db.QueryRow(ctx, countAgentForgeSubscriptionsForArtifact, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Kind, + arg.Number, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteAgentForgeSubscription = `-- name: DeleteAgentForgeSubscription :one +DELETE FROM agent_forge_subscriptions + WHERE id = $1 AND agent_account_id = $2 +RETURNING forge_provider, forge_host, repo, kind, number +` + +type DeleteAgentForgeSubscriptionParams struct { + ID string + AgentAccountID string +} + +type DeleteAgentForgeSubscriptionRow struct { + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 +} + +// Scoped to the calling agent (id AND agent). RETURNING the coordinate drives the +// one-tx GC of the artifact cursor when this was the last subscription. +func (q *Queries) DeleteAgentForgeSubscription(ctx context.Context, arg DeleteAgentForgeSubscriptionParams) (DeleteAgentForgeSubscriptionRow, error) { + row := q.db.QueryRow(ctx, deleteAgentForgeSubscription, arg.ID, arg.AgentAccountID) + var i DeleteAgentForgeSubscriptionRow + err := row.Scan( + &i.ForgeProvider, + &i.ForgeHost, + &i.Repo, + &i.Kind, + &i.Number, + ) + return i, err +} + +const ensureAgentForgeSubscription = `-- name: EnsureAgentForgeSubscription :one + +INSERT INTO agent_forge_subscriptions + (id, agent_account_id, forge_provider, forge_host, repo, kind, number, scope, project) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (agent_account_id, forge_provider, forge_host, repo, kind, number, project) DO UPDATE + SET agent_account_id = EXCLUDED.agent_account_id +RETURNING id +` + +type EnsureAgentForgeSubscriptionParams struct { + ID string + AgentAccountID string + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 + Scope int16 + Project string +} + +// Agent-forge-subscription / artifact-cursor queries (sqlc adoption T6, +// RIG-3034). These replace the inline SQL literals in +// internal/store/forge_subscriptions.go; the hand-written Store methods keep +// their signatures, the door-side validation (validSubscriptionCoordinate / +// validCoordinate), the scope normalization, the ErrConflict/ErrInvalidArgument/ +// ErrNotFound mapping via pgErrIs, and the two hand-written tx seams: the +// DeleteAgentForgeSubscription GC (WithTx) and the ListForgeNotifyTargets row +// grouping. The read queries feed the ForgeNotifySubscriber / ForgeArtifactCursor +// / ForgeNotifyTarget mappers, which convert the generated rows (provider/kind +// ints, BIGINT numbers, LEFT-JOIN-nullable cursor columns) back to the domain +// types. +// Idempotent on the UNIQUE coordinate: the no-op DO UPDATE (re-set agent to +// itself) makes RETURNING fire on conflict so a repeat returns the stored id. +func (q *Queries) EnsureAgentForgeSubscription(ctx context.Context, arg EnsureAgentForgeSubscriptionParams) (string, error) { + row := q.db.QueryRow(ctx, ensureAgentForgeSubscription, + arg.ID, + arg.AgentAccountID, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Kind, + arg.Number, + arg.Scope, + arg.Project, + ) + var id string + err := row.Scan(&id) + return id, err +} + +const gCForgeArtifactCursorIfUnsubscribed = `-- name: GCForgeArtifactCursorIfUnsubscribed :exec +DELETE FROM forge_artifact_cursors + WHERE forge_artifact_cursors.forge_provider = $1 AND forge_artifact_cursors.forge_host = $2 AND forge_artifact_cursors.repo = $3 AND forge_artifact_cursors.kind = $4 AND forge_artifact_cursors.number = $5 + AND NOT EXISTS ( + SELECT 1 FROM agent_forge_subscriptions + WHERE agent_forge_subscriptions.forge_provider = $1 AND agent_forge_subscriptions.forge_host = $2 AND agent_forge_subscriptions.repo = $3 AND agent_forge_subscriptions.kind = $4 AND agent_forge_subscriptions.number = $5 + ) +` + +type GCForgeArtifactCursorIfUnsubscribedParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 +} + +// Collects the coordinate's cursor IFF no subscription for it remains (the NOT +// EXISTS guard leaves it in place if any other agent still subscribes). +func (q *Queries) GCForgeArtifactCursorIfUnsubscribed(ctx context.Context, arg GCForgeArtifactCursorIfUnsubscribedParams) error { + _, err := q.db.Exec(ctx, gCForgeArtifactCursorIfUnsubscribed, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Kind, + arg.Number, + ) + return err +} + +const listForgeNotifyTargets = `-- name: ListForgeNotifyTargets :many +SELECT s.repo, s.kind, + (CASE WHEN s.scope = 2 THEN 0 ELSE s.number END)::BIGINT AS coord_number, + s.id, s.agent_account_id, s.delivered_revision, s.project, + (c.forge_provider IS NOT NULL)::boolean AS has_cursor, + c.etag, c.comments_etag, c.checks_etag, c.revision, c.snapshot, c.polled_at +FROM agent_forge_subscriptions s +LEFT JOIN forge_artifact_cursors c + ON c.forge_provider = s.forge_provider + AND c.forge_host = s.forge_host + AND c.repo = s.repo + AND c.kind = s.kind + AND c.number = CASE WHEN s.scope = 2 THEN 0 ELSE s.number END +WHERE s.forge_provider = $1 AND s.forge_host = $2 +ORDER BY s.repo, s.kind, coord_number +` + +type ListForgeNotifyTargetsParams struct { + ForgeProvider int16 + ForgeHost string +} + +type ListForgeNotifyTargetsRow struct { + Repo string + Kind int16 + CoordNumber int64 + ID string + AgentAccountID string + DeliveredRevision string + Project string + HasCursor bool + Etag pgtype.Text + CommentsEtag pgtype.Text + ChecksEtag pgtype.Text + Revision pgtype.Text + Snapshot []byte + PolledAt pgtype.Timestamptz +} + +// The reconcile sweep's work list for one (provider, host): each subscribed +// coordinate with its LEFT-JOINed shared FETCH cursor (nullable when never +// observed) and the subscriber rows, container-scope rows collapsed per +// (repo, kind) to coord_number 0. The Go groups the flat rows into targets. +func (q *Queries) ListForgeNotifyTargets(ctx context.Context, arg ListForgeNotifyTargetsParams) ([]ListForgeNotifyTargetsRow, error) { + rows, err := q.db.Query(ctx, listForgeNotifyTargets, arg.ForgeProvider, arg.ForgeHost) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListForgeNotifyTargetsRow + for rows.Next() { + var i ListForgeNotifyTargetsRow + if err := rows.Scan( + &i.Repo, + &i.Kind, + &i.CoordNumber, + &i.ID, + &i.AgentAccountID, + &i.DeliveredRevision, + &i.Project, + &i.HasCursor, + &i.Etag, + &i.CommentsEtag, + &i.ChecksEtag, + &i.Revision, + &i.Snapshot, + &i.PolledAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const loadForgeArtifactCursor = `-- name: LoadForgeArtifactCursor :one +SELECT etag, comments_etag, checks_etag, revision, snapshot, polled_at +FROM forge_artifact_cursors +WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5 +` + +type LoadForgeArtifactCursorParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 +} + +type LoadForgeArtifactCursorRow struct { + Etag string + CommentsEtag string + ChecksEtag string + Revision string + Snapshot []byte + PolledAt pgtype.Timestamptz +} + +func (q *Queries) LoadForgeArtifactCursor(ctx context.Context, arg LoadForgeArtifactCursorParams) (LoadForgeArtifactCursorRow, error) { + row := q.db.QueryRow(ctx, loadForgeArtifactCursor, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Kind, + arg.Number, + ) + var i LoadForgeArtifactCursorRow + err := row.Scan( + &i.Etag, + &i.CommentsEtag, + &i.ChecksEtag, + &i.Revision, + &i.Snapshot, + &i.PolledAt, + ) + return i, err +} + +const subscribersForArtifact = `-- name: SubscribersForArtifact :many +SELECT id, agent_account_id, delivered_revision, project +FROM agent_forge_subscriptions +WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 + AND ( + (scope = 1 AND number = $5) + OR ($6::boolean AND scope = 2 AND number = 0 AND project = $7) + ) +` + +type SubscribersForArtifactParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 + Column6 bool + Project string +} + +type SubscribersForArtifactRow struct { + ID string + AgentAccountID string + DeliveredRevision string + Project string +} + +// Exact-artifact subscribers, plus (on an opened event) the container-scope +// subscribers for the same container/project. +func (q *Queries) SubscribersForArtifact(ctx context.Context, arg SubscribersForArtifactParams) ([]SubscribersForArtifactRow, error) { + rows, err := q.db.Query(ctx, subscribersForArtifact, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Kind, + arg.Number, + arg.Column6, + arg.Project, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SubscribersForArtifactRow + for rows.Next() { + var i SubscribersForArtifactRow + if err := rows.Scan( + &i.ID, + &i.AgentAccountID, + &i.DeliveredRevision, + &i.Project, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertForgeArtifactCursor = `-- name: UpsertForgeArtifactCursor :exec +INSERT INTO forge_artifact_cursors + (forge_provider, forge_host, repo, kind, number, etag, comments_etag, checks_etag, revision, snapshot, polled_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +ON CONFLICT (forge_provider, forge_host, repo, kind, number) DO UPDATE + SET etag = EXCLUDED.etag, + comments_etag = EXCLUDED.comments_etag, + checks_etag = EXCLUDED.checks_etag, + revision = EXCLUDED.revision, + snapshot = EXCLUDED.snapshot, + polled_at = EXCLUDED.polled_at +` + +type UpsertForgeArtifactCursorParams struct { + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 + Etag string + CommentsEtag string + ChecksEtag string + Revision string + Snapshot []byte + PolledAt pgtype.Timestamptz +} + +func (q *Queries) UpsertForgeArtifactCursor(ctx context.Context, arg UpsertForgeArtifactCursorParams) error { + _, err := q.db.Exec(ctx, upsertForgeArtifactCursor, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Kind, + arg.Number, + arg.Etag, + arg.CommentsEtag, + arg.ChecksEtag, + arg.Revision, + arg.Snapshot, + arg.PolledAt, + ) + return err +} diff --git a/go/internal/store/db/issues.sql.go b/go/internal/store/db/issues.sql.go new file mode 100644 index 000000000..fa61186f3 --- /dev/null +++ b/go/internal/store/db/issues.sql.go @@ -0,0 +1,222 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: issues.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getIssue = `-- name: GetIssue :one +SELECT id, forge_provider, forge_host, repo, number, + title, body, forge_state, url, forge_account, labels, agent_handle, + state, priority, assignee, summary, branch +FROM issues +WHERE id = $1 +` + +type GetIssueRow struct { + ID string + ForgeProvider int16 + ForgeHost string + Repo string + Number int64 + Title string + Body string + ForgeState string + Url string + ForgeAccount string + Labels []string + AgentHandle string + State int16 + Priority string + Assignee string + Summary string + Branch string +} + +func (q *Queries) GetIssue(ctx context.Context, id string) (GetIssueRow, error) { + row := q.db.QueryRow(ctx, getIssue, id) + var i GetIssueRow + err := row.Scan( + &i.ID, + &i.ForgeProvider, + &i.ForgeHost, + &i.Repo, + &i.Number, + &i.Title, + &i.Body, + &i.ForgeState, + &i.Url, + &i.ForgeAccount, + &i.Labels, + &i.AgentHandle, + &i.State, + &i.Priority, + &i.Assignee, + &i.Summary, + &i.Branch, + ) + return i, err +} + +const listIssues = `-- name: ListIssues :many +SELECT id, forge_provider, forge_host, repo, number, + title, body, forge_state, url, forge_account, labels, agent_handle, + state, priority, assignee, summary, branch +FROM issues +ORDER BY id +` + +type ListIssuesRow struct { + ID string + ForgeProvider int16 + ForgeHost string + Repo string + Number int64 + Title string + Body string + ForgeState string + Url string + ForgeAccount string + Labels []string + AgentHandle string + State int16 + Priority string + Assignee string + Summary string + Branch string +} + +func (q *Queries) ListIssues(ctx context.Context) ([]ListIssuesRow, error) { + rows, err := q.db.Query(ctx, listIssues) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListIssuesRow + for rows.Next() { + var i ListIssuesRow + if err := rows.Scan( + &i.ID, + &i.ForgeProvider, + &i.ForgeHost, + &i.Repo, + &i.Number, + &i.Title, + &i.Body, + &i.ForgeState, + &i.Url, + &i.ForgeAccount, + &i.Labels, + &i.AgentHandle, + &i.State, + &i.Priority, + &i.Assignee, + &i.Summary, + &i.Branch, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setIssueState = `-- name: SetIssueState :execrows +UPDATE issues SET state = $2 WHERE id = $1 +` + +type SetIssueStateParams struct { + ID string + State int16 +} + +func (q *Queries) SetIssueState(ctx context.Context, arg SetIssueStateParams) (int64, error) { + result, err := q.db.Exec(ctx, setIssueState, arg.ID, arg.State) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const upsertIssueForgeFields = `-- name: UpsertIssueForgeFields :one + +WITH up AS ( + INSERT INTO issues + (id, forge_provider, forge_host, repo, number, + title, body, forge_state, url, forge_account, labels, agent_handle, + forge_updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT (forge_provider, forge_host, repo, number) DO UPDATE + SET title = EXCLUDED.title, body = EXCLUDED.body, + forge_state = EXCLUDED.forge_state, url = EXCLUDED.url, + forge_account = EXCLUDED.forge_account, labels = EXCLUDED.labels, + agent_handle = EXCLUDED.agent_handle, + forge_updated_at = EXCLUDED.forge_updated_at + WHERE issues.forge_updated_at IS NULL + OR EXCLUDED.forge_updated_at IS NULL + OR EXCLUDED.forge_updated_at >= issues.forge_updated_at + RETURNING id + ) + SELECT id FROM up + UNION ALL + SELECT id FROM issues + WHERE NOT EXISTS (SELECT 1 FROM up) + AND forge_provider = $2 AND forge_host = $3 AND repo = $4 AND number = $5 + LIMIT 1 +` + +type UpsertIssueForgeFieldsParams struct { + ID string + ForgeProvider int16 + ForgeHost string + Repo string + Number int64 + Title string + Body string + ForgeState string + Url string + ForgeAccount string + Labels []string + AgentHandle string + ForgeUpdatedAt pgtype.Timestamptz +} + +// Issue-domain queries (sqlc adoption T6, RIG-3034). These replace the inline +// SQL literals in internal/store/issues.go; the hand-written Store methods keep +// their signatures, the door-side validation, the ErrNotFound/ErrInvalidArgument +// mapping, and the RowsAffected branch (SetIssueState is :execrows). GetIssue / +// ListIssues feed issueFromColumns (via issueFromGetRow / issueFromListRow), +// which maps the generated row (forge_provider/state ints, number BIGINT) back +// to the domain Issue. +// Insert-or-update at the forge coordinate with the OQ-6(a) recency guard; the +// ON CONFLICT sets ONLY forge columns (never state/machinery), and the CTE's +// fallback SELECT keeps the returned id stable when the guard skips the UPDATE. +func (q *Queries) UpsertIssueForgeFields(ctx context.Context, arg UpsertIssueForgeFieldsParams) (string, error) { + row := q.db.QueryRow(ctx, upsertIssueForgeFields, + arg.ID, + arg.ForgeProvider, + arg.ForgeHost, + arg.Repo, + arg.Number, + arg.Title, + arg.Body, + arg.ForgeState, + arg.Url, + arg.ForgeAccount, + arg.Labels, + arg.AgentHandle, + arg.ForgeUpdatedAt, + ) + var id string + err := row.Scan(&id) + return id, err +} diff --git a/go/internal/store/db/linear_sessions.sql.go b/go/internal/store/db/linear_sessions.sql.go new file mode 100644 index 000000000..c741fcc5d --- /dev/null +++ b/go/internal/store/db/linear_sessions.sql.go @@ -0,0 +1,69 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: linear_sessions.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const linearAgentSession = `-- name: LinearAgentSession :one +SELECT linear_session_id, manager_account_id, channel_id, topic_id, linear_issue_id, created_at +FROM linear_agent_sessions +WHERE linear_session_id = $1 +` + +func (q *Queries) LinearAgentSession(ctx context.Context, linearSessionID string) (LinearAgentSession, error) { + row := q.db.QueryRow(ctx, linearAgentSession, linearSessionID) + var i LinearAgentSession + err := row.Scan( + &i.LinearSessionID, + &i.ManagerAccountID, + &i.ChannelID, + &i.TopicID, + &i.LinearIssueID, + &i.CreatedAt, + ) + return i, err +} + +const upsertLinearAgentSession = `-- name: UpsertLinearAgentSession :execrows + +INSERT INTO linear_agent_sessions + (linear_session_id, manager_account_id, channel_id, topic_id, linear_issue_id) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (linear_session_id) DO NOTHING +` + +type UpsertLinearAgentSessionParams struct { + LinearSessionID string + ManagerAccountID string + ChannelID string + TopicID string + LinearIssueID pgtype.Text +} + +// Linear-agent-session queries (sqlc adoption T6, RIG-3034). These replace the +// inline SQL literals in internal/store/linear_sessions.go; the hand-written +// Store methods keep their signatures, the RowsAffected branch (Upsert returns +// created via :execrows), the textOrNull linear_issue_id NULL discipline, and the +// ErrNotFound/ErrInvalidArgument mapping. The LinearAgentSession read maps the +// generated row (nullable linear_issue_id, created_at timestamp) back to the +// domain LinearAgentSessionRow inline. +func (q *Queries) UpsertLinearAgentSession(ctx context.Context, arg UpsertLinearAgentSessionParams) (int64, error) { + result, err := q.db.Exec(ctx, upsertLinearAgentSession, + arg.LinearSessionID, + arg.ManagerAccountID, + arg.ChannelID, + arg.TopicID, + arg.LinearIssueID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/go/internal/store/db/querier.go b/go/internal/store/db/querier.go index 192274be0..fb93d17e5 100644 --- a/go/internal/store/db/querier.go +++ b/go/internal/store/db/querier.go @@ -13,7 +13,10 @@ import ( type Querier interface { AccountVisibleTo(ctx context.Context, arg AccountVisibleToParams) (bool, error) AcquireOwnerTreeLock(ctx context.Context, hashtext string) error + ActivityFor(ctx context.Context, dollar_1 []string) ([]AgentActivity, error) AdvanceDeliveryCursor(ctx context.Context, arg AdvanceDeliveryCursorParams) error + AdvanceForgeDeliveredRevision(ctx context.Context, arg AdvanceForgeDeliveredRevisionParams) (int64, error) + AgentForContainer(ctx context.Context, containerName string) (string, error) // Presence-component read queries (sqlc adoption T4, RIG-3034). These replace the // const-hoisted SQL in internal/store/presence_reads.go (it was never in the // inline-sql-gate allowlist — the gate is literal-at-callsite scoped and this @@ -34,7 +37,25 @@ type Querier interface { AgentNeighborhood(ctx context.Context, id string) ([]AgentNeighborhoodRow, error) AgentOwnersByIDs(ctx context.Context, dollar_1 []string) ([]string, error) AgentSubtree(ctx context.Context, accountID string) ([]AgentSubtreeRow, error) + // Feeds isAgentWorkspaceVisible: membership on the agent's home channel. + AgentWorkspaceVisible(ctx context.Context, arg AgentWorkspaceVisibleParams) (bool, error) AgentsByOwner(ctx context.Context, ownerUserID string) ([]AgentsByOwnerRow, error) + AuthoredArtifactByCoordinate(ctx context.Context, arg AuthoredArtifactByCoordinateParams) (ForgeAuthoredArtifact, error) + AuthoredArtifactByRequestID(ctx context.Context, arg AuthoredArtifactByRequestIDParams) (ForgeAuthoredArtifact, error) + // Agent-transcript queries (sqlc adoption T5, RIG-3034). These replace the + // inline SQL literals in internal/store/agent_transcripts.go; the hand-written + // Store methods keep their exact signatures and own the two-tier flush + // orchestration (PUT-before-prune, the RepeatableRead/ReadOnly resume snapshot + // tx, the safety-valve cut-point loop) plus the uint64<->int64 seq narrowing + // (toInt64/toUint64). They map generated rows into TranscriptEntryRow and + // ArchiveSegmentRow (via the transcriptRowsFromDB/segmentRowsFromDB helpers). + // + // COALESCE(MAX/SUM(...), 0) sites carry an explicit ::BIGINT cast so sqlc types + // them int64 rather than interface{} (mirrors messages.sql MessagesHeadSeq). The + // SessionTranscript and SafetyValveSegments reads are each issued on BOTH the + // pool (the eponymous method) and a snapshot tx (SessionResumeSnapshot, via + // WithTx), so one generated query backs both call sites. + BindLifetime(ctx context.Context, sessionID string) (int64, error) ChannelAgentMembers(ctx context.Context, arg ChannelAgentMembersParams) ([]string, error) ChannelGroupVisibleTo(ctx context.Context, arg ChannelGroupVisibleToParams) (bool, error) ChannelMemberExists(ctx context.Context, arg ChannelMemberExistsParams) (bool, error) @@ -43,18 +64,46 @@ type Querier interface { ChannelVisibleTo(ctx context.Context, arg ChannelVisibleToParams) (bool, error) ChannelsByNameForViewer(ctx context.Context, arg ChannelsByNameForViewerParams) ([]ChannelsByNameForViewerRow, error) ClearOwedMention(ctx context.Context, arg ClearOwedMentionParams) (int64, error) + CollectSegment(ctx context.Context, arg CollectSegmentParams) ([]CollectSegmentRow, error) ConvertDMChannel(ctx context.Context, arg ConvertDMChannelParams) error CoordinationReports(ctx context.Context, parentAgentID pgtype.Text) ([]string, error) + CountAgentForgeSubscriptionsForArtifact(ctx context.Context, arg CountAgentForgeSubscriptionsForArtifactParams) (int64, error) CountAgentMembers(ctx context.Context, channelID string) (int64, error) CountChannelPins(ctx context.Context, channelID string) (CountChannelPinsRow, error) CountOwedMentions(ctx context.Context) (int64, error) CountRootAgents(ctx context.Context, ownerUserID string) (int64, error) + CurrentAgentConfig(ctx context.Context) (CurrentAgentConfigRow, error) + DeclaredSecrets(ctx context.Context) ([]Secret, error) + DeleteAgentConfig(ctx context.Context) error + // Scoped to the calling agent (id AND agent). RETURNING the coordinate drives the + // one-tx GC of the artifact cursor when this was the last subscription. + DeleteAgentForgeSubscription(ctx context.Context, arg DeleteAgentForgeSubscriptionParams) (DeleteAgentForgeSubscriptionRow, error) + DeleteAgentPlacement(ctx context.Context, containerName string) error DeleteChannelMember(ctx context.Context, arg DeleteChannelMemberParams) (int64, error) DeleteChannelPin(ctx context.Context, arg DeleteChannelPinParams) error DeleteChannelPinReturningPosition(ctx context.Context, arg DeleteChannelPinReturningPositionParams) (int32, error) + DeleteSecret(ctx context.Context, name string) (int64, error) DeleteTopic(ctx context.Context, id string) error + // Agent-forge-subscription / artifact-cursor queries (sqlc adoption T6, + // RIG-3034). These replace the inline SQL literals in + // internal/store/forge_subscriptions.go; the hand-written Store methods keep + // their signatures, the door-side validation (validSubscriptionCoordinate / + // validCoordinate), the scope normalization, the ErrConflict/ErrInvalidArgument/ + // ErrNotFound mapping via pgErrIs, and the two hand-written tx seams: the + // DeleteAgentForgeSubscription GC (WithTx) and the ListForgeNotifyTargets row + // grouping. The read queries feed the ForgeNotifySubscriber / ForgeArtifactCursor + // / ForgeNotifyTarget mappers, which convert the generated rows (provider/kind + // ints, BIGINT numbers, LEFT-JOIN-nullable cursor columns) back to the domain + // types. + // Idempotent on the UNIQUE coordinate: the no-op DO UPDATE (re-set agent to + // itself) makes RETURNING fire on conflict so a repeat returns the stored id. + EnsureAgentForgeSubscription(ctx context.Context, arg EnsureAgentForgeSubscriptionParams) (string, error) EnsureChannelMember(ctx context.Context, arg EnsureChannelMemberParams) error + EnsureForgeRepoSubscription(ctx context.Context, arg EnsureForgeRepoSubscriptionParams) error FindAskMessage(ctx context.Context, arg FindAskMessageParams) ([]FindAskMessageRow, error) + // Collects the coordinate's cursor IFF no subscription for it remains (the NOT + // EXISTS guard leaves it in place if any other agent still subscribes). + GCForgeArtifactCursorIfUnsubscribed(ctx context.Context, arg GCForgeArtifactCursorIfUnsubscribedParams) error GetAccount(ctx context.Context, id string) (GetAccountRow, error) GetAccountByGlobalHandle(ctx context.Context, handle string) (GetAccountByGlobalHandleRow, error) GetAccountByOwnerHandle(ctx context.Context, arg GetAccountByOwnerHandleParams) (GetAccountByOwnerHandleRow, error) @@ -81,15 +130,35 @@ type Querier interface { // The member INSERT/DELETE reuse EnsureChannelMember (accounts.sql) and // DeleteChannelMember (channels.sql) — the statements are identical. GetCoordinationGroup(ctx context.Context, arg GetCoordinationGroupParams) (string, error) + GetDMChannelByName(ctx context.Context, arg GetDMChannelByNameParams) (GetDMChannelByNameRow, error) GetGlobalHandleID(ctx context.Context, handle string) (string, error) + // Feeds isReservedDMGroupTx: the reserved-DM-group discriminator (name AND + // VisibilityOwner) the CreateChannel create-guard keys on. + GetGroupNameVisibility(ctx context.Context, id string) (GetGroupNameVisibilityRow, error) + GetIssue(ctx context.Context, id string) (GetIssueRow, error) GetMessageBlocks(ctx context.Context, id string) ([]byte, error) GetMessageByRequestID(ctx context.Context, arg GetMessageByRequestIDParams) ([]GetMessageByRequestIDRow, error) + // Peer-DM channel queries (sqlc adoption T6, RIG-3034; dm.go was added to the + // store after the design record froze — the record's "plus any residue"). These + // replace the inline SQL literals in internal/store/dm.go; the hand-written Store + // methods keep their signatures and every seam that is NOT a single statement: + // the per-owner advisory lock (LockDM), the resolution/insert loop, the R3 + // verify-reconcile belt, the transitive-owner membership expansion, and the + // cursor seeding (seedChannelDeliveryCursors, delivery_cursors.sql). The member + // INSERTs reuse EnsureChannelMember (accounts.sql) — the statement is identical. + // Visibility-discriminated get-half: a wider (SHARED) planted __dm__ group must + // NEVER be adopted, so visibility = $3 (bound to VisibilityOwner) excludes it. + GetOwnerDMGroup(ctx context.Context, arg GetOwnerDMGroupParams) (string, error) GetPageCursorSeq(ctx context.Context, arg GetPageCursorSeqParams) (int64, error) GetTopic(ctx context.Context, id string) (Topic, error) GetTopicByName(ctx context.Context, arg GetTopicByNameParams) (GetTopicByNameRow, error) GetTopicChannel(ctx context.Context, id string) (string, error) GetVisibleAgentHandleID(ctx context.Context, arg GetVisibleAgentHandleIDParams) (string, error) GetVisibleGlobalHandleID(ctx context.Context, arg GetVisibleGlobalHandleIDParams) (string, error) + // Feeds requireGroupCreateAuthz: owner, agent-owner, or SHARED-visibility group. + GroupCreateAuthorized(ctx context.Context, arg GroupCreateAuthorizedParams) (bool, error) + HotTailBytes(ctx context.Context, arg HotTailBytesParams) (int64, error) + HotTailSizes(ctx context.Context, arg HotTailSizesParams) ([]HotTailSizesRow, error) InSweepSet(ctx context.Context, arg InSweepSetParams) (bool, error) // Account-domain queries (sqlc adoption T2, RIG-3034). These replace the inline // SQL literals that lived in internal/store/accounts.go; the hand-written Store @@ -108,7 +177,15 @@ type Querier interface { InsertAccount(ctx context.Context, arg InsertAccountParams) error InsertAccountHandle(ctx context.Context, arg InsertAccountHandleParams) error InsertAgentAccount(ctx context.Context, arg InsertAgentAccountParams) error + // Agent-session queries (sqlc adoption T5, RIG-3034). These replace the inline + // SQL literals that lived in internal/store/agent_sessions.go; the hand-written + // Store methods keep their exact signatures and wrap these generated calls, + // mapping AccountID newtypes and the not-found/forbidden merge (D9) by hand. No + // domain xFromRow mapper — the session reads project scalar columns the methods + // return directly. + InsertAgentSession(ctx context.Context, arg InsertAgentSessionParams) error InsertAgentWorkspaceIgnore(ctx context.Context, arg InsertAgentWorkspaceIgnoreParams) error + InsertArchiveSegment(ctx context.Context, arg InsertArchiveSegmentParams) error InsertChannel(ctx context.Context, arg InsertChannelParams) error // Channel-domain queries (sqlc adoption T3, RIG-3034). These replace the inline // SQL literals that lived in internal/store/channels.go; the hand-written Store @@ -126,14 +203,52 @@ type Querier interface { InsertChannelPin(ctx context.Context, arg InsertChannelPinParams) error InsertCoordinationChannel(ctx context.Context, arg InsertCoordinationChannelParams) (string, error) InsertCoordinationGroup(ctx context.Context, arg InsertCoordinationGroupParams) error + // Born kind=DM, zero-value policy (OPEN, ownerless) + mandatory; poison-free via + // ON CONFLICT DO NOTHING on the partial unique index (a concurrent open yields + // zero rows, never a raised unique-violation). + InsertDMChannel(ctx context.Context, arg InsertDMChannelParams) (string, error) InsertHomeChannel(ctx context.Context, arg InsertHomeChannelParams) error InsertMessage(ctx context.Context, arg InsertMessageParams) (InsertMessageRow, error) + InsertOwnerDMGroup(ctx context.Context, arg InsertOwnerDMGroupParams) error + // Secrets-registry queries (sqlc adoption T6, RIG-3034). These replace the inline + // SQL literals in internal/store/secrets.go; the hand-written Store methods keep + // their signatures, the door-side validation (name grammar, kind routing), the + // ErrConflict/ErrInvalidArgument/ErrNotFound mapping, and the RowsAffected branch + // (DeleteSecretDeclaration is :execrows). DeclaredSecrets maps the generated row + // back to the domain SecretDeclaration (delivery/kind ints -> named types). + InsertSecret(ctx context.Context, arg InsertSecretParams) error InsertSystemAccount(ctx context.Context, accountID string) error + // Tenant-bootstrap queries (sqlc adoption T6, RIG-3034). These replace the inline + // SQL literals in internal/store/tenant.go; the hand-written Store methods keep + // their signatures and the unique-violation-means-fetch idempotent bootstrap + // shape (BootstrapTenant falls back to TenantIDBySlug on a duplicate slug). + InsertTenant(ctx context.Context, arg InsertTenantParams) error + // Token-domain queries (sqlc adoption T6, RIG-3034). These replace the inline + // SQL literals in internal/store/tokens.go; the hand-written Store methods keep + // their signatures, the ErrConflict/ErrNotFound/ErrTokenRevoked mapping, and the + // RowsAffected branching (RevokeToken is :execrows). ResolveTokenHash maps the + // generated row (subject_kind/subject_id/revoked) back to the domain Subject. + InsertTokenHash(ctx context.Context, arg InsertTokenHashParams) error InsertTopicIgnore(ctx context.Context, arg InsertTopicIgnoreParams) error + InsertTranscriptEntry(ctx context.Context, arg InsertTranscriptEntryParams) (int64, error) InsertUserAccount(ctx context.Context, arg InsertUserAccountParams) error IsAgentAccount(ctx context.Context, accountID string) (bool, error) + IsEnabledForgeRepo(ctx context.Context, repo string) (bool, error) + LatestCheckpointSeq(ctx context.Context, sessionID string) (int64, error) + LatestSessionForAccount(ctx context.Context, agentAccountID string) (string, error) + LinearAgentSession(ctx context.Context, linearSessionID string) (LinearAgentSession, error) + ListAgentPlacementsForRunner(ctx context.Context, runnerID string) ([]ListAgentPlacementsForRunnerRow, error) + ListAuthoredArtifactsByAgent(ctx context.Context, agentAccountID string) ([]ForgeAuthoredArtifact, error) ListChannelGroups(ctx context.Context, accountID string) ([]ListChannelGroupsRow, error) ListChannels(ctx context.Context, accountID string) ([]ListChannelsRow, error) + ListEnabledForgeRepoSubscriptions(ctx context.Context, arg ListEnabledForgeRepoSubscriptionsParams) ([]ListEnabledForgeRepoSubscriptionsRow, error) + ListEnabledForgeRepos(ctx context.Context) ([]string, error) + // The reconcile sweep's work list for one (provider, host): each subscribed + // coordinate with its LEFT-JOINed shared FETCH cursor (nullable when never + // observed) and the subscriber rows, container-scope rows collapsed per + // (repo, kind) to coord_number 0. The Go groups the flat rows into targets. + ListForgeNotifyTargets(ctx context.Context, arg ListForgeNotifyTargetsParams) ([]ListForgeNotifyTargetsRow, error) + ListIssues(ctx context.Context) ([]ListIssuesRow, error) ListMessages(ctx context.Context, arg ListMessagesParams) ([]ListMessagesRow, error) // Topic-domain queries (sqlc adoption T4, RIG-3034). These replace the inline // SQL literals in internal/store/topics.go; the hand-written Store methods keep @@ -144,6 +259,15 @@ type Querier interface { ListTopics(ctx context.Context, arg ListTopicsParams) ([]Topic, error) ListVisibleAccounts(ctx context.Context, id string) ([]ListVisibleAccountsRow, error) LoadDeliveryCursor(ctx context.Context, arg LoadDeliveryCursorParams) (LoadDeliveryCursorRow, error) + LoadForgeArtifactCursor(ctx context.Context, arg LoadForgeArtifactCursorParams) (LoadForgeArtifactCursorRow, error) + // Forge repo-subscription / watermark queries (sqlc adoption T6, RIG-3034). These + // replace the inline SQL literals in internal/store/forge_cursors.go; the + // hand-written Store methods keep their signatures, the door-side validation + // (validCoordinate), the ErrNotFound mapping, and the RowsAffected branches + // (StoreForgeRepoWatermark / SetForgeRepoSubscriptionEnabled are :execrows). The + // read methods map the generated rows (provider int, nullable swept_updated_at) + // back to the domain time.Time / ForgeRepoSubscription. + LoadForgeRepoWatermark(ctx context.Context, arg LoadForgeRepoWatermarkParams) (LoadForgeRepoWatermarkRow, error) // Channel-pins (pinned board) queries (sqlc adoption T3, RIG-3034). These // replace the inline SQL literals in internal/store/channel_pins.go; the // hand-written Store methods and the in-tx FOR UPDATE lock / cap-check control @@ -152,6 +276,7 @@ type Querier interface { LockChannelMandatoryKind(ctx context.Context, id string) (LockChannelMandatoryKindRow, error) LockChannelPolicy(ctx context.Context, id string) (LockChannelPolicyRow, error) LockOwnerCoordination(ctx context.Context, dollar_1 pgtype.Text) error + LockOwnerDM(ctx context.Context, dollar_1 pgtype.Text) error MarkMentionsRouted(ctx context.Context, arg MarkMentionsRoutedParams) error MergeTopicLastSeq(ctx context.Context, arg MergeTopicLastSeqParams) error MessageByID(ctx context.Context, id string) (MessageByIDRow, error) @@ -162,14 +287,43 @@ type Querier interface { OwedMentions(ctx context.Context, agentAccountID string) ([]OwedMentionsRow, error) OwnerHasPresentAgent(ctx context.Context, arg OwnerHasPresentAgentParams) (bool, error) PinnedEntries(ctx context.Context, channelID string) ([]PinnedEntriesRow, error) + PlacementForAgent(ctx context.Context, agentAccountID string) (PlacementForAgentRow, error) + PruneTranscriptEntries(ctx context.Context, arg PruneTranscriptEntriesParams) error + // Agent config-bundle queries (sqlc adoption T5, RIG-3034). These replace the + // inline SQL literals in internal/store/agent_config.go; the hand-written Store + // methods keep their signatures and own the bundle validation/hash and the + // tar-walk member inventory (Go-side, over the returned BYTEA). The config + // bundle is a fleet-wide singleton row (singleton = TRUE). + PutAgentConfig(ctx context.Context, arg PutAgentConfigParams) error + ReassertDMMandatory(ctx context.Context, id string) error + // Agent-placement queries (sqlc adoption T5, RIG-3034). These replace the inline + // SQL literals in internal/store/agent_placements.go; the hand-written Store + // methods keep their signatures and map the placement rows into the + // AgentPlacement domain struct (AccountID newtype done inline in the Go). + RecordAgentPlacement(ctx context.Context, arg RecordAgentPlacementParams) error + // Forge-authored-artifact queries (sqlc adoption T6, RIG-3034). These replace the + // inline SQL literals in internal/store/forge_authored.go; the hand-written Store + // methods keep their signatures, the door-side validation (valid/validCoordinate), + // the ErrConflict/ErrInvalidArgument/ErrNotFound mapping via pgErrIs, and the + // textOrNull client_request_id NULL discipline. The read queries feed + // authoredArtifactFromRow, which maps the generated row (provider/kind ints, + // number BIGINT, nullable client_request_id) back to the domain AuthoredArtifact. + // WRITE-ONCE authorship: the DO UPDATE deliberately omits agent_account_id and + // owner_user_id, so a re-land never rewrites who authored the artifact. + RecordAuthoredArtifact(ctx context.Context, arg RecordAuthoredArtifactParams) error RecordOwedMention(ctx context.Context, arg RecordOwedMentionParams) error + RemarkSafetyValveSuperseded(ctx context.Context, arg RemarkSafetyValveSupersededParams) error RenameTopic(ctx context.Context, arg RenameTopicParams) error + RequireAgentSessionSubscriber(ctx context.Context, arg RequireAgentSessionSubscriberParams) (bool, error) ResolveAckMessage(ctx context.Context, arg ResolveAckMessageParams) (int64, error) ResolveCoordinationManager(ctx context.Context, id string) (ResolveCoordinationManagerRow, error) ResolveOwner(ctx context.Context, accountID string) (string, error) + ResolveTokenHash(ctx context.Context, hash []byte) (ResolveTokenHashRow, error) ResolveTopicForUpdate(ctx context.Context, arg ResolveTopicForUpdateParams) (string, error) ResolveTopicRenameTarget(ctx context.Context, arg ResolveTopicRenameTargetParams) (string, error) ReviveTopic(ctx context.Context, id string) error + RevokeToken(ctx context.Context, hash []byte) (int64, error) + SafetyValveSegments(ctx context.Context, arg SafetyValveSegmentsParams) ([]SafetyValveSegmentsRow, error) // Scaffold-only query proving sqlc generation works end to end (T1). // // This is NOT a real store query: the per-domain query files land in T2..T6 as @@ -190,8 +344,20 @@ type Querier interface { SeedDeliveryCursor(ctx context.Context, arg SeedDeliveryCursorParams) error SeedHomeChannelMembers(ctx context.Context, arg SeedHomeChannelMembersParams) error SelfAuthoredSeqsAbove(ctx context.Context, arg SelfAuthoredSeqsAboveParams) ([]int64, error) + SessionBase(ctx context.Context, sessionID string) (int64, error) + SessionMaxEntrySeq(ctx context.Context, sessionID string) (int64, error) + SessionTranscript(ctx context.Context, sessionID string) ([]SessionTranscriptRow, error) + // Agent-activity queries (sqlc adoption T5, RIG-3034). These replace the inline + // SQL literals in internal/store/agent_activity.go; the hand-written Store + // methods keep their signatures and map the ActivityFor rows into the + // AgentActivity domain struct (agentActivityFromRow-equivalent, done inline in + // the Go — absent-from-table means absent-from-map). + SetActivity(ctx context.Context, arg SetActivityParams) error + SetForgeRepoSubscriptionEnabled(ctx context.Context, arg SetForgeRepoSubscriptionEnabledParams) (int64, error) + SetIssueState(ctx context.Context, arg SetIssueStateParams) (int64, error) SetTopicArchived(ctx context.Context, arg SetTopicArchivedParams) error SharesVisibleChannel(ctx context.Context, arg SharesVisibleChannelParams) (bool, error) + StoreForgeRepoWatermark(ctx context.Context, arg StoreForgeRepoWatermarkParams) (int64, error) SubscribeConvertedDMParties(ctx context.Context, channelID string) error // Delivery-consumer read queries (sqlc adoption T4, RIG-3034). These replace the // inline SQL literals in internal/store/delivery_reads.go; the hand-written Store @@ -200,7 +366,20 @@ type Querier interface { // error mapping. MessageByID shares the message projection the Go drains via // messageFromParts. SubscribedAgents(ctx context.Context, arg SubscribedAgentsParams) ([]string, error) + // Exact-artifact subscribers, plus (on an opened event) the container-scope + // subscribers for the same container/project. + SubscribersForArtifact(ctx context.Context, arg SubscribersForArtifactParams) ([]SubscribersForArtifactRow, error) SweepChannels(ctx context.Context, accountID string) ([]string, error) + TenantIDBySlug(ctx context.Context, slug string) (string, error) + TokenHashExists(ctx context.Context, hash []byte) (bool, error) + // Authorization-probe queries (sqlc adoption T6, RIG-3034). These replace the + // inline SQL literals in internal/store/authz.go; the hand-written helpers keep + // their signatures and the not-found/forbidden merge, wrapping these EXISTS + // probes (each returns a bare bool). requireChannelMember / isChannelMember reuse + // ChannelMemberExists (channels.sql) — the statement is textually identical — so + // only the three probes without an existing query live here. + // Feeds IsTopicChannelMember: membership on the channel that owns the topic. + TopicChannelMemberExists(ctx context.Context, arg TopicChannelMemberExistsParams) (bool, error) TopicChannelNames(ctx context.Context, id string) (TopicChannelNamesRow, error) UndeliveredMessages(ctx context.Context, accountID string) ([]UndeliveredMessagesRow, error) UnroutedMentionMessages(ctx context.Context, arg UnroutedMentionMessagesParams) ([]UnroutedMentionMessagesRow, error) @@ -210,6 +389,26 @@ type Querier interface { UpdateMessageBlocksAsAuthor(ctx context.Context, arg UpdateMessageBlocksAsAuthorParams) (UpdateMessageBlocksAsAuthorRow, error) UpdateTopicLastSeq(ctx context.Context, arg UpdateTopicLastSeqParams) error UpsertChannelMember(ctx context.Context, arg UpsertChannelMemberParams) error + UpsertForgeArtifactCursor(ctx context.Context, arg UpsertForgeArtifactCursorParams) error + // Issue-domain queries (sqlc adoption T6, RIG-3034). These replace the inline + // SQL literals in internal/store/issues.go; the hand-written Store methods keep + // their signatures, the door-side validation, the ErrNotFound/ErrInvalidArgument + // mapping, and the RowsAffected branch (SetIssueState is :execrows). GetIssue / + // ListIssues feed issueFromColumns (via issueFromGetRow / issueFromListRow), + // which maps the generated row (forge_provider/state ints, number BIGINT) back + // to the domain Issue. + // Insert-or-update at the forge coordinate with the OQ-6(a) recency guard; the + // ON CONFLICT sets ONLY forge columns (never state/machinery), and the CTE's + // fallback SELECT keeps the returned id stable when the guard skips the UPDATE. + UpsertIssueForgeFields(ctx context.Context, arg UpsertIssueForgeFieldsParams) (string, error) + // Linear-agent-session queries (sqlc adoption T6, RIG-3034). These replace the + // inline SQL literals in internal/store/linear_sessions.go; the hand-written + // Store methods keep their signatures, the RowsAffected branch (Upsert returns + // created via :execrows), the textOrNull linear_issue_id NULL discipline, and the + // ErrNotFound/ErrInvalidArgument mapping. The LinearAgentSession read maps the + // generated row (nullable linear_issue_id, created_at timestamp) back to the + // domain LinearAgentSessionRow inline. + UpsertLinearAgentSession(ctx context.Context, arg UpsertLinearAgentSessionParams) (int64, error) } var _ Querier = (*Queries)(nil) diff --git a/go/internal/store/db/secrets.sql.go b/go/internal/store/db/secrets.sql.go new file mode 100644 index 000000000..eb7634fed --- /dev/null +++ b/go/internal/store/db/secrets.sql.go @@ -0,0 +1,89 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: secrets.sql + +package db + +import ( + "context" +) + +const declaredSecrets = `-- name: DeclaredSecrets :many +SELECT name, delivery, kind, provider, host, declared_by, created_at, updated_at +FROM secrets ORDER BY name +` + +func (q *Queries) DeclaredSecrets(ctx context.Context) ([]Secret, error) { + rows, err := q.db.Query(ctx, declaredSecrets) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Secret + for rows.Next() { + var i Secret + if err := rows.Scan( + &i.Name, + &i.Delivery, + &i.Kind, + &i.Provider, + &i.Host, + &i.DeclaredBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteSecret = `-- name: DeleteSecret :execrows +DELETE FROM secrets WHERE name = $1 +` + +func (q *Queries) DeleteSecret(ctx context.Context, name string) (int64, error) { + result, err := q.db.Exec(ctx, deleteSecret, name) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const insertSecret = `-- name: InsertSecret :exec + +INSERT INTO secrets (name, delivery, kind, provider, host, declared_by) +VALUES ($1, $2, $3, $4, $5, $6) +` + +type InsertSecretParams struct { + Name string + Delivery int16 + Kind int16 + Provider string + Host string + DeclaredBy string +} + +// Secrets-registry queries (sqlc adoption T6, RIG-3034). These replace the inline +// SQL literals in internal/store/secrets.go; the hand-written Store methods keep +// their signatures, the door-side validation (name grammar, kind routing), the +// ErrConflict/ErrInvalidArgument/ErrNotFound mapping, and the RowsAffected branch +// (DeleteSecretDeclaration is :execrows). DeclaredSecrets maps the generated row +// back to the domain SecretDeclaration (delivery/kind ints -> named types). +func (q *Queries) InsertSecret(ctx context.Context, arg InsertSecretParams) error { + _, err := q.db.Exec(ctx, insertSecret, + arg.Name, + arg.Delivery, + arg.Kind, + arg.Provider, + arg.Host, + arg.DeclaredBy, + ) + return err +} diff --git a/go/internal/store/db/tenant.sql.go b/go/internal/store/db/tenant.sql.go new file mode 100644 index 000000000..9d10d1456 --- /dev/null +++ b/go/internal/store/db/tenant.sql.go @@ -0,0 +1,47 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: tenant.sql + +package db + +import ( + "context" +) + +const insertTenant = `-- name: InsertTenant :exec + +INSERT INTO tenants (id, slug, display_name, created_at_unix_ms) VALUES ($1, $2, $3, $4) +` + +type InsertTenantParams struct { + ID string + Slug string + DisplayName string + CreatedAtUnixMs int64 +} + +// Tenant-bootstrap queries (sqlc adoption T6, RIG-3034). These replace the inline +// SQL literals in internal/store/tenant.go; the hand-written Store methods keep +// their signatures and the unique-violation-means-fetch idempotent bootstrap +// shape (BootstrapTenant falls back to TenantIDBySlug on a duplicate slug). +func (q *Queries) InsertTenant(ctx context.Context, arg InsertTenantParams) error { + _, err := q.db.Exec(ctx, insertTenant, + arg.ID, + arg.Slug, + arg.DisplayName, + arg.CreatedAtUnixMs, + ) + return err +} + +const tenantIDBySlug = `-- name: TenantIDBySlug :one +SELECT id FROM tenants WHERE slug = $1 +` + +func (q *Queries) TenantIDBySlug(ctx context.Context, slug string) (string, error) { + row := q.db.QueryRow(ctx, tenantIDBySlug, slug) + var id string + err := row.Scan(&id) + return id, err +} diff --git a/go/internal/store/db/tokens.sql.go b/go/internal/store/db/tokens.sql.go new file mode 100644 index 000000000..1dc3d614a --- /dev/null +++ b/go/internal/store/db/tokens.sql.go @@ -0,0 +1,71 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: tokens.sql + +package db + +import ( + "context" +) + +const insertTokenHash = `-- name: InsertTokenHash :exec + +INSERT INTO tokens (hash, subject_kind, subject_id) VALUES ($1, $2, $3) +` + +type InsertTokenHashParams struct { + Hash []byte + SubjectKind int16 + SubjectID string +} + +// Token-domain queries (sqlc adoption T6, RIG-3034). These replace the inline +// SQL literals in internal/store/tokens.go; the hand-written Store methods keep +// their signatures, the ErrConflict/ErrNotFound/ErrTokenRevoked mapping, and the +// RowsAffected branching (RevokeToken is :execrows). ResolveTokenHash maps the +// generated row (subject_kind/subject_id/revoked) back to the domain Subject. +func (q *Queries) InsertTokenHash(ctx context.Context, arg InsertTokenHashParams) error { + _, err := q.db.Exec(ctx, insertTokenHash, arg.Hash, arg.SubjectKind, arg.SubjectID) + return err +} + +const resolveTokenHash = `-- name: ResolveTokenHash :one +SELECT subject_kind, subject_id, (revoked_at IS NOT NULL)::boolean AS revoked FROM tokens WHERE hash = $1 +` + +type ResolveTokenHashRow struct { + SubjectKind int16 + SubjectID string + Revoked bool +} + +func (q *Queries) ResolveTokenHash(ctx context.Context, hash []byte) (ResolveTokenHashRow, error) { + row := q.db.QueryRow(ctx, resolveTokenHash, hash) + var i ResolveTokenHashRow + err := row.Scan(&i.SubjectKind, &i.SubjectID, &i.Revoked) + return i, err +} + +const revokeToken = `-- name: RevokeToken :execrows +UPDATE tokens SET revoked_at = now() WHERE hash = $1 AND revoked_at IS NULL +` + +func (q *Queries) RevokeToken(ctx context.Context, hash []byte) (int64, error) { + result, err := q.db.Exec(ctx, revokeToken, hash) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const tokenHashExists = `-- name: TokenHashExists :one +SELECT EXISTS (SELECT 1 FROM tokens WHERE hash = $1) +` + +func (q *Queries) TokenHashExists(ctx context.Context, hash []byte) (bool, error) { + row := q.db.QueryRow(ctx, tokenHashExists, hash) + var exists bool + err := row.Scan(&exists) + return exists, err +} diff --git a/go/internal/store/dm.go b/go/internal/store/dm.go index 446dbae3d..249c522b3 100644 --- a/go/internal/store/dm.go +++ b/go/internal/store/dm.go @@ -5,6 +5,9 @@ import ( "fmt" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // dmGroupName is the fixed reserved name of the per-owner DM group — the @@ -48,11 +51,12 @@ func (s *Store) EnsureOwnerDMGroupTx(ctx context.Context, tx pgx.Tx, ownerUserID return "", fmt.Errorf("%w: owner user id is required", ErrInvalidArgument) } - var existing string - switch err := tx.QueryRow(ctx, - `SELECT id FROM channel_groups WHERE owner_user_id = $1 AND name = $2 AND parent_group_id IS NULL AND visibility = $3`, - string(ownerUserID), dmGroupName, int32(VisibilityOwner), - ).Scan(&existing); { + qtx := db.New(tx) + switch existing, err := qtx.GetOwnerDMGroup(ctx, db.GetOwnerDMGroupParams{ + OwnerUserID: string(ownerUserID), + Name: dmGroupName, + Visibility: int16(VisibilityOwner), + }); { case err == nil: return ChannelGroupID(existing), nil case !noRows(err): @@ -60,10 +64,12 @@ func (s *Store) EnsureOwnerDMGroupTx(ctx context.Context, tx pgx.Tx, ownerUserID } id := newID() - if _, err := tx.Exec(ctx, - `INSERT INTO channel_groups (id, name, parent_group_id, owner_user_id, visibility) VALUES ($1, $2, NULL, $3, $4)`, - id, dmGroupName, string(ownerUserID), int32(VisibilityOwner), - ); err != nil { + if err := qtx.InsertOwnerDMGroup(ctx, db.InsertOwnerDMGroupParams{ + ID: id, + Name: dmGroupName, + OwnerUserID: string(ownerUserID), + Visibility: int16(VisibilityOwner), + }); err != nil { return "", fmt.Errorf("store: insert dm group: %w", err) } return ChannelGroupID(id), nil @@ -109,22 +115,20 @@ func (s *Store) UpsertDMChannelTx(ctx context.Context, tx pgx.Tx, spec DMChannel return "", false, err } + qtx := db.New(tx) + groupID := pgtype.Text{String: string(spec.GroupID), Valid: true} for { - var ( - existingID string - existingKind int32 - ) - switch err := tx.QueryRow(ctx, - `SELECT id, kind FROM channels WHERE group_id = $1 AND name = $2`, - string(spec.GroupID), spec.Name, - ).Scan(&existingID, &existingKind); { + switch existing, err := qtx.GetDMChannelByName(ctx, db.GetDMChannelByNameParams{ + GroupID: groupID, + Name: spec.Name, + }); { case err == nil: // Resume: the R3 belt verifies + reconciles the resolved row before // adopting it, and returns ErrNotFound on a wrong-kind squat. - if err := verifyReconcileDMTx(ctx, tx, ChannelID(existingID), ChannelKind(existingKind), members); err != nil { + if err := verifyReconcileDMTx(ctx, tx, ChannelID(existing.ID), ChannelKind(existing.Kind), members); err != nil { return "", false, err } - return ChannelID(existingID), false, nil + return ChannelID(existing.ID), false, nil case !noRows(err): return "", false, fmt.Errorf("store: resolve dm channel: %w", err) } @@ -135,21 +139,20 @@ func (s *Store) UpsertDMChannelTx(ctx context.Context, tx pgx.Tx, spec DMChannel // rows returned rather than a raised unique-violation — so the tx is // never poisoned; we loop and resume the committed row. id := newID() - switch err := tx.QueryRow(ctx, - `INSERT INTO channels (id, name, group_id, kind, post_policy, owner_account_id, mandatory_subscription) `+ - `VALUES ($1, $2, $3, $4, $5, NULL, $6) `+ - `ON CONFLICT (group_id, name) WHERE group_id IS NOT NULL DO NOTHING `+ - `RETURNING id`, - id, spec.Name, string(spec.GroupID), int32(ChannelKindDM), - int32(ChannelPostPolicyOpen), true, - ).Scan(&id); { + switch insertedID, err := qtx.InsertDMChannel(ctx, db.InsertDMChannelParams{ + ID: id, + Name: spec.Name, + GroupID: groupID, + Kind: int16(ChannelKindDM), + PostPolicy: int16(ChannelPostPolicyOpen), + MandatorySubscription: true, + }); { case err == nil: for _, m := range members { - if _, err := tx.Exec(ctx, - `INSERT INTO channel_members (channel_id, account_id, subscribed) VALUES ($1, $2, FALSE) `+ - `ON CONFLICT (channel_id, account_id) DO NOTHING`, - id, string(m), - ); err != nil { + if err := qtx.EnsureChannelMember(ctx, db.EnsureChannelMemberParams{ + ChannelID: insertedID, + AccountID: string(m), + }); err != nil { return "", false, upsertMemberErr(err, m) } } @@ -158,10 +161,10 @@ func (s *Store) UpsertDMChannelTx(ctx context.Context, tx pgx.Tx, spec DMChannel // delivery cursor MUST be seeded in this same tx — an un-seeded // delivery target is the fail-DANGEROUS D2 hazard. Self-guarding // (agent-only) and idempotent, so human members are a no-op. - if err := seedChannelDeliveryCursors(ctx, tx, ChannelID(id)); err != nil { + if err := seedChannelDeliveryCursors(ctx, tx, ChannelID(insertedID)); err != nil { return "", false, err } - return ChannelID(id), true, nil + return ChannelID(insertedID), true, nil case noRows(err): // A concurrent open won the (group, name) race. Re-SELECT resolves it // as a resume on the next iteration. @@ -180,7 +183,7 @@ func (s *Store) UpsertDMChannelTx(ctx context.Context, tx pgx.Tx, spec DMChannel // the advisory lock takes; a hash collision across two owners is a benign // redundant wait, never a wrong result. func LockOwnerDMTx(ctx context.Context, tx pgx.Tx, ownerUserID AccountID) error { - if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('dm:' || $1))`, string(ownerUserID)); err != nil { + if err := db.New(tx).LockOwnerDM(ctx, pgtype.Text{String: string(ownerUserID), Valid: true}); err != nil { return fmt.Errorf("store: lock owner dm: %w", err) } return nil @@ -206,10 +209,8 @@ func verifyReconcileDMTx(ctx context.Context, tx pgx.Tx, channelID ChannelID, ki if kind != ChannelKindDM { return fmt.Errorf("%w: dm channel %q", ErrNotFound, channelID) } - if _, err := tx.Exec(ctx, - `UPDATE channels SET mandatory_subscription = TRUE WHERE id = $1 AND mandatory_subscription = FALSE`, - string(channelID), - ); err != nil { + qtx := db.New(tx) + if err := qtx.ReassertDMMandatory(ctx, string(channelID)); err != nil { return fmt.Errorf("store: reassert dm mandatory: %w", err) } // Seed EVERY current agent member's delivery cursor (not only re-added ones), @@ -222,11 +223,10 @@ func verifyReconcileDMTx(ctx context.Context, tx pgx.Tx, channelID ChannelID, ki return err } for _, m := range wanted { - if _, err := tx.Exec(ctx, - `INSERT INTO channel_members (channel_id, account_id, subscribed) VALUES ($1, $2, FALSE) `+ - `ON CONFLICT (channel_id, account_id) DO NOTHING`, - string(channelID), string(m), - ); err != nil { + if err := qtx.EnsureChannelMember(ctx, db.EnsureChannelMemberParams{ + ChannelID: string(channelID), + AccountID: string(m), + }); err != nil { return upsertMemberErr(err, m) } } @@ -248,16 +248,9 @@ func verifyReconcileDMTx(ctx context.Context, tx pgx.Tx, channelID ChannelID, ki // matches, guarding CreateChannel against it makes squatting a dm--… name // impossible with no in-advance existence check. func isReservedDMGroupTx(ctx context.Context, tx pgx.Tx, groupID ChannelGroupID) (bool, error) { - var ( - name string - vis int32 - ) - switch err := tx.QueryRow(ctx, - `SELECT name, visibility FROM channel_groups WHERE id = $1`, - string(groupID), - ).Scan(&name, &vis); { + switch row, err := db.New(tx).GetGroupNameVisibility(ctx, string(groupID)); { case err == nil: - return name == dmGroupName && ChannelGroupVisibility(vis) == VisibilityOwner, nil + return row.Name == dmGroupName && ChannelGroupVisibility(row.Visibility) == VisibilityOwner, nil case noRows(err): return false, nil default: diff --git a/go/internal/store/forge_authored.go b/go/internal/store/forge_authored.go index fdaade458..cb8fb231c 100644 --- a/go/internal/store/forge_authored.go +++ b/go/internal/store/forge_authored.go @@ -4,7 +4,9 @@ import ( "context" "fmt" - "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // The DL-055 forge ownership index (design @@ -84,19 +86,18 @@ func (s *Store) RecordAuthoredArtifact(ctx context.Context, a AuthoredArtifact) if err := a.valid(); err != nil { return err } - if _, err := s.pool.Exec(ctx, - `INSERT INTO forge_authored_artifacts - (forge_provider, forge_host, repo, kind, number, - agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (forge_provider, forge_host, repo, kind, number) DO UPDATE - SET session_id = EXCLUDED.session_id, - client_request_id = EXCLUDED.client_request_id, - created_at_unix_ms = EXCLUDED.created_at_unix_ms`, - int32(a.Provider), a.Host, a.Repo, int32(a.Kind), int64(a.Number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number) written to a BIGINT, always well within the int64 domain — never near the uint64 ceiling. - string(a.AgentAccountID), string(a.OwnerUserID), a.SessionID, - nullIfEmpty(a.ClientRequestID), a.CreatedAtUnixMS, - ); err != nil { + if err := s.q.RecordAuthoredArtifact(ctx, db.RecordAuthoredArtifactParams{ + ForgeProvider: int16(a.Provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum (forge_authored_artifacts.forge_provider), always within int16 + ForgeHost: a.Host, + Repo: a.Repo, + Kind: int16(a.Kind), //nolint:gosec // G115: ForgeArtifactKind is a CHECK-constrained 1/2 enum (forge_authored_artifacts.kind), always within int16 + Number: int64(a.Number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number) written to a BIGINT, always well within the int64 domain — never near the uint64 ceiling. + AgentAccountID: string(a.AgentAccountID), + OwnerUserID: string(a.OwnerUserID), + SessionID: a.SessionID, + ClientRequestID: textOrNull(a.ClientRequestID), + CreatedAtUnixMs: a.CreatedAtUnixMS, + }); err != nil { if pgErrIs(err, pgUniqueViolation) { return fmt.Errorf("%w: client request id %q already authored for agent %q", ErrConflict, a.ClientRequestID, a.AgentAccountID) } @@ -120,21 +121,17 @@ func (s *Store) AuthoredArtifactByRequestID(ctx context.Context, agent AccountID if clientRequestID == "" { return AuthoredArtifact{}, false, nil } - row := s.pool.QueryRow(ctx, - `SELECT forge_provider, forge_host, repo, kind, number, - agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms - FROM forge_authored_artifacts - WHERE agent_account_id = $1 AND client_request_id = $2`, - string(agent), clientRequestID, - ) - a, err := scanAuthoredArtifact(row) + row, err := s.q.AuthoredArtifactByRequestID(ctx, db.AuthoredArtifactByRequestIDParams{ + AgentAccountID: string(agent), + ClientRequestID: pgtype.Text{String: clientRequestID, Valid: true}, + }) if err != nil { if noRows(err) { return AuthoredArtifact{}, false, nil } return AuthoredArtifact{}, false, fmt.Errorf("store: read authored artifact by request id: %w", err) } - return a, true, nil + return authoredArtifactFromRow(row), true, nil } // AuthoredArtifactByCoordinate reads the ownership row at a forge coordinate — @@ -151,21 +148,20 @@ func (s *Store) AuthoredArtifactByCoordinate(ctx context.Context, provider Forge if kind == ForgeArtifactKindUnspecified { return AuthoredArtifact{}, fmt.Errorf("%w: artifact kind is required", ErrInvalidArgument) } - row := s.pool.QueryRow(ctx, - `SELECT forge_provider, forge_host, repo, kind, number, - agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms - FROM forge_authored_artifacts - WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5`, - int32(provider), host, repo, int32(kind), int64(number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number) written to a BIGINT, always well within the int64 domain. - ) - a, err := scanAuthoredArtifact(row) + row, err := s.q.AuthoredArtifactByCoordinate(ctx, db.AuthoredArtifactByCoordinateParams{ + ForgeProvider: int16(provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: host, + Repo: repo, + Kind: int16(kind), //nolint:gosec // G115: ForgeArtifactKind is a CHECK-constrained 1/2 enum, always within int16 + Number: int64(number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number) written to a BIGINT, always well within the int64 domain. + }) if err != nil { if noRows(err) { return AuthoredArtifact{}, fmt.Errorf("%w: authored artifact at coordinate %d/%s/%s kind %d number %d", ErrNotFound, provider, host, repo, kind, number) } return AuthoredArtifact{}, fmt.Errorf("store: read authored artifact by coordinate: %w", err) } - return a, nil + return authoredArtifactFromRow(row), nil } // ListAuthoredArtifactsByAgent reads every artifact the agent authored, ordered @@ -175,66 +171,46 @@ func (s *Store) ListAuthoredArtifactsByAgent(ctx context.Context, agent AccountI if agent == "" { return nil, fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) } - rows, err := s.pool.Query(ctx, - `SELECT forge_provider, forge_host, repo, kind, number, - agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms - FROM forge_authored_artifacts - WHERE agent_account_id = $1 - ORDER BY created_at_unix_ms ASC, forge_provider ASC, forge_host ASC, repo ASC, kind ASC, number ASC`, - string(agent), - ) + rows, err := s.q.ListAuthoredArtifactsByAgent(ctx, string(agent)) if err != nil { return nil, fmt.Errorf("store: list authored artifacts by agent: %w", err) } - defer rows.Close() - var out []AuthoredArtifact - for rows.Next() { - a, err := scanAuthoredArtifact(rows) - if err != nil { - return nil, fmt.Errorf("store: scan authored artifact: %w", err) - } - out = append(out, a) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate authored artifacts: %w", err) + for _, r := range rows { + out = append(out, authoredArtifactFromRow(r)) } return out, nil } -// scanAuthoredArtifact scans one row into an AuthoredArtifact, mapping the -// nullable client_request_id column to "" (no key) via a pgx-native scan. -func scanAuthoredArtifact(row pgx.Row) (AuthoredArtifact, error) { - var ( - a AuthoredArtifact - provider int32 - kind int32 - number int64 - agent string - owner string - reqID *string - ) - if err := row.Scan(&provider, &a.Host, &a.Repo, &kind, &number, - &agent, &owner, &a.SessionID, &reqID, &a.CreatedAtUnixMS); err != nil { - return AuthoredArtifact{}, err - } - a.Provider = ForgeProvider(provider) - a.Kind = ForgeArtifactKind(kind) - a.Number = uint64(number) //nolint:gosec // G115: number is a BIGINT written only from a canonical uint64 artifact number (RecordAuthoredArtifact narrows nothing), so the stored value is always within the uint64 domain. - a.AgentAccountID = AccountID(agent) - a.OwnerUserID = AccountID(owner) - if reqID != nil { - a.ClientRequestID = *reqID - } - return a, nil +// authoredArtifactFromRow maps a generated forge_authored_artifacts row into an +// AuthoredArtifact, mapping the nullable client_request_id column to "" (no key) +// and the int16/BIGINT columns back to their named/uint types. +func authoredArtifactFromRow(r db.ForgeAuthoredArtifact) AuthoredArtifact { + a := AuthoredArtifact{ + Provider: ForgeProvider(r.ForgeProvider), + Host: r.ForgeHost, + Repo: r.Repo, + Kind: ForgeArtifactKind(r.Kind), + Number: uint64(r.Number), //nolint:gosec // G115: number is a BIGINT written only from a canonical uint64 artifact number, so the stored value is always within the uint64 domain. + AgentAccountID: AccountID(r.AgentAccountID), + OwnerUserID: AccountID(r.OwnerUserID), + SessionID: r.SessionID, + CreatedAtUnixMS: r.CreatedAtUnixMs, + } + if r.ClientRequestID.Valid { + a.ClientRequestID = r.ClientRequestID.String + } + return a } -// nullIfEmpty maps the empty client_request_id (no key supplied) to a typed nil -// so it stores as SQL NULL — the partial unique memo index only constrains -// non-NULL keys, so null-key rows never collide. -func nullIfEmpty(s string) *string { +// textOrNull maps an empty string (no value supplied) to an invalid pgtype.Text +// so it stores as SQL NULL. Used where a generated query parameter is a +// pgtype.Text: client_request_id (the partial unique memo index constrains only +// non-NULL keys, so null-key rows never collide) and linear_issue_id (plain +// nullable provenance, no unique index — NULL is faithful "none" storage). +func textOrNull(s string) pgtype.Text { if s == "" { - return nil + return pgtype.Text{} } - return &s + return pgtype.Text{String: s, Valid: true} } diff --git a/go/internal/store/forge_authored_test.go b/go/internal/store/forge_authored_test.go index e4979a07c..c69e45dd3 100644 --- a/go/internal/store/forge_authored_test.go +++ b/go/internal/store/forge_authored_test.go @@ -62,15 +62,15 @@ func TestAuthoredArtifactByRequestIDEmptyKeyMiss(t *testing.T) { } } -// TestNullIfEmpty pins the NULL client_request_id mapping: "" becomes a typed -// nil (SQL NULL, so null-key rows never collide under the partial unique memo -// index), a non-empty key is passed through by value. -func TestNullIfEmpty(t *testing.T) { - if got := nullIfEmpty(""); got != nil { - t.Fatalf("nullIfEmpty(\"\") = %v, want nil (SQL NULL)", *got) +// TestTextOrNull pins the NULL client_request_id/linear_issue_id mapping: "" +// becomes an invalid pgtype.Text (SQL NULL, so null-key rows never collide under +// the partial unique memo index), a non-empty key is passed through by value. +func TestTextOrNull(t *testing.T) { + if got := textOrNull(""); got.Valid { + t.Fatalf("textOrNull(\"\") = %+v, want invalid (SQL NULL)", got) } - got := nullIfEmpty("req-1") - if got == nil || *got != "req-1" { - t.Fatalf("nullIfEmpty(%q) = %v, want a pointer to it", "req-1", got) + got := textOrNull("req-1") + if !got.Valid || got.String != "req-1" { + t.Fatalf("textOrNull(%q) = %+v, want {String:%q, Valid:true}", "req-1", got, "req-1") } } diff --git a/go/internal/store/forge_cursors.go b/go/internal/store/forge_cursors.go index 299511ed9..6859403db 100644 --- a/go/internal/store/forge_cursors.go +++ b/go/internal/store/forge_cursors.go @@ -2,11 +2,12 @@ package store import ( "context" - "errors" "fmt" "time" - "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // The board arm's durable state (RIG-2883): the per-REPO poll targets and their @@ -51,24 +52,21 @@ func (s *Store) LoadForgeRepoWatermark(ctx context.Context, provider ForgeProvid if err := validCoordinate(provider, host, repo); err != nil { return time.Time{}, "", err } - var swept *time.Time - var etag string - err := s.pool.QueryRow(ctx, - `SELECT swept_updated_at, list_etag - FROM forge_repo_subscriptions - WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3`, - int32(provider), host, repo, - ).Scan(&swept, &etag) - if errors.Is(err, pgx.ErrNoRows) { + row, err := s.q.LoadForgeRepoWatermark(ctx, db.LoadForgeRepoWatermarkParams{ + ForgeProvider: int16(provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum (forge_repo_subscriptions.forge_provider), always within int16 + ForgeHost: host, + Repo: repo, + }) + if noRows(err) { return time.Time{}, "", nil } if err != nil { return time.Time{}, "", fmt.Errorf("store: load forge repo watermark: %w", err) } - if swept == nil { - return time.Time{}, etag, nil + if !row.SweptUpdatedAt.Valid { + return time.Time{}, row.ListEtag, nil } - return *swept, etag, nil + return row.SweptUpdatedAt.Time, row.ListEtag, nil } // StoreForgeRepoWatermark writes the repo's swept_updated_at watermark and @@ -79,20 +77,21 @@ func (s *Store) StoreForgeRepoWatermark(ctx context.Context, provider ForgeProvi if err := validCoordinate(provider, host, repo); err != nil { return err } - var swept *time.Time + var swept pgtype.Timestamptz if !mark.IsZero() { - swept = &mark - } - tag, err := s.pool.Exec(ctx, - `UPDATE forge_repo_subscriptions - SET swept_updated_at = $4, list_etag = $5, updated_at = now() - WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3`, - int32(provider), host, repo, swept, etag, - ) + swept = pgtype.Timestamptz{Time: mark, Valid: true} + } + affected, err := s.q.StoreForgeRepoWatermark(ctx, db.StoreForgeRepoWatermarkParams{ + ForgeProvider: int16(provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: host, + Repo: repo, + SweptUpdatedAt: swept, + ListEtag: etag, + }) if err != nil { return fmt.Errorf("store: store forge repo watermark: %w", err) } - if tag.RowsAffected() == 0 { + if affected == 0 { return fmt.Errorf("%w: forge repo subscription (%d, %q, %q)", ErrNotFound, provider, host, repo) } return nil @@ -106,12 +105,12 @@ func (s *Store) EnsureForgeRepoSubscription(ctx context.Context, sub ForgeRepoSu if err := validCoordinate(sub.Provider, sub.Host, sub.Repo); err != nil { return err } - if _, err := s.pool.Exec(ctx, - `INSERT INTO forge_repo_subscriptions (forge_provider, forge_host, repo, enabled) - VALUES ($1, $2, $3, $4) - ON CONFLICT (forge_provider, forge_host, repo) DO NOTHING`, - int32(sub.Provider), sub.Host, sub.Repo, sub.Enabled, - ); err != nil { + if err := s.q.EnsureForgeRepoSubscription(ctx, db.EnsureForgeRepoSubscriptionParams{ + ForgeProvider: int16(sub.Provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: sub.Host, + Repo: sub.Repo, + Enabled: sub.Enabled, + }); err != nil { return fmt.Errorf("store: ensure forge repo subscription: %w", err) } return nil @@ -128,29 +127,11 @@ func (s *Store) EnsureForgeRepoSubscription(ctx context.Context, sub ForgeRepoSu // watermark under the coordinate-keyed Load/Store methods — thread (provider, // host) through this seam before enabling multi-host. func (s *Store) ListEnabledForgeRepos(ctx context.Context) ([]string, error) { - rows, err := s.pool.Query(ctx, - `SELECT repo - FROM forge_repo_subscriptions - WHERE enabled = TRUE - ORDER BY repo ASC`, - ) + repos, err := s.q.ListEnabledForgeRepos(ctx) if err != nil { return nil, fmt.Errorf("store: list enabled forge repos: %w", err) } - defer rows.Close() - - var out []string - for rows.Next() { - var repo string - if err := rows.Scan(&repo); err != nil { - return nil, fmt.Errorf("store: scan forge repo: %w", err) - } - out = append(out, repo) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate forge repos: %w", err) - } - return out, nil + return repos, nil } // IsEnabledForgeRepo reports whether an enabled subscription exists for the repo @@ -162,13 +143,8 @@ func (s *Store) IsEnabledForgeRepo(ctx context.Context, repo string) (bool, erro if repo == "" { return false, fmt.Errorf("%w: repo is required", ErrInvalidArgument) } - var exists bool - if err := s.pool.QueryRow(ctx, - `SELECT EXISTS ( - SELECT 1 FROM forge_repo_subscriptions - WHERE repo = $1 AND enabled = TRUE)`, - repo, - ).Scan(&exists); err != nil { + exists, err := s.q.IsEnabledForgeRepo(ctx, repo) + if err != nil { return false, fmt.Errorf("store: is enabled forge repo: %w", err) } return exists, nil @@ -184,30 +160,21 @@ func (s *Store) ListEnabledForgeRepoSubscriptions(ctx context.Context, provider if host == "" { return nil, fmt.Errorf("%w: forge host is required", ErrInvalidArgument) } - rows, err := s.pool.Query(ctx, - `SELECT forge_provider, forge_host, repo, enabled - FROM forge_repo_subscriptions - WHERE forge_provider = $1 AND forge_host = $2 AND enabled = TRUE - ORDER BY repo ASC`, - int32(provider), host, - ) + rows, err := s.q.ListEnabledForgeRepoSubscriptions(ctx, db.ListEnabledForgeRepoSubscriptionsParams{ + ForgeProvider: int16(provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: host, + }) if err != nil { return nil, fmt.Errorf("store: list enabled forge repo subscriptions: %w", err) } - defer rows.Close() - var out []ForgeRepoSubscription - for rows.Next() { - var sub ForgeRepoSubscription - var p int32 - if err := rows.Scan(&p, &sub.Host, &sub.Repo, &sub.Enabled); err != nil { - return nil, fmt.Errorf("store: scan forge repo subscription: %w", err) - } - sub.Provider = ForgeProvider(p) - out = append(out, sub) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate forge repo subscriptions: %w", err) + for _, r := range rows { + out = append(out, ForgeRepoSubscription{ + Provider: ForgeProvider(r.ForgeProvider), + Host: r.ForgeHost, + Repo: r.Repo, + Enabled: r.Enabled, + }) } return out, nil } @@ -221,16 +188,16 @@ func (s *Store) SetForgeRepoSubscriptionEnabled(ctx context.Context, provider Fo if err := validCoordinate(provider, host, repo); err != nil { return err } - tag, err := s.pool.Exec(ctx, - `UPDATE forge_repo_subscriptions - SET enabled = $4, updated_at = now() - WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3`, - int32(provider), host, repo, enabled, - ) + affected, err := s.q.SetForgeRepoSubscriptionEnabled(ctx, db.SetForgeRepoSubscriptionEnabledParams{ + ForgeProvider: int16(provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: host, + Repo: repo, + Enabled: enabled, + }) if err != nil { return fmt.Errorf("store: set forge repo subscription enabled: %w", err) } - if tag.RowsAffected() == 0 { + if affected == 0 { return fmt.Errorf("%w: forge repo subscription (%d, %q, %q)", ErrNotFound, provider, host, repo) } return nil diff --git a/go/internal/store/forge_subscriptions.go b/go/internal/store/forge_subscriptions.go index 51b266c9b..650bdb13d 100644 --- a/go/internal/store/forge_subscriptions.go +++ b/go/internal/store/forge_subscriptions.go @@ -2,11 +2,13 @@ package store import ( "context" - "errors" "fmt" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // The DL-053 agent-notification subscription writer (RIG-2732 Piece 1, design @@ -136,18 +138,18 @@ func (s *Store) EnsureAgentForgeSubscription(ctx context.Context, sub AgentForge if sub.AgentAccountID == "" { return "", fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) } - var id string - if err := s.pool.QueryRow(ctx, - `INSERT INTO agent_forge_subscriptions - (id, agent_account_id, forge_provider, forge_host, repo, kind, number, scope, project) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (agent_account_id, forge_provider, forge_host, repo, kind, number, project) DO UPDATE - SET agent_account_id = EXCLUDED.agent_account_id - RETURNING id`, - newID(), string(sub.AgentAccountID), int32(sub.Provider), sub.Host, sub.Repo, - int32(sub.Kind), int64(sub.Number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number, or 0 for a container) written to a BIGINT, always well within the int64 domain. - int32(normalizeScope(sub.Scope)), sub.Project, - ).Scan(&id); err != nil { + id, err := s.q.EnsureAgentForgeSubscription(ctx, db.EnsureAgentForgeSubscriptionParams{ + ID: newID(), + AgentAccountID: string(sub.AgentAccountID), + ForgeProvider: int16(sub.Provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum (agent_forge_subscriptions.forge_provider), always within int16 + ForgeHost: sub.Host, + Repo: sub.Repo, + Kind: int16(sub.Kind), //nolint:gosec // G115: ForgeArtifactKind is a CHECK-constrained 1/2 enum, always within int16 + Number: int64(sub.Number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number, or 0 for a container) written to a BIGINT, always well within the int64 domain. + Scope: int16(normalizeScope(sub.Scope)), //nolint:gosec // G115: ForgeSubscriptionScope is a CHECK-constrained 1/2 enum (normalized), always within int16 + Project: sub.Project, + }) + if err != nil { if pgErrIs(err, pgForeignKeyViolation) { return "", fmt.Errorf("%w: unknown agent %q", ErrInvalidArgument, sub.AgentAccountID) } @@ -176,33 +178,18 @@ func (s *Store) DeleteAgentForgeSubscription(ctx context.Context, agent AccountI return fmt.Errorf("%w: subscription id is required", ErrInvalidArgument) } return s.WithTx(ctx, func(tx pgx.Tx) error { - var ( - provider int32 - host string - repo string - kind int32 - number int64 - ) - if err := tx.QueryRow(ctx, - `DELETE FROM agent_forge_subscriptions - WHERE id = $1 AND agent_account_id = $2 - RETURNING forge_provider, forge_host, repo, kind, number`, - subscriptionID, string(agent), - ).Scan(&provider, &host, &repo, &kind, &number); err != nil { + qtx := db.New(tx) + coord, err := qtx.DeleteAgentForgeSubscription(ctx, db.DeleteAgentForgeSubscriptionParams{ + ID: subscriptionID, + AgentAccountID: string(agent), + }) + if err != nil { if noRows(err) { return fmt.Errorf("%w: subscription %q", ErrNotFound, subscriptionID) } return fmt.Errorf("store: delete agent forge subscription: %w", err) } - if _, err := tx.Exec(ctx, - `DELETE FROM forge_artifact_cursors - WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5 - AND NOT EXISTS ( - SELECT 1 FROM agent_forge_subscriptions - WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5 - )`, - provider, host, repo, kind, number, - ); err != nil { + if err := qtx.GCForgeArtifactCursorIfUnsubscribed(ctx, db.GCForgeArtifactCursorIfUnsubscribedParams(coord)); err != nil { return fmt.Errorf("store: garbage-collect forge artifact cursor: %w", err) } return nil @@ -218,15 +205,17 @@ func (s *Store) AgentForgeSubscriptionsForArtifact(ctx context.Context, provider if err := validSubscriptionCoordinate(provider, host, repo, kind, number, ForgeSubscriptionScopeArtifact, ""); err != nil { return 0, err } - var n int - if err := s.pool.QueryRow(ctx, - `SELECT count(*) FROM agent_forge_subscriptions - WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5`, - int32(provider), host, repo, int32(kind), int64(number), //nolint:gosec // G115: number is a canonical forge artifact number written to a BIGINT, always within the int64 domain. - ).Scan(&n); err != nil { + n, err := s.q.CountAgentForgeSubscriptionsForArtifact(ctx, db.CountAgentForgeSubscriptionsForArtifactParams{ + ForgeProvider: int16(provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: host, + Repo: repo, + Kind: int16(kind), //nolint:gosec // G115: ForgeArtifactKind is a CHECK-constrained 1/2 enum, always within int16 + Number: int64(number), //nolint:gosec // G115: canonical artifact number written to a BIGINT, always within the int64 domain. + }) + if err != nil { return 0, fmt.Errorf("store: count agent forge subscriptions for artifact: %w", err) } - return n, nil + return int(n), nil } // ForgeNotifySubscriber is one subscriber the notify path fans a change out to: @@ -296,34 +285,26 @@ func (s *Store) SubscribersForArtifact(ctx context.Context, provider ForgeProvid if number == 0 { return nil, fmt.Errorf("%w: artifact number is required", ErrInvalidArgument) } - rows, err := s.pool.Query(ctx, - `SELECT id, agent_account_id, delivered_revision, project - FROM agent_forge_subscriptions - WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 - AND ( - (scope = 1 AND number = $5) - OR ($6 AND scope = 2 AND number = 0 AND project = $7) - )`, - int32(provider), host, repo, int32(kind), - int64(number), //nolint:gosec // G115: canonical artifact number in a BIGINT domain. - openedEvent, project, - ) + rows, err := s.q.SubscribersForArtifact(ctx, db.SubscribersForArtifactParams{ + ForgeProvider: int16(provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: host, + Repo: repo, + Kind: int16(kind), //nolint:gosec // G115: ForgeArtifactKind is a CHECK-constrained 1/2 enum, always within int16 + Number: int64(number), //nolint:gosec // G115: canonical artifact number in a BIGINT domain. + Column6: openedEvent, + Project: project, + }) if err != nil { return nil, fmt.Errorf("store: subscribers for artifact: %w", err) } - defer rows.Close() var out []ForgeNotifySubscriber - for rows.Next() { - var sub ForgeNotifySubscriber - var agent string - if err := rows.Scan(&sub.SubscriptionID, &agent, &sub.DeliveredRevision, &sub.Project); err != nil { - return nil, fmt.Errorf("store: scan artifact subscriber: %w", err) - } - sub.AgentAccountID = AccountID(agent) - out = append(out, sub) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate artifact subscribers: %w", err) + for _, r := range rows { + out = append(out, ForgeNotifySubscriber{ + SubscriptionID: r.ID, + AgentAccountID: AccountID(r.AgentAccountID), + DeliveredRevision: r.DeliveredRevision, + Project: r.Project, + }) } return out, nil } @@ -343,103 +324,60 @@ func (s *Store) ListForgeNotifyTargets(ctx context.Context, provider ForgeProvid if host == "" { return nil, fmt.Errorf("%w: forge host is required", ErrInvalidArgument) } - rows, err := s.pool.Query(ctx, - `SELECT s.repo, s.kind, - CASE WHEN s.scope = 2 THEN 0 ELSE s.number END AS coord_number, - s.id, s.agent_account_id, s.delivered_revision, s.project, - c.forge_provider IS NOT NULL AS has_cursor, - c.etag, c.comments_etag, c.checks_etag, c.revision, c.snapshot, c.polled_at - FROM agent_forge_subscriptions s - LEFT JOIN forge_artifact_cursors c - ON c.forge_provider = s.forge_provider - AND c.forge_host = s.forge_host - AND c.repo = s.repo - AND c.kind = s.kind - AND c.number = CASE WHEN s.scope = 2 THEN 0 ELSE s.number END - WHERE s.forge_provider = $1 AND s.forge_host = $2 - ORDER BY s.repo, s.kind, coord_number`, - int32(provider), host, - ) + rows, err := s.q.ListForgeNotifyTargets(ctx, db.ListForgeNotifyTargetsParams{ + ForgeProvider: int16(provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: host, + }) if err != nil { return nil, fmt.Errorf("store: list forge notify targets: %w", err) } - defer rows.Close() var ( out []ForgeNotifyTarget cur *ForgeNotifyTarget // the target the current run of rows belongs to ) - for rows.Next() { - var ( - repo string - kind int32 - coordNumber int64 - subID string - agent string - delivered string - project string - hasCursor bool - etag *string - commentsETag *string - checksETag *string - revision *string - snapshot []byte - polledAt *time.Time - ) - if err := rows.Scan(&repo, &kind, &coordNumber, &subID, &agent, &delivered, &project, - &hasCursor, &etag, &commentsETag, &checksETag, &revision, &snapshot, &polledAt); err != nil { - return nil, fmt.Errorf("store: scan forge notify target: %w", err) - } + for _, r := range rows { + kind := r.Kind // coord_number is a canonical artifact number (or 0) from a BIGINT, // always within the uint64 domain — cast once, reuse for the coordinate // compare and both target/cursor constructs. - coord := uint64(coordNumber) //nolint:gosec // G115: see above. - if cur == nil || cur.Repo != repo || int32(cur.Kind) != kind || cur.Number != coord { + coord := uint64(r.CoordNumber) //nolint:gosec // G115: see above. + if cur == nil || cur.Repo != r.Repo || int16(cur.Kind) != kind || cur.Number != coord { out = append(out, ForgeNotifyTarget{ Provider: provider, Host: host, - Repo: repo, + Repo: r.Repo, Kind: ForgeArtifactKind(kind), Number: coord, }) cur = &out[len(out)-1] - if hasCursor { + if r.HasCursor { cur.Cursor = &ForgeArtifactCursor{ Provider: provider, Host: host, - Repo: repo, + Repo: r.Repo, Kind: ForgeArtifactKind(kind), Number: coord, - ETag: derefString(etag), - CommentsETag: derefString(commentsETag), - ChecksETag: derefString(checksETag), - Revision: derefString(revision), - Snapshot: snapshot, + ETag: r.Etag.String, + CommentsETag: r.CommentsEtag.String, + ChecksETag: r.ChecksEtag.String, + Revision: r.Revision.String, + Snapshot: r.Snapshot, } - if polledAt != nil { - cur.Cursor.PolledAt = *polledAt + if r.PolledAt.Valid { + cur.Cursor.PolledAt = r.PolledAt.Time } } } cur.Subscribers = append(cur.Subscribers, ForgeNotifySubscriber{ - SubscriptionID: subID, - AgentAccountID: AccountID(agent), - DeliveredRevision: delivered, - Project: project, + SubscriptionID: r.ID, + AgentAccountID: AccountID(r.AgentAccountID), + DeliveredRevision: r.DeliveredRevision, + Project: r.Project, }) } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate forge notify targets: %w", err) - } return out, nil } -func derefString(p *string) string { - if p == nil { - return "" - } - return *p -} - // UpsertForgeArtifactCursor writes (inserts or replaces) the shared per-artifact // FETCH cursor at cur's coordinate, keyed by the PK (provider, host, repo, kind, // number). number == 0 is the legal container-scope reconcile cursor row (the PK @@ -456,21 +394,19 @@ func (s *Store) UpsertForgeArtifactCursor(ctx context.Context, cur ForgeArtifact if polledAt.IsZero() { polledAt = time.Now().UTC() } - if _, err := s.pool.Exec(ctx, - `INSERT INTO forge_artifact_cursors - (forge_provider, forge_host, repo, kind, number, etag, comments_etag, checks_etag, revision, snapshot, polled_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - ON CONFLICT (forge_provider, forge_host, repo, kind, number) DO UPDATE - SET etag = EXCLUDED.etag, - comments_etag = EXCLUDED.comments_etag, - checks_etag = EXCLUDED.checks_etag, - revision = EXCLUDED.revision, - snapshot = EXCLUDED.snapshot, - polled_at = EXCLUDED.polled_at`, - int32(cur.Provider), cur.Host, cur.Repo, int32(cur.Kind), - int64(cur.Number), //nolint:gosec // G115: canonical artifact number (or 0 container) in a BIGINT domain. - cur.ETag, cur.CommentsETag, cur.ChecksETag, cur.Revision, cur.Snapshot, polledAt, - ); err != nil { + if err := s.q.UpsertForgeArtifactCursor(ctx, db.UpsertForgeArtifactCursorParams{ + ForgeProvider: int16(cur.Provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: cur.Host, + Repo: cur.Repo, + Kind: int16(cur.Kind), //nolint:gosec // G115: ForgeArtifactKind is a CHECK-constrained 1/2 enum, always within int16 + Number: int64(cur.Number), //nolint:gosec // G115: canonical artifact number (or 0 container) in a BIGINT domain. + Etag: cur.ETag, + CommentsEtag: cur.CommentsETag, + ChecksEtag: cur.ChecksETag, + Revision: cur.Revision, + Snapshot: cur.Snapshot, + PolledAt: pgtype.Timestamptz{Time: polledAt, Valid: true}, + }); err != nil { return fmt.Errorf("store: upsert forge artifact cursor: %w", err) } return nil @@ -491,20 +427,34 @@ func (s *Store) LoadForgeArtifactCursor(ctx context.Context, provider ForgeProvi if kind != ForgeArtifactKindIssue && kind != ForgeArtifactKindPullRequest { return nil, fmt.Errorf("%w: artifact kind must be issue or pull_request", ErrInvalidArgument) } - cur := ForgeArtifactCursor{Provider: provider, Host: host, Repo: repo, Kind: kind, Number: number} - err := s.pool.QueryRow(ctx, - `SELECT etag, comments_etag, checks_etag, revision, snapshot, polled_at - FROM forge_artifact_cursors - WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5`, - int32(provider), host, repo, int32(kind), - int64(number), //nolint:gosec // G115: canonical artifact number (or 0 container) in a BIGINT domain. - ).Scan(&cur.ETag, &cur.CommentsETag, &cur.ChecksETag, &cur.Revision, &cur.Snapshot, &cur.PolledAt) - if errors.Is(err, pgx.ErrNoRows) { + row, err := s.q.LoadForgeArtifactCursor(ctx, db.LoadForgeArtifactCursorParams{ + ForgeProvider: int16(provider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum, always within int16 + ForgeHost: host, + Repo: repo, + Kind: int16(kind), + Number: int64(number), //nolint:gosec // G115: canonical artifact number (or 0 container) in a BIGINT domain. + }) + if noRows(err) { return nil, nil //nolint:nilnil // a never-observed cursor is (nil, nil) by the load contract: the caller (notify router via forgeNotifyStore, serve.go:1067) guards nil as "unobserved". A sentinel would force every reader to special-case it. } if err != nil { return nil, fmt.Errorf("store: load forge artifact cursor: %w", err) } + cur := ForgeArtifactCursor{ + Provider: provider, + Host: host, + Repo: repo, + Kind: kind, + Number: number, + ETag: row.Etag, + CommentsETag: row.CommentsEtag, + ChecksETag: row.ChecksEtag, + Revision: row.Revision, + Snapshot: row.Snapshot, + } + if row.PolledAt.Valid { + cur.PolledAt = row.PolledAt.Time + } return &cur, nil } @@ -521,16 +471,15 @@ func (s *Store) AdvanceForgeDeliveredRevision(ctx context.Context, agent Account if subscriptionID == "" { return fmt.Errorf("%w: subscription id is required", ErrInvalidArgument) } - tag, err := s.pool.Exec(ctx, - `UPDATE agent_forge_subscriptions - SET delivered_revision = $3, delivered_at = now() - WHERE id = $2 AND agent_account_id = $1`, - string(agent), subscriptionID, revision, - ) + affected, err := s.q.AdvanceForgeDeliveredRevision(ctx, db.AdvanceForgeDeliveredRevisionParams{ + AgentAccountID: string(agent), + ID: subscriptionID, + DeliveredRevision: revision, + }) if err != nil { return fmt.Errorf("store: advance forge delivered revision: %w", err) } - if tag.RowsAffected() == 0 { + if affected == 0 { return fmt.Errorf("%w: subscription %q", ErrNotFound, subscriptionID) } return nil diff --git a/go/internal/store/issues.go b/go/internal/store/issues.go index 74cecf080..c9c8c322d 100644 --- a/go/internal/store/issues.go +++ b/go/internal/store/issues.go @@ -4,6 +4,10 @@ import ( "context" "fmt" "time" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // IssueState mirrors compass.v1 IssueState (UNSPECIFIED=0 .. ARCHIVED=8). A @@ -130,39 +134,26 @@ func (s *Store) UpsertIssueForgeFields(ctx context.Context, in IssueForgeFields) } // A zero time stores SQL NULL so the recency guard's NULL arm keeps the // write additive; a set time drives the >= comparison in ON CONFLICT. - var forgeUpdatedAt *time.Time + var forgeUpdatedAt pgtype.Timestamptz if !in.ForgeUpdatedAt.IsZero() { - forgeUpdatedAt = &in.ForgeUpdatedAt + forgeUpdatedAt = pgtype.Timestamptz{Time: in.ForgeUpdatedAt, Valid: true} } - var id string - if err := s.pool.QueryRow(ctx, - `WITH up AS ( - INSERT INTO issues - (id, forge_provider, forge_host, repo, number, - title, body, forge_state, url, forge_account, labels, agent_handle, - forge_updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - ON CONFLICT (forge_provider, forge_host, repo, number) DO UPDATE - SET title = EXCLUDED.title, body = EXCLUDED.body, - forge_state = EXCLUDED.forge_state, url = EXCLUDED.url, - forge_account = EXCLUDED.forge_account, labels = EXCLUDED.labels, - agent_handle = EXCLUDED.agent_handle, - forge_updated_at = EXCLUDED.forge_updated_at - WHERE issues.forge_updated_at IS NULL - OR EXCLUDED.forge_updated_at IS NULL - OR EXCLUDED.forge_updated_at >= issues.forge_updated_at - RETURNING id - ) - SELECT id FROM up - UNION ALL - SELECT id FROM issues - WHERE NOT EXISTS (SELECT 1 FROM up) - AND forge_provider = $2 AND forge_host = $3 AND repo = $4 AND number = $5 - LIMIT 1`, - newID(), int32(in.ForgeProvider), in.ForgeHost, in.Repo, int64(in.Number), - in.Title, in.Body, in.ForgeState, in.URL, in.ForgeAccount, labels, in.AgentHandle, - forgeUpdatedAt, - ).Scan(&id); err != nil { + id, err := s.q.UpsertIssueForgeFields(ctx, db.UpsertIssueForgeFieldsParams{ + ID: newID(), + ForgeProvider: int16(in.ForgeProvider), //nolint:gosec // G115: ForgeProvider is a CHECK-constrained 1..4 enum (issues.forge_provider), always within int16 + ForgeHost: in.ForgeHost, + Repo: in.Repo, + Number: int64(in.Number), + Title: in.Title, + Body: in.Body, + ForgeState: in.ForgeState, + Url: in.URL, + ForgeAccount: in.ForgeAccount, + Labels: labels, + AgentHandle: in.AgentHandle, + ForgeUpdatedAt: forgeUpdatedAt, + }) + if err != nil { return "", fmt.Errorf("store: upsert issue forge fields: %w", err) } return id, nil @@ -177,14 +168,14 @@ func (s *Store) SetIssueState(ctx context.Context, id string, state IssueState) if id == "" { return fmt.Errorf("%w: id is required", ErrInvalidArgument) } - tag, err := s.pool.Exec(ctx, - `UPDATE issues SET state = $2 WHERE id = $1`, - id, int32(state), - ) + affected, err := s.q.SetIssueState(ctx, db.SetIssueStateParams{ + ID: id, + State: int16(state), //nolint:gosec // G115: IssueState is a CHECK-constrained 1..8 enum (issues.state), always within int16 + }) if err != nil { return fmt.Errorf("store: set issue state: %w", err) } - if tag.RowsAffected() == 0 { + if affected == 0 { return fmt.Errorf("%w: issue %q does not exist", ErrNotFound, id) } return nil @@ -197,82 +188,78 @@ func (s *Store) GetIssue(ctx context.Context, id string) (Issue, error) { if id == "" { return Issue{}, fmt.Errorf("%w: id is required", ErrInvalidArgument) } - row := s.pool.QueryRow(ctx, - `SELECT id, forge_provider, forge_host, repo, number, - title, body, forge_state, url, forge_account, labels, agent_handle, - state, priority, assignee, summary, branch - FROM issues - WHERE id = $1`, - id, - ) - iss, err := scanIssue(row) + row, err := s.q.GetIssue(ctx, id) if err != nil { if noRows(err) { return Issue{}, fmt.Errorf("%w: issue %q does not exist", ErrNotFound, id) } return Issue{}, fmt.Errorf("store: get issue: %w", err) } - return iss, nil + return issueFromGetRow(row), nil } // ListIssues reads every issue, ordered by id for a deterministic result (like // ListAgentPlacementsForRunner). It is the projection's rehydrate read (part // 4). An empty table yields a non-nil empty slice, not an error. func (s *Store) ListIssues(ctx context.Context) ([]Issue, error) { - rows, err := s.pool.Query(ctx, - `SELECT id, forge_provider, forge_host, repo, number, - title, body, forge_state, url, forge_account, labels, agent_handle, - state, priority, assignee, summary, branch - FROM issues - ORDER BY id`, - ) + rows, err := s.q.ListIssues(ctx) if err != nil { return nil, fmt.Errorf("store: list issues: %w", err) } - defer rows.Close() - - issues := []Issue{} - for rows.Next() { - iss, err := scanIssue(rows) - if err != nil { - return nil, fmt.Errorf("store: scan issue: %w", err) - } - issues = append(issues, iss) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate issues: %w", err) + issues := make([]Issue, 0, len(rows)) + for _, r := range rows { + issues = append(issues, issueFromListRow(r)) } return issues, nil } -// scanRow is the subset of pgx.Row/pgx.Rows scanIssue needs, so it serves both -// the single-row GetIssue and the ListIssues loop. -type scanRow interface { - Scan(dest ...any) error +// issueFromGetRow maps a generated GetIssue row into a domain Issue. The +// forge_provider/state int16 columns convert to their named types; number is a +// BIGINT written only from a canonical uint32; an empty labels array normalizes +// to nil to match the module's empty→nil contract. +func issueFromGetRow(r db.GetIssueRow) Issue { + return issueFromColumns(r.ID, r.ForgeProvider, r.ForgeHost, r.Repo, r.Number, + r.Title, r.Body, r.ForgeState, r.Url, r.ForgeAccount, r.Labels, r.AgentHandle, + r.State, r.Priority, r.Assignee, r.Summary, r.Branch) +} + +// issueFromListRow maps a generated ListIssues row into a domain Issue (identical +// column set to GetIssue; sqlc emits a distinct row type per query). +func issueFromListRow(r db.ListIssuesRow) Issue { + return issueFromColumns(r.ID, r.ForgeProvider, r.ForgeHost, r.Repo, r.Number, + r.Title, r.Body, r.ForgeState, r.Url, r.ForgeAccount, r.Labels, r.AgentHandle, + r.State, r.Priority, r.Assignee, r.Summary, r.Branch) } -// scanIssue scans one issues row into an Issue. forge_provider/state are scanned -// through int32 then converted to their named types; an empty labels array is -// normalized to nil to match the module's empty→nil contract. -func scanIssue(row scanRow) (Issue, error) { - var ( - iss Issue - forgeProvider int32 - number int64 - state int32 - ) - if err := row.Scan( - &iss.ID, &forgeProvider, &iss.ForgeHost, &iss.Repo, &number, - &iss.Title, &iss.Body, &iss.ForgeState, &iss.URL, &iss.ForgeAccount, &iss.Labels, &iss.AgentHandle, - &state, &iss.Priority, &iss.Assignee, &iss.Summary, &iss.Branch, - ); err != nil { - return Issue{}, err +// issueFromColumns builds an Issue from the shared issue projection both reads +// select, folding the int16→named-type conversions, the uint32 number narrowing, +// and the empty-labels→nil normalization into one place. +func issueFromColumns( + id string, forgeProvider int16, forgeHost, repo string, number int64, + title, body, forgeState, url, forgeAccount string, labels []string, agentHandle string, + state int16, priority, assignee, summary, branch string, +) Issue { + iss := Issue{ + ID: id, + ForgeProvider: ForgeProvider(forgeProvider), + ForgeHost: forgeHost, + Repo: repo, + Number: uint32(number), //nolint:gosec // G115: number is a BIGINT written only from a canonical uint32 (UpsertIssueForgeFields narrows in.Number), so it is always within the uint32 domain + Title: title, + Body: body, + ForgeState: forgeState, + URL: url, + ForgeAccount: forgeAccount, + Labels: labels, + AgentHandle: agentHandle, + State: IssueState(state), + Priority: priority, + Assignee: assignee, + Summary: summary, + Branch: branch, } - iss.ForgeProvider = ForgeProvider(forgeProvider) - iss.Number = uint32(number) //nolint:gosec // G115: number is a BIGINT written only from a canonical uint32 (UpsertIssueForgeFields narrows in.Number), so it is always within the uint32 domain - iss.State = IssueState(state) if len(iss.Labels) == 0 { iss.Labels = nil } - return iss, nil + return iss } diff --git a/go/internal/store/linear_sessions.go b/go/internal/store/linear_sessions.go index 2b3609750..1c461ac1c 100644 --- a/go/internal/store/linear_sessions.go +++ b/go/internal/store/linear_sessions.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/jackc/pgx/v5" + "github.com/RigelBuild/compass/go/internal/store/db" ) // The Linear Agent Session association (compass-linear-agent-responder @@ -40,18 +40,17 @@ func (s *Store) UpsertLinearAgentSession(ctx context.Context, row LinearAgentSes if row.LinearSessionID == "" { return false, fmt.Errorf("%w: linear session id is required", ErrInvalidArgument) } - tag, err := s.pool.Exec(ctx, - `INSERT INTO linear_agent_sessions - (linear_session_id, manager_account_id, channel_id, topic_id, linear_issue_id) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (linear_session_id) DO NOTHING`, - row.LinearSessionID, string(row.ManagerAccountID), string(row.ChannelID), - row.TopicID, nullIfEmpty(row.LinearIssueID), - ) + affected, err := s.q.UpsertLinearAgentSession(ctx, db.UpsertLinearAgentSessionParams{ + LinearSessionID: row.LinearSessionID, + ManagerAccountID: string(row.ManagerAccountID), + ChannelID: string(row.ChannelID), + TopicID: row.TopicID, + LinearIssueID: textOrNull(row.LinearIssueID), + }) if err != nil { return false, fmt.Errorf("store: upsert linear agent session: %w", err) } - return tag.RowsAffected() == 1, nil + return affected == 1, nil } // LinearAgentSession reads the association for linearSessionID — the `prompted` @@ -61,38 +60,24 @@ func (s *Store) LinearAgentSession(ctx context.Context, linearSessionID string) if linearSessionID == "" { return LinearAgentSessionRow{}, fmt.Errorf("%w: linear session id is required", ErrInvalidArgument) } - row := s.pool.QueryRow(ctx, - `SELECT linear_session_id, manager_account_id, channel_id, topic_id, linear_issue_id, created_at - FROM linear_agent_sessions - WHERE linear_session_id = $1`, - linearSessionID, - ) - r, err := scanLinearAgentSession(row) + row, err := s.q.LinearAgentSession(ctx, linearSessionID) if err != nil { if noRows(err) { return LinearAgentSessionRow{}, fmt.Errorf("%w: linear agent session %q", ErrNotFound, linearSessionID) } return LinearAgentSessionRow{}, fmt.Errorf("store: read linear agent session: %w", err) } - return r, nil -} - -// scanLinearAgentSession scans one row into a LinearAgentSessionRow, mapping the -// nullable linear_issue_id column to "" (no issue) via a pgx-native scan. -func scanLinearAgentSession(row pgx.Row) (LinearAgentSessionRow, error) { - var ( - r LinearAgentSessionRow - manager string - channel string - issueID *string - ) - if err := row.Scan(&r.LinearSessionID, &manager, &channel, &r.TopicID, &issueID, &r.CreatedAt); err != nil { - return LinearAgentSessionRow{}, err + out := LinearAgentSessionRow{ + LinearSessionID: row.LinearSessionID, + ManagerAccountID: AccountID(row.ManagerAccountID), + ChannelID: ChannelID(row.ChannelID), + TopicID: row.TopicID, + } + if row.LinearIssueID.Valid { + out.LinearIssueID = row.LinearIssueID.String } - r.ManagerAccountID = AccountID(manager) - r.ChannelID = ChannelID(channel) - if issueID != nil { - r.LinearIssueID = *issueID + if row.CreatedAt.Valid { + out.CreatedAt = row.CreatedAt.Time } - return r, nil + return out, nil } diff --git a/go/internal/store/queries/agent_activity.sql b/go/internal/store/queries/agent_activity.sql new file mode 100644 index 000000000..5b9ed22ee --- /dev/null +++ b/go/internal/store/queries/agent_activity.sql @@ -0,0 +1,17 @@ +-- Agent-activity queries (sqlc adoption T5, RIG-3034). These replace the inline +-- SQL literals in internal/store/agent_activity.go; the hand-written Store +-- methods keep their signatures and map the ActivityFor rows into the +-- AgentActivity domain struct (agentActivityFromRow-equivalent, done inline in +-- the Go — absent-from-table means absent-from-map). + +-- name: SetActivity :exec +INSERT INTO agent_activity (agent_account_id, activity, activity_at_unix_ms) +VALUES ($1, $2, $3) +ON CONFLICT (agent_account_id) +DO UPDATE SET activity = EXCLUDED.activity, + activity_at_unix_ms = EXCLUDED.activity_at_unix_ms; + +-- name: ActivityFor :many +SELECT agent_account_id, activity, activity_at_unix_ms +FROM agent_activity +WHERE agent_account_id = ANY($1::text[]); diff --git a/go/internal/store/queries/agent_config.sql b/go/internal/store/queries/agent_config.sql new file mode 100644 index 000000000..15d47613d --- /dev/null +++ b/go/internal/store/queries/agent_config.sql @@ -0,0 +1,17 @@ +-- Agent config-bundle queries (sqlc adoption T5, RIG-3034). These replace the +-- inline SQL literals in internal/store/agent_config.go; the hand-written Store +-- methods keep their signatures and own the bundle validation/hash and the +-- tar-walk member inventory (Go-side, over the returned BYTEA). The config +-- bundle is a fleet-wide singleton row (singleton = TRUE). + +-- name: PutAgentConfig :exec +INSERT INTO agent_config_bundle (singleton, version, bundle) +VALUES (TRUE, $1, $2) +ON CONFLICT (singleton) +DO UPDATE SET version = EXCLUDED.version, bundle = EXCLUDED.bundle, updated_at = now(); + +-- name: CurrentAgentConfig :one +SELECT version, bundle FROM agent_config_bundle WHERE singleton = TRUE; + +-- name: DeleteAgentConfig :exec +DELETE FROM agent_config_bundle WHERE singleton = TRUE; diff --git a/go/internal/store/queries/agent_placements.sql b/go/internal/store/queries/agent_placements.sql new file mode 100644 index 000000000..be2bc4e27 --- /dev/null +++ b/go/internal/store/queries/agent_placements.sql @@ -0,0 +1,27 @@ +-- Agent-placement queries (sqlc adoption T5, RIG-3034). These replace the inline +-- SQL literals in internal/store/agent_placements.go; the hand-written Store +-- methods keep their signatures and map the placement rows into the +-- AgentPlacement domain struct (AccountID newtype done inline in the Go). + +-- name: RecordAgentPlacement :exec +INSERT INTO agent_placements (agent_account_id, runner_id, container_name) +VALUES ($1, $2, $3) +ON CONFLICT (agent_account_id) DO UPDATE + SET runner_id = EXCLUDED.runner_id, + container_name = EXCLUDED.container_name, + updated_at = now(); + +-- name: AgentForContainer :one +SELECT agent_account_id FROM agent_placements WHERE container_name = $1; + +-- name: ListAgentPlacementsForRunner :many +SELECT agent_account_id, runner_id, container_name + FROM agent_placements + WHERE runner_id = $1 + ORDER BY agent_account_id; + +-- name: DeleteAgentPlacement :exec +DELETE FROM agent_placements WHERE container_name = $1; + +-- name: PlacementForAgent :one +SELECT runner_id, container_name FROM agent_placements WHERE agent_account_id = $1; diff --git a/go/internal/store/queries/agent_sessions.sql b/go/internal/store/queries/agent_sessions.sql new file mode 100644 index 000000000..cf9c2342e --- /dev/null +++ b/go/internal/store/queries/agent_sessions.sql @@ -0,0 +1,26 @@ +-- Agent-session queries (sqlc adoption T5, RIG-3034). These replace the inline +-- SQL literals that lived in internal/store/agent_sessions.go; the hand-written +-- Store methods keep their exact signatures and wrap these generated calls, +-- mapping AccountID newtypes and the not-found/forbidden merge (D9) by hand. No +-- domain xFromRow mapper — the session reads project scalar columns the methods +-- return directly. + +-- name: InsertAgentSession :exec +INSERT INTO agent_sessions (session_id, agent_account_id, recorded_at_unix_ms) +VALUES ($1, $2, $3); + +-- name: LatestSessionForAccount :one +SELECT session_id + FROM agent_sessions + WHERE agent_account_id = $1 + ORDER BY recorded_at_unix_ms DESC, session_id DESC + LIMIT 1; + +-- name: RequireAgentSessionSubscriber :one +SELECT EXISTS ( + SELECT 1 + FROM agent_sessions se + JOIN agent_accounts ag ON ag.account_id = se.agent_account_id + JOIN channel_members cm ON cm.channel_id = ag.home_channel_id + AND cm.account_id = $2 + WHERE se.session_id = $1); diff --git a/go/internal/store/queries/agent_transcripts.sql b/go/internal/store/queries/agent_transcripts.sql new file mode 100644 index 000000000..ab2cc997d --- /dev/null +++ b/go/internal/store/queries/agent_transcripts.sql @@ -0,0 +1,89 @@ +-- Agent-transcript queries (sqlc adoption T5, RIG-3034). These replace the +-- inline SQL literals in internal/store/agent_transcripts.go; the hand-written +-- Store methods keep their exact signatures and own the two-tier flush +-- orchestration (PUT-before-prune, the RepeatableRead/ReadOnly resume snapshot +-- tx, the safety-valve cut-point loop) plus the uint64<->int64 seq narrowing +-- (toInt64/toUint64). They map generated rows into TranscriptEntryRow and +-- ArchiveSegmentRow (via the transcriptRowsFromDB/segmentRowsFromDB helpers). +-- +-- COALESCE(MAX/SUM(...), 0) sites carry an explicit ::BIGINT cast so sqlc types +-- them int64 rather than interface{} (mirrors messages.sql MessagesHeadSeq). The +-- SessionTranscript and SafetyValveSegments reads are each issued on BOTH the +-- pool (the eponymous method) and a snapshot tx (SessionResumeSnapshot, via +-- WithTx), so one generated query backs both call sites. + +-- name: BindLifetime :one +UPDATE agent_sessions + SET base_entry_seq = COALESCE( + (SELECT MAX(te.entry_seq) + FROM agent_session_transcript_entries te + WHERE te.session_id = $1), 0) + WHERE session_id = $1 +RETURNING base_entry_seq; + +-- name: SessionBase :one +SELECT base_entry_seq FROM agent_sessions WHERE session_id = $1; + +-- name: InsertTranscriptEntry :execrows +INSERT INTO agent_session_transcript_entries + (session_id, entry_seq, checkpoint, entry_json, idempotency_key) + VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (idempotency_key) DO NOTHING; + +-- name: SessionTranscript :many +SELECT e.entry_seq, e.checkpoint, e.entry_json + FROM agent_session_transcript_entries e + WHERE e.session_id = $1 + AND e.entry_seq >= COALESCE( + (SELECT MAX(cp.entry_seq) + FROM agent_session_transcript_entries cp + WHERE cp.session_id = $1 AND cp.checkpoint), 0) + ORDER BY entry_seq; + +-- name: SafetyValveSegments :many +SELECT object_key, min_entry_seq, max_entry_seq, kind + FROM agent_session_archive_segments + WHERE session_id = $1 AND kind = $2 + ORDER BY min_entry_seq; + +-- name: HotTailBytes :one +SELECT COALESCE(SUM(octet_length(entry_json)), 0)::BIGINT AS bytes + FROM agent_session_transcript_entries + WHERE session_id = $1 AND entry_seq > $2; + +-- name: HotTailSizes :many +SELECT entry_seq, octet_length(entry_json)::BIGINT AS bytes + FROM agent_session_transcript_entries + WHERE session_id = $1 AND entry_seq > $2 + ORDER BY entry_seq; + +-- name: LatestCheckpointSeq :one +SELECT COALESCE(MAX(entry_seq), 0)::BIGINT AS seq + FROM agent_session_transcript_entries + WHERE session_id = $1 AND checkpoint; + +-- name: SessionMaxEntrySeq :one +SELECT COALESCE(MAX(entry_seq), 0)::BIGINT AS seq + FROM agent_session_transcript_entries + WHERE session_id = $1; + +-- name: CollectSegment :many +SELECT entry_seq, entry_json + FROM agent_session_transcript_entries + WHERE session_id = $1 AND entry_seq >= $2 AND entry_seq <= $3 + ORDER BY entry_seq; + +-- name: InsertArchiveSegment :exec +INSERT INTO agent_session_archive_segments + (session_id, object_key, min_entry_seq, max_entry_seq, kind) + VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (session_id, object_key) DO NOTHING; + +-- name: PruneTranscriptEntries :exec +DELETE FROM agent_session_transcript_entries + WHERE session_id = $1 AND entry_seq >= $2 AND entry_seq <= $3; + +-- name: RemarkSafetyValveSuperseded :exec +UPDATE agent_session_archive_segments + SET kind = 'superseded' + WHERE session_id = $1 AND kind = 'safety_valve' AND max_entry_seq < $2; diff --git a/go/internal/store/queries/authz.sql b/go/internal/store/queries/authz.sql new file mode 100644 index 000000000..d2f4d101c --- /dev/null +++ b/go/internal/store/queries/authz.sql @@ -0,0 +1,36 @@ +-- Authorization-probe queries (sqlc adoption T6, RIG-3034). These replace the +-- inline SQL literals in internal/store/authz.go; the hand-written helpers keep +-- their signatures and the not-found/forbidden merge, wrapping these EXISTS +-- probes (each returns a bare bool). requireChannelMember / isChannelMember reuse +-- ChannelMemberExists (channels.sql) — the statement is textually identical — so +-- only the three probes without an existing query live here. + +-- name: TopicChannelMemberExists :one +-- Feeds IsTopicChannelMember: membership on the channel that owns the topic. +SELECT EXISTS (SELECT 1 FROM topics t JOIN channel_members cm ON cm.channel_id = t.channel_id WHERE t.id = $1 AND cm.account_id = $2); + +-- name: GroupCreateAuthorized :one +-- Feeds requireGroupCreateAuthz: owner, agent-owner, or SHARED-visibility group. +SELECT EXISTS ( + SELECT 1 FROM channel_groups g + WHERE g.id = $1 AND ( + g.owner_user_id = $2 + -- Gates on BARE g.visibility = SHARED, not effective + -- (MIN-over-ancestry) visibility. Sound only because groups are + -- immutable post-create: the sole channel_groups mutation is the + -- CreateChannelGroup INSERT (no UpdateChannelGroup / re-parent + -- RPC), and CreateChannelGroup enforces child <= parent ceiling, + -- so bare-SHARED implies effective-SHARED. If a re-parent or + -- visibility-update RPC ever lands, switch this to + -- effectiveVisibilityCTE or it becomes a create-leak (a + -- bare-SHARED group nested under an OWNER parent would authorize + -- creates it should not). + OR g.visibility = $3 + OR g.owner_user_id = (SELECT owner_user_id FROM agent_accounts WHERE account_id = $2))); + +-- name: AgentWorkspaceVisible :one +-- Feeds isAgentWorkspaceVisible: membership on the agent's home channel. +SELECT EXISTS ( + SELECT 1 FROM agent_accounts ag + JOIN channel_members cm ON cm.channel_id = ag.home_channel_id AND cm.account_id = $1 + WHERE ag.account_id = $2); diff --git a/go/internal/store/queries/dm.sql b/go/internal/store/queries/dm.sql new file mode 100644 index 000000000..64d1f6b4f --- /dev/null +++ b/go/internal/store/queries/dm.sql @@ -0,0 +1,41 @@ +-- Peer-DM channel queries (sqlc adoption T6, RIG-3034; dm.go was added to the +-- store after the design record froze — the record's "plus any residue"). These +-- replace the inline SQL literals in internal/store/dm.go; the hand-written Store +-- methods keep their signatures and every seam that is NOT a single statement: +-- the per-owner advisory lock (LockDM), the resolution/insert loop, the R3 +-- verify-reconcile belt, the transitive-owner membership expansion, and the +-- cursor seeding (seedChannelDeliveryCursors, delivery_cursors.sql). The member +-- INSERTs reuse EnsureChannelMember (accounts.sql) — the statement is identical. + +-- name: GetOwnerDMGroup :one +-- Visibility-discriminated get-half: a wider (SHARED) planted __dm__ group must +-- NEVER be adopted, so visibility = $3 (bound to VisibilityOwner) excludes it. +SELECT id FROM channel_groups +WHERE owner_user_id = $1 AND name = $2 AND parent_group_id IS NULL AND visibility = $3; + +-- name: InsertOwnerDMGroup :exec +INSERT INTO channel_groups (id, name, parent_group_id, owner_user_id, visibility) +VALUES ($1, $2, NULL, $3, $4); + +-- name: GetDMChannelByName :one +SELECT id, kind FROM channels WHERE group_id = $1 AND name = $2; + +-- name: InsertDMChannel :one +-- Born kind=DM, zero-value policy (OPEN, ownerless) + mandatory; poison-free via +-- ON CONFLICT DO NOTHING on the partial unique index (a concurrent open yields +-- zero rows, never a raised unique-violation). +INSERT INTO channels (id, name, group_id, kind, post_policy, owner_account_id, mandatory_subscription) +VALUES ($1, $2, $3, $4, $5, NULL, $6) +ON CONFLICT (group_id, name) WHERE group_id IS NOT NULL DO NOTHING +RETURNING id; + +-- name: LockOwnerDM :exec +SELECT pg_advisory_xact_lock(hashtext('dm:' || $1)); + +-- name: ReassertDMMandatory :exec +UPDATE channels SET mandatory_subscription = TRUE WHERE id = $1 AND mandatory_subscription = FALSE; + +-- name: GetGroupNameVisibility :one +-- Feeds isReservedDMGroupTx: the reserved-DM-group discriminator (name AND +-- VisibilityOwner) the CreateChannel create-guard keys on. +SELECT name, visibility FROM channel_groups WHERE id = $1; diff --git a/go/internal/store/queries/forge_authored.sql b/go/internal/store/queries/forge_authored.sql new file mode 100644 index 000000000..1a2ef2be6 --- /dev/null +++ b/go/internal/store/queries/forge_authored.sql @@ -0,0 +1,38 @@ +-- Forge-authored-artifact queries (sqlc adoption T6, RIG-3034). These replace the +-- inline SQL literals in internal/store/forge_authored.go; the hand-written Store +-- methods keep their signatures, the door-side validation (valid/validCoordinate), +-- the ErrConflict/ErrInvalidArgument/ErrNotFound mapping via pgErrIs, and the +-- textOrNull client_request_id NULL discipline. The read queries feed +-- authoredArtifactFromRow, which maps the generated row (provider/kind ints, +-- number BIGINT, nullable client_request_id) back to the domain AuthoredArtifact. + +-- name: RecordAuthoredArtifact :exec +-- WRITE-ONCE authorship: the DO UPDATE deliberately omits agent_account_id and +-- owner_user_id, so a re-land never rewrites who authored the artifact. +INSERT INTO forge_authored_artifacts + (forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +ON CONFLICT (forge_provider, forge_host, repo, kind, number) DO UPDATE + SET session_id = EXCLUDED.session_id, + client_request_id = EXCLUDED.client_request_id, + created_at_unix_ms = EXCLUDED.created_at_unix_ms; + +-- name: AuthoredArtifactByRequestID :one +SELECT forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms +FROM forge_authored_artifacts +WHERE agent_account_id = $1 AND client_request_id = $2; + +-- name: AuthoredArtifactByCoordinate :one +SELECT forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms +FROM forge_authored_artifacts +WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5; + +-- name: ListAuthoredArtifactsByAgent :many +SELECT forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms +FROM forge_authored_artifacts +WHERE agent_account_id = $1 +ORDER BY created_at_unix_ms ASC, forge_provider ASC, forge_host ASC, repo ASC, kind ASC, number ASC; diff --git a/go/internal/store/queries/forge_cursors.sql b/go/internal/store/queries/forge_cursors.sql new file mode 100644 index 000000000..c2bf1fa04 --- /dev/null +++ b/go/internal/store/queries/forge_cursors.sql @@ -0,0 +1,44 @@ +-- Forge repo-subscription / watermark queries (sqlc adoption T6, RIG-3034). These +-- replace the inline SQL literals in internal/store/forge_cursors.go; the +-- hand-written Store methods keep their signatures, the door-side validation +-- (validCoordinate), the ErrNotFound mapping, and the RowsAffected branches +-- (StoreForgeRepoWatermark / SetForgeRepoSubscriptionEnabled are :execrows). The +-- read methods map the generated rows (provider int, nullable swept_updated_at) +-- back to the domain time.Time / ForgeRepoSubscription. + +-- name: LoadForgeRepoWatermark :one +SELECT swept_updated_at, list_etag +FROM forge_repo_subscriptions +WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3; + +-- name: StoreForgeRepoWatermark :execrows +UPDATE forge_repo_subscriptions + SET swept_updated_at = $4, list_etag = $5, updated_at = now() + WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3; + +-- name: EnsureForgeRepoSubscription :exec +INSERT INTO forge_repo_subscriptions (forge_provider, forge_host, repo, enabled) +VALUES ($1, $2, $3, $4) +ON CONFLICT (forge_provider, forge_host, repo) DO NOTHING; + +-- name: ListEnabledForgeRepos :many +SELECT repo +FROM forge_repo_subscriptions +WHERE enabled = TRUE +ORDER BY repo ASC; + +-- name: IsEnabledForgeRepo :one +SELECT EXISTS ( + SELECT 1 FROM forge_repo_subscriptions + WHERE repo = $1 AND enabled = TRUE); + +-- name: ListEnabledForgeRepoSubscriptions :many +SELECT forge_provider, forge_host, repo, enabled +FROM forge_repo_subscriptions +WHERE forge_provider = $1 AND forge_host = $2 AND enabled = TRUE +ORDER BY repo ASC; + +-- name: SetForgeRepoSubscriptionEnabled :execrows +UPDATE forge_repo_subscriptions + SET enabled = $4, updated_at = now() + WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3; diff --git a/go/internal/store/queries/forge_subscriptions.sql b/go/internal/store/queries/forge_subscriptions.sql new file mode 100644 index 000000000..3f1a69e44 --- /dev/null +++ b/go/internal/store/queries/forge_subscriptions.sql @@ -0,0 +1,95 @@ +-- Agent-forge-subscription / artifact-cursor queries (sqlc adoption T6, +-- RIG-3034). These replace the inline SQL literals in +-- internal/store/forge_subscriptions.go; the hand-written Store methods keep +-- their signatures, the door-side validation (validSubscriptionCoordinate / +-- validCoordinate), the scope normalization, the ErrConflict/ErrInvalidArgument/ +-- ErrNotFound mapping via pgErrIs, and the two hand-written tx seams: the +-- DeleteAgentForgeSubscription GC (WithTx) and the ListForgeNotifyTargets row +-- grouping. The read queries feed the ForgeNotifySubscriber / ForgeArtifactCursor +-- / ForgeNotifyTarget mappers, which convert the generated rows (provider/kind +-- ints, BIGINT numbers, LEFT-JOIN-nullable cursor columns) back to the domain +-- types. + +-- name: EnsureAgentForgeSubscription :one +-- Idempotent on the UNIQUE coordinate: the no-op DO UPDATE (re-set agent to +-- itself) makes RETURNING fire on conflict so a repeat returns the stored id. +INSERT INTO agent_forge_subscriptions + (id, agent_account_id, forge_provider, forge_host, repo, kind, number, scope, project) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (agent_account_id, forge_provider, forge_host, repo, kind, number, project) DO UPDATE + SET agent_account_id = EXCLUDED.agent_account_id +RETURNING id; + +-- name: DeleteAgentForgeSubscription :one +-- Scoped to the calling agent (id AND agent). RETURNING the coordinate drives the +-- one-tx GC of the artifact cursor when this was the last subscription. +DELETE FROM agent_forge_subscriptions + WHERE id = $1 AND agent_account_id = $2 +RETURNING forge_provider, forge_host, repo, kind, number; + +-- name: GCForgeArtifactCursorIfUnsubscribed :exec +-- Collects the coordinate's cursor IFF no subscription for it remains (the NOT +-- EXISTS guard leaves it in place if any other agent still subscribes). +DELETE FROM forge_artifact_cursors + WHERE forge_artifact_cursors.forge_provider = $1 AND forge_artifact_cursors.forge_host = $2 AND forge_artifact_cursors.repo = $3 AND forge_artifact_cursors.kind = $4 AND forge_artifact_cursors.number = $5 + AND NOT EXISTS ( + SELECT 1 FROM agent_forge_subscriptions + WHERE agent_forge_subscriptions.forge_provider = $1 AND agent_forge_subscriptions.forge_host = $2 AND agent_forge_subscriptions.repo = $3 AND agent_forge_subscriptions.kind = $4 AND agent_forge_subscriptions.number = $5 + ); + +-- name: CountAgentForgeSubscriptionsForArtifact :one +SELECT count(*) FROM agent_forge_subscriptions + WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5; + +-- name: SubscribersForArtifact :many +-- Exact-artifact subscribers, plus (on an opened event) the container-scope +-- subscribers for the same container/project. +SELECT id, agent_account_id, delivered_revision, project +FROM agent_forge_subscriptions +WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 + AND ( + (scope = 1 AND number = $5) + OR ($6::boolean AND scope = 2 AND number = 0 AND project = $7) + ); + +-- name: ListForgeNotifyTargets :many +-- The reconcile sweep's work list for one (provider, host): each subscribed +-- coordinate with its LEFT-JOINed shared FETCH cursor (nullable when never +-- observed) and the subscriber rows, container-scope rows collapsed per +-- (repo, kind) to coord_number 0. The Go groups the flat rows into targets. +SELECT s.repo, s.kind, + (CASE WHEN s.scope = 2 THEN 0 ELSE s.number END)::BIGINT AS coord_number, + s.id, s.agent_account_id, s.delivered_revision, s.project, + (c.forge_provider IS NOT NULL)::boolean AS has_cursor, + c.etag, c.comments_etag, c.checks_etag, c.revision, c.snapshot, c.polled_at +FROM agent_forge_subscriptions s +LEFT JOIN forge_artifact_cursors c + ON c.forge_provider = s.forge_provider + AND c.forge_host = s.forge_host + AND c.repo = s.repo + AND c.kind = s.kind + AND c.number = CASE WHEN s.scope = 2 THEN 0 ELSE s.number END +WHERE s.forge_provider = $1 AND s.forge_host = $2 +ORDER BY s.repo, s.kind, coord_number; + +-- name: UpsertForgeArtifactCursor :exec +INSERT INTO forge_artifact_cursors + (forge_provider, forge_host, repo, kind, number, etag, comments_etag, checks_etag, revision, snapshot, polled_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +ON CONFLICT (forge_provider, forge_host, repo, kind, number) DO UPDATE + SET etag = EXCLUDED.etag, + comments_etag = EXCLUDED.comments_etag, + checks_etag = EXCLUDED.checks_etag, + revision = EXCLUDED.revision, + snapshot = EXCLUDED.snapshot, + polled_at = EXCLUDED.polled_at; + +-- name: LoadForgeArtifactCursor :one +SELECT etag, comments_etag, checks_etag, revision, snapshot, polled_at +FROM forge_artifact_cursors +WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5; + +-- name: AdvanceForgeDeliveredRevision :execrows +UPDATE agent_forge_subscriptions + SET delivered_revision = $3, delivered_at = now() + WHERE id = $2 AND agent_account_id = $1; diff --git a/go/internal/store/queries/issues.sql b/go/internal/store/queries/issues.sql new file mode 100644 index 000000000..6f98b4408 --- /dev/null +++ b/go/internal/store/queries/issues.sql @@ -0,0 +1,52 @@ +-- Issue-domain queries (sqlc adoption T6, RIG-3034). These replace the inline +-- SQL literals in internal/store/issues.go; the hand-written Store methods keep +-- their signatures, the door-side validation, the ErrNotFound/ErrInvalidArgument +-- mapping, and the RowsAffected branch (SetIssueState is :execrows). GetIssue / +-- ListIssues feed issueFromColumns (via issueFromGetRow / issueFromListRow), +-- which maps the generated row (forge_provider/state ints, number BIGINT) back +-- to the domain Issue. + +-- name: UpsertIssueForgeFields :one +-- Insert-or-update at the forge coordinate with the OQ-6(a) recency guard; the +-- ON CONFLICT sets ONLY forge columns (never state/machinery), and the CTE's +-- fallback SELECT keeps the returned id stable when the guard skips the UPDATE. +WITH up AS ( + INSERT INTO issues + (id, forge_provider, forge_host, repo, number, + title, body, forge_state, url, forge_account, labels, agent_handle, + forge_updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT (forge_provider, forge_host, repo, number) DO UPDATE + SET title = EXCLUDED.title, body = EXCLUDED.body, + forge_state = EXCLUDED.forge_state, url = EXCLUDED.url, + forge_account = EXCLUDED.forge_account, labels = EXCLUDED.labels, + agent_handle = EXCLUDED.agent_handle, + forge_updated_at = EXCLUDED.forge_updated_at + WHERE issues.forge_updated_at IS NULL + OR EXCLUDED.forge_updated_at IS NULL + OR EXCLUDED.forge_updated_at >= issues.forge_updated_at + RETURNING id + ) + SELECT id FROM up + UNION ALL + SELECT id FROM issues + WHERE NOT EXISTS (SELECT 1 FROM up) + AND forge_provider = $2 AND forge_host = $3 AND repo = $4 AND number = $5 + LIMIT 1; + +-- name: SetIssueState :execrows +UPDATE issues SET state = $2 WHERE id = $1; + +-- name: GetIssue :one +SELECT id, forge_provider, forge_host, repo, number, + title, body, forge_state, url, forge_account, labels, agent_handle, + state, priority, assignee, summary, branch +FROM issues +WHERE id = $1; + +-- name: ListIssues :many +SELECT id, forge_provider, forge_host, repo, number, + title, body, forge_state, url, forge_account, labels, agent_handle, + state, priority, assignee, summary, branch +FROM issues +ORDER BY id; diff --git a/go/internal/store/queries/linear_sessions.sql b/go/internal/store/queries/linear_sessions.sql new file mode 100644 index 000000000..03a4fe2a2 --- /dev/null +++ b/go/internal/store/queries/linear_sessions.sql @@ -0,0 +1,18 @@ +-- Linear-agent-session queries (sqlc adoption T6, RIG-3034). These replace the +-- inline SQL literals in internal/store/linear_sessions.go; the hand-written +-- Store methods keep their signatures, the RowsAffected branch (Upsert returns +-- created via :execrows), the textOrNull linear_issue_id NULL discipline, and the +-- ErrNotFound/ErrInvalidArgument mapping. The LinearAgentSession read maps the +-- generated row (nullable linear_issue_id, created_at timestamp) back to the +-- domain LinearAgentSessionRow inline. + +-- name: UpsertLinearAgentSession :execrows +INSERT INTO linear_agent_sessions + (linear_session_id, manager_account_id, channel_id, topic_id, linear_issue_id) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (linear_session_id) DO NOTHING; + +-- name: LinearAgentSession :one +SELECT linear_session_id, manager_account_id, channel_id, topic_id, linear_issue_id, created_at +FROM linear_agent_sessions +WHERE linear_session_id = $1; diff --git a/go/internal/store/queries/secrets.sql b/go/internal/store/queries/secrets.sql new file mode 100644 index 000000000..bed66e304 --- /dev/null +++ b/go/internal/store/queries/secrets.sql @@ -0,0 +1,17 @@ +-- Secrets-registry queries (sqlc adoption T6, RIG-3034). These replace the inline +-- SQL literals in internal/store/secrets.go; the hand-written Store methods keep +-- their signatures, the door-side validation (name grammar, kind routing), the +-- ErrConflict/ErrInvalidArgument/ErrNotFound mapping, and the RowsAffected branch +-- (DeleteSecretDeclaration is :execrows). DeclaredSecrets maps the generated row +-- back to the domain SecretDeclaration (delivery/kind ints -> named types). + +-- name: InsertSecret :exec +INSERT INTO secrets (name, delivery, kind, provider, host, declared_by) +VALUES ($1, $2, $3, $4, $5, $6); + +-- name: DeleteSecret :execrows +DELETE FROM secrets WHERE name = $1; + +-- name: DeclaredSecrets :many +SELECT name, delivery, kind, provider, host, declared_by, created_at, updated_at +FROM secrets ORDER BY name; diff --git a/go/internal/store/queries/tenant.sql b/go/internal/store/queries/tenant.sql new file mode 100644 index 000000000..7cab59318 --- /dev/null +++ b/go/internal/store/queries/tenant.sql @@ -0,0 +1,10 @@ +-- Tenant-bootstrap queries (sqlc adoption T6, RIG-3034). These replace the inline +-- SQL literals in internal/store/tenant.go; the hand-written Store methods keep +-- their signatures and the unique-violation-means-fetch idempotent bootstrap +-- shape (BootstrapTenant falls back to TenantIDBySlug on a duplicate slug). + +-- name: InsertTenant :exec +INSERT INTO tenants (id, slug, display_name, created_at_unix_ms) VALUES ($1, $2, $3, $4); + +-- name: TenantIDBySlug :one +SELECT id FROM tenants WHERE slug = $1; diff --git a/go/internal/store/queries/tokens.sql b/go/internal/store/queries/tokens.sql new file mode 100644 index 000000000..a9cb05052 --- /dev/null +++ b/go/internal/store/queries/tokens.sql @@ -0,0 +1,17 @@ +-- Token-domain queries (sqlc adoption T6, RIG-3034). These replace the inline +-- SQL literals in internal/store/tokens.go; the hand-written Store methods keep +-- their signatures, the ErrConflict/ErrNotFound/ErrTokenRevoked mapping, and the +-- RowsAffected branching (RevokeToken is :execrows). ResolveTokenHash maps the +-- generated row (subject_kind/subject_id/revoked) back to the domain Subject. + +-- name: InsertTokenHash :exec +INSERT INTO tokens (hash, subject_kind, subject_id) VALUES ($1, $2, $3); + +-- name: ResolveTokenHash :one +SELECT subject_kind, subject_id, (revoked_at IS NOT NULL)::boolean AS revoked FROM tokens WHERE hash = $1; + +-- name: RevokeToken :execrows +UPDATE tokens SET revoked_at = now() WHERE hash = $1 AND revoked_at IS NULL; + +-- name: TokenHashExists :one +SELECT EXISTS (SELECT 1 FROM tokens WHERE hash = $1); diff --git a/go/internal/store/secrets.go b/go/internal/store/secrets.go index 746a6cbbb..04061299c 100644 --- a/go/internal/store/secrets.go +++ b/go/internal/store/secrets.go @@ -5,6 +5,8 @@ import ( "fmt" "regexp" "time" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // SecretDelivery is how a declared secret is delivered into a container — the @@ -87,11 +89,14 @@ func (s *Store) DeclareSecret(ctx context.Context, actor AccountID, name string, if err := validateKindRouting(kind, provider, host); err != nil { return err } - if _, err := s.pool.Exec(ctx, - `INSERT INTO secrets (name, delivery, kind, provider, host, declared_by) - VALUES ($1, $2, $3, $4, $5, $6)`, - name, int32(delivery), int32(kind), provider, host, string(actor), - ); err != nil { + if err := s.q.InsertSecret(ctx, db.InsertSecretParams{ + Name: name, + Delivery: int16(delivery), //nolint:gosec // G115: SecretDelivery is a CHECK-constrained 0/1 enum (secrets.delivery), always within int16 + Kind: int16(kind), //nolint:gosec // G115: SecretKind is a CHECK-constrained 0/1/2 enum (secrets.kind), always within int16 + Provider: provider, + Host: host, + DeclaredBy: string(actor), + }); err != nil { if pgErrIs(err, pgUniqueViolation) { return fmt.Errorf("%w: secret %q already declared", ErrConflict, name) } @@ -154,11 +159,11 @@ func validateKindRouting(kind SecretKind, provider, host string) error { // enforced at the T7 RPC edge, not re-litigated per row here. func (s *Store) DeleteSecretDeclaration(ctx context.Context, actor AccountID, name string) error { _ = actor // see doc: name-keyed global registry; actor is audit context, not a filter - tag, err := s.pool.Exec(ctx, "DELETE FROM secrets WHERE name = $1", name) + affected, err := s.q.DeleteSecret(ctx, name) if err != nil { return fmt.Errorf("store: delete secret declaration: %w", err) } - if tag.RowsAffected() == 0 { + if affected == 0 { return fmt.Errorf("%w: secret %q", ErrNotFound, name) } return nil @@ -169,32 +174,22 @@ func (s *Store) DeleteSecretDeclaration(ctx context.Context, actor AccountID, na // (inject-all: no per-agent filter in the MVP). It never returns a value — // there is none stored. func (s *Store) DeclaredSecrets(ctx context.Context) ([]SecretDeclaration, error) { - rows, err := s.pool.Query(ctx, - `SELECT name, delivery, kind, provider, host, declared_by, created_at, updated_at - FROM secrets ORDER BY name`) + rows, err := s.q.DeclaredSecrets(ctx) if err != nil { return nil, fmt.Errorf("store: list declared secrets: %w", err) } - defer rows.Close() - var out []SecretDeclaration - for rows.Next() { - var ( - d SecretDeclaration - delivery int32 - kind int32 - declaredBy string - ) - if err := rows.Scan(&d.Name, &delivery, &kind, &d.Provider, &d.Host, &declaredBy, &d.CreatedAt, &d.UpdatedAt); err != nil { - return nil, fmt.Errorf("store: scan declared secret: %w", err) - } - d.Delivery = SecretDelivery(delivery) - d.Kind = SecretKind(kind) - d.DeclaredBy = AccountID(declaredBy) - out = append(out, d) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: iterate declared secrets: %w", err) + for _, r := range rows { + out = append(out, SecretDeclaration{ + Name: r.Name, + Delivery: SecretDelivery(r.Delivery), + Kind: SecretKind(r.Kind), + Provider: r.Provider, + Host: r.Host, + DeclaredBy: AccountID(r.DeclaredBy), + CreatedAt: r.CreatedAt.Time, + UpdatedAt: r.UpdatedAt.Time, + }) } return out, nil } diff --git a/go/internal/store/store.go b/go/internal/store/store.go index 6e921fd27..a74adc918 100644 --- a/go/internal/store/store.go +++ b/go/internal/store/store.go @@ -65,14 +65,6 @@ type Store struct { bootstrapTenantID TenantID } -// querier is the read surface shared by the pool and a transaction, so a scan -// helper (scanChannels) or an authorization probe (requireChannelMember) can -// run against either. Both *pgxpool.Pool and pgx.Tx satisfy it. -type querier interface { - Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) - QueryRow(ctx context.Context, sql string, args ...any) pgx.Row -} - // Open connects to Postgres at dsn (a pgx pool), applies any pending embedded // migrations under an advisory lock, and verifies the resulting schema version // matches what this binary expects — refusing to serve on a failed migration diff --git a/go/internal/store/tenant.go b/go/internal/store/tenant.go index a3f3a14f1..9705be6df 100644 --- a/go/internal/store/tenant.go +++ b/go/internal/store/tenant.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "time" + + "github.com/RigelBuild/compass/go/internal/store/db" ) const ( @@ -20,10 +22,12 @@ const ( // serves. func (s *Store) BootstrapTenant(ctx context.Context) (TenantID, error) { id := newID() - if _, err := s.pool.Exec(ctx, - "INSERT INTO tenants (id, slug, display_name, created_at_unix_ms) VALUES ($1, $2, $3, $4)", - id, bootstrapTenantSlug, bootstrapTenantDisplayName, time.Now().UnixMilli(), - ); err != nil { + if err := s.q.InsertTenant(ctx, db.InsertTenantParams{ + ID: id, + Slug: bootstrapTenantSlug, + DisplayName: bootstrapTenantDisplayName, + CreatedAtUnixMs: time.Now().UnixMilli(), + }); err != nil { if pgErrIs(err, pgUniqueViolation) { return s.tenantIDBySlug(ctx, bootstrapTenantSlug) } @@ -35,8 +39,8 @@ func (s *Store) BootstrapTenant(ctx context.Context) (TenantID, error) { // tenantIDBySlug fetches an existing tenant id by slug, backing // BootstrapTenant's idempotent restart path. func (s *Store) tenantIDBySlug(ctx context.Context, slug string) (TenantID, error) { - var id string - if err := s.pool.QueryRow(ctx, "SELECT id FROM tenants WHERE slug = $1", slug).Scan(&id); err != nil { + id, err := s.q.TenantIDBySlug(ctx, slug) + if err != nil { return "", fmt.Errorf("store: resolve tenant by slug: %w", err) } return TenantID(id), nil diff --git a/go/internal/store/tokens.go b/go/internal/store/tokens.go index 5cde5df53..031b90980 100644 --- a/go/internal/store/tokens.go +++ b/go/internal/store/tokens.go @@ -3,6 +3,8 @@ package store import ( "context" "fmt" + + "github.com/RigelBuild/compass/go/internal/store/db" ) // PutTokenHash stores a token's SHA-256 hash with its subject (design.md: @@ -13,10 +15,11 @@ func (s *Store) PutTokenHash(ctx context.Context, hash [32]byte, subj Subject) e if subj.ID == "" { return fmt.Errorf("%w: token subject id is required", ErrInvalidArgument) } - if _, err := s.pool.Exec(ctx, - "INSERT INTO tokens (hash, subject_kind, subject_id) VALUES ($1, $2, $3)", - hash[:], int32(subj.Kind), subj.ID, - ); err != nil { + if err := s.q.InsertTokenHash(ctx, db.InsertTokenHashParams{ + Hash: hash[:], + SubjectKind: int16(subj.Kind), //nolint:gosec // G115: SubjectKind is a CHECK-constrained 0/1 enum (tokens.subject_kind), always within int16 + SubjectID: subj.ID, + }); err != nil { if pgErrIs(err, pgUniqueViolation) { return fmt.Errorf("%w: token hash already stored", ErrConflict) } @@ -31,25 +34,17 @@ func (s *Store) PutTokenHash(ctx context.Context, hash [32]byte, subj Subject) e // ErrTokenRevoked — the two are distinct so the door can tell a withdrawn // credential from an unknown one. func (s *Store) ResolveTokenHash(ctx context.Context, hash [32]byte) (Subject, error) { - var ( - kind int32 - subjectID string - revoked bool - ) - err := s.pool.QueryRow(ctx, - "SELECT subject_kind, subject_id, revoked_at IS NOT NULL FROM tokens WHERE hash = $1", - hash[:], - ).Scan(&kind, &subjectID, &revoked) + row, err := s.q.ResolveTokenHash(ctx, hash[:]) if err != nil { if noRows(err) { return Subject{}, fmt.Errorf("%w: token hash", ErrNotFound) } return Subject{}, fmt.Errorf("store: resolve token hash: %w", err) } - if revoked { + if row.Revoked { return Subject{}, ErrTokenRevoked } - return Subject{Kind: SubjectKind(kind), ID: subjectID}, nil + return Subject{Kind: SubjectKind(row.SubjectKind), ID: row.SubjectID}, nil } // RevokeToken marks a token hash revoked (design.md:1183). Idempotent: revoking @@ -57,20 +52,15 @@ func (s *Store) ResolveTokenHash(ctx context.Context, hash [32]byte) (Subject, e // issued is ErrNotFound, so a caller learns a bad revoke target rather than // silently succeeding. func (s *Store) RevokeToken(ctx context.Context, hash [32]byte) error { - tag, err := s.pool.Exec(ctx, - "UPDATE tokens SET revoked_at = now() WHERE hash = $1 AND revoked_at IS NULL", - hash[:], - ) + affected, err := s.q.RevokeToken(ctx, hash[:]) if err != nil { return fmt.Errorf("store: revoke token: %w", err) } - if tag.RowsAffected() == 0 { + if affected == 0 { // Either the hash is unknown, or it was already revoked. Distinguish so // an unknown target is an error but a repeat revoke is a no-op success. - var exists bool - if err := s.pool.QueryRow(ctx, - "SELECT EXISTS (SELECT 1 FROM tokens WHERE hash = $1)", hash[:], - ).Scan(&exists); err != nil { + exists, err := s.q.TokenHashExists(ctx, hash[:]) + if err != nil { return fmt.Errorf("store: check token exists: %w", err) } if !exists { diff --git a/go/moon.yml b/go/moon.yml index e34b0c077..a35bdc80f 100644 --- a/go/moon.yml +++ b/go/moon.yml @@ -252,13 +252,67 @@ tasks: runFromWorkspaceRoot: false inputs: *sqlc_sources + sqlc-vet: + # `sqlc vet` with the sqlc/db-prepare rule PREPAREs every generated query + # against a live, schema-loaded Postgres — the strongest check sqlc offers + # (operator/typecast validity, column existence, index-referenced-by-hint) + # beyond what static analysis sees. It needs a real database, so unlike + # sqlc-drift (pure-local, no DB, in `ci` deps) this task is NOT in the moon + # `ci` battery: the moon job realizes no service (ci.yml:27-28). It is invoked + # instead by the env-bearing `pgtest` CI job — which already runs the + # postgres:16-alpine service and exports COMPASS_TEST_DATABASE_DSN — via + # `moon run compass-go:sqlc-vet`, the same peel-into-a-service-job pattern as + # pgtest/microvm/forge/gtk4. The task provisions its own throwaway DB: + # 1. DSN-gated skip. On a dev box with no Postgres the task echoes a notice + # and exits 0 — the same posture as the pgtest suites' DSN-gated skip + # (COMPASS_TEST_DATABASE_DSN drives the real-Postgres lane; absent it, + # skip rather than red). Either COMPASS_TEST_DATABASE_DSN (the CI service + # DSN) or SQLC_DATABASE_URL satisfies the gate; the task derives its own + # per-run URL, so the pgtest job needs no extra env beyond the DSN it + # already exports. + # 2. Throwaway DB. With a DSN present it creates a uniquely-named + # (sqlcvet_$$) database on that server, applies + # internal/store/migrations/*.sql in order via psql, points + # SQLC_DATABASE_URL at it, runs the vet, and drops the database on exit + # (trap, mirroring sqlc-drift's cleanup trap). The throwaway isolates + # db-prepare from whatever else shares the service DB and leaves no + # residue. `set -e` + psql ON_ERROR_STOP make any create/migrate/vet + # failure fail the task. + # go/cmd/compass-postgres (the private-postgres wrapper) is deliberately NOT + # reused: that is the stack supervisor's process-managed instance, whereas the + # CI postgres service is simpler and already provisioned — vet only needs a + # bare server to PREPARE against, not a managed lifecycle. psql is on PATH + # from the devenv `postgresql` package. + script: | + set -e + dsn="${COMPASS_TEST_DATABASE_DSN:-${SQLC_DATABASE_URL:-}}" + if [ -z "$dsn" ]; then + echo "sqlc-vet: no COMPASS_TEST_DATABASE_DSN/SQLC_DATABASE_URL — skipping db-prepare vet (dev box without the CI Postgres service)" + exit 0 + fi + db="sqlcvet_$$" + trap 'psql "$dsn" -c "DROP DATABASE IF EXISTS $db" >/dev/null 2>&1 || true' EXIT + psql "$dsn" -v ON_ERROR_STOP=1 -q -c "CREATE DATABASE $db" + vet_url=$(printf '%s' "$dsn" | sed -E "s#/[^/?]+(\?|\$)#/${db}\1#") + for f in internal/store/migrations/*.sql; do + psql "$vet_url" -v ON_ERROR_STOP=1 -q -f "$f" + done + SQLC_DATABASE_URL="$vet_url" sqlc vet + options: + runFromWorkspaceRoot: false + inputs: *sqlc_sources + ci: # module is affected: format, vet, lint (incl. exhaustiveness), -race test, # build, the supply-chain fence, and the sqlc drift gate. `drift` is NOT a # dep here — it belongs to the schema pipeline (compass-proto) and is # scheduled through the gen tree, matching compass-proto/moon.yml's own # `ci` composition. `sqlc-drift` IS a dep: it is fully local (no - # cross-project delegation, no DB), so it belongs in ci directly. + # cross-project delegation, no DB), so it belongs in ci directly. `sqlc-vet` + # is deliberately NOT a dep: it needs a live Postgres, which the moon battery + # has no business realizing (ci.yml has no service on the moon job); it is + # invoked instead by the env-bearing `pgtest` CI job, the same way pgtest/ + # microvm/forge/gtk4 are peeled into their own service-carrying peer jobs. deps: ['fmt', 'vet', 'lint', 'nilaway', 'test', 'build', 'vuln', 'licenses', 'sqlc-drift'] options: cache: false diff --git a/go/sqlc.yaml b/go/sqlc.yaml index b9eaa08be..ee881156a 100644 --- a/go/sqlc.yaml +++ b/go/sqlc.yaml @@ -9,3 +9,9 @@ sql: out: "internal/store/db" sql_package: "pgx/v5" emit_interface: true # Querier, for the store's tx/pool duality + database: + uri: "${SQLC_DATABASE_URL}" # sqlc vet's db-prepare target (CI throwaway DB); see moon sqlc-vet + analyzer: + database: false # keep `generate`/`sqlc-drift` DB-free; db-prepare (vet) still uses database.uri + rules: + - sqlc/db-prepare # PREPARE every query against the live schema — the strongest vet diff --git a/tools/inline-sql-gate/index.test.ts b/tools/inline-sql-gate/index.test.ts index f08f0e704..5c1a9a50b 100644 --- a/tools/inline-sql-gate/index.test.ts +++ b/tools/inline-sql-gate/index.test.ts @@ -140,6 +140,84 @@ func f() {}`; }); }); +// --------------------------------------------------------------------------- +// Identifier-passed SQL at a pgx receiver — the T7 promotion (rule b). +// --------------------------------------------------------------------------- + +describe("identifier-passed SQL at a pgx receiver is flagged (T7)", () => { + test("pool.Query with a bare-identifier SQL arg is flagged", () => { + const src = `func f() { + rows, err := pool.Query(ctx, q, arg) + _ = rows + _ = err +}`; + const fs = scanText(STORE, src); + expect(fs.length).toBe(1); + expect(fs[0]?.snippet).toContain("pool.Query(ctx, q, arg)"); + }); + + test("conn.Exec(ctx, ddl) — the store.go migration-runner shape — is flagged", () => { + const src = `func f() { + if _, err := conn.Exec(ctx, ddl); err != nil { + return err + } +}`; + const fs = scanText(STORE, src); + expect(fs.length).toBe(1); + expect(fs[0]?.snippet).toContain("conn.Exec(ctx, ddl)"); + }); + + test("tx.Exec(ctx, m.sql) — a dotted selector SQL arg — is flagged", () => { + const src = `func f() { + if _, err := tx.Exec(ctx, m.sql); err != nil { + return err + } +}`; + const fs = scanText(STORE, src); + expect(fs.length).toBe(1); + expect(fs[0]?.snippet).toContain("tx.Exec(ctx, m.sql)"); + }); + + test("s.pool.QueryRow with a bare-identifier SQL arg is flagged", () => { + const src = `func f() { + err := s.pool.QueryRow(ctx, query, id).Scan(&v) + _ = err +}`; + const fs = scanText(STORE, src); + expect(fs.length).toBe(1); + expect(fs[0]?.snippet).toContain("s.pool.QueryRow(ctx, query, id)"); + }); + + test("a non-pgx runtime.Exec(ctx, id, spec) with an identifier SQL slot is NOT flagged", () => { + // The receiver `runtime` is not a pgx handle, so the identifier `id` in + // the slot is a container id, not hoisted SQL — the false-positive guard. + const src = `func f() { + out, err := r.runtime.Exec(ctx, id, spec) + _ = out + _ = err +}`; + expect(scanText("go/internal/runtime/agent.go", src)).toEqual([]); + }); + + test("a pgx call whose SQL slot is itself a call is NOT flagged (not a hoisted name)", () => { + const src = `func f() { + _, err := pool.Exec(ctx, buildQuery(t), arg) + _ = err +}`; + expect(scanText(STORE, src)).toEqual([]); + }); + + test("only the SQL slot is tested, never the params (a bare-identifier 2nd param does not double-flag)", () => { + const src = `func f() { + _, err := pool.Exec(ctx, q, someIdentParam) + _ = err +}`; + const fs = scanText(STORE, src); + expect(fs.length).toBe(1); + expect(fs[0]?.snippet).toContain("pool.Exec(ctx, q, someIdentParam)"); + }); +}); + // --------------------------------------------------------------------------- // isExcludedPath — generated package + test files. // --------------------------------------------------------------------------- diff --git a/tools/inline-sql-gate/index.ts b/tools/inline-sql-gate/index.ts index 990856b20..b9322d934 100644 --- a/tools/inline-sql-gate/index.ts +++ b/tools/inline-sql-gate/index.ts @@ -3,22 +3,34 @@ // // The rule (design record § "The inline-SQL ban"): // -// A `.Query(` / `.QueryRow(` / `.Exec(` call whose SQL argument — the first -// string-literal argument, TOKENIZED ACROSS NEWLINES because the store -// overwhelmingly puts the literal on the line AFTER the call — is a Go string -// literal (backtick or double-quoted, including `+`-concatenated literals) -// containing a SQL keyword (SELECT|INSERT|UPDATE|DELETE|WITH|CREATE|DROP) is -// banned in go/**/*.go, EXCEPT: +// A `.Query(` / `.QueryRow(` / `.Exec(` call carries banned inline SQL in +// its SQL slot (the first argument after `ctx`) when EITHER: +// (a) that argument is a Go string literal (backtick or double-quoted, +// including `+`-concatenated literals), TOKENIZED ACROSS NEWLINES +// because the store overwhelmingly puts the literal on the line AFTER +// the call, containing a SQL keyword +// (SELECT|INSERT|UPDATE|DELETE|WITH|CREATE|DROP) — flagged at ANY +// receiver, or +// (b) the call's RECEIVER is a pgx pool/tx/conn handle and that argument is +// a bare identifier or simple selector (`q`, `ddl`, `m.sql`) — SQL +// hoisted into a const/var and passed by name (the `queryAgents` shape). +// Banned in go/**/*.go, EXCEPT: // 1. go/internal/store/db/** — the sqlc-generated package, // 2. **/*_test.go — tests legitimately poke raw SQL, // 3. an explicit, checked-in allowlist of file paths (ALLOWLIST below). // // The tokenizer is load-bearing. A line-scoped grep would MISS the dominant // store shape — `s.pool.Exec(ctx,\n\t"INSERT …")` — where the literal sits on -// the line after the call. It also must NOT flag a non-pgx `Exec(ctx, id, spec)` -// (runtime/compute), whose immediate arguments are identifiers, not a SQL -// literal — so the discriminator is "the argument STARTS with a string -// delimiter", which an identifier or an expression never does. +// the line after the call. +// +// Two guards keep the identifier rule (b) from firing on non-pgx calls. First, +// it is RECEIVER-SCOPED: only a pgx handle (last receiver segment in +// {pool, tx, conn, c}) has a SQL slot, so a runtime/compute +// `r.runtime.Exec(ctx, id, spec)` / `g.client.Exec(ctx, req)` — whose receiver +// is not a pgx handle — is never a query no matter what its args look like. +// Second, only the SQL slot (arg after `ctx`) is tested, never the params, and +// only a bare identifier/selector qualifies (a call/composite/concatenation is +// not a hoisted-SQL name). // // The ratchet: the allowlist is seeded to every store file that carries inline // SQL today, so the gate is GREEN on current main while banning any NEW inline @@ -26,12 +38,11 @@ // the stale-entry check (fail-closed) then fails the gate if an allowlist entry // no longer matches any finding, so a migrated file cannot be left allowlisted. // -// Known gap (deferred to T7, per the record's residual-risk note): SQL hoisted -// into a `const`/variable and passed as an identifier (`queryAgents(ctx, sql, -// arg)`, `QueryRow(ctx, q, …)`) escapes a literal-at-callsite scan. Those files -// therefore produce NO finding here and are NOT allowlisted in T1; the record -// promotes the identifier-passed shape to gating once the migration is -// complete. +// Identifier-passed SQL is GATED (T7): once every store domain migrated, the +// only remaining identifier-passed sites are the migration runner's +// `conn.Exec(ctx, ddl)` / `tx.Exec(ctx, m.sql)` in the PERMANENTLY-allowlisted +// go/internal/store/store.go, so promoting rule (b) leaves the gate green while +// banning any NEW const-hoisted SQL at a pgx call site. // // Inputs (env): // GATE_ROOT - directory to scan (default: git toplevel). @@ -53,6 +64,23 @@ export const GO_GLOB = "go/**/*.go"; const SQL_KEYWORD_RE = /\b(?:SELECT|INSERT|UPDATE|DELETE|WITH|CREATE|DROP)\b/i; /** pgx query methods. QueryRow before Query so the longer name wins. */ const CALL_RE = /\.(?:QueryRow|Query|Exec)\(/g; +/** + * The last receiver segment names that ARE a pgx pool/tx/conn handle in this + * codebase: `s.pool`→pool, `tx`, `conn`, `c` (a *pgx.Conn). Only these carry a + * SQL slot, so a bare-identifier SQL arg (`q`, `ddl`, `m.sql`) is flagged ONLY + * at these receivers — the structural exclusion of runtime/compute + * `r.runtime.Exec(ctx, id, spec)` / `g.client.Exec(ctx, req)` (receivers + * runtime/client/engine), whose identifier args are not SQL. sqlc's own + * `s.q.GetFoo(…)` never matches CALL_RE (not Query/QueryRow/Exec). + */ +const PGX_RECEIVERS: Record = { + pool: true, + tx: true, + conn: true, + c: true, +}; +/** A bare identifier or dotted selector — `q`, `ddl`, `m.sql`, `query`. */ +const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*/; /** * The ratcheting allowlist: files permitted to carry inline SQL today. Seeded @@ -67,42 +95,18 @@ const CALL_RE = /\.(?:QueryRow|Query|Exec)\(/g; * - go/internal/pgshare/pgshare.go — the build-tagged test harness; CREATE/ * DROP SCHEMA with an interpolated, self-generated identifier. * - * NOTE (T1): agent_tree.go and presence_reads.go carry inline SQL only as - * const-hoisted identifiers passed to the call (not literals at the call site), - * so this literal-scoped gate produces no finding for them and they are - * deliberately omitted — seeding them would trip the fail-closed stale-entry - * check. They are covered by the record's T7 identifier-passed-SQL promotion. - * - * NOTE (T1): dm.go was added to the store AFTER the design record froze (§T3 - * states "There is no dm.go" and predates it); it is a genuine store domain - * file carrying inline-SQL literals, so the invariant "every currently - * inline-SQL store file is allowlisted so the gate is green on main" requires - * seeding it. It migrates (and its entry drops) in a per-domain task like the rest. + * NOTE (T7): the migration is complete — every store domain file's inline SQL + * moved to sqlc, so the allowlist is down to the two PERMANENT entries below. + * The identifier-passed-SQL promotion (rule (b) above) is now LIVE: the only + * remaining bare-identifier SQL at a pgx receiver is the migration runner's + * `conn.Exec(ctx, ddl)` / `tx.Exec(ctx, m.sql)` in store.go, which is + * permanently allowlisted — so the gate stays green while banning any NEW + * const-hoisted SQL (the former `agent_tree.go`/`presence_reads.go` shape) + * anywhere else in go/. */ export const ALLOWLIST: string[] = [ - // Store domain files carrying inline-SQL literals AT the call site (the - // shape this gate flags): the record's 24-file list minus agent_tree.go + - // presence_reads.go (const-hoisted, not literal-at-callsite — see above), - // plus dm.go (added post-record). Each drops as its domain migrates. - // accounts.go migrated in T2; channels/channel_pins/coordination in T3; - // messages/topics/delivery_cursors/delivery_reads in T4 (RIG-3034). - // agent_tree.go + presence_reads.go were never seeded here (const-hoisted SQL, - // so the literal-scoped gate produced no finding). - "go/internal/store/agent_sessions.go", - "go/internal/store/agent_transcripts.go", - "go/internal/store/agent_activity.go", - "go/internal/store/agent_config.go", - "go/internal/store/agent_placements.go", - "go/internal/store/authz.go", - "go/internal/store/tokens.go", - "go/internal/store/secrets.go", - "go/internal/store/issues.go", - "go/internal/store/forge_authored.go", - "go/internal/store/forge_cursors.go", - "go/internal/store/forge_subscriptions.go", - "go/internal/store/tenant.go", - "go/internal/store/linear_sessions.go", - "go/internal/store/dm.go", + // Every store domain file's inline SQL has migrated to sqlc (T2..T6, + // RIG-3034); the two PERMANENT raw-SQL files below are all that remain. // Permanent raw-SQL files. "go/internal/store/store.go", "go/internal/pgshare/pgshare.go", @@ -382,6 +386,38 @@ function sourceLine(text: string, line: number, starts: number[]): string { return text.slice(start, end); } +/** + * The last receiver segment of a `.Query|.QueryRow|.Exec` call — the token + * immediately before the `.` at `dot`. For `s.pool.Exec(` returns "pool"; for + * `tx.Exec(` returns "tx"; for `r.runtime.Exec(` returns "runtime". Returns "" + * when no identifier immediately precedes the dot (e.g. a `).Exec(` chained off + * a call result), which is never a pgx handle. + */ +function receiverSegment(text: string, dot: number): string { + let i = dot - 1; + while (i >= 0 && /[A-Za-z0-9_]/.test(text.charAt(i))) i--; + return text.slice(i + 1, dot); +} + +/** + * If `s` begins with a bare identifier or dotted selector (`q`, `ddl`, `m.sql`) + * and nothing else follows it but trivia/comma/close, return that identifier's + * leading segment; otherwise null. A trailing `(` (call), `[` (index), `+` + * (concat), or `{` (composite) disqualifies it — those are expressions, not a + * hoisted-SQL name. + */ +function bareIdentifier(s: string): string | null { + const m = IDENT_RE.exec(s); + if (m === null) return null; + const rest = s.slice(m[0].length); + const nextMeaningful = firstMeaningfulIndex(rest); + if (nextMeaningful >= 0) { + const c = rest.charAt(nextMeaningful); + if (c !== "," && c !== ")") return null; + } + return m[0].split(".")[0] ?? m[0]; +} + /** * Scan ONE Go file's text for inline-SQL findings. Pure: no I/O, no allowlist, * no exit — returns every raw finding so callers can apply the allowlist and @@ -396,23 +432,46 @@ export function scanText(file: string, text: string): Finding[] { if (dot === undefined || mask[dot] !== 1) continue; const open = dot + m[0].length - 1; const args = parseArgs(text, open); - // The SQL slot is the first STRING-LITERAL argument (an identifier or an - // expression — ctx, handle.id, spec, q, sql — never starts with a - // delimiter, which is the structural exclusion of non-pgx Exec calls). - for (const arg of args) { - const fm = firstMeaningfulIndex(arg.text); - if (fm < 0) continue; - const lead = arg.text.charAt(fm); - if (lead !== '"' && lead !== "`") continue; - if (!SQL_KEYWORD_RE.test(stringContents(arg.text))) break; - const line = lineOf(arg.start + fm, starts); + const push = (index: number) => { + const line = lineOf(index, starts); findings.push({ file, line, snippet: sourceLine(text, line, starts).trim(), }); + }; + + // Rule (a): the first STRING-LITERAL argument is the SQL slot (an + // identifier or expression — ctx, handle.id, spec — never starts with a + // delimiter). Flag it iff it carries a SQL keyword. Fires at ANY receiver. + let flagged = false; + for (const arg of args) { + const fm = firstMeaningfulIndex(arg.text); + if (fm < 0) continue; + const lead = arg.text.charAt(fm); + if (lead !== '"' && lead !== "`") continue; + if (SQL_KEYWORD_RE.test(stringContents(arg.text))) { + push(arg.start + fm); + flagged = true; + } break; } + if (flagged) continue; + + // Rule (b, T7): SQL hoisted into a const/var and passed by name. Fires + // ONLY at a pgx pool/tx/conn receiver — the load-bearing guard against + // runtime/compute `r.runtime.Exec(ctx, id, spec)` false positives — and + // ONLY on the SQL slot (the arg after ctx), never a param. A bare + // identifier/selector (`q`, `ddl`, `m.sql`) there is banned; a call or + // composite expression is not a hoisted-SQL name and is left alone. + if (!PGX_RECEIVERS[receiverSegment(text, dot)]) continue; + const slot = args[1]; + if (slot === undefined) continue; + const fm = firstMeaningfulIndex(slot.text); + if (fm < 0) continue; + const ident = bareIdentifier(slot.text.slice(fm)); + if (ident === null || ident === "ctx") continue; + push(slot.start + fm); } return findings; }