Skip to content

feat: add doctor command (#151)#193

Open
asphaltbuffet wants to merge 6 commits into
mainfrom
add-doctor-command
Open

feat: add doctor command (#151)#193
asphaltbuffet wants to merge 6 commits into
mainfrom
add-doctor-command

Conversation

@asphaltbuffet
Copy link
Copy Markdown
Owner

Closes #151

Implements the full doctor command across the storage, eventbus, app, and command layers.

What's included

internal/storeTruncateEntitiesTx (#157)

Transactional DELETE of all entities_current rows. Called exclusively by TruncateAndReplay during projection rebuilds.

internal/eventbusTruncateAndReplay (#159)

Rebuilds entities_current from the full event log without writing new events. Uses a separate applyEventProjectionOnlyTx dispatch path to avoid growing the event log on reparent replay (see ADR-0009).

internal/app — doctor methods (#158, #160, #161)

  • ValidateEventLog — checks each event for known type, unmarshalable payload, non-empty EntityID, and valid entity.created fields
  • CheckProjectionConsistency — logical diff between event log and projection: phantom rows, missing rows, stale last_event_id
  • TruncateAndReplay — thin delegation to bus.TruncateAndReplay

cmd/doctor — command layer (#162, #163)

Follows the established NewDefaultXxxCmd / NewXxxCmd(app interface) / buildXxxCmd / runXxx pattern.

Run order:

  1. Config file validation + DB path resolution (always runs)
  2. ValidateEventLog
  3. CheckProjectionConsistency
  4. --rebuild: TruncateAndReplay if clean, or with --force (-f)

Flags: --rebuild, --force / -f, --json

Key behaviours:

  • All checks run unless infrastructure fails (DB can't be opened) — config issues don't block DB checks (ADR-0010)
  • Exits non-zero when any issue found; prints OK when all clear
  • --rebuild skipped when issues present unless --force is set; exit is still non-zero when issues were found regardless of rebuild outcome
  • --json emits a single document: {"healthy": bool, "issue_count": int, "issues": [...], "rebuilt": int|omitted} (ADR-0011)

internal/cliOutputWriter.Issue

New method rendering [kind] description in warning orange. Always shown regardless of quiet mode. JSON mode emits {"kind": ..., "description": ...}. Issue #192 tracks aligning config check to the same convention.

Docs

  • ADR-0010: doctor check abort rule
  • ADR-0011: doctor --json output shape
  • CONTEXT.md: Healthy glossary entry

Adds Store.TruncateEntitiesTx(ctx, tx) which deletes all rows from
entities_current within a caller-supplied transaction. Called exclusively
by Bus.TruncateAndReplay during projection rebuilds.

Tests verify committed truncate empties the table and a rolled-back
truncate restores rows.
closes #159

Add Bus.TruncateAndReplay(ctx) (int, error) that rebuilds entities_current
from the full event log without writing new events.

Implementation:
- applyEventProjectionOnlyTx: new dispatch switch identical to applyEventTx
  except EntityReparentedEvent uses handleEntityReparentedProjectionOnlyTx
  (skips propagatePathChangesTx) and EntityPathChangedEvent is applied
  directly via handleEntityPathChanged — no event-log writes in either case
- handleEntityReparentedProjectionOnlyTx: recomputes entity path via
  ComputeEntityPathTx without fetching descendants or calling propagate
- writeEvent now accepts an applyFn parameter; Dispatch passes applyEventTx
- TruncateAndReplay: loads all events before opening the write transaction
  (read-then-write), truncates entities_current via TruncateEntitiesTx, then
  replays each event through applyEventProjectionOnlyTx in event_id ASC order
- Returns count of non-EntityPathChangedEvent events applied

Also fixes pre-existing DoctorKind lint warning from #158.
ADR-0009 documents the projection-only rebuild pattern.
Issue #191 tracks the equivalent ReplayEvent/import fix.

Integration test seeds a reparent scenario (produces EntityPathChangedEvent
rows in the log), calls TruncateAndReplay, and asserts: correct returned
count, correct projection state, and event log length unchanged.
Add CheckProjectionConsistency(ctx) to *App in internal/app. Performs a
logical diff between the event log and entities_current projection without
mutating state.

Algorithm:
- Derive expected-present set: entity.created minus entity.removed events
- Phantom check: projection rows not in expected-present set
- Missing check: expected-present entities absent from projection
- Stale check: projection rows whose LastEventID != max event_id for entity

Implementation is split across two unexported helpers:
- buildProjectionSets: single-pass scan over all events; builds
  expected-present map and max event_id per entity
- checkProjectionRows: set-difference checks against the full projection

Also adds Store.ListAllEntities to internal/store/entities.go — same as
ListEntities but includes removed rows, needed to detect phantoms.

Tests cover: clean state, phantom row, missing row, stale LastEventID.
All use the openTestAppWithStore helper pattern for direct store access.

Closes #160
Add cmd/doctor/ following the established command pattern with a
doctorApp interface covering ValidateEventLog, CheckProjectionConsistency,
and TruncateAndReplay.

Also adds OutputWriter.Issue(kind, description) to internal/cli — renders
'[kind] description' in warning orange; JSON mode emits {kind, description}.
Always shown regardless of quiet mode.

Run order:
1. Config file validation (config.Check) + DB path resolution — aborts if
   any config issue found
2. ValidateEventLog — issues printed, continues to projection check
3. CheckProjectionConsistency — issues printed
4. --rebuild: TruncateAndReplay if clean, or with --force (-f)

Exits non-zero if any issue found; prints 'OK' when all clear.
Registered in cmd/root.go after status; comment marks man as the first
hidden internal command.

GitHub issue #192 tracks aligning cmd/config check output to the same
[kind] description convention.

Closes #162
Add --json flag to cmd/doctor emitting a single JSON object:
  {"healthy": bool, "issue_count": int, "issues": [...], "rebuilt": int|omitted}

- rebuilt is omitted when --rebuild was not passed or rebuild was skipped
- healthy is false and exit is non-zero when any issue is present
- event_id is null when the issue is not tied to a specific event

Refactors runDoctor to accumulate all issues before rendering, enabling
the single-document output. Extracts collectIssues and emitResult helpers
to keep cognitive complexity within limits.

Also relaxes the config-check abort rule from #162: config issues no
longer block DB checks. All checks run unless their infrastructure
prerequisite failed (DB cannot be opened). Config issues are collected
and passed into runDoctor alongside app-layer issues.

Adds jsonDoctorResult/jsonIssue test structs so JSON round-trip tests
assert integer fields as integers, not float64.

Adds ADR-0010 (doctor check abort rule), ADR-0011 (--json output shape),
and CONTEXT.md Healthy glossary entry.

Closes #163
@asphaltbuffet
Copy link
Copy Markdown
Owner Author

Code review

Found 2 issues:

  1. buildProjectionSets treats soft-deleted entities as hard-deleted, causing false-positive "phantom row" errors on every removed entity

EntityRemovedEvent is a soft-delete: handleEntityRemoved sets status = removed and leaves the row in entities_current. ListAllEntities returns all rows including removed ones. But buildProjectionSets excludes removed entities from expectedPresent, so checkProjectionRows flags every legitimately removed entity as a phantom projection row — reporting a broken projection on any healthy database that has ever had an entity removed.

created[id] = true
case inventory.EntityRemovedEvent:
removed[id] = true
}
}
expectedPresent := make(map[string]bool, len(created))
for id := range created {
if !removed[id] {
expectedPresent[id] = true
}

  1. runConfigCheck has a stale doc comment claiming it returns bool; function actually returns []app.DoctorIssue

The comment at line 96 reads "Returns true if any config issues were found" — the function signature was changed to return a slice but this comment block was not updated. The duplicate comment block immediately below (lines 97–98) is the correct version.

// runConfigCheck validates config files and DB path resolution.
// Returns true if any config issues were found.
// runConfigCheck validates config files and DB path resolution.
// Returns any config issues found.
func runConfigCheck(out *cli.OutputWriter, cfg *config.Config) []app.DoctorIssue {

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

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.

add doctor command

1 participant