feat(pinballmap): tie guard for duplicate same-title cabinets (PP-o355.15) - #1798
feat(pinballmap): tie guard for duplicate same-title cabinets (PP-o355.15)#1798timothyfroehlich wants to merge 4 commits into
Conversation
…5.15)
PBM's POST /location_machine_xrefs is find-or-create on (location_id,
machine_id), so two PinPoint machines matched to one catalog title cannot each
own a distinct lmx at our location. Migration 0052 mirrors that with a partial
UNIQUE on pinballmap_machine_id WHERE pinballmap_listed.
resolveListingHolder() is the single source of the rule, so auto-link
(PP-o355.20) consumes it rather than re-deriving it — two implementations would
drift and the index would start rejecting writes one of them believed legal.
1. An existing listing is knowledge, so the incumbent wins outright. We
created that lmx, so we know whose it is: no tie, however many same-title
cabinets share its availability, and even if its own availability has
drifted into an invalid one (that is a §6 hard flag, not an ambiguity).
2. Nobody listed → drop availabilities that make Listed invalid, rank the
rest by MACHINE_PRESENCE_RANK, and either return the sole best or a tie.
A tie only ever means WE decline to choose. No banner, no hidden actions, no
paused syncing — a tie with nobody listed is indistinguishable from "not
listed", and that invisibility is the point. findListingTies() exposes the
reportable condition for the admin dashboard (PP-o355.7).
The 23505 backstop turned out to be two real defects, both reproduced by the
new integration test before fixing:
- `machines` has TWO unique constraints and both raise 23505, so
createMachineAction answered a duplicate-listing collision with "Initials
are already taken" — an actively wrong diagnosis for a request whose
initials were fine.
- updateMachineAction had no 23505 catch at all, so the same collision
surfaced as a generic 500.
Both now name the cabinet that already holds the listing.
getPostgresErrorConstraint() reads BOTH driver spellings, which is load-bearing
rather than defensive: postgres-js (production) exposes `constraint_name`
(postgres/src/connection.js:46) while PGlite (every integration test) exposes
`constraint`. Reading one spelling passes one environment and silently fails
the other — the first cut of this fix went green in unit tests and dead in the
integration suite for exactly that reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…g them `check:rule-ids` bans the bare "rule N" shape outright — it cannot distinguish a local enumeration from a fragile AGENTS.md rule-number citation, and per its own docstring banning the pattern is the only sound gate. My comments numbered the two branches of resolveListingHolder "Rule 1" / "Rule 2" and tripped it. Naming them INCUMBENT and OPEN CONTEST is better regardless: it says what the branch decides rather than where it sits in a list, and removes any chance of reading them as citations of project rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo
There was a problem hiding this comment.
Pull request overview
Adds a single, reusable rule for choosing (or declining to choose) which PinPoint machine “holds” a PinballMap listing when multiple cabinets share the same catalog title, and hardens machine write paths to correctly diagnose PinballMap listing unique-index collisions (instead of misreporting them as initials conflicts or surfacing 500s).
Changes:
- Introduces
resolveListingHolder()/findListingTies()to centralize the tie/selection rule for same-title cabinets. - Adds
getPostgresErrorConstraint()to distinguish which unique constraint triggered a 23505 across both postgres-js and PGlite driver shapes. - Updates machine create/update actions to return a specific, user-actionable VALIDATION error on duplicate PinballMap listing collisions, plus new unit + integration coverage.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/integration/pinballmap-listing-conflict.test.ts | New PGlite integration coverage for the 23505 backstop and correct error messaging. |
| src/lib/pinballmap/listing-holder.ts | Implements the pure selection/tie rule for determining a listing holder among same-title cabinets. |
| src/lib/pinballmap/listing-holder.test.ts | Unit tests covering incumbent/candidate/tie/none outcomes and fleet-wide tie reporting. |
| src/lib/db/postgres-errors.ts | Adds a constraint-name extractor that works across both postgres-js and PGlite error shapes. |
| src/lib/db/postgres-errors.test.ts | Unit tests for constraint-name extraction and for distinguishing multiple 23505 sources. |
| src/app/(app)/m/actions.ts | Differentiates duplicate-listing collisions from initials collisions and returns actionable VALIDATION results. |
… the guard Review findings on #1798, all verified before acting. Extract the 23505 recognition + message into ~/lib/pinballmap/listing-conflict so all four write paths share one implementation. They had drifted into three different answers for the same failure, which is exactly what one source prevents: - createMachineAction / updateMachineAction: fixed in the previous commit. - linkPinballmapEntryAction returned err("SERVER", …) with a generic string, and its own comment deferred the fix to this bead. A duplicate lister is a user-fixable condition — unlist the other cabinet — so SERVER made the UI treat it as a crash. Now VALIDATION, naming the incumbent. - The verify/heal transaction sets pinballmapListed: true with NO catch at all. Cabinets A and B share a title, A is listed and holds the lmx, B is linked but unlisted; verifying B heals it onto A's lmx and the partial unique index rejects it — a 500 in the very duplicate-title case this bead exists for, and the one write path the first commit missed. Also stop claiming a guard that is not running. resolveListingHolder has no production caller yet — it lands ahead of PP-o355.20 on purpose, because auto-link may not set listed = true until the rule exists — but the comments described it in the present tense as "the primary defence" and the 23505 catch as a mere last resort. At head that catch is the ONLY defence. Both the module docblock and the integration-test header now say so. Two doc defects from the previous commit: PBM_LISTED_UNIQUE had been inserted inside createMachineAction's JSDoc (so the constant was documented as "Create Machine Action" and the action's block began mid-sentence), and the driver-asymmetry warning in postgres-errors.ts sat in its own block above the real JSDoc, orphaned — invisible to editor hover, which is the one place it most needed to appear. Merged into the attaching block. Filed PP-o355.29 (P1) for the finding deliberately left out of scope: pinballmapListed is client-settable, so a form post can record a machine as listed with no lmx and no PBM write, consuming the unique slot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo
… constraint Self-review of the previous commit. Routing linkPinballmapEntryAction and the verify/heal transaction through isPbmListingConflict narrowed them from "any 23505" to "23505 whose constraint_name matches" — and getPostgresErrorConstraint returns undefined whenever a driver drops the field. That would send a correctable condition back to being a 500, which is the exact failure the previous commit set out to remove, and it contradicts the caveat in postgres-errors.ts telling callers to keep a general fallback. Both of these writes touch only the listing columns, so the one-lister index is the only unique constraint they can violate and the bare code is the correct test. The narrow check stays where it is genuinely needed: create/update, which can violate EITHER the initials unique or the listing index and must tell them apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo
Scope grew after the first review — summary of what landed sinceThree commits after
Recognition + message extracted to Also in
Review statusCopilot returned the quota-limited placeholder on head
CI green, threads resolved, no UI files touched (no screenshots needed). —Claude-PbmListUnlist |
Summary
Server-side only, no UI. Unblocks PP-o355.20 (auto-link), which may not set
listed = trueuntil the rule deciding which cabinet may hold a listing exists.resolveListingHolder()is the single exported source of that rule, so auto-link consumes it rather than re-deriving it — two implementations would drift, and the partial unique index would start rejecting writes one of them believed were legal.pending_arrival,removed), rank the rest byMACHINE_PRESENCE_RANK, return either the sole best or a tie descriptor.A tie only ever means we decline to choose. No banner, no hidden actions, no paused syncing — a human clicking "list this one" is the disambiguation. A tie with nobody listed renders identically to "not listed", and that invisibility is the point (refresher §7).
findListingTies()exposes the reportable condition for the admin dashboard (PP-o355.7) without building any page here.The 23505 backstop was two real defects, not one
Both reproduced by the new integration test before being fixed:
machineshas two unique constraints and both raise SQLSTATE 23505, socreateMachineActionanswered a duplicate-listing collision with "Initials are already taken" — an actively wrong diagnosis for a request whose initials were fine and unique.updateMachineActionhad no 23505 catch at all, so the same collision surfaced as a generic 500.Both now name the cabinet that already holds the listing.
One thing worth a careful look
getPostgresErrorConstraint()reads both driver spellings, and that is load-bearing rather than defensive:constraint_name—node_modules/postgres/src/connection.js:46maps wire fieldn.constraint.Reading one spelling passes one environment and silently fails the other. The first cut of this fix did exactly that — green in unit tests, dead in the integration suite — which is how the asymmetry was found. Both are now covered by explicit unit tests.
Test Plan
resolveListingHolder/findListingTiesacross the incumbent/no-incumbent × tie/no-tie matrix, plus degenerate inputs — written before the implementation.getPostgresErrorConstraint, including both driver spellings and the two-constraints-one-SQLSTATE case.updateMachineActionreturns VALIDATION not a 500, and a genuine initials collision still reports as an initials problem (regression guard).pnpm run preflight— typecheck, lint, 2024 unit tests, build, 518 integration tests all pass.Note on smoke E2E: preflight's Mobile Safari smoke reports pre-existing
responsive-overflowfailures on/m/[initials]tab routes. Bisected to confirm they are not from this branch (which touches no UI) and not a regression from #1762 —87552bad, the commit before #1762 merged, fails worse (3 failed vs 1 failed + 2 flaky today). Filed as PP-h490, including the reason nobody noticed: Mobile Safari smoke runs only in the post-merge, non-requiredtest-e2e-comprehensivejob.Related Issues
PP-o355.15 (epic PP-o355). Unblocks PP-o355.20.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo