---
title: Migrate persisted saved-view column ids on read
type: task
status: completed
description: >-
  Saved views persisted in user_data_views were never passed through migrate_table_state on read —
  only localStorage snapshots and short URLs were — so a renamed column id stranded until someone
  ran the one-shot script, and the SPA silently dropped the column on render. Closed by migrating
  every persisted table_state as it enters the store.
base_uri: user:task/league/data-views/migrate-saved-view-column-ids-on-read.md
created_at: '2026-08-30T00:42:13.223Z'
entity_id: db1c4ffd-bc36-48a4-a2be-1d43cddd3f13
finished_at: '2026-08-30T04:02:40.654Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-08-30T04:48:03.804Z'
visibility_analyzed_at: '2026-11-05T23:48:54.515Z'
---

# Migrate persisted saved-view column ids on read

<task>Close the surface gap that lets a saved view in `user_data_views` keep a renamed column id indefinitely, so the SPA drops that column on render and raises `Field not found for column_id` as a log_error signal.</task>

## Mechanism (closed 2026-08-30, deployed in 02076fbf6)

`libs-shared/data-views-saved-view-migration.mjs` declares COLUMN_ID rules for the `saved_view`, `local_storage` and `short_url` surfaces. Only two of the three were applied at read time:

- **local_storage** — `libs-shared/data-view-storage/migrations.mjs` runs the versioned chain (`v1_to_v2` calls `migrate_table_state`).
- **short_url** — `app/core/data-views/parse-table-state-from-url.mjs` imports `apply_column_id_rename`.
- **saved_view** — nothing. `migrate_table_state` appeared nowhere in `app/`, and `libs-server/get-data-view-results.mjs` imports only `build_param_key_rewrite` and `RATE_TYPE_RENAMES`, so the server rewrote PARAM keys on the query boundary but never column ids.

A server-persisted saved view was therefore migrated only by running `scripts/migrate-data-views-saved.mjs --apply` by hand, and that step was wired to nothing. The SPA's reselect `mapProps` at `app/views/pages/data-views/index.js:61` does not throw despite the error text: it calls `report_error` and `continue`s, so the column is dropped from the render and the view loads without it. The user silently loses a column, and the only trace is the `log_error` signal.

Now closed: `app/core/data-views/reducer.js` and `app/core/plays-view/reducer.js` migrate every persisted `table_state` as it enters the store — both fetch paths, the save response, and a restored browser snapshot.

## Evidence

Signals 127159 and 127160 (2026-08-28 18:58Z, user 134, view `be781a5d-57fc-4ff0-9d4c-8a1dc5c7fa51`). The 2026-08-17 counting-stat conform moved `pass_yds`/`rush_yds` in `projected-table-fields.js`; commit a7e6a8d23 added the reverse COLUMN_ID rules the same day, but the one-shot was never run against production, so the row still held `player_season_projected_pass_yards` and `player_season_projected_rush_yards` eleven days later while its neighbours (`rec_yds`, `pass_atts`, `rush_tds`) resolved normally.

Verified locally: `migrate_table_state` on that exact `table_state` returns `changed: true` and rewrites both ids to `_yds` spellings that resolve in `data_view_fields_index`.

## Plan

- [x] Apply the column-id migration on the saved-view read path. Landed in both view reducers rather than only `app/core/data-views/reducer.js` — `user_plays_views` had the identical hole on its server-fetch path, and `RESTORE_DATA_VIEW_TABLE_STATE` needed it too, since the versioned localStorage chain skips a snapshot already at `STORAGE_SCHEMA_VERSION`.
- [x] Decide write-back. **Read-only**, no write-back: a view hydrated from a shared `/u/<hash>` link can carry a foreign owner, and the `save_data_view` saga forks any such view into a new row, so an automatic write-back would mint duplicate views for readers of other people's links. The one-shot script stays the durable rewrite.
- [x] Add the reachability gate — `test/data-views.saved-view-column-id-read-coverage.spec.mjs`, driving the reducers rather than `migrate_table_state`, with its rule population derived from `RENAME_REGISTRY`. Shown red before being trusted: 10 of its 13 assertions fail when the migration is stubbed to identity, and the 3 that stay green are the negative controls.
- [x] Backfill — run against production 2026-08-30: 33 of 189 views rewritten, re-run reports `changed_count 0`.
- [x] Deploy — `02076fbf6` to both hosts 2026-08-30. Required `deploy:all` rather than a frontend-only deploy, because `libs-server/get-data-view-results.mjs` imports the same module.

## Open, recorded but not fixed

Neither blocks the closure; both are on the observation log with their reasoning.

- `scripts/migrate-data-views-saved.mjs` was reported to hang after `db.destroy()`. Not reproducible in four configurations, and no cause found in our code. The one untested configuration is `--apply` against production performing real writes.
- `migrate_entries_array` renames a `sort` entry only when its `column_id` also appears in `columns`/`prefix_columns`/`where`, so a legacy id living in `sort` alone survives migration. Pre-existing on all three surfaces, no regression here, and the only consequence is an unapplied sort — `find_sort_column` already skips an id not among the selected columns.
