refactor(store): wave B.1 — domain repo scaffolding + compliance read canary (#245)#246
Conversation
… from non-store packages (#243) First wave of the storage abstraction tracker (#242). Stops handler, projector, scim, idp, and control packages from reaching for pgx.ErrNoRows directly; centralizes not-found recognition in store.IsNotFound so a future backend (libSQL/Turso as illustrative example, NoSQL as architectural ceiling) registers its own no-rows error in one place rather than in every caller. No behaviour change. errors.Is semantics are preserved end-to-end; the helpers_test bridge test (TestHandleGetError_NotFound) locks both pgx.ErrNoRows and store.ErrNotFound through the recognizer. Adds: server/internal/store/notfound.go Touches: 34 files across api/, scim/, idp/, projectors/, control/. Closes #243.
… canary (#245) First sub-wave of the Wave B migration under tracker #242. Introduces the repository-pattern scaffolding (Repos struct, internal/store/postgres package, Store.SetRepos / Store.Repos accessor) and migrates one small read-only domain — device-compliance reads — end-to-end to validate the pattern. What lands: - internal/store/compliance.go — ComplianceRepo interface plus domain types ComplianceCheckResult / ComplianceSummary. Field names normalized away from sqlc row-type identifiers so handlers depend on stable shapes, not on generated code. - internal/store/repos.go — Repos struct (interface-typed fields). Adding a domain = adding a field + wiring in postgres.NewRepos. - internal/store/postgres/{compliance,wire}.go — Postgres impl wrapping *generated.Queries. Translates pgx.ErrNoRows -> store.ErrNotFound at the repo boundary so callers depend only on store.IsNotFound (per Wave A's contract). - Store.SetRepos / Store.Repos() — boot code wires Repos after store.New; Repos() panics if SetRepos was never called so misconfiguration fails loudly. SetRepos used at three sites: cmd/control/main.go, cmd/indexer/main.go, internal/testutil/postgres.go. - compliance_handler.go and compliance_policy_handler.go — the four device-compliance read sites now flow through store.Repos().Compliance instead of Store.Queries(). Why a canary first: Wave B as designed (interfaces for 20 domains + full handler migration + retire Store.Queries) is multi-PR work. This sub-PR proves the pattern is sound before bulk migrations begin. Subsequent Wave B.N sub-PRs each migrate one or two additional domains. Non-goals: - Other domains. They come in B.2..B.N. - Transactional repos / UnitOfWork. Compliance reads are single-statement; defer the tx wiring until a domain needs it. - Retiring Store.Queries(). Stays exported as the transition pathway for un-migrated domains; comes private once the last caller migrates. Closes #245.
…iccheck SA1019 (#245) Wave B.1's first attempt marked Store.Queries() with the godoc "Deprecated:" keyword. staticcheck SA1019 then flagged every existing caller (~50 sites in scim/, search/, internal handlers, testutil) as a CI failure, even though those callers are part of the planned migration set, not unsanctioned uses. Drop the magic keyword, keep the prose. The marker re-enters naturally when Queries() goes unexported at the end of Wave B — at that point any remaining caller IS a bug.
📝 WalkthroughWalkthroughThis PR centralizes "not found" error detection by introducing a store-level ChangesStore abstraction and compliance repository scaffolding
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/api/compliance_handler.go`:
- Around line 33-41: The repo error handling for
h.store.Repos().Compliance.DeviceResults and .DeviceSummary currently maps all
errors to ErrInternal/connect.CodeInternal; update both error branches to detect
the repository's not-found sentinel (e.g. compare err with the repo's
ErrNotFound or use errors.Is) and return apiErrorCtx(ctx, ErrNotFound,
connect.CodeNotFound, "<message>") for that case, and only fall back to
apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "<message>") for other
errors; keep the original log/description text but use connect.CodeNotFound for
missing resources and ensure you reference the same symbols apiErrorCtx,
ErrNotFound (or repo sentinel), and connect.CodeNotFound.
In `@internal/idp/linker.go`:
- Around line 142-144: The lookup fallback is returning early when err is nil
because the code calls store.IsNotFound(err) without a nil guard; update the
condition in the fallback branch around the store.IsNotFound(err) check so it
only treats non-nil errors as failures (e.g., change the guard to require err !=
nil && !store.IsNotFound(err)) so that a nil err continues to Step 2/3 after
stale-link cleanup; ensure this change is applied to the branch that currently
contains the return fmt.Errorf("lookup identity link: %w", err).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 4a25c8ed-bc26-443c-bd4d-326b907e6f02
📒 Files selected for processing (45)
cmd/control/main.gocmd/indexer/main.gointernal/api/action_crud.gointernal/api/action_dispatch.gointernal/api/assignment_handler.gointernal/api/auth_handler.gointernal/api/certificate_handler.gointernal/api/compliance_handler.gointernal/api/compliance_policy_handler.gointernal/api/device_group_handler.gointernal/api/device_handler.gointernal/api/helpers.gointernal/api/helpers_test.gointernal/api/idp_handler.gointernal/api/registration_handler.gointernal/api/role_handler.gointernal/api/search_listener.gointernal/api/search_listener_e2e_test.gointernal/api/sso_handler.gointernal/api/terminal_handler.gointernal/api/totp_handler.gointernal/api/user_group_handler.gointernal/api/user_handler.gointernal/control/inbox_worker.gointernal/idp/linker.gointernal/idp/linker_test.gointernal/projectors/assignment_listener.gointernal/projectors/assignment_test.gointernal/projectors/device_group_listener.gointernal/projectors/user_group_listener.gointernal/scim/auth.gointernal/scim/groups.gointernal/scim/groups_create.gointernal/scim/groups_helpers.gointernal/scim/groups_mutate.gointernal/scim/users.gointernal/scim/users_create.gointernal/scim/users_mutate.gointernal/store/compliance.gointernal/store/notfound.gointernal/store/postgres/compliance.gointernal/store/postgres/wire.gointernal/store/repos.gointernal/store/store.gointernal/testutil/postgres.go
| results, err := h.store.Repos().Compliance.DeviceResults(ctx, deviceID) | ||
| if err != nil { | ||
| return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to query compliance results") | ||
| } | ||
|
|
||
| summary, err := h.store.Queries().GetDeviceComplianceSummary(ctx, deviceID) | ||
| summary, err := h.store.Repos().Compliance.DeviceSummary(ctx, deviceID) | ||
| if err != nil { | ||
| return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to query compliance summary") | ||
| } |
There was a problem hiding this comment.
Map repo not-found errors to a non-internal Connect code.
Both repo calls currently collapse all failures to connect.CodeInternal. If the compliance repo returns a not-found condition, clients get a 500 instead of a resource error.
Suggested fix
results, err := h.store.Repos().Compliance.DeviceResults(ctx, deviceID)
if err != nil {
+ if store.IsNotFound(err) {
+ return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
+ }
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to query compliance results")
}
summary, err := h.store.Repos().Compliance.DeviceSummary(ctx, deviceID)
if err != nil {
+ if store.IsNotFound(err) {
+ return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
+ }
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to query compliance summary")
}As per coding guidelines: internal/api/*_handler.go: Errors must use apiErrorCtx with proper Connect error codes. Never silently ignore errors.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| results, err := h.store.Repos().Compliance.DeviceResults(ctx, deviceID) | |
| if err != nil { | |
| return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to query compliance results") | |
| } | |
| summary, err := h.store.Queries().GetDeviceComplianceSummary(ctx, deviceID) | |
| summary, err := h.store.Repos().Compliance.DeviceSummary(ctx, deviceID) | |
| if err != nil { | |
| return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to query compliance summary") | |
| } | |
| results, err := h.store.Repos().Compliance.DeviceResults(ctx, deviceID) | |
| if err != nil { | |
| if store.IsNotFound(err) { | |
| return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") | |
| } | |
| return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to query compliance results") | |
| } | |
| summary, err := h.store.Repos().Compliance.DeviceSummary(ctx, deviceID) | |
| if err != nil { | |
| if store.IsNotFound(err) { | |
| return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found") | |
| } | |
| return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to query compliance summary") | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/api/compliance_handler.go` around lines 33 - 41, The repo error
handling for h.store.Repos().Compliance.DeviceResults and .DeviceSummary
currently maps all errors to ErrInternal/connect.CodeInternal; update both error
branches to detect the repository's not-found sentinel (e.g. compare err with
the repo's ErrNotFound or use errors.Is) and return apiErrorCtx(ctx,
ErrNotFound, connect.CodeNotFound, "<message>") for that case, and only fall
back to apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "<message>") for
other errors; keep the original log/description text but use
connect.CodeNotFound for missing resources and ensure you reference the same
symbols apiErrorCtx, ErrNotFound (or repo sentinel), and connect.CodeNotFound.
| if !store.IsNotFound(err) { | ||
| return nil, fmt.Errorf("lookup identity link: %w", err) | ||
| } |
There was a problem hiding this comment.
Add a nil guard before the not-found check in the lookup fallback.
At Line 142, this branch also runs when err == nil (after stale-link cleanup), which causes an unintended early return instead of continuing to Step 2/3.
Proposed fix
- if !store.IsNotFound(err) {
+ if err != nil && !store.IsNotFound(err) {
return nil, fmt.Errorf("lookup identity link: %w", err)
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if !store.IsNotFound(err) { | |
| return nil, fmt.Errorf("lookup identity link: %w", err) | |
| } | |
| if err != nil && !store.IsNotFound(err) { | |
| return nil, fmt.Errorf("lookup identity link: %w", err) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/idp/linker.go` around lines 142 - 144, The lookup fallback is
returning early when err is nil because the code calls store.IsNotFound(err)
without a nil guard; update the condition in the fallback branch around the
store.IsNotFound(err) check so it only treats non-nil errors as failures (e.g.,
change the guard to require err != nil && !store.IsNotFound(err)) so that a nil
err continues to Step 2/3 after stale-link cleanup; ensure this change is
applied to the branch that currently contains the return fmt.Errorf("lookup
identity link: %w", err).
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.
…) (#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.
Summary
First sub-wave of the Wave B migration under tracker #242. Introduces the repository-pattern scaffolding (`Repos` struct, `internal/store/postgres` package, `Store.SetRepos` / `Store.Repos()` accessor) and migrates one small read-only domain — device-compliance reads — end-to-end to validate the pattern.
Stacked on #244 (Wave A). Until #244 merges, this PR's diff includes Wave A's commits; review the head commit (`refactor(store): wave B.1`) for B.1 scope. After #244 merges, I'll rebase this onto main.
What lands
Why a canary first
Wave B as designed in `project_storage_abstraction_plan.md` (interfaces for ~20 domains + full handler migration + retire `Store.Queries()`) is multi-PR work. This sub-PR proves the pattern is sound before bulk migrations begin. Subsequent Wave B.N sub-PRs each migrate one or two additional domains; `Store.Queries()` goes private once the last caller migrates.
Non-goals
Acceptance
Test plan
Part of #242. Closes #245.
Summary by CodeRabbit
Bug Fixes
Chores