Skip to content

Commit 8e6bf70

Browse files
authored
refactor(store): wave B.2 — settings + identity_link + logs repos (#247) (#248)
Continues the per-domain migration started by Wave B.1 (#246). Three small handlers move from Store.Queries() to domain-specific repos under store.Repos(); cross-domain reads (GetUserByID, GetDeviceByID) stay on Queries until those domains migrate in later sub-waves. Domains migrated: - SettingsRepo (GetServer) — settings_handler.go. - IdentityLinkRepo (Get / ListForUser / CountForUser) — identity_link_handler.go + populateUserIdentityLinks in user_handler.go. ListForUser returns the join-shape IdentityLinkWithProvider so handlers can't confuse it with the basic Get shape. - LogsRepo (CreateQueryResult / GetQueryResult / ExpirePendingQueryResult) — logs_handler.go. First write-side migration in Wave B; the :exec signatures stay opaque to handlers, matching the read-side pattern. Each new repo's Postgres impl translates pgx.ErrNoRows to store.ErrNotFound at the boundary, matching the contract established in Wave A (#244) and applied in Wave B.1. Non-goals: - User / device repos — cross-domain reads in these handlers remain on Store.Queries() until those domains migrate. - Compliance-policy queries — separate sub-wave. Closes #247.
1 parent e4e0a64 commit 8e6bf70

12 files changed

Lines changed: 319 additions & 23 deletions

File tree

internal/api/identity_link_handler.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"github.com/manchtools/power-manage/server/internal/eventtypes"
1313
"github.com/manchtools/power-manage/server/internal/eventtypes/payloads"
1414
"github.com/manchtools/power-manage/server/internal/store"
15-
db "github.com/manchtools/power-manage/server/internal/store/generated"
1615
)
1716

1817
// IdentityLinkHandler handles self-service identity linking RPCs.
@@ -33,7 +32,7 @@ func (h *IdentityLinkHandler) ListIdentityLinks(ctx context.Context, req *connec
3332
return nil, err
3433
}
3534

36-
links, err := h.store.Queries().ListIdentityLinksForUser(ctx, userCtx.ID)
35+
links, err := h.store.Repos().IdentityLink.ListForUser(ctx, userCtx.ID)
3736
if err != nil {
3837
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to list identity links")
3938
}
@@ -60,7 +59,7 @@ func (h *IdentityLinkHandler) UnlinkIdentity(ctx context.Context, req *connect.R
6059
}
6160

6261
// Get the link to verify ownership
63-
link, err := h.store.Queries().GetIdentityLinkByID(ctx, req.Msg.LinkId)
62+
link, err := h.store.Repos().IdentityLink.Get(ctx, req.Msg.LinkId)
6463
if err != nil {
6564
return nil, handleGetError(ctx, err, ErrIdentityLinkNotFound, "identity link not found")
6665
}
@@ -78,7 +77,7 @@ func (h *IdentityLinkHandler) UnlinkIdentity(ctx context.Context, req *connect.R
7877
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get user")
7978
}
8079

81-
linkCount, err := h.store.Queries().CountIdentityLinksForUser(ctx, targetUserID)
80+
linkCount, err := h.store.Repos().IdentityLink.CountForUser(ctx, targetUserID)
8281
if err != nil {
8382
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to count identity links")
8483
}
@@ -106,7 +105,7 @@ func (h *IdentityLinkHandler) UnlinkIdentity(ctx context.Context, req *connect.R
106105
}
107106

108107
// identityLinkRowToProto converts a joined identity link row to a proto message.
109-
func identityLinkRowToProto(link db.ListIdentityLinksForUserRow) *pm.IdentityLink {
108+
func identityLinkRowToProto(link store.IdentityLinkWithProvider) *pm.IdentityLink {
110109
protoLink := &pm.IdentityLink{
111110
Id: link.ID,
112111
UserId: link.UserID,

internal/api/logs_handler.go

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,7 @@ func (h *LogsHandler) QueryDeviceLogs(ctx context.Context, req *connect.Request[
5656
queryID := ulid.Make().String()
5757

5858
// Create pending result row
59-
if err := h.store.Queries().CreateLogQueryResult(ctx, generated.CreateLogQueryResultParams{
60-
QueryID: queryID,
61-
DeviceID: msg.DeviceId,
62-
}); err != nil {
59+
if err := h.store.Repos().Logs.CreateQueryResult(ctx, queryID, msg.DeviceId); err != nil {
6360
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to create log query result")
6461
}
6562

@@ -83,10 +80,7 @@ func (h *LogsHandler) QueryDeviceLogs(ctx context.Context, req *connect.Request[
8380
); err != nil {
8481
h.logger.Error("log query enqueue failed; marking result expired",
8582
"query_id", queryID, "device_id", msg.DeviceId, "error", err)
86-
if expireErr := h.store.Queries().ExpirePendingLogQueryResult(ctx, generated.ExpirePendingLogQueryResultParams{
87-
QueryID: queryID,
88-
Error: fmt.Sprintf("dispatch enqueue failed: %v", err),
89-
}); expireErr != nil {
83+
if expireErr := h.store.Repos().Logs.ExpirePendingQueryResult(ctx, queryID, fmt.Sprintf("dispatch enqueue failed: %v", err)); expireErr != nil {
9084
h.logger.Error("failed to mark enqueue-failed log query result as expired",
9185
"query_id", queryID, "error", expireErr)
9286
}
@@ -104,18 +98,15 @@ func (h *LogsHandler) QueryDeviceLogs(ctx context.Context, req *connect.Request[
10498

10599
// GetDeviceLogResult polls for the result of a dispatched log query.
106100
func (h *LogsHandler) GetDeviceLogResult(ctx context.Context, req *connect.Request[pm.GetDeviceLogResultRequest]) (*connect.Response[pm.GetDeviceLogResultResponse], error) {
107-
result, err := h.store.Queries().GetLogQueryResult(ctx, req.Msg.QueryId)
101+
result, err := h.store.Repos().Logs.GetQueryResult(ctx, req.Msg.QueryId)
108102
if err != nil {
109103
return nil, apiErrorCtx(ctx, ErrQueryResultNotFound, connect.CodeNotFound, "log query result not found")
110104
}
111105

112106
// Auto-expire pending results that have been waiting too long
113107
if !result.Completed && time.Since(result.CreatedAt) > logQueryResultTimeout {
114108
timeoutErr := "log query timed out: device did not respond within 5 minutes"
115-
if err := h.store.Queries().ExpirePendingLogQueryResult(ctx, generated.ExpirePendingLogQueryResultParams{
116-
QueryID: result.QueryID,
117-
Error: timeoutErr,
118-
}); err != nil {
109+
if err := h.store.Repos().Logs.ExpirePendingQueryResult(ctx, result.QueryID, timeoutErr); err != nil {
119110
h.logger.Warn("failed to expire pending log query result", "query_id", result.QueryID, "error", err)
120111
}
121112
result.Completed = true

internal/api/settings_handler.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func NewSettingsHandler(st *store.Store, logger *slog.Logger, systemActions *Sys
3232

3333
// GetServerSettings returns the current server settings.
3434
func (h *SettingsHandler) GetServerSettings(ctx context.Context, req *connect.Request[pm.GetServerSettingsRequest]) (*connect.Response[pm.GetServerSettingsResponse], error) {
35-
settings, err := h.store.Queries().GetServerSettings(ctx)
35+
settings, err := h.store.Repos().Settings.GetServer(ctx)
3636
if err != nil {
3737
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get server settings")
3838
}
@@ -62,7 +62,7 @@ func (h *SettingsHandler) UpdateServerSettings(ctx context.Context, req *connect
6262
}
6363

6464
// Read back from projection
65-
settings, err := h.store.Queries().GetServerSettings(ctx)
65+
settings, err := h.store.Repos().Settings.GetServer(ctx)
6666
if err != nil {
6767
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to read server settings")
6868
}

internal/api/user_handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,7 @@ func (h *UserHandler) UpdateUserSshSettings(ctx context.Context, req *connect.Re
799799

800800
// populateUserIdentityLinks loads identity links for a user and attaches them to the proto User.
801801
func (h *UserHandler) populateUserIdentityLinks(ctx context.Context, user *pm.User) {
802-
links, err := h.store.Queries().ListIdentityLinksForUser(ctx, user.Id)
802+
links, err := h.store.Repos().IdentityLink.ListForUser(ctx, user.Id)
803803
if err != nil {
804804
return
805805
}

internal/store/identity_link.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package store
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// IdentityLink is the per-user SSO-link projection row.
9+
type IdentityLink struct {
10+
ID string
11+
UserID string
12+
ProviderID string
13+
ExternalID string
14+
ExternalEmail string
15+
ExternalName string
16+
LinkedAt time.Time
17+
LastLoginAt *time.Time
18+
}
19+
20+
// IdentityLinkWithProvider is the join shape used by the
21+
// ListForUser query, which augments each link with display fields
22+
// from identity_providers_projection. Kept distinct from IdentityLink
23+
// so callers can't mistakenly assume provider info is populated on a
24+
// basic Get.
25+
type IdentityLinkWithProvider struct {
26+
IdentityLink
27+
ProviderName string
28+
ProviderSlug string
29+
}
30+
31+
// IdentityLinkRepo reads SSO-link projection rows for self-service
32+
// identity management. Writes happen via the IdentityUnlinked /
33+
// IdentityLinked event types, not through this interface.
34+
type IdentityLinkRepo interface {
35+
// Get returns the identity link with the given ID. Returns
36+
// ErrNotFound if the link does not exist (unlinked or never
37+
// existed).
38+
Get(ctx context.Context, id string) (IdentityLink, error)
39+
40+
// ListForUser returns all identity links owned by the user,
41+
// joined with provider display info, ordered newest-linked
42+
// first. Returns an empty slice when the user has no links.
43+
ListForUser(ctx context.Context, userID string) ([]IdentityLinkWithProvider, error)
44+
45+
// CountForUser returns the number of identity links for the
46+
// user. Used by the "cannot unlink last auth method"
47+
// pre-condition in UnlinkIdentity.
48+
CountForUser(ctx context.Context, userID string) (int64, error)
49+
}

internal/store/logs.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package store
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// LogQueryResult is the device-log-query result row. Backed by
9+
// log_query_results, NOT by the event store — this is a derived
10+
// scratchpad for the async dispatch/poll dance, so the shape can
11+
// evolve without event-schema implications.
12+
type LogQueryResult struct {
13+
QueryID string
14+
DeviceID string
15+
Completed bool
16+
Success bool
17+
Error string
18+
Logs string
19+
CreatedAt time.Time
20+
CompletedAt *time.Time
21+
}
22+
23+
// LogsRepo manages the device-log-query result rows used by the
24+
// QueryDeviceLogs / GetDeviceLogResult RPC pair. The agent writes
25+
// the completed result via a separate gateway path; this repo
26+
// covers the creation, expiry, and read sites the control handler
27+
// owns.
28+
type LogsRepo interface {
29+
// CreateQueryResult inserts a fresh pending row for the given
30+
// query+device pair. Called when the control handler dispatches
31+
// a log query to the agent.
32+
CreateQueryResult(ctx context.Context, queryID, deviceID string) error
33+
34+
// GetQueryResult returns the row for the given query ID.
35+
// Returns ErrNotFound when no such row exists (caller used a
36+
// bogus query ID, or the row was already aged out by the
37+
// periodic cleanup).
38+
GetQueryResult(ctx context.Context, queryID string) (LogQueryResult, error)
39+
40+
// ExpirePendingQueryResult marks a pending row as failed with
41+
// the supplied error message. Used by the auto-expiry path
42+
// (5-minute poll timeout) and by the dispatch-failure recovery
43+
// in QueryDeviceLogs.
44+
ExpirePendingQueryResult(ctx context.Context, queryID, errMsg string) error
45+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package postgres
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/manchtools/power-manage/server/internal/store"
8+
"github.com/manchtools/power-manage/server/internal/store/generated"
9+
)
10+
11+
// IdentityLink implements store.IdentityLinkRepo against the
12+
// identity_links_projection via sqlc-generated queries.
13+
type IdentityLink struct {
14+
q *generated.Queries
15+
}
16+
17+
// NewIdentityLink returns an IdentityLink repo bound to the given
18+
// sqlc handle.
19+
func NewIdentityLink(q *generated.Queries) *IdentityLink {
20+
return &IdentityLink{q: q}
21+
}
22+
23+
// Get returns a single identity link by its ID. pgx.ErrNoRows is
24+
// translated to store.ErrNotFound.
25+
func (i *IdentityLink) Get(ctx context.Context, id string) (store.IdentityLink, error) {
26+
row, err := i.q.GetIdentityLinkByID(ctx, id)
27+
if err != nil {
28+
return store.IdentityLink{}, fmt.Errorf("identity_link: get: %w", translateNotFound(err))
29+
}
30+
return store.IdentityLink{
31+
ID: row.ID,
32+
UserID: row.UserID,
33+
ProviderID: row.ProviderID,
34+
ExternalID: row.ExternalID,
35+
ExternalEmail: row.ExternalEmail,
36+
ExternalName: row.ExternalName,
37+
LinkedAt: row.LinkedAt,
38+
LastLoginAt: row.LastLoginAt,
39+
}, nil
40+
}
41+
42+
// ListForUser returns the user's links joined with provider display
43+
// fields. The underlying :many query returns an empty slice (not an
44+
// error) when the user has no links.
45+
func (i *IdentityLink) ListForUser(ctx context.Context, userID string) ([]store.IdentityLinkWithProvider, error) {
46+
rows, err := i.q.ListIdentityLinksForUser(ctx, userID)
47+
if err != nil {
48+
return nil, fmt.Errorf("identity_link: list for user: %w", err)
49+
}
50+
out := make([]store.IdentityLinkWithProvider, len(rows))
51+
for j, r := range rows {
52+
out[j] = store.IdentityLinkWithProvider{
53+
IdentityLink: store.IdentityLink{
54+
ID: r.ID,
55+
UserID: r.UserID,
56+
ProviderID: r.ProviderID,
57+
ExternalID: r.ExternalID,
58+
ExternalEmail: r.ExternalEmail,
59+
ExternalName: r.ExternalName,
60+
LinkedAt: r.LinkedAt,
61+
LastLoginAt: r.LastLoginAt,
62+
},
63+
ProviderName: r.ProviderName,
64+
ProviderSlug: r.ProviderSlug,
65+
}
66+
}
67+
return out, nil
68+
}
69+
70+
// CountForUser returns the number of identity links the user owns.
71+
// The COUNT(*) :one query always returns a row, so the
72+
// pgx.ErrNoRows path is unreachable today — the translateNotFound
73+
// wrap stays as future-proofing if the query shape ever changes.
74+
func (i *IdentityLink) CountForUser(ctx context.Context, userID string) (int64, error) {
75+
n, err := i.q.CountIdentityLinksForUser(ctx, userID)
76+
if err != nil {
77+
return 0, fmt.Errorf("identity_link: count for user: %w", translateNotFound(err))
78+
}
79+
return n, nil
80+
}

internal/store/postgres/logs.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package postgres
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/manchtools/power-manage/server/internal/store"
8+
"github.com/manchtools/power-manage/server/internal/store/generated"
9+
)
10+
11+
// Logs implements store.LogsRepo against log_query_results.
12+
type Logs struct {
13+
q *generated.Queries
14+
}
15+
16+
// NewLogs returns a Logs repo bound to the given sqlc handle.
17+
func NewLogs(q *generated.Queries) *Logs {
18+
return &Logs{q: q}
19+
}
20+
21+
// CreateQueryResult inserts a pending row. The underlying :exec
22+
// query has no return value beyond errors; backend constraint
23+
// violations (e.g. duplicate query_id) surface unchanged.
24+
func (l *Logs) CreateQueryResult(ctx context.Context, queryID, deviceID string) error {
25+
if err := l.q.CreateLogQueryResult(ctx, generated.CreateLogQueryResultParams{
26+
QueryID: queryID,
27+
DeviceID: deviceID,
28+
}); err != nil {
29+
return fmt.Errorf("logs: create query result: %w", err)
30+
}
31+
return nil
32+
}
33+
34+
// GetQueryResult returns the result row. pgx.ErrNoRows is
35+
// translated to store.ErrNotFound.
36+
func (l *Logs) GetQueryResult(ctx context.Context, queryID string) (store.LogQueryResult, error) {
37+
row, err := l.q.GetLogQueryResult(ctx, queryID)
38+
if err != nil {
39+
return store.LogQueryResult{}, fmt.Errorf("logs: get query result: %w", translateNotFound(err))
40+
}
41+
return store.LogQueryResult{
42+
QueryID: row.QueryID,
43+
DeviceID: row.DeviceID,
44+
Completed: row.Completed,
45+
Success: row.Success,
46+
Error: row.Error,
47+
Logs: row.Logs,
48+
CreatedAt: row.CreatedAt,
49+
CompletedAt: row.CompletedAt,
50+
}, nil
51+
}
52+
53+
// ExpirePendingQueryResult marks a pending row as failed. The :exec
54+
// query's WHERE clause includes `completed = FALSE` so calling on
55+
// an already-completed row is a no-op and not an error.
56+
func (l *Logs) ExpirePendingQueryResult(ctx context.Context, queryID, errMsg string) error {
57+
if err := l.q.ExpirePendingLogQueryResult(ctx, generated.ExpirePendingLogQueryResultParams{
58+
QueryID: queryID,
59+
Error: errMsg,
60+
}); err != nil {
61+
return fmt.Errorf("logs: expire pending: %w", err)
62+
}
63+
return nil
64+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package postgres
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/manchtools/power-manage/server/internal/store"
8+
"github.com/manchtools/power-manage/server/internal/store/generated"
9+
)
10+
11+
// Settings implements store.SettingsRepo against the Postgres
12+
// server_settings_projection via sqlc-generated queries.
13+
type Settings struct {
14+
q *generated.Queries
15+
}
16+
17+
// NewSettings returns a Settings repo bound to the given sqlc handle.
18+
func NewSettings(q *generated.Queries) *Settings {
19+
return &Settings{q: q}
20+
}
21+
22+
// GetServer returns the global settings row, translating
23+
// pgx.ErrNoRows to store.ErrNotFound at the repo boundary so callers
24+
// rely solely on store.IsNotFound.
25+
func (s *Settings) GetServer(ctx context.Context) (store.ServerSettings, error) {
26+
row, err := s.q.GetServerSettings(ctx)
27+
if err != nil {
28+
return store.ServerSettings{}, fmt.Errorf("settings: get server: %w", translateNotFound(err))
29+
}
30+
return store.ServerSettings{
31+
UserProvisioningEnabled: row.UserProvisioningEnabled,
32+
SshAccessForAll: row.SshAccessForAll,
33+
UpdatedAt: row.UpdatedAt,
34+
}, nil
35+
}

0 commit comments

Comments
 (0)