fix(share): order swap delete-before-update and distinguish unique violations

Addresses review on PR #227.

- setAlias confirmed-reassign branch: DELETE the target page's existing
  alias row(s) BEFORE retargeting `byName` onto the page, instead of after.
  The new partial unique index `(workspace_id, page_id)` is non-deferrable
  and checked at each statement, so retargeting first momentarily left two
  rows for the page -> immediate 23505 -> rolled-back tx surfaced as a
  misleading "Alias already taken" (regressing a previously-working swap onto
  a page that already had its own alias). The reordered branch needs no
  trailing self-heal. JSDoc updated to describe the real ordering.

- catch block: the postgres@3.x driver exposes the violated index as
  `err.constraint_name` (with `.constraint` as a fallback). Map
  `share_aliases_workspace_id_alias_unique` -> "Alias already taken" and the
  new `share_aliases_workspace_id_page_id_unique` -> a distinct ALIAS_PAGE_RACE
  outcome (a concurrent same-page write, not a name clash). Always log the
  constraint name on any 23505 so the race is diagnosable.

- migration 20260627T120000: document that the dedup DELETE is intended,
  irreversible data loss (old duplicate `/l/<old>` links start 404ing after
  upgrade; `down()` cannot restore the rows). Same note added to CHANGELOG
  [Unreleased] Fixed.

Tests:
- integration: confirmed reassign onto a page that ALREADY has its own alias
  (RED before the reorder); migration up() dedup scoping across pages and a
  second workspace; mid-transaction error -> BadRequest with clean rollback.
- unit: constraint_name distinguishing (alias index, page_id index, fallback
  `.constraint`, no-info default) and non-unique error -> BadRequest; retarget
  test now asserts delete-before-update order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-27 02:49:48 +03:00
parent 9d2bec8eb8
commit e682bbccd1
5 changed files with 323 additions and 22 deletions
@@ -12,6 +12,13 @@ import { type Kysely, sql } from 'kysely';
* `(workspace_id, page_id)`. It is partial (`WHERE page_id IS NOT NULL`) so that
* multiple DANGLING aliases (target page deleted -> `page_id` SET NULL) can
* still coexist without colliding.
*
* ⚠️ IRREVERSIBLE DATA LOSS (intended): the dedup DELETE below permanently drops
* every alias row but the newest per page. Those duplicates were live `/l/<old>`
* pointers (resolved by name via `findByAliasAndWorkspace`, not by page), so
* after this upgrade any such OLD vanity link starts returning the SPA 404. This
* is the point — it kills the orphan rows the pre-invariant bug accumulated —
* but `down()` only drops the unique index; it CANNOT restore the deleted rows.
*/
export async function up(db: Kysely<any>): Promise<void> {
// Reap legacy duplicates: for each (workspace_id, page_id) keep only the row