---
title: Harden share-link round-trip and shorten-URL hygiene
type: task
status: completed
description: >-
  Close round-trip gaps for rank_aggregation/scatter_plot_options/disable_scatter_plot in the
  share-link URL pipeline, add error handling to handle_shareable_link, return 400 from the
  shorten-URL POST on canonicalize failures, and drop the redundant urls_url_key UNIQUE constraint
  to remove a latent PostgreSQL btree key-size cliff.
base_uri: user:task/league/harden-share-link-roundtrip-and-shorten-url-hygiene.md
created_at: '2026-05-11T03:09:36.760Z'
entity_id: 15b89e39-e07c-4382-865b-443350b5d8c9
finished_at: '2026-05-11T15:51:29.913Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[████████████████████-█████████-███-███-███-███-████████]]
started_at: '2026-05-11T15:05:24.011Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-06-03T04:08:43.887Z'
visibility_analyzed_at: '2026-11-05T23:49:36.358Z'
---

# Implementation Plan: Harden share-link round-trip and shorten-URL hygiene

## Overview

- Close round-trip gaps in the share-link / shorten-URL pipeline so changes to `rank_aggregation`, `scatter_plot_options`, and `disable_scatter_plot` flow through the URL like every other `table_state` field.
- Fix the state-pipeline defect that strips `disable_scatter_plot` on every table-state mutation.
- Make the share-link button degrade gracefully on shorten-API failure instead of getting stuck on "Generating Link".
- Stop the shorten-URL POST from silently storing dead-end chained-URL rows when the inner hash can't be resolved.
- Remove a latent PostgreSQL btree key-size cliff on `urls.url` by dropping the redundant `urls_url_key` UNIQUE constraint and relying on the existing `urls_url_hash_key` UNIQUE for deduplication.
- Establish a single declarative manifest in `react-table` so the writer and parser cannot drift on which `table_state` fields participate in URLs.

## Background

Predecessor: [[████████████████████-█████████-███-███-███-███-████████]] (completed 2026-05-06) replaced the 302-redirect resolution path with a JSON API + SPA route and introduced idempotent shortening for canonical URLs that carry a `view_id`. That work fixed delivery; this work fixes round-trip data integrity on the same surface.

Findings (review pass against `react-table/src/table-menu/table-menu.js`, `react-table/src/table/table.js`, `league/app/core/data-views/parse-table-state-from-url.mjs`, `league/app/views/pages/short-url-resolver/short-url-resolver.js`, `league/app/views/pages/data-views/data-views.js`, `league/app/views/pages/plays/plays.js`, `league/api/routes/shorten-url.mjs`, `league/libs-shared/resolve-short-url-chain.mjs`, `league/db/schema.postgres.sql`):

