diff --git a/go/internal/store/authz.go b/go/internal/store/authz.go index db439f58..0f828000 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/authz.sql.go b/go/internal/store/db/authz.sql.go new file mode 100644 index 00000000..10e111e5 --- /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 00000000..14ff45bf --- /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 00000000..30fa9564 --- /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 00000000..2d278210 --- /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 00000000..2c857d28 --- /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 00000000..fa61186f --- /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 00000000..c741fcc5 --- /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 eed724b3..fb93d17e 100644 --- a/go/internal/store/db/querier.go +++ b/go/internal/store/db/querier.go @@ -15,6 +15,7 @@ type Querier interface { 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 @@ -36,7 +37,11 @@ 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 @@ -62,19 +67,43 @@ type Querier interface { 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) @@ -101,15 +130,33 @@ 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) @@ -156,18 +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 @@ -178,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 @@ -186,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) @@ -204,11 +295,22 @@ type Querier interface { // 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 @@ -216,9 +318,11 @@ type Querier interface { 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). // @@ -249,8 +353,11 @@ type Querier interface { // 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 @@ -259,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) @@ -269,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 00000000..eb7634fe --- /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 00000000..9d10d145 --- /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 00000000..1dc3d614 --- /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 446dbae3..249c522b 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 fdaade45..cb8fb231 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 e4979a07..c69e45dd 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 299511ed..6859403d 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 51b266c9..650bdb13 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 74cecf08..c9c8c322 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 2b360975..1c461ac1 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/authz.sql b/go/internal/store/queries/authz.sql new file mode 100644 index 00000000..d2f4d101 --- /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 00000000..64d1f6b4 --- /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 00000000..1a2ef2be --- /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 00000000..c2bf1fa0 --- /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 00000000..3f1a69e4 --- /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 00000000..6f98b440 --- /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 00000000..03a4fe2a --- /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 00000000..bed66e30 --- /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 00000000..7cab5931 --- /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 00000000..a9cb0505 --- /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 746a6cbb..04061299 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 6e921fd2..a74adc91 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 a3f3a14f..9705be6d 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 5cde5df5..031b9098 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/tools/inline-sql-gate/index.ts b/tools/inline-sql-gate/index.ts index b898e12e..64eed8a0 100644 --- a/tools/inline-sql-gate/index.ts +++ b/tools/inline-sql-gate/index.ts @@ -80,24 +80,8 @@ const CALL_RE = /\.(?:QueryRow|Query|Exec)\(/g; * seeding it. It migrates (and its entry drops) in a per-domain task like the rest. */ 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/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",