11#!/usr/bin/env bun
22
33/**
4- * One-off repair for `user_table_rows.order_key` rows mis-ordered by the
5- * collation bug fixed in migration 0228.
4+ * One-off repair for DUPLICATE `user_table_rows.order_key` values.
65 *
7- * Fractional `order_key`s are base-62 strings the fractional-indexing library
8- * compares BYTEWISE (ASCII: `0-9 < A-Z < a-z`). Before migration 0228 the column
9- * compared under the database's `en_US.UTF-8` locale, where lowercase interleaves
10- * with/precedes uppercase ("a0" < "Zz", the opposite of bytewise). Keys minted in
11- * that window were anchored to the wrong neighbors, so a table's keys can be
12- * out of order — or duplicated — under bytewise comparison. That makes inserts
13- * throw `generateKeyBetween`'s `a >= b` assertion and rows display out of order.
6+ * Fractional `order_key`s must be unique within a table: they're the authoritative
7+ * row order under `TABLES_FRACTIONAL_ORDERING`, and `generateKeyBetween` throws
8+ * `a >= b` if a neighbor lookup ever returns a key equal to the anchor. Some tables
9+ * accumulated duplicate keys — e.g. a batch insert that minted the same key for many
10+ * rows, or keys written before the collation fix in migration 0228. This script
11+ * finds every table with duplicate keys and re-keys it, minting a fresh DISTINCT run
12+ * with `nKeysBetween` while preserving the current display order.
1413 *
15- * This script finds every table whose ` order_key`s are mis-assigned: walking rows
16- * in their authoritative `position, id` order, a row's key is `>=` the next row's
17- * under bytewise (`COLLATE "C"`) comparison. That covers swapped keys (a low
18- * position holding a bytewise-larger key than a higher one) and duplicates. Each
19- * flagged table is re-keyed from `position` order — the legacy authoritative order
20- * the original backfill also used — minting a fresh, evenly-spaced, distinct run
21- * with `nKeysBetween` .
14+ * Ordering matters. Rows are re-keyed in `( order_key, id)` order — exactly how the
15+ * app sorts them under the flag (`order_key` authoritative, `id` as the tiebreak
16+ * duplicates currently fall back to). We deliberately do NOT re-key by `position`:
17+ * with the fractional flag on, `position` is only an append counter (a row inserted
18+ * in the middle gets a mid-range key but the largest `position`), so re-keying by
19+ * `position` would scramble the real order. Run AFTER migration 0228 so `order_key`
20+ * sorts bytewise (`COLLATE "C"`), matching the library and the app .
2221 *
23- * Distinct from `backfill-table-order-keys.ts`, which keys tables with NULL keys;
24- * this one repairs tables that are fully keyed but bytewise-disordered. Run it
25- * AFTER migration 0228 so the re-key writes and sorts under `COLLATE "C"` .
22+ * Distinct from `backfill-table-order-keys.ts`, which keys rows with NULL keys. This
23+ * one only touches tables that have actual duplicates — a table whose keys merely
24+ * disagree with `position` (normal for flag-on middle-inserts) is left alone .
2625 *
27- * Per-table-atomic: each table is re-keyed inside one transaction holding the
28- * same per-table advisory lock the app uses for inserts, so a concurrent insert
29- * can't interleave. Idempotent: a table whose keys are already distinct and
30- * ordered is never selected, so a re-run after a partial failure is safe.
26+ * Per-table-atomic: each table is re-keyed inside one transaction holding the same
27+ * per-table advisory lock the app uses for inserts, so a concurrent insert can't
28+ * interleave. Idempotent: a table with no duplicate keys is never selected, so a
29+ * re-run after a partial failure is safe. `--dry-run` sizes without locking or
30+ * writing.
3131 *
3232 * Usage:
3333 * DATABASE_URL=... bun run apps/sim/scripts/repair-table-order-key-collation.ts
@@ -64,39 +64,29 @@ export async function runRepair(): Promise<void> {
6464 const stats = { tables : 0 , tablesKeyed : 0 , rowsKeyed : 0 , failed : 0 }
6565
6666 try {
67- // Tables with a bytewise (`COLLATE "C"`) inversion or duplicate among their
68- // non-null keys. Walking rows in their authoritative `position, id` order (the
69- // order the re-key writes), a healthy table has strictly INCREASING keys; flag
70- // any table where a row's key is `>=` the next row's. Ordering by `position`
71- // (not by `order_key`) is what makes this detect actual mis-assignment — e.g.
72- // pos 0 holding "a0" while pos 1 holds "Zz" (bytewise "Zz" < "a0") — and not
73- // just adjacent duplicates. The explicit `COLLATE "C"` keeps the comparison
74- // bytewise whether or not migration 0228 has been applied yet.
67+ // Tables that have at least one duplicate `order_key`. This is the only genuine
68+ // corruption: distinct keys that merely disagree with `position` are normal
69+ // under the flag and must NOT be touched.
7570 const pending = await db . execute < { table_id : string } > ( sql `
7671 SELECT DISTINCT table_id FROM (
77- SELECT
78- table_id,
79- order_key,
80- LEAD(order_key) OVER (
81- PARTITION BY table_id ORDER BY position, id
82- ) AS next_key
72+ SELECT table_id
8373 FROM user_table_rows
8474 WHERE order_key IS NOT NULL
85- ) t
86- WHERE next_key IS NOT NULL AND order_key COLLATE "C" >= next_key COLLATE "C"
75+ GROUP BY table_id, order_key
76+ HAVING count(*) > 1
77+ ) d
8778 ` )
8879
8980 console . log (
90- `Repair starting — ${ pending . length } table(s) with mis-ordered keys${ dryRun ? ' [DRY RUN]' : '' } `
81+ `Repair starting — ${ pending . length } table(s) with duplicate keys${ dryRun ? ' [DRY RUN]' : '' } `
9182 )
9283
9384 for ( const { table_id : tableId } of pending ) {
9485 stats . tables += 1
9586 try {
9687 if ( dryRun ) {
9788 // Sizing only — count outside any transaction/lock so we never serialize
98- // live inserts on the table (taking the advisory lock just to count would
99- // make the dry run the opposite of safe).
89+ // live inserts on the table.
10090 const [ row ] = await db
10191 . select ( { rowCount : sql < number > `count(*)` . mapWith ( Number ) } )
10292 . from ( userTableRows )
@@ -112,11 +102,14 @@ export async function runRepair(): Promise<void> {
112102 await trx . execute (
113103 sql `SELECT pg_advisory_xact_lock(hashtextextended(${ `user_table_rows_pos:${ tableId } ` } , 0))`
114104 )
105+ // Read in the app's display order — `order_key` (bytewise via COLLATE "C"
106+ // after migration 0228), `id` as the duplicate tiebreak — so the fresh run
107+ // preserves exactly what users currently see, minus the duplication.
115108 const rows = await trx
116109 . select ( { id : userTableRows . id } )
117110 . from ( userTableRows )
118111 . where ( eq ( userTableRows . tableId , tableId ) )
119- . orderBy ( asc ( userTableRows . position ) , asc ( userTableRows . id ) )
112+ . orderBy ( asc ( userTableRows . orderKey ) , asc ( userTableRows . id ) )
120113
121114 if ( rows . length === 0 ) return 0
122115 const keys = nKeysBetween ( null , null , rows . length )
0 commit comments