refactor(store): wave A — ErrNotFound sentinel + scrub pgx.ErrNoRows (#243)#244
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.
📝 WalkthroughWalkthroughThis PR centralizes "not found" error detection across the codebase by introducing a store-level abstraction ( ChangesCentralize "not found" error detection behind store abstraction
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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: 1
🤖 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/idp/linker.go`:
- Around line 142-143: The current error branch returns whenever
store.IsNotFound(err) is false, but since err can be nil after a successful link
lookup this causes an early return and blocks the stale-link fallback; update
the conditional around the lookup error (the check using store.IsNotFound and
the error wrap that returns "lookup identity link: %w") to only return on a real
error (i.e. require err != nil && !store.IsNotFound(err)), and ensure the
subsequent logic that handles a found link with a missing user continues into
the auto-link/auto-create fallback paths (inspect the lookup code and follow-up
branches around the identity link lookup to confirm flow).
🪄 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: 6377cbb0-e6b3-429a-8c74-a23f8ca4fb93
📒 Files selected for processing (35)
internal/api/action_crud.gointernal/api/action_dispatch.gointernal/api/assignment_handler.gointernal/api/auth_handler.gointernal/api/certificate_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/notfound.go
| if !store.IsNotFound(err) { | ||
| return nil, fmt.Errorf("lookup identity link: %w", err) |
There was a problem hiding this comment.
Stale-link fallback exits before Step 2/3.
When the initial link lookup succeeds and the linked user is missing, err is still nil here, so this branch returns early and prevents the intended fallback flow (auto-link/auto-create).
Suggested 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 - 143, The current error branch
returns whenever store.IsNotFound(err) is false, but since err can be nil after
a successful link lookup this causes an early return and blocks the stale-link
fallback; update the conditional around the lookup error (the check using
store.IsNotFound and the error wrap that returns "lookup identity link: %w") to
only return on a real error (i.e. require err != nil && !store.IsNotFound(err)),
and ensure the subsequent logic that handles a found link with a missing user
continues into the auto-link/auto-create fallback paths (inspect the lookup code
and follow-up branches around the identity link lookup to confirm flow).
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 wave of the storage-abstraction tracker (#242). Stops handler/projector/scim/idp/control packages from reaching for
pgx.ErrNoRowsdirectly; centralizes not-found recognition instore.IsNotFound(err).Why this matters: when a second backend (libSQL/Turso as the illustrative example, NoSQL as the architectural ceiling) lands later, its no-rows error gets registered inside
store.IsNotFound— not in every caller.Changes
server/internal/store/notfound.go— definesstore.ErrNotFoundsentinel +store.IsNotFound(err) boolrecognizer that today knows bothpgx.ErrNoRowsandstore.ErrNotFound.internal/api/helpers.go::handleGetErrorto usestore.IsNotFound. Dropspgximport.errors.Is(err, pgx.ErrNoRows)→store.IsNotFound(err)across:internal/api/— 18 filesinternal/scim/— 8 filesinternal/idp/— 2 files (incl. test mock returningstore.ErrNotFoundinstead ofpgx.ErrNoRows)internal/projectors/— 4 files (incl. comment cleanup)internal/control/inbox_worker.gopgximports (33 files; goimports clean).TestHandleGetError_NotFoundnow exercises bothpgx_nativeandstore_abstrsentinels throughhandleGetErrorto lock the recognizer contract.Non-goals
Acceptance
pgx.ErrNoRowsoutsideinternal/store/+generated/is now confined to:helpers_test.go(the bridge test fixture — intentional).go vet ./...,gofmt -l server/,go build ./server/...: clean.TestHandleGetError_NotFound/{pgx_native,store_abstr}, sample handler integration suite, allinternal/projectors,internal/scim,internal/idp,internal/control,internal/store.internal/apisuite hangs locally onTestProxyStoreLuksKey_MissingFieldstestcontainer setup (~30 min) — pre-existing environmental issue on this host, NOT a regression from this PR. Pushing to let CI verify with proper Docker.Test plan
Part of #242. Closes #243.
Summary by CodeRabbit