feat(accessories): track serialized accessories with type, compatibility, attachments, and sharing - #106
Conversation
Plan for issue #23. Corrects the issue's stale premise: #8 already shipped the `accessory` parent with serial_number/is_nfa, and grant.parent_type already included 'ammo' — so this evolves the shipped entity rather than adding a parallel `suppressor` one. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…I (U1, U5) Adds `accessory.type`, a controlled, CHECK-backed structural discriminator, and relaxes the free-text `category` from required to optional. The two classifications coexist deliberately (#23 KD4): `type` answers "which subtype's rules apply" and is the seam future per-type detail tables key off; `category` answers "what does the owner call it" and keeps #8's long tail (bipod, red dot mount) that an enum would reject. Migration 0022 is generated then hand-edited in two places SQL can express but drizzle cannot generate: the one-shot backfill that maps existing categories onto `type` case-insensitively, and the accessory grant-cleanup trigger. The backfill never rewrites `category`, so an unmapped value keeps its original string and simply lands on `other` — lossless, and safe to revisit later with a better mapping. 0022 also carries the `accessory_firearm` and `accessory_attachment` tables and the widened `grant_parent_type_valid` CHECK, so U2/U3/U4 add domain code against a schema that is already in place (KTD8: one migration). Covered by a migration test that stands up its own Postgres, migrates through 0021 only, seeds real pre-migration rows, and then applies the shipped 0022 — the mapping runs exactly once in production and cannot be re-run to fix a bad result. `accessoryDisplayName` gains a type-label fallback and the list link renders it: with `category` now optional, both would otherwise render empty, leaving a row link with no accessible name. Registers both new tables in the backup export order — the round-trip regression guard caught their absence, which would have silently dropped them from every backup. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Adds the accessory ↔ firearm compatibility relation: "which hosts does this fit", many-to-many and ordered, alongside the mount `current_firearm_id` already records. Both edges persist deliberately (#23 KD2) — a suppressor fits five hosts while mounted on at most one — and the R6 tests assert the independence in both directions so a later contributor cannot collapse one into the other without a red test. #23 asked for this to reuse the magazine implementation rather than duplicate it. Rather than porting by copy, the shared invariants now live in `src/domain/compatibility/relation.ts` and both parents bind to it. The deciding factor was not line count: the duplicated block contains the visibility gate that refuses to link a firearm the actor cannot see. Two hand-maintained copies means two places an authorization fix has to land, and the copy that gets missed is the one that leaks. Only the insert row shape differs per parent, since drizzle's `.values()` is keyed by model property name and cannot be derived from a column reference. The magazine suite passes untouched, which is the evidence that the extraction preserved behavior. The form's firearm picker is lifted to `components/inventory/compatible-firearms-field.tsx` and shared the same way — the plan left "can the picker be lifted cheaply?" open, and it is: the control was already a self-contained fieldset. Compatibility offers every VISIBLE firearm, deliberately wider than the mount picker's editable set. Declaring a fit is a statement about the accessory, not a write to the firearm; mounting stays narrower because it does write that relationship and carries a cross-tenant guard. The detail view lists what it fits for every viewer including a view-only grantee (R15/R17); it is editable only through the edit form, which a viewer is never offered, so no separate read-only picker is needed. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Adds `accessory_attachment` — the mounts, pistons, end caps and muzzle devices that make a serialized accessory fit a host — plus its service and the detail-view panel that manages it. Attachments are child records in the `firearm_photo` mold (#23 KTD7): no `owner_id`, no grants of their own. Every read and write authorizes through the parent accessory, which is what guarantees an attachment can never be reachable by someone who cannot reach the accessory — including once U4 makes accessories independently shareable, since the parent gate is the one place that decision has to land. The parent-permission check moves to `requireAccessoryEdit` / `requireAccessoryView` in the auth layer, shared with the accessory service rather than re-implemented here. The three-way outcome is the reason it is shared: a `view` holder is visible-but-forbidden (403) while anything outside the visible set must be indistinguishable from absent (404), so the response cannot be used to probe for accessories the requester should not know exist. It is also the single place `assertWritesAllowed` is enforced, so a new mutating path cannot forget the maintenance-mode gate. Recording an attachment deliberately does not touch compatibility. #23 notes a piston is what makes a can fit a host, but DERIVING compatibility from one is an explicit scope boundary, so nothing here writes to `accessory_firearm`. Every control is reachable by role and accessible name — no `data-testid` (R18). A viewer gets the list and no mutating affordance; that is presentation only, and the domain-layer tests assert the real gate rejects a viewer's writes with 403 and a stranger's with 404. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…, U8, U9) Reverses #8's deliberate decision that an accessory carries no grants of its own. Under that model an unmounted suppressor was invisible to everyone but its owner — exactly backwards for the item most likely to be lent, co-owned through a trust, or shown to an armorer. The reversal is additive, not a replacement. An accessory is now reachable by three paths: owned ∪ directly granted ∪ mounted on a firearm the requester can see and the effective permission is the strongest any path grants, with ownership always winning. The inherited path is RETAINED specifically so nobody who can see a mounted accessory today silently loses that access; AE3 is the no-regression test and is the one most worth keeping. `ParentType` gains its `accessory` arm in `visibility.ts`, and the mount-inheritance half stays in `accessory-visibility.ts` — pushing `current_firearm_id` semantics into the generic auth layer would make one parent type special (KTD6). Widening the union turned out to need no other edit: `parentTable` was the only exhaustive dispatch, so a clean typecheck really was the completeness signal KTD5 predicted. Written test-first per the unit's execution note: the sharing spec was red on 12 cases before `visibility.ts` was touched. Service-rule configuration is deliberately NOT opened up. #23 made accessories shareable for inventory visibility; `rules-service.ts` still requires direct ownership, and its comment now says why rather than citing the no-longer-true "accessories aren't a ParentType". The e2e journey surfaced a property worth pinning down: compatibility reads are viewer-relative, so a grantee sees only the hosts also shared with them. Sharing an accessory must not disclose the identity of firearms the grantee has no access to, so the spec now shares exactly one of three hosts and asserts the other two stay hidden. CONCEPTS.md gains Accessory Type, Accessory Compatibility, and Attachment — plus an Accessory entry, which #8 never added at all — and the Grant, Child record, and Compatibility entries are corrected where #23 made them stale. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…on and editing Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…ession found in review Eleven-reviewer pass over the branch. Four actionable findings, no P0s; all fixed in-branch rather than deferred. **A firearm's loadout could be changed by someone with no access to it.** `authorizeMount` only checked firearm permission when mounting ONTO a firearm. That was safe by construction until this branch: previously the only way to hold `edit` on an accessory was to inherit it from the firearm it was mounted to, so passing the accessory gate implied permission on that firearm. A direct accessory grant breaks the implication — an accessory-only editor could detach it from a gun they cannot even see. Now guarded on the detach side too, with tests proving owners and firearm edit-grantees are unaffected. **An edit grant conferred deletion.** Every other grantable parent type routes delete through an owner-only gate; sharing at `edit` has never meant "may destroy". #8 nonetheless shipped delete-by-firearm-edit-grantee for accessories, so the two rules are reconciled rather than one overriding the other: `requireAccessoryDelete` keeps #8's inherited path exactly as shipped and applies the repo-wide owner-only rule to the grant path this branch adds. (The reviewer reported this as newly introduced; `main` shows the inherited half predates it. Only the direct-grant half is new.) That fix then made the UI lie — the detail view still offered Delete to any editor. An action refused after the click is worse than one never offered, so `canDelete` is now resolved server-side from data already in hand. My own new e2e test caught it. **Type-only accessories rendered a blank link on the firearm detail page.** A third hand-written copy of the label fallback still ended at `category`, which this branch made optional. Deleted the copy rather than patching it: all three surfaces now share `accessoryDisplayName`, which finally has a test. **Performance.** "Owned union granted firearms" was resolved three times per accessories page load. It is now derived once from the permission map already being fetched — its keys are that set — and threaded through. Also corrected two comments that claimed more than the code delivers: the migration's CHECK validates set MEMBERSHIP, not mapping CORRECTNESS (its value list is hand-copied from the same constant the backfill is, so it cannot independently catch a wrong-but-valid mapping), and the parallel permission lookups only genuinely overlap on the pool, not on a transaction. New coverage for the gaps reviewers named: the label fallback, the delete policy, update-rollback on a rejected compatibility id, the documented compatibility-clearing footgun, service-rule config staying owner-only under a direct grant, magazine/accessory parity through the shared compatibility core, and e2e for the edit paths and edit-grant sharing. One timezone-fragile date comparison fixed per docs/solutions. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis change adds typed serialized accessories with many-to-many firearm compatibility, child attachments, independent sharing, viewer-relative authorization, migration support, reusable compatibility UI, and end-to-end coverage. ChangesSerialized accessories
Sequence Diagram(s)sequenceDiagram
participant Owner
participant AccessoryPage
participant AccessoryService
participant Compatibility
participant Attachments
participant GrantService
Owner->>AccessoryPage: Create or edit accessory
AccessoryPage->>AccessoryService: Submit typed accessory and compatible firearm IDs
AccessoryService->>Compatibility: Replace compatibility links
AccessoryPage->>Attachments: Create or update attachment
Owner->>GrantService: Share accessory
GrantService-->>AccessoryPage: Revalidate accessory views
AccessoryPage-->>Owner: Render accessory, compatibility, attachments, and permissions
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
✨ Simplify code
Warning Review ran into problems🔥 ProblemsThese MCP integrations need to be re-authenticated in the Integrations settings: Notion Comment |
…serial-numbers-firearm-compatibility-and-attachments
There was a problem hiding this comment.
Pull request overview
Adds first-class serialized accessories to MagStacker by evolving the existing accessory parent into a grantable, typed, compatibility-aware item with attachment child records, plus UI + migration + test coverage to support the new model (Fixes #23).
Changes:
- Make
accessorya grantableParentType, resolving effective permission via strongest-path (owned ∪ direct grant ∪ mounted-on-visible-firearm). - Add controlled
accessory.type,accessory_firearmcompatibility (many-to-many, ordered), andaccessory_attachmentchild records, with migration 0022 including a one-shot category→type backfill. - Share magazine/accessory compatibility invariants via a common relation module and add broad unit + migration + Playwright coverage (including a new serialized-accessories e2e journey).
Reviewed changes
Copilot reviewed 52 out of 52 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/test-support/factories.ts | Update service-rule parent-type commentary after accessory becomes grantable. |
| src/domain/validation-messages.ts | Replace category-required message with accessory/attachment type validation messages. |
| src/domain/service-intervals/rules-service.ts | Clarify that accessory service rules remain owner-only despite new sharing. |
| src/domain/range-sessions/tests/service.test.ts | Update fixtures to supply required accessory type. |
| src/domain/magazines/compatibility.ts | Rebind magazine compatibility to shared compatibility relation core. |
| src/domain/firearms/service.ts | Allow passing precomputed visible firearm ids to avoid duplicate visibility queries. |
| src/domain/firearms/mount-options.ts | Extend mount context with visibleFirearms options for compatibility picker. |
| src/domain/compatibility/relation.ts | New shared compatibility relation implementation (dedupe/visibility/atomic replace/viewer-relative read). |
| src/domain/compatibility/tests/relation.test.ts | New tests asserting magazine/accessory compatibility behavior stays identical. |
| src/domain/accessories/validate.ts | Make type required/validated; make category optional free text. |
| src/domain/accessories/service.ts | Add compatibility to accessory CRUD; tighten edit/delete gates; thread visible-firearm set for perf. |
| src/domain/accessories/display.ts | Ensure accessory display name falls back to type label when category is blank. |
| src/domain/accessories/constants.ts | Introduce controlled ACCESSORY_TYPES / ATTACHMENT_TYPES + label helpers. |
| src/domain/accessories/compatibility.ts | New accessory↔firearm compatibility binding using shared relation core. |
| src/domain/accessories/attachments.ts | New attachment CRUD/validation authorized through parent accessory. |
| src/domain/accessories/tests/validate.test.ts | Update validation tests for required type and optional category. |
| src/domain/accessories/tests/service.test.ts | Update service tests for new type requirement + sharing/delete rules. |
| src/domain/accessories/tests/display.test.ts | New tests pinning accessory display-name fallback chain. |
| src/domain/accessories/tests/compatibility.test.ts | New tests for accessory compatibility invariants + mount independence. |
| src/domain/accessories/tests/attachments.test.ts | New tests for attachment CRUD + auth-through-parent behavior. |
| src/demo/inventory.ts | Add type to demo accessory seed shape and fixtures. |
| src/db/migrations/meta/_journal.json | Register migration 0022. |
| src/db/migrations/0022_confused_lockheed.sql | New migration adding attachments/compatibility/type, grant parent type, backfill, and cleanup trigger. |
| src/db/inventory-schema.ts | Add accessory type + new tables + checks + grant parent_type expansion. |
| src/db/tests/migration-0022-accessory-type-backfill.test.ts | New integration test exercising 0022 backfill against real pre-migration rows. |
| src/backup/table-order.ts | Include new accessory child tables in backup export order. |
| src/auth/visibility.ts | Add accessory to ParentType; split resolveGrantPermission from ownership-aware resolvePermission. |
| src/auth/accessory-visibility.ts | Implement strongest-path accessory permission + new edit/delete/view gates + mount authorization fix. |
| src/auth/tests/accessory-sharing.test.ts | New tests covering direct grants, inherited path retention, strongest-permission, and delete policy. |
| scripts/seed-demo.ts | Pass accessory type when seeding demo data. |
| e2e/service-intervals-sharing.spec.ts | Update accessory creation flow to select Type. |
| e2e/fixtures/user-pool.ts | Add new e2e users for serialized accessories scenario. |
| e2e/fixtures/demo-seed.ts | Update e2e demo seed accessory creation to select Type. |
| e2e/accessories.spec.ts | Update accessories e2e flows to select Type. |
| e2e/accessories-serialized.spec.ts | New e2e journey covering serialized accessories: type, compatibility, attachments, sharing, cascade delete. |
| CONCEPTS.md | Document Accessory, Attachment, and Accessory Compatibility concepts + grant model update. |
| components/inventory/compatible-firearms-field.tsx | New shared checkbox picker + toggle helper for compatibility selection. |
| app/(app)/magazines/magazine-form.tsx | Reuse shared compatible-firearms component and toggle helper. |
| app/(app)/grants/share-control.tsx | Allow edit grants for accessories (still view-only for magazines). |
| app/(app)/grants/actions.ts | Revalidate /accessories on share/revoke. |
| app/(app)/firearms/mounted-accessories.tsx | Use accessoryDisplayName to avoid empty/unnamed accessory links. |
| app/(app)/accessories/page.tsx | Thread visible-firearm id set through page queries; pass type + visibleFirearms into view. |
| app/(app)/accessories/attachments-section.tsx | New client UI panel for attachment list + inline add/edit/delete. |
| app/(app)/accessories/actions.ts | Add server actions for attachment CRUD + path revalidation. |
| app/(app)/accessories/accessory-form.tsx | Add required Type select + compatibility picker integration. |
| app/(app)/accessories/accessory-detail-view.tsx | Add ShareControl, compatibility readout, attachments panel, and canDelete gating. |
| app/(app)/accessories/accessories-view.tsx | Add Type column + ShareControl for accessories; adjust row labels and a11y disambiguation. |
| app/(app)/accessories/[id]/page.tsx | Fetch attachments, compute canDelete, and thread visible-firearm ids through queries. |
| .github/instructions/mermaid.instructions.md | Add Mermaid diagram assistance instructions (tooling doc). |
| .github/copilot-instructions.md | Link Mermaid instructions into Copilot instructions. |
Suppressed comments (1)
app/(app)/accessories/actions.ts:105
deleteAttachmentActionalso trusts the caller-providedaccessoryIdforrevalidatePath. Since deletion is keyed byattachmentId, a mismatched{attachmentId, accessoryId}can delete successfully but revalidate the wrong page, leaving the real accessory detail view stale.
Consider returning the parent accessoryId from deleteAttachment(...) (or returning it from the SQL DELETE ... RETURNING) so the action can revalidate the correct route without trusting client input.
export async function deleteAttachmentAction(
attachmentId: string,
accessoryId: string,
): Promise<ActionResult> {
return withActionContext("accessories", async (userId) => {
await deleteAttachment(userId, attachmentId);
revalidatePath(`/accessories/${accessoryId}`);
return { ok: true };
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (4)
src/db/__tests__/migration-0022-accessory-type-backfill.test.ts (1)
118-118: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRename
byCategoryto match its key.The map is keyed by
serial_number, not by category. Every lookup uses a serial (byCategory.get("fx-longtail")). The name misleads a reader of the assertions.♻️ Rename to `bySerial`
- let byCategory: Map<string, AccessoryRow>; + let bySerial: Map<string, AccessoryRow>;Update the assignment and all
byCategory.get(...)/byCategory.sizereads accordingly.🤖 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 `@src/db/__tests__/migration-0022-accessory-type-backfill.test.ts` at line 118, Rename the map variable byCategory to bySerial in the migration test, updating its declaration, assignment, and every byCategory.get(...) and byCategory.size reference to match its serial_number key.src/domain/accessories/__tests__/display.test.ts (1)
57-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDrive the exhaustive loop from
ACCESSORY_TYPES.The literal array omits
"muzzle device"and will not pick up any type added later. The sibling suitesrc/domain/accessories/__tests__/validate.test.tsalready loops overACCESSORY_TYPESat Line 18. Use the same source so this "never empty" guarantee stays exhaustive.♻️ Loop the controlled set
test("never returns an empty string for any valid accessory shape", () => { - for (const type of ["suppressor", "optic", "light", "laser", "other"]) { + for (const type of ACCESSORY_TYPES) { expect(accessoryDisplayName({ ...base, type }).trim()).not.toBe(""); } });Add the import:
import { describe, expect, test } from "bun:test"; +import { ACCESSORY_TYPES } from "../constants"; import {🤖 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 `@src/domain/accessories/__tests__/display.test.ts` around lines 57 - 61, Update the exhaustive test in “never returns an empty string for any valid accessory shape” to import and iterate over ACCESSORY_TYPES instead of the hard-coded type array, matching the approach in the sibling validation suite and covering all current and future accessory types.src/domain/accessories/__tests__/compatibility.test.ts (1)
154-161: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd the positive shared-reader case for viewer-relative compatibility reads.
This test pins only the negative direction: an outsider sees nothing. It passes even if the filter were owner-only rather than visibility-relative. Add a grantee who holds a
viewgrant onfirearmAand assertloadAccessoryCompatibilityreturns[firearmA]for that grantee while still droppingfirearmB.That asymmetric expectation is what proves the filter follows the visible set instead of ownership.
As per coding guidelines: "Test inventory parity behavior against the pinned parity specification, including two-user sharing and authorization tests."
🤖 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 `@src/domain/accessories/__tests__/compatibility.test.ts` around lines 154 - 161, Add a positive shared-reader case to the compatibility read tests: grant a grantee “view” access to firearmA but not firearmB, then assert loadAccessoryCompatibility returns [firearmA] for that grantee. Retain the existing outsider assertion and use the established grant/setup helpers so the test verifies visibility-relative filtering rather than owner-only behavior.Source: Coding guidelines
e2e/accessories-serialized.spec.ts (1)
52-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse accessible locators instead of generic
formselectors.These lines use
locator("form")or a fallback to it. Target the existing labeled controls directly, or give the form an accessible name and usegetByRole("form", { name }). This keeps the test aligned with the required selector policy.Also applies to: 67-79, 116-119, 138-140, 149-157, 167-173
🤖 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 `@e2e/accessories-serialized.spec.ts` around lines 52 - 57, Replace the generic form fallback selectors in the test cases around the existing labeled controls with accessible locators only. Update the affected sections, including the flows using Name, Caliber, Type, and Action, to target controls directly or use getByRole("form", { name }) after assigning an accessible form name; remove locator("form") usage while preserving the current interactions.Source: Coding guidelines
🤖 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 `@app/`(app)/accessories/accessories-view.tsx:
- Around line 126-135: Update the linkLabel callback to capture the current
labelCounts directly instead of reading from the mutable labelCountsRef during
render. Add labelCounts to the useCallback dependency array and remove the
render-time labelCountsRef mutation and associated access.
In `@app/`(app)/accessories/attachments-section.tsx:
- Around line 148-163: Update confirmDelete to clear the existing serverError
before starting the delete retry, ensuring a subsequent successful delete does
not leave the prior failure message visible.
In `@CONCEPTS.md`:
- Around line 26-32: Update the owned-parent taxonomy in the Relationships
section of CONCEPTS.md to include Accessory alongside Firearms, Magazines, and
Ammo, and change the stated count from three to four. Use the canonical
“Accessory” terminology already defined in the Accessory concept.
In `@e2e/accessories-serialized.spec.ts`:
- Around line 242-251: Extend the read-only assertions in the R17 test to verify
zero attachment edit controls by querying buttons with name /^Edit .*
attachment$/. Keep the existing exact-name Edit assertion and other
mutation-affordance checks unchanged.
- Around line 147-175: Extend the edit flow in the test step around the first
“Save changes” to reopen the accessory editor and assert the “Type” control has
value “muzzle device” before restoring it to “suppressor.” Keep the existing
compatibility assertions and final restoration behavior unchanged.
In `@src/auth/__tests__/accessory-sharing.test.ts`:
- Around line 334-349: Rename the test describing the unmounted accessory
scenario to reflect that the direct accessory editor is refused when they lack
permission to edit the target firearm, while preserving the existing setup and
NotFoundError assertion.
In `@src/domain/accessories/display.ts`:
- Line 28: Update the category selection expression in the accessory label logic
to trim the category once, use that trimmed value for the non-empty check, and
return the trimmed value; retain accessoryTypeLabel(a.type) as the fallback for
blank categories.
In `@src/domain/compatibility/__tests__/relation.test.ts`:
- Around line 115-144: Add a positive two-user sharing test near the existing
visibility-gate tests: grant owner access to the second user’s firearm, then
verify replaceMagazineCompatibility and replaceAccessoryCompatibility succeed
and loadMagazineCompatibility and loadAccessoryCompatibility retain that firearm
ID. Reuse the existing sharing and fixture helpers, and assert parity for both
compatibility types.
---
Nitpick comments:
In `@e2e/accessories-serialized.spec.ts`:
- Around line 52-57: Replace the generic form fallback selectors in the test
cases around the existing labeled controls with accessible locators only. Update
the affected sections, including the flows using Name, Caliber, Type, and
Action, to target controls directly or use getByRole("form", { name }) after
assigning an accessible form name; remove locator("form") usage while preserving
the current interactions.
In `@src/db/__tests__/migration-0022-accessory-type-backfill.test.ts`:
- Line 118: Rename the map variable byCategory to bySerial in the migration
test, updating its declaration, assignment, and every byCategory.get(...) and
byCategory.size reference to match its serial_number key.
In `@src/domain/accessories/__tests__/compatibility.test.ts`:
- Around line 154-161: Add a positive shared-reader case to the compatibility
read tests: grant a grantee “view” access to firearmA but not firearmB, then
assert loadAccessoryCompatibility returns [firearmA] for that grantee. Retain
the existing outsider assertion and use the established grant/setup helpers so
the test verifies visibility-relative filtering rather than owner-only behavior.
In `@src/domain/accessories/__tests__/display.test.ts`:
- Around line 57-61: Update the exhaustive test in “never returns an empty
string for any valid accessory shape” to import and iterate over ACCESSORY_TYPES
instead of the hard-coded type array, matching the approach in the sibling
validation suite and covering all current and future accessory types.
🪄 Autofix
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: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)
Review profile: CHILL
Plan: Pro
Run ID: 2161e7eb-f605-4209-838e-fcaab8653ba7
📒 Files selected for processing (52)
.github/copilot-instructions.md.github/instructions/mermaid.instructions.mdCONCEPTS.mdapp/(app)/accessories/[id]/page.tsxapp/(app)/accessories/accessories-view.tsxapp/(app)/accessories/accessory-detail-view.tsxapp/(app)/accessories/accessory-form.tsxapp/(app)/accessories/actions.tsapp/(app)/accessories/attachments-section.tsxapp/(app)/accessories/page.tsxapp/(app)/firearms/mounted-accessories.tsxapp/(app)/grants/actions.tsapp/(app)/grants/share-control.tsxapp/(app)/magazines/magazine-form.tsxcomponents/inventory/compatible-firearms-field.tsxdocs/plans/2026-08-04-001-feat-serialized-accessories-plan.mde2e/accessories-serialized.spec.tse2e/accessories.spec.tse2e/fixtures/demo-seed.tse2e/fixtures/user-pool.tse2e/service-intervals-sharing.spec.tsscripts/seed-demo.tssrc/auth/__tests__/accessory-sharing.test.tssrc/auth/accessory-visibility.tssrc/auth/visibility.tssrc/backup/table-order.tssrc/db/__tests__/migration-0022-accessory-type-backfill.test.tssrc/db/inventory-schema.tssrc/db/migrations/0022_confused_lockheed.sqlsrc/db/migrations/meta/0022_snapshot.jsonsrc/db/migrations/meta/_journal.jsonsrc/demo/inventory.tssrc/domain/accessories/__tests__/attachments.test.tssrc/domain/accessories/__tests__/compatibility.test.tssrc/domain/accessories/__tests__/display.test.tssrc/domain/accessories/__tests__/service.test.tssrc/domain/accessories/__tests__/validate.test.tssrc/domain/accessories/attachments.tssrc/domain/accessories/compatibility.tssrc/domain/accessories/constants.tssrc/domain/accessories/display.tssrc/domain/accessories/service.tssrc/domain/accessories/validate.tssrc/domain/compatibility/__tests__/relation.test.tssrc/domain/compatibility/relation.tssrc/domain/firearms/mount-options.tssrc/domain/firearms/service.tssrc/domain/magazines/compatibility.tssrc/domain/range-sessions/__tests__/service.test.tssrc/domain/service-intervals/rules-service.tssrc/domain/validation-messages.tssrc/test-support/factories.ts
Revalidate attachment pages from the updated row, not the caller. Both attachment actions keyed on `attachmentId` alone while invalidating the cache path built from a separately-supplied `accessoryId`, so a mismatched pair would update the right attachment and refresh some other accessory's page — leaving the page that actually changed stale. The parent id now comes from the write itself, which also lets the redundant parameter go. Return the trimmed category from `accessoryDisplayName`. The branch tested `category.trim()` but returned the raw value, and migration 0022 preserves pre-existing categories verbatim — the backfill test pins `" light "` as a surviving row — so the padding was reaching a link's accessible name. Clear a stale delete error before retrying an attachment delete; a successful retry was leaving the previous failure banner on screen. CONCEPTS.md still described three owned parents after Accessory became the fourth. Test fixes, all of which were passing for the wrong reason: the e2e type-edit step changed and restored `type` without asserting either save (a write path dropping `type` would have passed on the compatibility assertions alone); the read-only viewer assertion matched the exact name "Edit", which never matches "Edit Piston attachment", so it would have passed with every attachment control rendered; a sharing test's name claimed an accessory was "freely mountable" while asserting NotFoundError; the shared-core parity suite proved only that an unseeable firearm is rejected, never that a firearm *shared* to the actor is linkable — the gate is visibility, not ownership, and only the negative was covered. Declined one finding: moving `labelCounts` into `linkLabel`'s dependencies. The ref indirection is deliberate and documented on `main` — adding that dependency rebuilds `columns` and remounts the actions cell, which closes the ShareControl dialog mid-flow. This branch put a ShareControl in that cell, so the suggested change would reintroduce exactly the regression the pattern exists to prevent. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Both nitpicks fixed in 7e7fcc0. The map is renamed to The exhaustive display-name loop now iterates |
There was a problem hiding this comment.
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 (1)
CONCEPTS.md (1)
56-56: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winCorrect the Accessory Compatibility wording.
Change “this can fits these five hosts” to “this can fit these five hosts.”
🤖 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 `@CONCEPTS.md` at line 56, In the Accessory compatibility description, correct the grammar by changing “this can fits these five hosts” to “this can fit these five hosts.”
🤖 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 `@CONCEPTS.md`:
- Around line 7-9: Update the Firearm definition in CONCEPTS.md to describe
Firearm as one of the four owned parents, keeping the surrounding ownership and
child-record documentation unchanged.
In `@src/domain/accessories/attachments.ts`:
- Around line 143-151: Serialize authorization with attachment mutations so
revokeGrant cannot commit between permission validation and deleteAttachment or
updateAttachment. Lock the shared authorization rows consistently in the
transaction, or enforce active permission directly in each mutation predicate,
ensuring revoked editors cannot mutate attachments. Add a concurrency test
covering grant revocation racing with attachment deletion and update.
---
Outside diff comments:
In `@CONCEPTS.md`:
- Line 56: In the Accessory compatibility description, correct the grammar by
changing “this can fits these five hosts” to “this can fit these five hosts.”
🪄 Autofix
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: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)
Review profile: CHILL
Plan: Pro
Run ID: d3293605-1f7d-48d9-a8e3-e36e59910415
📒 Files selected for processing (10)
CONCEPTS.mdapp/(app)/accessories/actions.tsapp/(app)/accessories/attachments-section.tsxe2e/accessories-serialized.spec.tssrc/auth/__tests__/accessory-sharing.test.tssrc/db/__tests__/migration-0022-accessory-type-backfill.test.tssrc/domain/accessories/__tests__/display.test.tssrc/domain/accessories/attachments.tssrc/domain/accessories/display.tssrc/domain/compatibility/__tests__/relation.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- src/domain/accessories/display.ts
- src/db/tests/migration-0022-accessory-type-backfill.test.ts
- e2e/accessories-serialized.spec.ts
- src/auth/tests/accessory-sharing.test.ts
- src/domain/accessories/tests/display.test.ts
- app/(app)/accessories/attachments-section.tsx
The Relationships section was updated to four owned parents when Accessory joined them; the Firearm definition still said three. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…e guards Findings from a five-lens review pass (comments, types, silent failures, test coverage, general). No criticals; all fixed here. `parentTable` could route a future parent type at the WRONG TABLE with no build error. It ended in `return accessory` rather than an exhaustive check, so adding a fifth `ParentType` arm and forgetting this dispatch would compile cleanly and silently read accessories. Now a switch with a `never` default. That also falsifies a claim this file made about itself, repeated in an earlier commit message: that widening `ParentType` is safe because `tsc` surfaces every arm needing an update. It doesn't — the fallback absorbed anything. The docblock now says what's true and points at the deliberate allowlist in share-control.tsx, which existed precisely BECAUSE the union is not self-enforcing. Two comments in this branch contradicted each other on that point. Removed the post-commit fallback in create/updateAccessory. It was unreachable — `attachCompatibility` maps over the array it is handed, so a one-element input always yields one element — and the comment justifying it described a concurrent-delete race that cannot occur. It was added while over-applying an earlier advisory. The sibling magazine service has no such fallback, which is the shape this should have matched. Left in place it would have become a landmine: a later change that dropped a row would return fabricated `compatibleFirearmIds: []` as though it were verified state. `listAttachments` re-authorizes through the parent and can throw its own NotFoundError, but sat outside the page's notFound() catch — a grant revoked between the two calls surfaced as an unhandled error rather than a 404, and there is no error boundary under app/. `CompatibilityRelation` is now generic over its table, so `buildRow` is checked against that table's real insert model. Verified by deliberately mis-wiring the accessory binding to emit `magazineId`: it now fails typecheck, where before it compiled and would only have failed at runtime. `type` — this branch's headline field — was never round-tripped through `updateAccessory`. Every test passed the same value on create and update, so none would have noticed if the update path stopped writing it. Added the round-trip, and the e2e edit step now asserts the saved value in both directions instead of checking a serial number that was already on screen. Also: `.$type<>()` on both `type` columns so the controlled set survives the read boundary; `exact: true` on the owner Edit locator, since Playwright matches accessible names by substring and the bare name also matched "Edit Piston attachment", making `.first()` resolve by DOM order; and the dead `Link` import left by the CompatibleFirearmsField extraction. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
src/domain/accessories/service.ts (3)
235-240: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftPreserve compatibility links that are outside the editor’s visible firearm set.
An edit grantee receives
compatibleFirearmIdsfiltered by visible firearms, butAccessoryFormsubmits that filtered list toupdateAccessory. The wholesale replacement can delete existing links to firearms the editor cannot see when the editor saves another field.Keep the documented omission-clears behavior. When an editor submits compatibility IDs, preserve existing links outside the editor’s visible firearm set and replace only the visible portion. Add a regression test for an edit grantee with a hidden compatibility link.
🤖 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 `@src/domain/accessories/service.ts` around lines 235 - 240, Update the compatibility replacement flow around replaceAccessoryCompatibility so submitted IDs continue to clear all links when compatibility is omitted, but when an edit grantee submits filtered compatibility IDs, only replace links within the editor’s visible firearm set and preserve hidden links. Pass the appropriate visible-firearm scope from the surrounding update logic, and add a regression test covering an edit grantee saving with a hidden compatibility link.Sources: Coding guidelines, Path instructions
172-188: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy liftPrevent authorization revocation races before merging.
resolveCreateOwner,authorizeCreateMount,requireAccessoryEdit,authorizeMount, andrequireAccessoryDeleteread permissions, then write without locking authorization rows or rechecking permission. The transaction alone does not prevent a concurrentrevokeGrantfrom deleting a grant between these operations. The accessory mutation can then commit for a revoked actor. Apply a consistent serialization strategy to these paths and other mutation services using the same gates. Add a concurrency regression test for revocation during authorization.🤖 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 `@src/domain/accessories/service.ts` around lines 172 - 188, Serialize authorization checks with revocation by locking the relevant authorization rows or otherwise rechecking permissions immediately before each accessory mutation commits. Apply this consistently across resolveCreateOwner, authorizeCreateMount, requireAccessoryEdit, authorizeMount, and requireAccessoryDelete, plus other mutation services using these gates, and add a concurrency regression test covering revokeGrant racing with authorization.Sources: Coding guidelines, Learnings, MCP tools
219-228: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftLock the accessory row before deriving
installedDate.Under PostgreSQL READ COMMITTED, this
SELECTdoes not lock the row. A concurrentmountAccessorycan commit before the update, causing the stale snapshot to overwrite the newinstalledDatewithnull. A concurrent unmount can also make the update fail the mount constraint when the input contains an installed date. Add.for("update")to this query, or derive the invariant atomically, and add a concurrency regression test.🤖 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 `@src/domain/accessories/service.ts` around lines 219 - 228, Update the accessory lookup in the transaction before persistableFields is called to lock the selected row for update using the query builder’s row-locking mechanism, preserving the existing not-found behavior and ensuring installedDate is derived from the locked currentFirearmId. Add a concurrency regression test covering concurrent mount and unmount updates.Sources: Coding guidelines, MCP tools
🤖 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.
Outside diff comments:
In `@src/domain/accessories/service.ts`:
- Around line 235-240: Update the compatibility replacement flow around
replaceAccessoryCompatibility so submitted IDs continue to clear all links when
compatibility is omitted, but when an edit grantee submits filtered
compatibility IDs, only replace links within the editor’s visible firearm set
and preserve hidden links. Pass the appropriate visible-firearm scope from the
surrounding update logic, and add a regression test covering an edit grantee
saving with a hidden compatibility link.
- Around line 172-188: Serialize authorization checks with revocation by locking
the relevant authorization rows or otherwise rechecking permissions immediately
before each accessory mutation commits. Apply this consistently across
resolveCreateOwner, authorizeCreateMount, requireAccessoryEdit, authorizeMount,
and requireAccessoryDelete, plus other mutation services using these gates, and
add a concurrency regression test covering revokeGrant racing with
authorization.
- Around line 219-228: Update the accessory lookup in the transaction before
persistableFields is called to lock the selected row for update using the query
builder’s row-locking mechanism, preserving the existing not-found behavior and
ensuring installedDate is derived from the locked currentFirearmId. Add a
concurrency regression test covering concurrent mount and unmount updates.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)
Review profile: CHILL
Plan: Pro
Run ID: ee07141f-5049-41ca-bdbc-751b83e28591
📒 Files selected for processing (12)
app/(app)/accessories/[id]/page.tsxapp/(app)/magazines/magazine-form.tsxe2e/accessories-serialized.spec.tssrc/auth/visibility.tssrc/db/inventory-schema.tssrc/domain/accessories/__tests__/attachments.test.tssrc/domain/accessories/__tests__/service.test.tssrc/domain/accessories/attachments.tssrc/domain/accessories/compatibility.tssrc/domain/accessories/service.tssrc/domain/compatibility/relation.tssrc/domain/magazines/compatibility.ts
💤 Files with no reviewable changes (1)
- app/(app)/magazines/magazine-form.tsx
🚧 Files skipped from review as they are similar to previous changes (9)
- src/domain/magazines/compatibility.ts
- src/domain/accessories/compatibility.ts
- src/domain/accessories/tests/attachments.test.ts
- src/domain/accessories/attachments.ts
- app/(app)/accessories/[id]/page.tsx
- src/domain/compatibility/relation.ts
- src/auth/visibility.ts
- src/db/inventory-schema.ts
- src/domain/accessories/tests/service.test.ts
Corrects the record: 8f91c97's message states "the e2e edit step now asserts the saved value in both directions instead of checking a serial number that was already on screen." That change was never applied. The edit that was meant to add it silently matched nothing — the surrounding block had already been rewritten in an earlier round — and the result was not checked. The e2e run afterwards passed, which proved only that an unchanged test still passes. So for two rounds the one finding flagged as most important was recorded as fixed while the assertions did not exist. The step still ended by asserting a serial number that was on screen before it ran. The assertions exist now, and are verified rather than asserted: with the update path patched to drop `type`, the new unit round-trip fails by name and the e2e fails at the exact new `toHaveValue`. Restored, both pass. They were also racy on first write — reopening the edit form immediately after saving reads the previous form's value, which passed in isolation and failed under full-suite load. The step now waits for the form to close, which is the signal the write landed and the view refreshed. Also from this review round: - `CompatibilityRelation`'s type parameter had a `= PgTable` default, so a binding could omit the argument and silently fall back to the unchecked shape the generic was added to prevent. Default dropped; omission is now TS2314. The read paths take a narrower `CompatibilityColumns` view, since they never build a row and drizzle's `.from()` rejects a generic table. Still open, and now stated in the file: the three column fields are bare `PgColumn` and are not tied to the table, so pointing one at another table's column still compiles. That is also why the `as string` casts at the read sites are load-bearing rather than removable. - `listAttachments`/`getAccessory` now use the shared `asNotFound` helper instead of a hand-rolled catch. The helper was already imported in that file and used three times, and its own docstring describes this exact scenario. Removing the duplication orphaned the `NotFoundError` import. - `exact: true` on the remaining accessory-level Edit locators. The viewer's absence check deliberately keeps the bare name, since substring matching is what makes one assertion cover the attachment buttons too. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…s work Both are cases where a check appeared to prove something it did not. logic-errors/non-exhaustive-union-dispatch-silently-routes-to-wrong-table — `parentTable` mapped a `ParentType` to its table with an if/else chain ending in an unconditional `return`, so widening the union resolved a new member to whatever that fallback named, with a clean typecheck. The near-miss is verifiable: on `main` the fallback was `return ammo`, so adding `"accessory"` without touching the function would have run every accessory permission check against the ammo table. The rule the doc lands on is to treat "the typecheck passed" as evidence only when you can name the construct that would have failed. test-failures/playwright-accessible-name-matches-by-substring — accessible names match by substring unless `exact: true` is passed, which cuts both ways and never goes red. A bare "Edit" also matches "Edit Piston attachment", so `.first()` was selecting by DOM order rather than by name; separately, the belief that a bare name could NOT match those buttons produced a confident wrong account of a passing assertion. The doc deliberately does not conclude "always use exact" — for an absence assertion the substring default makes the check stronger, and one spec now carries both forms on purpose. Written in lightweight mode (session near its context limit), so neither had a semantic overlap search or the semantic grounding validator. Cited paths and frontmatter were validated mechanically; the claims about those lines were checked by hand against the tree. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
"this can fits these five hosts" parses correctly with "can" as the noun, but two independent reviewers read it as a modal verb. A glossary line readers trip over is defective regardless of whether the grammar is defensible. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Compatibility reads are viewer-relative, so the list a caller submits is always built from a FILTERED view. The write path replaced wholesale, so an editor holding a grant on the item but not on one of its hosts silently deleted the links they were never shown — no error, and nothing to tell the owner. The replace is now bounded by the actor's visible set: rows outside it survive untouched and keep their ordinals, and the replaced slice is appended after them. "Omission clears" still holds, but only over what the actor could actually see. Fixed in the shared relation core rather than at the accessory call site, so magazines get it too — a magazine linked to a firearm whose grant was later revoked had the same silent loss on the owner's next save. Also de-flakes the accessory e2e type assertions, which failed while verifying this. The submit button relabels itself to "Saving…" while the action is in flight, so waiting for "Save changes" to disappear went true DURING the save. The wait now keys on the Edit button coming back — the one signal that means the write finished — and then reloads, so the reopened form is read from the persisted row rather than a cached render. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…eout Every test in this file drives a real Postgres container through key derivation, tar packing, a full-database wipe, and — twice — advisory-lock serialization of two concurrent restores. Several legitimately take 5–8 seconds against bun's 5s default, so the default was acting as a load tripwire rather than a signal about the code. Tripping it was not a contained failure. Bun marks the test failed but the body keeps running, so `afterAll` ended the pool underneath it and every remaining test in the file cascaded into "Cannot use a pool after calling end on the pool" — failures naming the wrong test and pointing at nothing real. That is why `just ci-check` failed in a different, larger subset on each run while this file passed 14/14 in isolation, and why the noise read as "flaky infrastructure" for long enough to get waved off twice. 60s is ~7x the slowest observed run, so a genuine hang still fails, just later. Scoped to this file rather than raised suite-wide, which would hide slowness in the ~1250 tests that have no business taking seconds. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
… suites Supersedes the per-file version in 8254b11, which was both too narrow and too noisy. Too narrow: scoping it to the restore suite was a judgment call made on the evidence available, and the very next gate run disproved it by failing in routes.test.ts the same way. Every suite in src/backup starts its own Postgres and does real bundle work per test, so they all sit against the same 5s tripwire. Too noisy: renaming the call sites to `containerTest(` reflowed the formatter's wrapping through every test body — a 930-line diff for a one-line change. Importing the helper aliased (`containerTest as test`) leaves all 78 call sites byte-identical, so the whole change is one import line per file. The failure mode is what justifies a shared helper over a per-test number: a timeout is not contained. Bun marks the test failed but the body keeps running, so `afterAll` ends the pool underneath it and the rest of the file cascades into "Cannot use a pool after calling end on the pool" — failures naming the wrong test. That is why ci-check failed in a different subset each run while each file passed in isolation. Not applied to the src/db migration suites: their per-test bodies are assertions over data prepared in `beforeAll`, which already carries its own 120s timeout, so the tight default is correct there. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…cade Two learnings from this branch that generalize past #23. The first is the pairing that caused the data loss: a viewer-relative read feeding a replace-all write. Each half reviews as correct, which is why it survived eleven reviewers and a green suite — the defect exists only in their composition, and only a two-user fixture can observe it. Any future grant-shareable many-to-many inherits the trap. The second is why `just ci-check` looked flaky: a bun timeout is not contained. The body keeps running past the failure, `afterAll` ends the pool underneath it, and the rest of the file fails for reasons that name the wrong tests. Written down mostly because "passes in isolation" reads as exoneration and is worthless signal for load-sensitive failures — it is what let this get dismissed twice. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…from Addresses PR review feedback on #106 that was reported outside the diff range and so never became a resolvable thread. `updateAccessory` read `currentFirearmId` and derived `installedDate` from it (R6) without locking. Under READ COMMITTED that is a lost update, and the repo already had the answer: `updateMagazine` takes `FOR UPDATE` on its label read for precisely this reason. Accessories just never got it. Both directions are real, and both now have a regression test that drives the interleaving deterministically — a second connection holds the row under FOR UPDATE, the update parks on the lock, and only then does the holder commit: - a mount committing mid-update loses its `installedDate`, because the update still believes the accessory is unmounted and writes null over a date set moments earlier; - an unmount committing mid-update leaves the update writing a date onto a now-unmounted row, violating `accessory_installed_date_requires_mount` and surfacing as a 500 on an edit that never touched the mount. Verified both fail without the lock: the first as a silent revert, the second as the CHECK violation. Also replaces every generic `locator("form").last()` in the accessories e2e journey with `getByRole("form", { name })`, and gives the accessory, attachment, and firearm forms accessible names so the role exists at all. `.last()` resolves by DOM order rather than by name, which is the same class of ambiguity that already sent two assertions in this branch at the wrong element. It is also an accessibility gain: each form is now a named landmark rather than an anonymous one. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
|
Working the four findings that were posted in review bodies rather than as inline threads — the "outside diff range" and nitpick sections have no resolve mechanism, so they don't show up as unresolved threads and are easy to lose.
Fixed in ccb6943. This was the real one, and the repo already had the answer sitting next to it: Both directions you described are real and both now have a regression test. The tests drive the interleaving deterministically rather than hoping for it — a second connection holds the row under
Verified both fail with the lock stashed: the first as a silent revert, the second as the CHECK violation.
Fixed in ccb6943. All eight uses in the accessories journey are now Worth recording that this was not cosmetic.
Covered, though not where you suggested. The regression tests added for the compatibility data-loss fix build exactly that fixture in That is the asymmetric expectation you asked for: a non-owner getting a non-empty result fails immediately if the filter is narrowed to ownership.
Already addressed — One process note: reviews on this branch are currently auto-paused ("It looks like this branch is under active development"). I previously read a green CodeRabbit check as a clean re-review of the newer commits, which was wrong — a paused review reports success without having looked. Triggering one below so the last several commits actually get read. |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (4)
src/domain/compatibility/relation.ts (1)
72-88: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
CompatibilityRelationredeclares three members it already inherits.
parentIdColumn,firearmIdColumn, andordinalColumnare declared identically inCompatibilityColumnsat lines 65-69. The redeclarations narrow nothing. Onlytable: TTableandbuildRoware real additions. Two copies of the same declaration can drift when one side changes.♻️ Keep only the members that refine the base
export interface CompatibilityRelation<TTable extends PgTable> extends CompatibilityColumns { - /** The join table itself. */ + /** The join table itself, narrowed so `buildRow` is checked against it. */ table: TTable; - /** The parent-id column (`magazine_id`, `accessory_id`, ...). */ - parentIdColumn: PgColumn; - /** The `firearm_id` column. */ - firearmIdColumn: PgColumn; - /** The `ordinal` column the stable read order is defined by. */ - ordinalColumn: PgColumn; /** Build one insertable row; keyed by the table's model property names. */ buildRow: ( parentId: string, firearmId: string, ordinal: number, ) => InferInsertModel<TTable>; }🤖 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 `@src/domain/compatibility/relation.ts` around lines 72 - 88, Remove the redundant parentIdColumn, firearmIdColumn, and ordinalColumn declarations from CompatibilityRelation, leaving the inherited CompatibilityColumns members intact. Keep only the TTable-specific table property and buildRow method in the interface.src/domain/accessories/__tests__/service.test.ts (2)
446-459: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value
doneis an eagerly started promise with no rejection handler until the test awaits it.
db.transaction(...)starts immediately at line 446. If the transaction rejects before the test reachesawait holder.done, Bun reports an unhandled rejection and can fail an unrelated test in the same process. Attach a no-op catch at creation and keep the original promise for the assertion.♻️ Suppress the interim unhandled rejection
const done = db.transaction(async (tx) => { @@ }); + // Started eagerly; keep the process from seeing an unhandled rejection + // in the window before the test awaits `done`. + done.catch(() => {}); return { locked, release, done };🤖 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 `@src/domain/accessories/__tests__/service.test.ts` around lines 446 - 459, Update the transaction promise assigned to done so it immediately has a no-op rejection handler while preserving the original promise for the later assertion. Apply this in the db.transaction flow around signalLocked and mayFinish without changing the transaction operations or error assertion behavior.
476-486: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThe interleaving depends on a fixed 250 ms sleep.
setTimeout(250)assumesupdateAccessoryreached itsFOR UPDATEread and parked on the lock. Under a loaded CI runner the holder can commit first. The test then passes without exercising the race, so a regression in the lock would not be caught. The suite header states the interleaving is deterministic; the sleep makes it probabilistic.Poll
pg_stat_activityfor the blocked backend instead of sleeping.♻️ Wait for the lock wait instead of sleeping
+ /** Resolves once a backend is blocked waiting on a lock. */ + async function waitForBlockedBackend(): Promise<void> { + for (let attempt = 0; attempt < 100; attempt++) { + const [row] = await db.execute<{ blocked: number }>( + sql`select count(*)::int as blocked from pg_stat_activity + where wait_event_type = 'Lock' and state = 'active'`, + ); + if (Number(row?.blocked ?? 0) > 0) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("update never parked on the row lock"); + }Then replace both sleeps:
- await new Promise((resolve) => setTimeout(resolve, 250)); + await waitForBlockedBackend(); holder.release();Also applies to: 508-516
🤖 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 `@src/domain/accessories/__tests__/service.test.ts` around lines 476 - 486, Replace the fixed 250 ms sleeps in the interleaving test around updateAccessory with polling of pg_stat_activity until the update backend is confirmed blocked on the row lock. Apply the same synchronization to the second occurrence, then release the holder only after that condition is observed so the test deterministically exercises the lock race.src/test-support/container-test.ts (1)
34-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace
voidwithundefinedin the callback return type.Use
body: () => undefined | Promise<unknown>to satisfylint/suspicious/noConfusingVoidTypewhile preserving Bun test callback compatibility.🤖 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 `@src/test-support/container-test.ts` around lines 34 - 38, Update the callback return type in containerTest from void to undefined, using () => undefined | Promise<unknown>, while preserving the existing Bun test invocation and callback compatibility.Sources: Coding guidelines, Linters/SAST tools
🤖 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
`@docs/solutions/logic-errors/non-exhaustive-union-dispatch-silently-routes-to-wrong-table.md`:
- Around line 77-79: Update the compiler-error fenced block in the documentation
to specify the text language, changing its opening fence to use text while
preserving the existing error output.
In
`@docs/solutions/test-failures/playwright-accessible-name-matches-by-substring.md`:
- Line 67: Update the inline Markdown code span in the documented
accessible-name expression to use double backticks around the complete
JavaScript template literal, preventing nested backticks from breaking Markdown
parsing and resolving MD038.
In `@src/domain/accessories/__tests__/service.test.ts`:
- Around line 418-427: Guard all three database-backed describe suites in
service.test.ts with the existing DATABASE_URL check, replacing each bare
describe declaration while preserving their database setup and test bodies.
Ensure every suite that invokes database helpers in beforeAll is skipped when
DATABASE_URL is unset.
---
Nitpick comments:
In `@src/domain/accessories/__tests__/service.test.ts`:
- Around line 446-459: Update the transaction promise assigned to done so it
immediately has a no-op rejection handler while preserving the original promise
for the later assertion. Apply this in the db.transaction flow around
signalLocked and mayFinish without changing the transaction operations or error
assertion behavior.
- Around line 476-486: Replace the fixed 250 ms sleeps in the interleaving test
around updateAccessory with polling of pg_stat_activity until the update backend
is confirmed blocked on the row lock. Apply the same synchronization to the
second occurrence, then release the holder only after that condition is observed
so the test deterministically exercises the lock race.
In `@src/domain/compatibility/relation.ts`:
- Around line 72-88: Remove the redundant parentIdColumn, firearmIdColumn, and
ordinalColumn declarations from CompatibilityRelation, leaving the inherited
CompatibilityColumns members intact. Keep only the TTable-specific table
property and buildRow method in the interface.
In `@src/test-support/container-test.ts`:
- Around line 34-38: Update the callback return type in containerTest from void
to undefined, using () => undefined | Promise<unknown>, while preserving the
existing Bun test invocation and callback compatibility.
🪄 Autofix
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: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)
Review profile: CHILL
Plan: Pro
Run ID: 46af5fac-be09-4a78-96c6-90d1ccd8f790
📒 Files selected for processing (29)
CONCEPTS.mdapp/(app)/accessories/[id]/page.tsxapp/(app)/accessories/accessory-form.tsxapp/(app)/accessories/attachments-section.tsxapp/(app)/firearms/firearm-form.tsxapp/(app)/magazines/magazine-form.tsxdocs/solutions/logic-errors/a-viewer-relative-read-feeding-a-replace-all-write-destroys-hidden-rows.mddocs/solutions/logic-errors/non-exhaustive-union-dispatch-silently-routes-to-wrong-table.mddocs/solutions/test-failures/a-test-timeout-ends-the-pool-and-cascades-into-unrelated-failures.mddocs/solutions/test-failures/playwright-accessible-name-matches-by-substring.mde2e/accessories-serialized.spec.tssrc/auth/__tests__/accessory-sharing.test.tssrc/auth/visibility.tssrc/backup/__tests__/db-roundtrip.test.tssrc/backup/__tests__/export-service.test.tssrc/backup/__tests__/maintenance.test.tssrc/backup/__tests__/restore-service.test.tssrc/backup/__tests__/routes.test.tssrc/backup/__tests__/write-path-maintenance-guard.test.tssrc/db/inventory-schema.tssrc/domain/accessories/__tests__/attachments.test.tssrc/domain/accessories/__tests__/service.test.tssrc/domain/accessories/attachments.tssrc/domain/accessories/compatibility.tssrc/domain/accessories/service.tssrc/domain/compatibility/__tests__/relation.test.tssrc/domain/compatibility/relation.tssrc/domain/magazines/compatibility.tssrc/test-support/container-test.ts
💤 Files with no reviewable changes (1)
- app/(app)/magazines/magazine-form.tsx
🚧 Files skipped from review as they are similar to previous changes (11)
- src/domain/magazines/compatibility.ts
- src/auth/tests/accessory-sharing.test.ts
- src/domain/accessories/compatibility.ts
- src/domain/accessories/tests/attachments.test.ts
- app/(app)/accessories/[id]/page.tsx
- src/domain/accessories/attachments.ts
- src/auth/visibility.ts
- src/db/inventory-schema.ts
- e2e/accessories-serialized.spec.ts
- app/(app)/accessories/attachments-section.tsx
- app/(app)/accessories/accessory-form.tsx
Both from PR review on #106. MD040: the compiler-error block in the non-exhaustive-union write-up opened a fence with no language; it is `text`. MD038: two spans in the Playwright write-up wrapped a JS template literal in single backticks and escaped the inner ones, which Markdown parses wrong and renders with visible backslashes. Double backticks let the inner backticks stand as content. Swept both files this branch added today for the same two classes rather than waiting for another round; they were already clean. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
|
@coderabbitai review |
|
A green review-bot check and a clean unresolved-thread count are not evidence that a PR was reviewed or that its findings were surfaced. Both signals misled me on #106, in four distinct ways: - CodeRabbit's check reported "pass — Review completed" while its reviews were auto-paused for rapid commits. It had read nothing. Triggering one explicitly then produced three findings, one a real data-integrity bug. - A later trigger was rate-limited, so the newest commit went unreviewed. - A reviews-API row stamped with the head SHA was cited as proof the head had been reviewed. It had an empty body — a thread reply, not a review pass. GitHub stamps replies with whatever the head is at reply time. - CodeRabbit files some findings in review *bodies* ("outside diff range", "nitpick"). Those never become resolvable threads, so a 0-unresolved count concealed a silent data-loss bug and later a missing row lock. The doc gives the commands that distinguish the cases — check bodylen before trusting a reviews row, read bodies as well as threads — and the general question worth asking of any automated reviewer: what does its success signal report when it did nothing? Also refines two CONCEPTS.md entries. Accessory Compatibility said reads are viewer-relative; this branch made writes viewer-relative too, so the glossary understated the rule. Magazine Compatibility gets the same correction — one shared core, one rule, so they cannot drift. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Summary
An owner can now treat a suppressor the way they actually own one: a serialized item that declares which of their hosts it fits, carries its own mounts and pistons, and can be lent to someone else on its own — even while it sits unmounted in the safe.
That last part reverses a decision #8 shipped deliberately. Under the old model an accessory had no shares of its own and was visible only through the firearm it happened to be attached to, so an unmounted can was invisible to everyone but its owner. Exactly backwards for the item most likely to be lent, co-owned through a trust, or shown to an armorer.
Issue #23 says the schema "has no home for them" and that
grant.parent_typeisin (firearm, magazine). Both were stale — #8 already shipped theaccessoryparent withserial_numberandis_nfa, andammowas already a grant type. Three of the four asks were genuinely new; the parent record was not. This evolves the shipped entity rather than adding a parallelsuppressorone.How visibility resolves now
An accessory is reachable three ways, and the requester holds the strongest permission any path grants:
Keeping the third path is why this is additive rather than a swap, and there is an explicit no-regression test for it.
Design decisions
typeandcategoryboth stay, with different jobs.typeis the controlled, CHECK-backed discriminator that decides which subtype's rules apply and is what future per-type detail tables (optics) will key off.categorystays free text and becomes optional, because #8 was right that the long tail — bipod, red dot mount — must not be forced into an enum. Collapsing them would have flattened real values toother."Fits" and "mounted on" are different facts. A can fits five hosts and is mounted on at most one. The existing
current_firearm_idkeeps its meaning (and theinstalled_dateand range-session snapshots that depend on it); the newaccessory_firearmjoin carries capability. Tests assert neither write path disturbs the other, in both directions.Delete is gated more tightly than edit. Every other grantable type routes delete through an owner-only gate. #8 nonetheless shipped delete-by-firearm-edit-grantee for accessories, so both rules hold: the inherited path keeps what it had, and the grant path this PR adds does not confer deletion.
The compatibility core is shared, not copied.
magazine_firearmandaccessory_firearmexpress the same relation, and the duplicated block contained the visibility gate that refuses to link a firearm the actor cannot see. Two hand-maintained copies means two places an authorization fix must land. The magazine suite passes untouched, which is the evidence the extraction preserved behavior.That shared core immediately earned its keep. Writes are now viewer-relative too, not just reads. Compatibility reads drop firearms outside the reader's visible set, so the list any editor submits was necessarily built from a filtered view — an editor holding a grant on the accessory but not on one of its hosts is handed a short list and has no way to know it. The original delete-all-then-reinsert read that unavoidable omission as a deletion and silently destroyed links the actor was never shown, with no error and nothing to tell the owner. The replace is now bounded by the actor's visible set: rows outside it survive untouched and keep their ordinals, and the replaced slice is appended after them. "Omission clears" still holds, but only over what the actor could actually see.
Because it landed in the core, magazines are fixed too — they had the same silent loss on a narrower trigger, when a magazine was linked to a cross-owner firearm whose grant was later revoked. This changes shipped magazine save behavior, but as a data-loss fix rather than an interface change, so it is not marked breaking.
Migration 0022 — read before deploying
The one statement here that is not ordinary DDL is a hand-written backfill that maps existing free-text
categoryonto the newtype. It runs once against real owner data and cannot be re-run.Know what the
accessory_type_validCHECK does and does not protect: its value list is hand-copied from the same constant the backfill'sWHEREclause is, so it validates set membership, not mapping correctness. A value that drifts out of the set aborts the whole migration — fail-safe. A value mapped to the wrong member of the set commits silently. DiffSELECT lower(trim(category)), count(*) FROM accessory GROUP BY 1either side of the deploy to confirm.Recovery is a forward fix, not a rollback:
categoryis never rewritten, so the source data survives a bad mapping. That property has its own test.Validation
just ci-checkgreen: 1254 unit/integration tests, 51 Playwright e2e.updateAccessoryas an edit grantee saving an unrelated field.just ci-checkhad been failing in a different subset ofsrc/backupon each run while every one of those files passed in isolation. Root cause was bun's 5s default timeout against suites that start their own Postgres: a timeout is not contained, soafterAllends the pool underneath the still-running body and the rest of the file cascades into "Cannot use a pool after calling end on the pool", naming the wrong tests. Fixed with a sharedcontainerTesthelper imported aliased so all 78 call sites stay byte-identical. This was noise I dismissed as flaky infrastructure twice before running it down.Session-settled decisions carried from planning: evolve the shipped
accessorywith atypediscriminator rather than adding asuppressortable (user-directed, over a dedicated table); accessories become independently shareable while retaining inherited visibility (user-approved, over inherit-only);categorysurvives as optional free text alongsidetype(user-approved, over dropping it); all nine units land in one PR (user-approved, over splitting by layer).Scope note: this branch also carries
6b76c9f, a Mermaid VS Code tooling doc unrelated to #23 (.github/copilot-instructions.mdplus.github/instructions/mermaid.instructions.md, 92 added lines, no runtime effect). Since the repo squash-merges, it folds into this commit. Keeping it — it is additive editor tooling that nothing depends on, and rewriting the branch to excise 92 lines of docs costs more than it returns. Called out here so the squashed commit is honest about its contents rather than silently carrying them.Deferred, with an issue rather than a residual: CodeRabbit flagged a CWE-367 TOCTOU window between the permission check and the write, then agreed it is not this PR's — the pattern predates #23 and spans every grant-gated mutation. Filed as #107 with the three options and a recommendation, so the finding outlives the review thread.
New concepts
Strongest-path permission resolution
What it is. When an item is reachable through more than one independent grant path, the resolver evaluates every path and takes the highest-ranked result, instead of returning the first path that matches.
Why here. The obvious alternative — check paths in a fixed order and return the first hit — silently downgrades. A user with a
viewgrant on the accessory andediton the firearm it is mounted to would land onviewpurely because that path was checked first. Ordering would become load-bearing, and the bug it produces is invisible until someone complains they cannot edit something they own access to.When not to use it. Only when paths are genuinely independent grants of the same right. If one path is a restriction rather than a grant — a deny rule, a maintenance-mode freeze, a legal hold — the strongest-wins union is exactly wrong, and the restriction has to gate the whole resolution instead.
Fixes #23