- B1. `handle_shareable_link` (`react-table/src/table-menu/table-menu.js:124-159`) serializes only `columns`, `prefix_columns`, `sort`, `where`, `splits`, `q`. The fields `rank_aggregation`, `scatter_plot_options`, `disable_scatter_plot` exist in `table_state` (grep `table_state\.` across react-table) but are never written to URL params. Any change confined to those fields produces an unchanged URL and re-shortening returns the same hash because of the idempotency property in `api/routes/shorten-url.mjs`.
- B2. `parse_table_state_from_url` (`league/app/core/data-views/parse-table-state-from-url.mjs:9-32`) does not read the same fields back. Independently, it does not parse `q` even though the writer emits it. (Note: `q` is new to the parser and to the data-views/plays dispatch sites only — the writer has emitted `q` since before this task; the asymmetry is part of B2's scope.)
- B3. Writer reads `selected_view.search`, parser reads `view_search_column_id`. The two halves can never round-trip through the same field name; `selected_view.search` is effectively always undefined in production so the `search` param has never been written. Asymmetry rather than a live bug, but a footgun.
- B4. `on_table_state_change` (`react-table/src/table/table.js:194-263`) destructures `sort, prefix_columns, columns, where, rank_aggregation, splits, q, scatter_plot_options` and does not include `disable_scatter_plot`. The field is stripped on every state mutation regardless of URL serialization.
- B6. `handle_shareable_link` has no try/catch around the shorten call. On network or 4xx/5xx failure, `set_link_state('Copied Link')` is never reached, the 2-second reset is never scheduled, and the button stays on "Generating Link" with no fallback to copying the long URL.
- B8. `canonicalize_short_url` (`league/api/routes/shorten-url.mjs:14-35`) swallows `resolve_short_url_chain` failures (`inner_hash_not_found`, `short_url_cycle`, `short_url_max_depth_exceeded`) and stores the raw chained URL. The cleanup script `scripts/canonicalize-chained-shorten-urls.mjs` exists specifically because this has produced dead-end rows in production.
- B12 (preemptive mitigation). The `urls` schema has both `urls_url_key UNIQUE (url)` and `urls_url_hash_key UNIQUE (url_hash)`. PostgreSQL btree index entries are capped at approximately 2700 bytes; long stored URLs (the prior task documented 26 KB Location-header inflation, implying stored URLs of similar size) sit at or beyond that limit. The `url_hash` UNIQUE already guarantees deduplication because `get_blake2b_hash` is deterministic; the `url` UNIQUE is redundant and the source of the cliff.

Scope decisions (per Phase 1 questionnaire):

- In scope: B1, B2, B3, B4, B6, B8, B12 preemptive mitigation, plus the manifest-decoupling refactor that prevents B1/B2 recurrence.
- Out of scope: B5 (legacy URLs without `view_id` regenerate fresh ids on every load — accepted as a known limitation), B7 (`has_table_state` heuristic narrowness — no reported failure), B9-B11 (no current impact), B13 (defensive note only).

## Design

### High-level approach

Workstreams grouped by domain. The react-table workstreams must complete and publish before the league consumer update; server hygiene + schema is independent and may run in parallel with the rest.

- **Workstream — Manifest:** declarative URL-field manifest in `react-table` (`SHARE_LINK_URL_SCHEMA` constant) consumed by writer and a new exported parser helper. Fixes B1, B2 at the root and prevents recurrence.
- **Workstream — State pipeline:** add `disable_scatter_plot` to the destructure and rebuild in `on_table_state_change`. Fixes B4.
- **Workstream — Share-link UX:** try/catch around the shorten call with long-URL clipboard fallback and a `'Copy Link Failed'` transient state. Fixes B6.
- **Workstream — Consumer update (league):** replace inline parser with a thin wrapper around the new react-table export that applies `migrate_entries_array` / `migrate_sort_array`. Update the three URL-load sites (`data-views.js`, `plays.js`, `short-url-resolver.js`) to dispatch the new fields. Extend `has_table_state` heuristics to recognize the new fields so URLs carrying only `rank_aggregation` / `scatter_plot_options` / `disable_scatter_plot` still hydrate. Pick `view_search_column_id` as canonical and remove the dead `search` branch from the writer. Fixes B3.
- **Workstream — Server hygiene + schema:** propagate canonicalize errors so POST returns 400 on `inner_hash_not_found` / `short_url_cycle` / `short_url_max_depth_exceeded`. Drop `urls_url_key` UNIQUE constraint; switch `onConflict` target to `url_hash`. Fixes B8, B12.

### Proposed structure

`react-table`:

- `src/constants.mjs`: add
  ```js
  export const SHARE_LINK_URL_SCHEMA = {
    table_state: {
      columns: 'array',
      prefix_columns: 'array',
      sort: 'array',
      where: 'array',
      splits: 'array',
      q: 'string',
      rank_aggregation: 'object',
      scatter_plot_options: 'object',
      disable_scatter_plot: 'boolean'
    },
    view: ['view_id', 'view_name', 'view_description', 'view_search_column_id']
  }
  ```
  Per-key type encodes serialization rules and empty-shape defaults (`[]` for `array`, `{}` for `object`, `''` for `string`, `false` for `boolean`). Top-of-file comment: this is the contract between `handle_shareable_link` and any consumer parser. New URL-shareable fields are added here, nowhere else. **Exception for `disable_scatter_plot`:** since the field can be both `true` and `false` meaningfully (saved-view + toggle scenarios), the writer always emits this boolean regardless of value — the empty-shape skip rule applies only to the structural empties (`[]`, `{}`, `''`). The parser still reads `get(key) === 'true'` so an absent key parses to `false` (matching the safe-table-state default in `react-table/src/validators/index.mjs:101`).
- `src/table-menu/table-menu.js`: `handle_shareable_link` iterates `SHARE_LINK_URL_SCHEMA.table_state` keys, branches on the declared type (`array`/`object` → `JSON.stringify`, `string` → raw, `boolean` → `String(value)`), and skips values equal to the empty-shape default. Iterates `SHARE_LINK_URL_SCHEMA.view` as strings (skip when falsy). Wrap the shorten call in try/catch; on failure copy the long URL and set a transient `'Copy Link Failed'` state.
- `src/table/table.js`: add `disable_scatter_plot` to the `on_table_state_change` destructure (line 195) and to the rebuilt `table_state` object (line 249).
- `src/utils/parse-url-params-to-table-state.js` (new): exported helper. Returns `{ table_state, view_fields }`. For each key in `SHARE_LINK_URL_SCHEMA.table_state`, branches on declared type: `array`/`object` → `JSON.parse(get(key) || 'null')` with empty-shape fallback (`[]` or `{}`); `string` → raw `.get(key) || ''`; `boolean` → `.get(key) === 'true'`. For each key in `SHARE_LINK_URL_SCHEMA.view`, raw `.get(key) || ''`. The two-key shape exists so consumers can re-flatten according to their own dispatch payload conventions (league's `parse_table_state_from_url` flattens to a flat object — see league section).
- `index.js`: re-export `parse_url_params_to_table_state` and `SHARE_LINK_URL_SCHEMA` as named exports.
- `test/share-link-round-trip.spec.js` (new): three suites — (1) round-trip for representative `table_state` covering every field in `SHARE_LINK_URL_SCHEMA.table_state`, asserting serialize + parse is identity and empty-shape values are omitted from URL output; (2) `disable_scatter_plot` preserved across an `on_table_state_change` cycle; (3) `handle_shareable_link` falls back to long-URL clipboard when `shorten_url` rejects (mocked).
- `package.json`: version bump.

`league`:

- `app/core/data-views/parse-table-state-from-url.mjs`: rewritten. Calls `parse_url_params_to_table_state(search_params)` from `react-table` to get `{ table_state, view_fields }`. Applies `migrate_entries_array` over `table_state.columns` / `prefix_columns` / `where` and `migrate_sort_array` over `table_state.sort`. **Re-flattens to a single flat object** with keys `{ columns, prefix_columns, where, sort, splits, q, rank_aggregation, scatter_plot_options, disable_scatter_plot, view_id, view_name, view_description, view_search_column_id }` so every existing destructure call site (data-views.js, plays.js, short-url-resolver.js) continues to work unchanged in shape — only new keys are added to the destructures. Also: replace the existing `'../../../libs-shared/data-views-nfl-week-migration.mjs'` relative import with `@libs-shared/data-views-nfl-week-migration.mjs` per `[[user:guideline/write/write-javascript.md]]`.
- `app/views/pages/data-views/data-views.js`: URL-load effect dispatches the extended fields in `table_state` and `saved_table_state`. Extend the `has_table_state` heuristic (line 112) to also fire when any of `rank_aggregation`, `scatter_plot_options`, or `disable_scatter_plot` carries non-empty data, so URLs containing only those fields hydrate correctly.
- `app/views/pages/plays/plays.js`: same dispatch extension. `rank_aggregation` / `scatter_plot_options` / `disable_scatter_plot` apply only if the plays reducer accepts them — at edit time, grep `plays_view_changed` and `PLAYS_VIEW_CHANGED` under `app/core/plays/` to determine which fields the reducer reads, and include only those in the dispatch.
- `app/views/pages/short-url-resolver/short-url-resolver.js`: mirror the data-views and plays extensions in both branches. Same conditional logic for plays.
- `api/routes/shorten-url.mjs`:
  - `canonicalize_short_url` lets `resolve_short_url_chain` errors propagate. Drop the try/catch.
  - `router.post('/?'')` outer try/catch maps `inner_hash_not_found`, `short_url_cycle`, `short_url_max_depth_exceeded` to `res.status(400).json({ error: <code> })`.
  - `db('urls').insert(...).onConflict('url_hash').ignore()` (was `'url'`). **This change must not deploy before the adhoc SQL runs in production** — see Deploy Ordering in Notes.
  - Update the swagger 400 response block to add enumerated examples (`inner_hash_not_found`, `short_url_cycle`, `short_url_max_depth_exceeded`) parallel to the existing `invalid_url` / `invalid_domain` examples.
- `db/adhoc/2026-05-10-drop-urls-url-unique.sql`: `ALTER TABLE urls DROP CONSTRAINT urls_url_key;`
- `db/schema.postgres.sql`: regenerated via `yarn export:schema`.
- `package.json` + `yarn.lock`: bump react-table to the version that exports `SHARE_LINK_URL_SCHEMA` and `parse_url_params_to_table_state`. Run `yarn why react-table` after `yarn install` to verify resolution against the local workspace version, not a cached registry version.
- `test/api-shorten-url.spec.mjs`: add cases — POST returns 400 with `inner_hash_not_found` for unknown inner hash (replace the existing "falls back to storing as-is" case at lines 148-158 which encodes the bug), POST returns 400 for cycle, deduplication still works for identical canonical URL (idempotency regression guard via `url_hash` conflict). The existing chained-URL idempotency test at lines 130-145 is the regression guard for the non-error canonicalization path — it must continue to pass post-refactor.
- `test/data-views-parse-table-state-from-url.spec.mjs`: add cases covering `q`, `rank_aggregation`, `scatter_plot_options`, `disable_scatter_plot`, and `view_search_column_id`. Verify the flat-object return shape (re-flattening from `parse_url_params_to_table_state`'s two-key output).

### Key components and relationships

- `SHARE_LINK_URL_SCHEMA` is the single source of truth for which `table_state` and view-level fields participate in URLs, and what their serialization type is. Writer and parser both consume it.
- `migrate_entries_array` / `migrate_sort_array` stay in league because they encode league-specific NFL-week-id rename history, not URL grammar.
- League's `parse_table_state_from_url` is the shape-adaptation layer: takes the helper's `{ table_state, view_fields }` and re-flattens to the flat object existing consumers destructure.
- Server boundary clarified: `canonicalize_short_url` either returns a known-good canonical URL or fails loudly. No silent dead-end rows.
- `urls_url_hash_key` becomes the sole deduplication mechanism. Hash collision risk at 128 bits is negligible (~2^64 entries before any 50%-collision concern).

### New dependencies / integration points

- No new external dependencies.
- `react-table` consumed via workspace dep from `league`. Implementation order: react-table changes + build + version bump first, then `yarn install` and consumer changes in `league`. Verify with `yarn dev` smoke before final commits.
- **Deploy ordering for the server workstream:** adhoc SQL runs first → server code deploys after. The `onConflict('url_hash')` code path still functions correctly while `urls_url_key` exists (both constraints would be in place), so the ordering is about ensuring the cliff is removed and not leaving a half-applied state if a rollback is needed. Tasks below carry this ordering explicitly.

### Backwards compatibility

- Short URLs created before this change do not carry the new fields. Loading them yields `undefined` for those fields — identical to today's behavior. Strict superset.
- POST 400 on unknown inner hash changes behavior for any caller relying on the prior silent fallback. Per the predecessor task's UA analysis, no scripted callers exist; safe.
- Schema drop is one-way without backfill. `urls_url_hash_key` continues to enforce uniqueness.
- B5 (legacy URLs without `view_id`) remains a documented known limitation.

### Knowledge Base Impact

- Domain entry doc: none required. Plan tracks under `task/league/`.
- Affected ABOUT.md / workflow files: verify `███████████████████████████████████████████` does not claim the share-link surface is round-trip-complete; update if so.
- Tag implications: existing tags suffice (`user:tag/league-xo-football.md`, `user:tag/software-task.md`).
- Observation candidates: on completion add `[architecture] react-table owns SHARE_LINK_URL_SCHEMA manifest; league parser delegates and layers nfl-week-id migrations.` so future field additions discover the contract.
- Guideline candidates: none. Manifest-driven serializer is local to this surface.
- Relations: `relates [[████████████████████-█████████-███-███-███-███-████████]]`.

### Working-tier needs

No multi-file intermediate artifacts. Only source edits, one adhoc SQL file, and test additions. No `scratch/` slug needed.

## Notes

- The B6 fallback (copy long URL on shorten failure) is a behavior change for users whose share-link generation was previously failing silently. The transient `'Copy Link Failed'` state surfaces it.
- The B12 schema change is small but production. The prerequisite measurement query is a sanity check, not a gate — dropping the constraint is safe even at low URL lengths because `url_hash` UNIQUE preserves dedup semantics.
- Cross-repo coordination is the riskiest part. Recommend: react-table feature branch + build, then `yarn link` or direct workspace consumption from a league worktree to validate before publishing the version bump.
- File naming follows snake_case + kebab-case per `[[user:guideline/write/write-javascript.md]]` and `[[user:guideline/write/write-software.md]]`. Imports use webpack aliases on the league frontend (`@core`, `@libs-shared`) and `#` aliases on the league backend (`#libs-shared`, `#db`). React-table uses `#src/*` alias internally.
- **Deploy ordering (cross-ref):** the adhoc SQL drop of `urls_url_key` must run on production before the `onConflict('url_hash')` code deploys — the canonical Tasks ordering for this is in the server workstream header.
- **Direct URL load vs `/u/<hash>` load:** the data-views and plays pages, when loaded directly with `?columns=...` in the address bar, mint a fresh `view_id` via `generate_view_id()`. This is intentional — it ensures a hand-crafted URL doesn't collide with a user's saved view ids. Only `ShortUrlResolver` preserves the URL's `view_id` for re-shortening idempotency on `/u/<hash>` loads. Not in scope to change.
- **B5 known limitation:** legacy short URLs created before commit `131037dc` (predecessor task) lack `view_id` in the stored canonical URL. Each load mints a fresh `view_id`, so re-shortening such a URL produces a different hash than the one being viewed. Deliberately not addressed in this plan.
- **Future cleanup follow-up:** once this plan ships and any straggler dead-end chained rows are purged, the client-side `resolve_short_url_chain` call in `short-url-resolver.js` becomes mostly redundant (the server now rejects new chained POSTs and old chained rows whose inner hash still exists resolve in one step). A separate future task can remove the client-side chain resolver.

## Plan

### Prerequisites

- [x] **Characterize `urls` table row size distribution**
  - Run `SELECT max(length(url)) AS max_url, avg(length(url))::int AS avg_url, count(*) AS row_count FROM urls;` against production via `yarn db:exec` (or read-only psql).
  - Record the three numbers as an observation on this task entity using `base entity observe`.
  - Purpose: confirm the schema change is preemptive (low risk) vs urgent (rows near or above 2700 bytes). Either way the change proceeds; this is a sanity check.

- [x] **Optional pre-deploy cleanup of legacy chained rows**
  - Run `node scripts/canonicalize-chained-shorten-urls.mjs` against production (read-only mode first to inventory, then apply).
  - Purpose: consolidate any straggler chained-URL rows that pre-date server-side canonicalization. The B8 change does not strictly require this — existing chained rows whose inner hash is in the DB still resolve via client-side `resolve_short_url_chain` on the SPA; ones whose inner hash is unknown were already broken (client 404). This step is hygiene that simplifies the future cleanup of client-side chain resolution noted in Notes. Skip if the script reports no chained rows.

### Core Implementation — react-table

- [x] **Add `SHARE_LINK_URL_SCHEMA` constant**
  - File: `repository/active/react-table/src/constants.mjs`
  - Export `SHARE_LINK_URL_SCHEMA` with the per-key type shape documented in the Design (`table_state: { columns: 'array', prefix_columns: 'array', sort: 'array', where: 'array', splits: 'array', q: 'string', rank_aggregation: 'object', scatter_plot_options: 'object', disable_scatter_plot: 'boolean' }` and `view: ['view_id', 'view_name', 'view_description', 'view_search_column_id']`).
  - Top-of-section comment: contract between the share-link writer and any URL-params parser. Each `table_state` key declares its serialization type, which drives the empty-shape default (`[]` / `{}` / `''` / `false`). Adding a URL-shareable field happens here, nowhere else.

- [x] **Add `parse_url_params_to_table_state` helper**
  - File: `repository/active/react-table/src/utils/parse-url-params-to-table-state.js` (new, `.js` extension consistent with sibling `src/utils/*.js` files)
  - Pure function. Input: a `URLSearchParams` instance. Output: `{ table_state, view_fields }` (two-key shape). For each key in `SHARE_LINK_URL_SCHEMA.table_state`, branch on declared type: `array` → `JSON.parse(get(key) || 'null') || []`; `object` → `JSON.parse(get(key) || 'null') || {}`; `string` → `get(key) || ''`; `boolean` → `get(key) === 'true'`. For each key in `SHARE_LINK_URL_SCHEMA.view`, `get(key) || ''`.
  - Snake_case named function; under 200 lines (trivially). Single positional `URLSearchParams` argument (no named-parameter destructuring — the function has only one input).
  - **Malformed JSON behavior (pinned by spec):** if `JSON.parse` throws on a corrupted array/object param, catch the error per-field and fall back to the empty-shape default (`[]` / `{}`). This matches the existing league parser behavior (`JSON.parse(... || 'null') || []`) and keeps a single bad param from blowing up the page. The new test suite includes a malformed-JSON case asserting this fallback.

- [x] **Re-export new symbols**
  - File: `repository/active/react-table/index.js`
  - Add named exports for `SHARE_LINK_URL_SCHEMA` and `parse_url_params_to_table_state`.

- [x] **Rewrite `handle_shareable_link` to iterate the manifest + add error handling**
  - File: `repository/active/react-table/src/table-menu/table-menu.js`
  - Replace the hand-rolled destructure + `if/append` block (lines 125-141) with iteration over `SHARE_LINK_URL_SCHEMA.table_state` (type-aware serialize) and `SHARE_LINK_URL_SCHEMA.view` (string).
  - Skip values matching the empty-shape default (`[]`, `{}`, `''`, `false`) to keep URLs compact.
  - Wrap the shorten path (lines 146-153) in try/catch. On failure: copy the long URL to clipboard, set `link_state` to `'Copy Link Failed'`, and schedule the existing 2s reset. Log to console.
  - Remove the dead `search` branch (writer-side B3 fix).

- [x] **Preserve `disable_scatter_plot` in `on_table_state_change`**
  - File: `repository/active/react-table/src/table/table.js`
  - Add `disable_scatter_plot` to the destructure at line 195-204 and to the rebuilt `table_state` object at 249-258.

- [x] **Round-trip and behavior specs**
  - File: `repository/active/react-table/test/share-link-round-trip.spec.js` (new file; no existing test covers this surface).
  - Cases:
    - For a `table_state` with every field in `SHARE_LINK_URL_SCHEMA.table_state` populated (arrays with content, objects with content, non-empty string for `q`, `disable_scatter_plot: true`), build params via the same iteration `handle_shareable_link` uses, then parse via `parse_url_params_to_table_state`, assert deep equality.
    - Empty-shape values (`[]`, `{}`, `''`, `false`) are omitted from the generated URL.
    - `on_table_state_change` preserves `disable_scatter_plot` through a mutation cycle.
    - `handle_shareable_link` falls back to long URL when `shorten_url` rejects (mocked), and `link_state` transitions to `'Copy Link Failed'`.

- [x] **Build and version bump**
  - Run `yarn lint && yarn build` in `repository/active/react-table`.
  - Bump version in `package.json` per the repo's existing semver discipline (patch — no public API removal, additive).
  - Commit changes.

### Core Implementation — league

- [x] **Bump react-table dep**
  - File: `repository/active/league/package.json` + `yarn.lock`
  - Update react-table version to the just-built one. Run `yarn install`.
  - Run `yarn why react-table` and confirm the resolution shows the new local workspace version, not a cached registry version. Commit both `package.json` and `yarn.lock` together.

- [x] **Refactor `parse_table_state_from_url`**
  - File: `repository/active/league/app/core/data-views/parse-table-state-from-url.mjs`
  - Replace inline `JSON.parse(search_params.get(...) || 'null') || []` block with a call to `parse_url_params_to_table_state(search_params)` from react-table, which returns `{ table_state, view_fields }`.
  - Apply `migrate_entries_array` over `table_state.columns` / `prefix_columns` / `where`; apply `migrate_sort_array` over `table_state.sort`.
  - **Re-flatten the two-key helper output to a single flat object** with keys `{ columns, prefix_columns, where, sort, splits, q, rank_aggregation, scatter_plot_options, disable_scatter_plot, view_id, view_name, view_description, view_search_column_id }`. This preserves the existing call-site contract; only new keys are added to consumer destructures. **Do not return the two-key shape directly — it would break every consumer.**
  - **`view_fields` re-flatten guidance:** the helper returns empty strings (`''`) for absent view-level fields. Pass these through verbatim into the flat object — do not convert to `undefined`. Consumers already treat falsy as absent (`view_id || generate_view_id()` etc.); preserving the empty-string shape is the contract.
  - Replace the existing relative import `'../../../libs-shared/data-views-nfl-week-migration.mjs'` with `@libs-shared/data-views-nfl-week-migration.mjs` per `[[user:guideline/write/write-javascript.md]]`.

- [x] **Update data-views URL-load dispatch + heuristic**
  - File: `repository/active/league/app/views/pages/data-views/data-views.js`
  - Include `q`, `rank_aggregation`, `scatter_plot_options`, `disable_scatter_plot` in both `table_state` and `saved_table_state` in the `data_view_changed` payload (lines 117-144).
  - Extend the `has_table_state` heuristic (line 112-113). Today: `columns.length || where.length || (prefix_columns.length && sort.length)`. After: also true when `Object.keys(rank_aggregation || {}).length`, `Object.keys(scatter_plot_options || {}).length`, or `disable_scatter_plot === true`. Without this, URLs containing only the new fields are silently dropped.

- [x] **Update plays URL-load dispatch**
  - File: `repository/active/league/app/views/pages/plays/plays.js`
  - At edit time, grep `plays_view_changed` and `PLAYS_VIEW_CHANGED` under `repository/active/league/app/core/plays/` (and the plays reducer/saga files) to identify which `table_state` keys the plays reducer accepts. Include in the dispatch only the new fields the reducer reads; omit ones plays does not consume.
  - Extend the plays page's own `has_table_state` heuristic in the same way as data-views, scoped to fields plays actually uses. **Single-rule invariant:** the heuristic must gate exactly the set of fields the dispatch includes — no heuristic-only field (dead branch) and no dispatch-only field (silently dropped on URL-only loads). Verify before committing.

- [x] **Update ShortUrlResolver dispatch**
  - File: `repository/active/league/app/views/pages/short-url-resolver/short-url-resolver.js`
  - Mirror the data-views and plays dispatch extensions in both branches of the resolver. Mirror the `has_table_state` heuristic extension in line 56-59.

- [x] **Parser spec extensions**
  - File: `repository/active/league/test/data-views-parse-table-state-from-url.spec.mjs`
  - Add cases for `q`, `rank_aggregation`, `scatter_plot_options`, `disable_scatter_plot`, `view_search_column_id`.
  - Verify the flat-object return shape (re-flattening from `parse_url_params_to_table_state`'s two-key output) so the test catches any future regression where someone returns the wrapper shape.
  - Verify the migration calls (`migrate_entries_array`, `migrate_sort_array`) still run on `columns`, `prefix_columns`, `where`, `sort` after the rewrite.

### Core Implementation — server

**Ordering:** within this workstream, run tasks in the listed order. The adhoc SQL ("Drop redundant UNIQUE constraint") must run on production before the code deploy that ships the `onConflict('url_hash')` change. Code with both constraints in place still works correctly, but running the adhoc first is the supported sequence and avoids a half-applied rollback state.

- [x] **Drop redundant UNIQUE constraint (run first on production)**
  - New file: `repository/active/league/db/adhoc/2026-05-10-drop-urls-url-unique.sql`
  - Content: `ALTER TABLE urls DROP CONSTRAINT urls_url_key;`
  - Run via `yarn db:exec db/adhoc/2026-05-10-drop-urls-url-unique.sql` against production **before** the server code change deploys.
  - Regenerate schema via `yarn export:schema`.
  - Commit the adhoc file + the schema diff.

- [x] **Propagate canonicalize failures as 400**
  - File: `repository/active/league/api/routes/shorten-url.mjs`
  - Drop the try/catch inside `canonicalize_short_url`. Let `resolve_short_url_chain` errors propagate.
  - In the POST handler outer try/catch, map known error messages (`inner_hash_not_found`, `short_url_cycle`, `short_url_max_depth_exceeded`) to `res.status(400).json({ error })`. Other errors continue to map to 500.
  - Update the swagger 400 response block to add enumerated examples (`inner_hash_not_found`, `short_url_cycle`, `short_url_max_depth_exceeded`) parallel to the existing `invalid_url` / `invalid_domain` examples — each as a distinct entry in the `examples` map so the API surface is self-documenting.

- [x] **Switch `onConflict` target (deploys after adhoc SQL)**
  - File: `repository/active/league/api/routes/shorten-url.mjs`
  - `db('urls').insert({ url, url_hash }).onConflict('url_hash').ignore()` (was `'url'`).
  - Deploy only after the schema drop has run in production.

- [x] **API spec updates**
  - File: `repository/active/league/test/api-shorten-url.spec.mjs`
  - Replace the existing "falls back to storing as-is when inner hash is unknown" case (lines 148-158) with `POST returns 400 with error: 'inner_hash_not_found'`.
  - Add case for `short_url_cycle` — construct a chained URL whose inner hash points to another chained URL pointing back. Server should return 400 with `error: 'short_url_cycle'`.
  - Add a regression guard: re-POSTing the same canonical URL twice still returns the same `url_hash` (deduplication via `onConflict('url_hash')`).
  - The existing chained-URL idempotency test at lines 130-145 is the regression guard for the non-error canonicalization path; verify it continues to pass after the try/catch removal in `canonicalize_short_url`.
  - Update the existing idempotency test if it relies on the old `'url'` conflict target.

### Validation

- [x] **Local round-trip smoke**
  - Run `yarn test --reporter min` in both repos. All passing.
  - Run `yarn dev` in league, navigate to a `/u/<hash>` URL containing `rank_aggregation`, adjust the rank-aggregation config, click Copy Link, paste, confirm the new hash differs from the loaded one and that loading the new hash hydrates the modified `rank_aggregation`.
  - Repeat with `scatter_plot_options` and `disable_scatter_plot`.
  - **`has_table_state` heuristic check:** construct a URL with only `rank_aggregation` (no columns, no where), navigate to it directly via `?rank_aggregation=...`, and confirm the dispatch fires and the view hydrates. Repeat for `scatter_plot_options` and `disable_scatter_plot`. If any of these silently no-op, the heuristic extension was not applied correctly.
  - Confirm B6 path: stop the API mid-click and observe the button fall back to copying the long URL with a `'Copy Link Failed'` state.

- [x] **Production smoke post-deploy**
  - `curl -X POST -H 'Content-Type: application/json' -d '{"url":"https://xo.football/u/deadbeef000000000000000000000000?columns=%5B%5D"}' https://xo.football/api/u` returns 400 with `error: 'inner_hash_not_found'`.
  - `curl -X POST` of a known-good canonical URL twice returns identical `url_hash` (deduplication via `url_hash` conflict).
  - Loading an existing pre-fix `/u/<hash>` URL (e.g., `6d55932c3e6cf47ac3a37e317f3ae4b0`) still resolves correctly — backwards compat regression check.

### Knowledge Base Updates

- [x] **Verify and possibly update `workflow/debug-data-view.md`**
  - File: `███████████████████████████████████████████`
  - Check if any phase claims share-link round-trip integrity. Update language if so. No edit required if absent.

- [x] **Record completion observations**
  - On completion add to this task entity:
    - `[architecture] react-table owns SHARE_LINK_URL_SCHEMA manifest; league parser delegates and layers nfl-week-id migrations.`
    - `[architecture] urls table dedup is via url_hash UNIQUE only; urls_url_key dropped to remove btree key-size cliff.`
    - `[limitation] Legacy short URLs without view_id (pre-131037dc) still mint a fresh view_id on every load; not addressed here.`
