Skip to content

refactor(store): wave B.13 — device repo (#269)#270

Merged
PaulDotterer merged 1 commit into
mainfrom
feat/269-device-repo
May 15, 2026
Merged

refactor(store): wave B.13 — device repo (#269)#270
PaulDotterer merged 1 commit into
mainfrom
feat/269-device-repo

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

`DeviceRepo` — read side of devices_projection. Branches off main directly.

Methods

  • `Get(key)` — `OwnerScope` for `:self`-scoped access; nil = admin view
  • `IsDeleted(id)`
  • `List(filter)` / `ListOnline` / `ListOffline` — match the 3-way status filter in ListDevices
  • `Count(ownerScope)` / `CountOnline` / `CountOffline` — paginated totals
  • `HostnamesByIDs(ids)` — bulk lookup for response-builder loops
  • `SyncInterval(deviceID)` — per-device offline-scheduler cadence

`Labels` stays as `json.RawMessage` at the boundary. Compliance status fields denormalized on the row.

Call sites migrated

  • `device_handler.go` — full sweep + 3-way switch + `deviceToProtoCtx` / `deviceToProtoWithAssignments` signatures
  • `user_selection_handler.go` — 2 `:self`-scoped Get sites
  • `search/index.go` — 2 sites
  • `control/inbox_worker.go` — 2 `IsDeviceDeleted` sites
  • Several other handlers using `GetDeviceByID` for verification

Non-goals

  • Per-device cross-domain queries (assignments, executions, maintenance windows, compliance evaluations) — migrate with their owning domains.
  • `CountMatchingDevicesForQuery` — PL/pgSQL evaluator, moves with Wave C.
  • Write-side projector queries — stay on `Queries()`.

Acceptance

  • Build / vet / staticcheck / gofmt: clean.
  • Tests pass: api (4m29s), control (9s).

Part of #242. Closes #269.

Summary by CodeRabbit

  • Refactor
    • Consolidated device lookups behind a new repository layer and updated handlers to use it, standardizing device existence checks, listing, and metadata retrieval across the app to improve reliability and maintainability.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0f3e7e5b-044d-4d4d-9749-bc4a2708814a

📥 Commits

Reviewing files that changed from the base of the PR and between 5e8ddb0 and 6be4d86.

📒 Files selected for processing (15)
  • internal/api/action_dispatch.go
  • internal/api/assignment_handler.go
  • internal/api/certificate_handler.go
  • internal/api/device_handler.go
  • internal/api/internal_handler.go
  • internal/api/logs_handler.go
  • internal/api/osquery_handler.go
  • internal/api/terminal_handler.go
  • internal/api/user_selection_handler.go
  • internal/control/inbox_worker.go
  • internal/search/index.go
  • internal/store/device.go
  • internal/store/postgres/device.go
  • internal/store/postgres/wire.go
  • internal/store/repos.go
🚧 Files skipped from review as they are similar to previous changes (15)
  • internal/store/postgres/wire.go
  • internal/store/repos.go
  • internal/control/inbox_worker.go
  • internal/api/logs_handler.go
  • internal/api/action_dispatch.go
  • internal/store/device.go
  • internal/search/index.go
  • internal/api/user_selection_handler.go
  • internal/api/assignment_handler.go
  • internal/api/certificate_handler.go
  • internal/api/terminal_handler.go
  • internal/api/osquery_handler.go
  • internal/api/internal_handler.go
  • internal/store/postgres/device.go
  • internal/api/device_handler.go

📝 Walkthrough

Walkthrough

Adds a Device read-side abstraction and Postgres implementation, wires it into Repos, and migrates handlers and workers to use store.Repos().Device for device existence, listing, hostname enrichment, deletion checks, and sync-interval reads.

Changes

Device Repository Abstraction & Migration

Layer / File(s) Summary
Device projection domain model and interface
internal/store/device.go
Defines Device, DeviceHostname, GetDeviceKey, ListDevicesFilter and the DeviceRepo interface for scoped/unscoped Get, IsDeleted, List/ListOnline/ListOffline, Count variants, HostnamesByIDs, and SyncInterval.
Postgres DeviceRepo implementation
internal/store/postgres/device.go
Implements DeviceRepo backed by sqlc generated.Queries; includes Get (owner-scope), IsDeleted, List variants, Count variants, HostnamesByIDs, SyncInterval, and row→domain mapping.
Store abstraction and wiring
internal/store/repos.go, internal/store/postgres/wire.go
Adds Device DeviceRepo to Repos and wires Device: NewDevice(q) in NewRepos.
Device handler refactor
internal/api/device_handler.go
ListDevices/counts use Repos().Device with ListDevicesFilter; GetDevice and mutation handlers use Repos().Device.Get for verification/readback; proto converters accept store.Device; hostname enrichment and LUKS handlers use repository methods.
API handler device verification
internal/api/action_dispatch.go, internal/api/assignment_handler.go, internal/api/certificate_handler.go, internal/api/logs_handler.go, internal/api/osquery_handler.go
Device-existence checks and removed unused generated imports: handlers now call h.store.Repos().Device.Get (replacing Queries().GetDeviceByID) and preserve previous not-found → ErrDeviceNotFound mapping.
Scoped access and internal handler migrations
internal/api/terminal_handler.go, internal/api/user_selection_handler.go, internal/api/internal_handler.go, internal/control/inbox_worker.go, internal/search/index.go
Terminal and user-selection use GetDeviceKey with OwnerScope; InternalHandler uses Device.Get and Device.SyncInterval; InboxWorker uses Device.IsDeleted; Search index warms devices and resolves hostnames via repository calls.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant DeviceHandler
  participant DeviceRepo
  participant ProtoConv
  Client->>DeviceHandler: ListDevicesRequest(filter)
  DeviceHandler->>DeviceRepo: List(filter) / Count(filter)
  DeviceRepo-->>DeviceHandler: []store.Device, int64
  DeviceHandler->>ProtoConv: deviceToProtoCtx(store.Device)
  ProtoConv-->>DeviceHandler: protobuf device
  DeviceHandler-->>Client: ListDevicesResponse
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Poem

A rabbit hops through store abstractions,
Swapping queries for repos with cheer,
Devices found with scoped instructions,
Handlers hum and logs appear—
Hooray, the repo spring is here! 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor(store): wave B.13 — device repo (#269)' accurately describes the main change: introducing the DeviceRepo abstraction for the store layer as part of wave B.13.
Linked Issues check ✅ Passed The PR implements all required DeviceRepo methods (Get, IsDeleted, List variants, Count variants, HostnamesByIDs, SyncInterval) and migrates all specified call sites (device_handler, user_selection_handler, search/index, inbox_worker, and various API handlers) as required by issue #269.
Out of Scope Changes check ✅ Passed All changes are directly in-scope: DeviceRepo interface definition, Postgres implementation, repository wiring, and call-site migrations to use the new repository as specified in issue #269. No unrelated refactoring or feature changes are present.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/269-device-repo

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/api/user_selection_handler.go (1)

44-50: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not collapse all repo errors into device not found in access checks.

These branches should only return CodeNotFound for store.IsNotFound(err); internal lookup failures should return CodeInternal.

Suggested fix (both call sites)
 	_, err = h.store.Repos().Device.Get(ctx, store.GetDeviceKey{
 		ID:         req.Msg.DeviceId,
 		OwnerScope: userFilterID(ctx, "ListDevices"),
 	})
 	if err != nil {
-		return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
+		if store.IsNotFound(err) {
+			return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
+		}
+		return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to look up device")
 	}

As per coding guidelines "Errors must use apiErrorCtx with proper Connect error codes. Never silently ignore errors."

Also applies to: 113-119

🤖 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/user_selection_handler.go` around lines 44 - 50, The current
access-check error handling collapses all Get errors into a "device not found"
NotFound response; change both call sites that call h.store.Repos().Device.Get
(the one using userFilterID in the access check and the later lookup) to inspect
the error with store.IsNotFound(err) and only return apiErrorCtx(...,
ErrDeviceNotFound, connect.CodeNotFound, ...) when IsNotFound is true; for any
other non-nil err return apiErrorCtx(ctx, err, connect.CodeInternal, "failed to
get device") (preserve the original err), keeping references to
h.store.Repos().Device.Get, userFilterID, apiErrorCtx, and ErrDeviceNotFound to
locate the sites.
internal/api/internal_handler.go (1)

68-72: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Differentiate not-found from repository failures.

Line 68 and Line 85 currently map every Device.Get error to device not found or deleted. This collapses transient/store failures into false 404s and hides internal faults.

Suggested fix
-	_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: deviceID})
-	if err != nil {
-		h.logger.Warn("device verification failed", "device_id", deviceID, "error", err)
-		return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found or deleted")
-	}
+	_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: deviceID})
+	if err != nil {
+		if store.IsNotFound(err) {
+			h.logger.Warn("device verification failed", "device_id", deviceID, "error", err)
+			return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found or deleted")
+		}
+		h.logger.Error("device verification lookup failed", "device_id", deviceID, "error", err)
+		return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to verify device")
+	}
-	if _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: deviceID}); err != nil {
-		h.logger.Warn("sync actions for unknown/deleted device", "device_id", deviceID)
-		return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found or deleted")
-	}
+	if _, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: deviceID}); err != nil {
+		if store.IsNotFound(err) {
+			h.logger.Warn("sync actions for unknown/deleted device", "device_id", deviceID)
+			return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found or deleted")
+		}
+		h.logger.Error("sync actions device lookup failed", "device_id", deviceID, "error", err)
+		return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to verify device")
+	}

As per coding guidelines: "Errors must use apiErrorCtx with proper Connect error codes. Never silently ignore errors."

Also applies to: 85-88

🤖 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/internal_handler.go` around lines 68 - 72, The current handling
of h.store.Repos().Device.Get treats all errors as "device not found or
deleted"; change it to distinguish not-found vs other repo failures by checking
the returned error (e.g., compare to the repository's NotFound sentinel or use
errors.Is). If the error indicates not found, return apiErrorCtx(ctx,
ErrDeviceNotFound, connect.CodeNotFound, "device not found or deleted"); for any
other error, log it with h.logger.Error including the error and deviceID and
return apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "internal error") (or
the appropriate internal error constant). Apply the same fix to the other
Device.Get call at the later block (lines ~85-88) so transient/store failures
are not mapped to 404s.
🤖 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/logs_handler.go`:
- Around line 38-41: The current device lookup in h.store.Repos().Device.Get
maps any error to ErrDeviceNotFound/CodeNotFound; change it to distinguish
not-found vs internal errors by using errors.Is (or equivalent) against the
repository's sentinel not-found error (e.g., store.ErrNotFound) after calling
h.store.Repos().Device.Get, returning apiErrorCtx(ctx, ErrDeviceNotFound,
connect.CodeNotFound, "device not found") only for not-found and returning
apiErrorCtx(ctx, err, connect.CodeInternal, "internal error retrieving device")
for all other errors so internal DB/repo failures yield connect.CodeInternal and
preserve the original error.

In `@internal/api/osquery_handler.go`:
- Around line 51-54: The repo Get call's error is always mapped to CodeNotFound;
change both existence checks (the call to h.store.Repos().Device.Get and the
similar check later) to distinguish a repository "not found" sentinel from other
errors: use errors.Is(err, store.ErrNotFound) (or the repo package's equivalent)
to return apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not
found"), and for any other non-nil err return apiErrorCtx(ctx, err,
connect.CodeInternal, "failed to fetch device") (i.e., map repository/internal
failures to connect.CodeInternal rather than CodeNotFound).

---

Outside diff comments:
In `@internal/api/internal_handler.go`:
- Around line 68-72: The current handling of h.store.Repos().Device.Get treats
all errors as "device not found or deleted"; change it to distinguish not-found
vs other repo failures by checking the returned error (e.g., compare to the
repository's NotFound sentinel or use errors.Is). If the error indicates not
found, return apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device
not found or deleted"); for any other error, log it with h.logger.Error
including the error and deviceID and return apiErrorCtx(ctx, ErrInternal,
connect.CodeInternal, "internal error") (or the appropriate internal error
constant). Apply the same fix to the other Device.Get call at the later block
(lines ~85-88) so transient/store failures are not mapped to 404s.

In `@internal/api/user_selection_handler.go`:
- Around line 44-50: The current access-check error handling collapses all Get
errors into a "device not found" NotFound response; change both call sites that
call h.store.Repos().Device.Get (the one using userFilterID in the access check
and the later lookup) to inspect the error with store.IsNotFound(err) and only
return apiErrorCtx(..., ErrDeviceNotFound, connect.CodeNotFound, ...) when
IsNotFound is true; for any other non-nil err return apiErrorCtx(ctx, err,
connect.CodeInternal, "failed to get device") (preserve the original err),
keeping references to h.store.Repos().Device.Get, userFilterID, apiErrorCtx, and
ErrDeviceNotFound to locate the sites.
🪄 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: e5dc66f4-84db-41cf-93b4-01a73793aa6a

📥 Commits

Reviewing files that changed from the base of the PR and between 8e6bf70 and 5e8ddb0.

📒 Files selected for processing (15)
  • internal/api/action_dispatch.go
  • internal/api/assignment_handler.go
  • internal/api/certificate_handler.go
  • internal/api/device_handler.go
  • internal/api/internal_handler.go
  • internal/api/logs_handler.go
  • internal/api/osquery_handler.go
  • internal/api/terminal_handler.go
  • internal/api/user_selection_handler.go
  • internal/control/inbox_worker.go
  • internal/search/index.go
  • internal/store/device.go
  • internal/store/postgres/device.go
  • internal/store/postgres/wire.go
  • internal/store/repos.go

Comment on lines +38 to 41
_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.DeviceId})
if err != nil {
return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve internal error semantics for device lookup.

This maps all repo errors to CodeNotFound. Internal DB/repo failures should return CodeInternal, otherwise outages are misreported as missing devices.

Suggested fix
-	_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.DeviceId})
-	if err != nil {
-		return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
-	}
+	_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.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 look up device")
+	}

As per coding guidelines "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.

Suggested change
_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.DeviceId})
if err != nil {
return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
}
_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.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 look up device")
}
🤖 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/logs_handler.go` around lines 38 - 41, The current device lookup
in h.store.Repos().Device.Get maps any error to ErrDeviceNotFound/CodeNotFound;
change it to distinguish not-found vs internal errors by using errors.Is (or
equivalent) against the repository's sentinel not-found error (e.g.,
store.ErrNotFound) after calling h.store.Repos().Device.Get, returning
apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
only for not-found and returning apiErrorCtx(ctx, err, connect.CodeInternal,
"internal error retrieving device") for all other errors so internal DB/repo
failures yield connect.CodeInternal and preserve the original error.

Comment on lines +51 to 54
_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.DeviceId})
if err != nil {
return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Differentiate not-found from repo/internal failures in both existence checks.

Both checks currently return CodeNotFound for any repository error. This hides backend failures and returns incorrect semantics to clients.

Suggested fix (apply in both methods)
-	_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.DeviceId})
-	if err != nil {
-		return nil, apiErrorCtx(ctx, ErrDeviceNotFound, connect.CodeNotFound, "device not found")
-	}
+	_, err := h.store.Repos().Device.Get(ctx, store.GetDeviceKey{ID: msg.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 look up device")
+	}

As per coding guidelines "Errors must use apiErrorCtx with proper Connect error codes. Never silently ignore errors."

Also applies to: 209-212

🤖 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/osquery_handler.go` around lines 51 - 54, The repo Get call's
error is always mapped to CodeNotFound; change both existence checks (the call
to h.store.Repos().Device.Get and the similar check later) to distinguish a
repository "not found" sentinel from other errors: use errors.Is(err,
store.ErrNotFound) (or the repo package's equivalent) to return apiErrorCtx(ctx,
ErrDeviceNotFound, connect.CodeNotFound, "device not found"), and for any other
non-nil err return apiErrorCtx(ctx, err, connect.CodeInternal, "failed to fetch
device") (i.e., map repository/internal failures to connect.CodeInternal rather
than CodeNotFound).

DeviceRepo — read side of devices_projection:

  Get / IsDeleted / List / ListOnline / ListOffline /
  Count / CountOnline / CountOffline /
  HostnamesByIDs / SyncInterval

The Get and List methods carry OwnerScope to model the :self-scoped
permission shape that previously used FilterUserID directly; nil =
admin view, non-nil = restrict to devices the user has assignment
access to (the underlying query returns ErrNotFound when the scope
excludes the row — handlers can't distinguish, by design).

Labels stays as json.RawMessage at the boundary per the JSONB
normalize plan. Compliance status (Status / CheckedAt / Total /
Passing) is denormalized on the device row by the compliance
projector and surfaced as-is.

Call sites migrated:
- internal/api/device_handler.go (large sweep, 3-way Online/Offline
  switch with matching count branches, deviceToProtoCtx +
  deviceToProtoWithAssignments signatures shifted to store.Device)
- internal/api/user_selection_handler.go (2 :self-scoped Get sites)
- internal/search/index.go (2 sites: ListDevices + GetDeviceByID
  for enrichment)
- internal/control/inbox_worker.go (IsDeviceDeleted, 2 sites)
- internal/api/{logs,osquery,terminal,assignment,compliance,
  certificate}_handler.go — GetDeviceByID for verification

Non-goals:
- Per-device cross-domain queries (assignments, executions,
  maintenance windows, compliance evaluations) migrate with their
  owning domains.
- CountMatchingDevicesForQuery — PL/pgSQL evaluator, Wave C.
- Write-side projector queries — stay on Queries().

Closes #269.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wave B.13: device repo

1 participant