Skip to content

refactor(store): wave E.4 — devices.labels JSONB → device_labels child table (#297)#298

Merged
PaulDotterer merged 2 commits into
mainfrom
feat/297-device-labels-child-table
May 16, 2026
Merged

refactor(store): wave E.4 — devices.labels JSONB → device_labels child table (#297)#298
PaulDotterer merged 2 commits into
mainfrom
feat/297-device-labels-child-table

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented May 16, 2026

Copy link
Copy Markdown
Contributor

Part of the storage-abstraction tracker (#242), Wave E (JSONB normalization). Closes Wave E — after this lands, no JSONB operator (->>, ->, @>, jsonb_*) remains in any sqlc query.

Summary

  • Migration 047: new device_labels(device_id FK, key, value, PK (device_id, key)) + idx_device_labels_key_value, backfilled, devices_projection.labels dropped.
  • Dynamic-group queue trigger re-pointed at device_labels INSERT/UPDATE/DELETE so re-evaluation still fires on label changes.
  • store.Device.Labels switches from json.RawMessage to map[string]string; repo populates via batched ListDeviceLabelsBatch.
  • Projector listener uses AdvanceDeviceProjectionVersion as the per-event guard around child-table writes (same shape as Wave E.3).
  • DeviceLabelsUpdated semantics: HasLabels distinguishes "preserve" from "clear all" — matches the PL/pgSQL COALESCE(payload, labels).
  • internal/dyngroupeval batch-loads labels via ListAllDeviceLabels for queue-drain and count-matching paths.
  • internal/search.FlattenLabels takes the typed map; warm-index paths in api + search updated.
  • api/device_handler.userToProto drops the json.Unmarshal block, copies the map directly.

DoD checks

  • go build + go vet clean
  • gofmt clean
  • internal/projectors device tests pass (104s integration suite)
  • internal/store dynamic-group tests pass (43s integration suite)
  • internal/api device + dynamic-group tests pass
  • CI integration suite

After this lands

Wave E DoD met. Remaining storage-abstraction roadmap items: Wave F (reactive triggers → Go listeners) and Wave G (EventStore interface). Wave H is the validation backend (worked example: Turso/libSQL). The whole stack is now JSONB-operator-free and ready for those.

Summary by CodeRabbit

  • Refactor

    • Normalized device labels into per-key storage and switched to typed label maps
    • Batch-loading of labels for evaluation and listing to improve performance
    • Per-label update flow and replay/version safeguards for safer concurrent updates
    • Search indexing now flattens typed label maps directly
  • Chores

    • Database migration added to move labels to a child table
  • Tests

    • Updated tests to assert label maps and child-table queries instead of JSON blobs

Review Change Stack

…d table (#297)

Final piece of Wave E (tracker #242). After this lands, no JSONB
operator (->>, ->, @>, jsonb_*) remains in any sqlc query.

Schema (migration 047):
- New device_labels(device_id FK CASCADE, key, value, PK (device_id, key))
  + idx_device_labels_key_value for the label-filter path.
- Backfill from devices_projection.labels via jsonb_each_text.
- Re-create the dynamic-group queue trigger against device_labels
  (INSERT/UPDATE/DELETE) — preserves the "re-evaluate on label change"
  behaviour the PL/pgSQL trigger had on the JSONB column.
- ALTER TABLE devices_projection DROP COLUMN labels.

Queries:
- UpsertDeviceProjection no longer writes labels — initial-labels go
  to the child table via SetDeviceLabel in the listener.
- Replace SetDeviceLabelKey / RemoveDeviceLabelKey / UpdateDeviceLabelsProjection
  with SetDeviceLabel (ON CONFLICT DO UPDATE) / RemoveDeviceLabel /
  ClearDeviceLabels.
- AdvanceDeviceProjectionVersion provides the per-event stale-replay
  guard the listener uses around child-table writes (same pattern as
  Wave E.3's TouchUserUpdatedAt).
- GetDevicesWithLabel rewritten as a JOIN against device_labels.
- ListDevicesForDynamicEvaluation strips the labels column;
  ListDeviceLabels / ListDeviceLabelsBatch / ListAllDeviceLabels are
  the new label-load entry points.

Domain types:
- store.Device.Labels is map[string]string. Postgres repo populates it
  via ListDeviceLabelsBatch in List/ListOnline/ListOffline and per-
  device ListDeviceLabels in Get.
- DeviceRegisteredPayload / DeviceLabelsUpdatedPayload carry typed
  map[string]string. New decodeLabelsMap helper coerces the JSON
  payload bytes into the parallel map (matching PL/pgSQL ::TEXT
  coercion for non-string values).
- DeviceLabelsUpdatedPayload grows HasLabels so the listener can
  distinguish "labels key absent on the wire (preserve)" from "labels
  key present and empty (clear all)" — replaces the SQL
  COALESCE(payload, labels) shape.

Consumers:
- internal/dyngroupeval batch-loads labels via ListAllDeviceLabels and
  threads the map[string]string per device through DeviceContext.
- internal/search.FlattenLabels takes the typed map directly; warm
  paths in api/search_listener.go + search/index.go updated.
- api/device_handler.userToProto drops the json.Unmarshal block and
  copies the map directly.

Tests:
- internal/projectors/device_test.go assertions converted to
  ListDeviceLabels rows + a labelRowsAsMap helper.

Refs #242.
@coderabbitai

coderabbitai Bot commented May 16, 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: d93c9c66-f446-4481-ba9f-80195b1dc778

📥 Commits

Reviewing files that changed from the base of the PR and between ee5cb9c and 392ed65.

📒 Files selected for processing (2)
  • internal/dyngroupeval/evaluator.go
  • internal/store/store_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/dyngroupeval/evaluator.go

📝 Walkthrough

Walkthrough

This PR refactors device labels from JSONB column storage to a normalized child table, introducing separate label read/write queries, updating the domain model to use typed maps, redecorating event payloads, batch-loading labels in the store repository, pre-loading labels in the evaluator, and updating all API consumers.

Changes

Device Labels Normalization

Layer / File(s) Summary
Domain model type change
internal/store/device.go
Device.Labels changes from json.RawMessage to map[string]string, with documentation updated to reflect that labels are loaded from the normalized device_labels child table.
Database schema: child table and triggers
internal/store/migrations/047_device_labels_child_table.sql
Migration creates device_labels with (device_id, key) primary key and (key, value) index; backfills from existing JSONB; replaces JSONB-column trigger with child-table trigger for dynamic-group queuing; includes full reversal in Down path.
SQL queries: label CRUD and reads
internal/store/queries/devices.sql, internal/store/queries/device_groups.sql
New label-reading queries (ListDeviceLabels, ListDeviceLabelsBatch, ListAllDeviceLabels) and version-guarded write queries (AdvanceDeviceProjectionVersion, SetDeviceLabel, RemoveDeviceLabel, ClearDeviceLabels); GetDevicesWithLabel now joins device_labels; UpsertDeviceProjection removes labels column; ListDevicesForDynamicEvaluation becomes id-only.
Store repository: batch label loading
internal/store/postgres/device.go
Get loads labels for single device; List, ListOnline, ListOffline use new deviceRowsToSlice batch helper that fetches all labels in one query and attaches them per device; deviceFromRow no longer constructs Labels from projection row.
Projector: event payload decoding
internal/projectors/device.go
New decodeLabelsMap helper parses JSONB to map[string]string with null-coercion; DeviceRegisteredPayload.Labels changes to map type; DeviceLabelsUpdatedPayload adds HasLabels bool to distinguish present-but-empty from absent labels; both payloads decoded via decodeLabelsMap.
Projector: event application with staleness protection
internal/projectors/device_listener.go
applyDeviceRegistered initializes labels per-key via SetDeviceLabel after upsert; applyDeviceLabelsUpdated skips when HasLabels is false, advances version to guard replays, clears and repopulates labels; applyDeviceLabelSet/Removed guard all updates with advanceDeviceVersion; new advanceDeviceVersion helper bumps projection_version to gate stale events.
Dynamic group evaluator: batch label pre-loading
internal/dyngroupeval/evaluator.go
EvaluateDeviceGroup and CountMatchingDevices refactored to load device IDs first, then pre-load all labels once via loadAllDeviceLabels into indexed map, and build per-device contexts from cached labels instead of per-device label fetches; buildDeviceContext signature updated to accept typed map.
API and search layers: typed label consumption
internal/api/search_listener.go, internal/api/device_handler.go, internal/search/index.go
search_listener loads labels via ListDeviceLabels and flattens map into SearchEntityData; device_handler directly assigns typed Labels; FlattenLabels signature changed from json.RawMessage to map[string]string for RediSearch flattening.
Tests: projector and lifecycle validation
internal/projectors/device_test.go, internal/store/store_test.go
Projector decoder tests updated to assert map values and HasLabels flag; lifecycle and projection tests rewritten to validate normalized storage by querying ListDeviceLabels and asserting label maps; labelRowsAsMap helper added.

Sequence Diagram

sequenceDiagram
    participant Event as Device Event
    participant Projector as Event Projector
    participant AdvanceVersion as Version Guard
    participant Queries as SQL Queries
    participant Repo as Store Repository
    participant Consumer as API Consumer

    Event->>Projector: DeviceRegistered/LabelsUpdated (seqNum, labels?)
    Projector->>Projector: decodeLabelsMap(rawJSON)
    alt DeviceLabelsUpdated with labels
        Projector->>AdvanceVersion: advanceDeviceVersion(seqNum)
        AdvanceVersion-->>Projector: success/stale
        alt version bump succeeds
            Projector->>Queries: ClearDeviceLabels(device_id)
            Projector->>Queries: SetDeviceLabel(device_id, key, value) [per key]
        else stale event
            Projector-->>Event: skip
        end
    else DeviceRegistered
        Projector->>Queries: UpsertDeviceProjection
        Projector->>Queries: SetDeviceLabel (per key)
    end

    Repo->>Queries: ListDeviceLabelsBatch(device_ids)
    Queries-->>Repo: [(device_id, key, value), ...]
    Repo->>Repo: attachLabels (map per device)
    Repo-->>Consumer: Device{Labels: map[string]string}

    Consumer->>Consumer: FlattenLabels(labels map)
    Consumer-->>Consumer: "key=value key2=value2"
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • manchtools/power-manage-server#297 — This PR directly implements the device labels normalization refactor from JSONB to child table across store, projector, evaluator, and API layers.

Possibly related PRs

Poem

🐰 Labels hopped from JSON to rows so fine,
Keys and values lined up in tidy line,
We batch them, guard them, and attach with care,
A rabbit cheers — no JSON mess to bear!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: refactoring device labels storage from JSONB to a child table, which aligns with the primary objective and all file changes in the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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/297-device-labels-child-table

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: 1

Caution

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

⚠️ Outside diff range comments (1)
internal/dyngroupeval/evaluator.go (1)

540-553: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove unused parseLabelsJSON function.

The staticcheck pipeline reports this function as unused (U1000). With the refactoring to loadAllDeviceLabels and typed map[string]string labels, this JSON parsing helper is no longer needed.

🗑️ Proposed fix: remove dead code
-func parseLabelsJSON(raw []byte) map[string]string {
-	if len(raw) == 0 {
-		return nil
-	}
-	var m map[string]any
-	if err := json.Unmarshal(raw, &m); err != nil {
-		return nil
-	}
-	out := make(map[string]string, len(m))
-	for k, v := range m {
-		out[k] = anyToString(v)
-	}
-	return out
-}
🤖 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/dyngroupeval/evaluator.go` around lines 540 - 553, Remove the dead
helper function parseLabelsJSON from internal/dyngroupeval/evaluator.go since
staticcheck flags it as unused (U1000) after refactoring to loadAllDeviceLabels
and typed map[string]string labels; delete the entire parseLabelsJSON
declaration (including its json.Unmarshal logic and any anyToString conversion
usage within it) to eliminate the unused symbol from the codebase.
🧹 Nitpick comments (1)
internal/projectors/device_test.go (1)

221-229: ⚡ Quick win

Add a decoder test for explicit empty labels object ({}) to pin clear-all semantics.

Current coverage checks “missing key” but not “present and empty,” which is the critical branch for HasLabels.

Suggested test addition
 func TestDeviceLabelsUpdatedFromEvent_Pure(t *testing.T) {
@@
 	t.Run("missing labels => HasLabels false (preserve existing rows)", func(t *testing.T) {
@@
 		assert.False(t, got.HasLabels)
 		assert.Nil(t, got.Labels)
 	})
+
+	t.Run("explicit empty labels object => HasLabels true (clear all)", func(t *testing.T) {
+		got, err := projectors.DeviceLabelsUpdatedFromEvent(store.PersistedEvent{
+			StreamType: "device", StreamID: "dev-1", EventType: "DeviceLabelsUpdated",
+			Data: jsonOrFail(t, map[string]any{"labels": map[string]any{}}),
+		})
+		require.NoError(t, err)
+		assert.True(t, got.HasLabels)
+		assert.Empty(t, got.Labels)
+	})
🤖 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/projectors/device_test.go` around lines 221 - 229, Add a new table
test calling projectors.DeviceLabelsUpdatedFromEvent where the event Data
includes an explicit "labels" key set to an empty object (e.g. jsonOrFail(t,
map[string]any{"labels": map[string]any{}})); assert that the returned struct
sets HasLabels to true and that Labels is an empty map (not nil) to pin the
clear-all semantics for DeviceLabelsUpdated events. Use the same test pattern as
the existing "missing labels" case and name it something like "empty labels
object => HasLabels true (clear all)".
🤖 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/projectors/device.go`:
- Around line 24-26: The code currently treats non-object JSON label payloads as
present because it only checks len(any) after json.Unmarshal, causing HasLabels
to be set for non-object values and triggering clear-all writes; update the
decode logic in internal/projectors/device.go (the block that does
json.Unmarshal(raw, &any) and the logic that sets HasLabels from raw.Labels) to
only consider labels present when the unmarshaled value is a JSON object:
unmarshal into an interface{}, assert it to map[string]interface{}, and set
HasLabels = true only if that assertion succeeds and the map has entries; apply
the same change to the second occurrence at the lines referenced (the block
around where raw.Labels is inspected).

---

Outside diff comments:
In `@internal/dyngroupeval/evaluator.go`:
- Around line 540-553: Remove the dead helper function parseLabelsJSON from
internal/dyngroupeval/evaluator.go since staticcheck flags it as unused (U1000)
after refactoring to loadAllDeviceLabels and typed map[string]string labels;
delete the entire parseLabelsJSON declaration (including its json.Unmarshal
logic and any anyToString conversion usage within it) to eliminate the unused
symbol from the codebase.

---

Nitpick comments:
In `@internal/projectors/device_test.go`:
- Around line 221-229: Add a new table test calling
projectors.DeviceLabelsUpdatedFromEvent where the event Data includes an
explicit "labels" key set to an empty object (e.g. jsonOrFail(t,
map[string]any{"labels": map[string]any{}})); assert that the returned struct
sets HasLabels to true and that Labels is an empty map (not nil) to pin the
clear-all semantics for DeviceLabelsUpdated events. Use the same test pattern as
the existing "missing labels" case and name it something like "empty labels
object => HasLabels true (clear all)".
🪄 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: 49fed5a2-d6f6-4724-aaf4-7583edb23814

📥 Commits

Reviewing files that changed from the base of the PR and between 17bd191 and ee5cb9c.

⛔ Files ignored due to path filters (3)
  • internal/store/generated/device_groups.sql.go is excluded by !**/generated/**
  • internal/store/generated/devices.sql.go is excluded by !**/generated/**
  • internal/store/generated/models.go is excluded by !**/generated/**
📒 Files selected for processing (12)
  • internal/api/device_handler.go
  • internal/api/search_listener.go
  • internal/dyngroupeval/evaluator.go
  • internal/projectors/device.go
  • internal/projectors/device_listener.go
  • internal/projectors/device_test.go
  • internal/search/index.go
  • internal/store/device.go
  • internal/store/migrations/047_device_labels_child_table.sql
  • internal/store/postgres/device.go
  • internal/store/queries/device_groups.sql
  • internal/store/queries/devices.sql

Comment on lines +24 to +26
if err := json.Unmarshal(raw, &any); err != nil || len(any) == 0 {
return nil
}

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

HasLabels is set for non-object payloads, causing unintended clear-all writes.

At Line 24 and Line 256, non-object labels payloads decode to nil but still set HasLabels=true (via len(raw.Labels) > 0), so the listener clears existing labels instead of skipping writes.

Proposed fix
 func decodeLabelsMap(raw []byte) map[string]string {
 	if len(raw) == 0 {
 		return nil
 	}
 	var any map[string]any
-	if err := json.Unmarshal(raw, &any); err != nil || len(any) == 0 {
+	if err := json.Unmarshal(raw, &any); err != nil {
+		return nil
+	}
+	if any == nil {
 		return nil
 	}
 	out := make(map[string]string, len(any))
 	for k, v := range any {
 		switch x := v.(type) {
@@
 	var raw payloads.DeviceLabelsUpdated
 	if err := json.Unmarshal(e.Data, &raw); err != nil {
 		return DeviceLabelsUpdatedPayload{}, fmt.Errorf("projector: invalid DeviceLabelsUpdated payload: %w", err)
 	}
-	if len(raw.Labels) > 0 {
-		out.Labels = decodeLabelsMap(raw.Labels)
+	if labels := decodeLabelsMap(raw.Labels); labels != nil {
+		out.Labels = labels
 		out.HasLabels = true
 	}
 	return out, nil
 }

Also applies to: 256-259

🤖 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/projectors/device.go` around lines 24 - 26, The code currently
treats non-object JSON label payloads as present because it only checks len(any)
after json.Unmarshal, causing HasLabels to be set for non-object values and
triggering clear-all writes; update the decode logic in
internal/projectors/device.go (the block that does json.Unmarshal(raw, &any) and
the logic that sets HasLabels from raw.Labels) to only consider labels present
when the unmarshaled value is a JSON object: unmarshal into an interface{},
assert it to map[string]interface{}, and set HasLabels = true only if that
assertion succeeds and the map has entries; apply the same change to the second
occurrence at the lines referenced (the block around where raw.Labels is
inspected).

CI caught two follow-ups on E.4:

- TestProjection_DeviceLabelSet still asserted against the dropped
  devices_projection.labels column; ported to query device_labels.
- internal/dyngroupeval.parseLabelsJSON became unreachable when
  buildDeviceContext started taking the typed map directly; staticcheck
  U1000 flagged it. Removed.

Refs #297.
@PaulDotterer PaulDotterer merged commit 6ddeebf into main May 16, 2026
5 checks passed
@PaulDotterer PaulDotterer deleted the feat/297-device-labels-child-table branch May 16, 2026 12:27
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.

1 participant