Files
simstudioai--sim/apps/sim/scripts/repair-table-order-key-collation.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

170 lines
7.0 KiB
TypeScript

#!/usr/bin/env bun
/**
* One-off repair for `user_table_rows.order_key` rows mis-ordered by the
* collation bug fixed in migration 0228.
*
* Fractional `order_key`s are base-62 strings the fractional-indexing library
* compares BYTEWISE (ASCII: `0-9 < A-Z < a-z`). Before migration 0228 the column
* compared under the database's `en_US.UTF-8` locale, where lowercase interleaves
* with/precedes uppercase ("a0" < "Zz", the opposite of bytewise). Keys minted in
* that window were anchored to the wrong neighbors, so a table's keys can be
* out of order — or duplicated — under bytewise comparison. That makes inserts
* throw `generateKeyBetween`'s `a >= b` assertion and rows display out of order.
*
* This script finds every table whose `order_key`s are mis-assigned: walking rows
* in their authoritative `position, id` order, a row's key is `>=` the next row's
* under bytewise (`COLLATE "C"`) comparison. That covers swapped keys (a low
* position holding a bytewise-larger key than a higher one) and duplicates. Each
* flagged table is re-keyed from `position` order — the legacy authoritative order
* the original backfill also used — minting a fresh, evenly-spaced, distinct run
* with `nKeysBetween`.
*
* Distinct from the `0001_backfill_table_order_keys` script migration
* (`packages/db/script-migrations/`), which keys tables with NULL keys;
* this one repairs tables that are fully keyed but bytewise-disordered. Run it
* AFTER migration 0228 so the re-key writes and sorts under `COLLATE "C"`.
*
* Per-table-atomic: each table is re-keyed inside one transaction holding the
* same per-table advisory lock the app uses for inserts, so a concurrent insert
* can't interleave. Idempotent: a table whose keys are already distinct and
* ordered is never selected, so a re-run after a partial failure is safe.
*
* Usage:
* DATABASE_URL=... bun run apps/sim/scripts/repair-table-order-key-collation.ts
* DATABASE_URL=... bun run apps/sim/scripts/repair-table-order-key-collation.ts --dry-run
*/
import { userTableRows } from '@sim/db/schema'
import { getErrorMessage } from '@sim/utils/errors'
import { asc, eq, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { nKeysBetween } from '@/lib/table/order-key'
/** Keeps each VALUES list well under Postgres's 65535-bound-param and JS call-stack ceilings. */
const WRITE_CHUNK_SIZE = 5000
export async function runRepair(): Promise<void> {
const dryRun = process.argv.includes('--dry-run')
const connectionString = process.env.DATABASE_URL ?? process.env.POSTGRES_URL
if (!connectionString) {
console.error('Missing DATABASE_URL or POSTGRES_URL')
process.exit(1)
}
const client = postgres(connectionString, {
prepare: false,
idle_timeout: 20,
connect_timeout: 30,
max: 5,
onnotice: () => {},
})
const db = drizzle(client)
const stats = { tables: 0, tablesKeyed: 0, rowsKeyed: 0, failed: 0 }
try {
// Tables with a bytewise (`COLLATE "C"`) inversion or duplicate among their
// non-null keys. Walking rows in their authoritative `position, id` order (the
// order the re-key writes), a healthy table has strictly INCREASING keys; flag
// any table where a row's key is `>=` the next row's. Ordering by `position`
// (not by `order_key`) is what makes this detect actual mis-assignment — e.g.
// pos 0 holding "a0" while pos 1 holds "Zz" (bytewise "Zz" < "a0") — and not
// just adjacent duplicates. The explicit `COLLATE "C"` keeps the comparison
// bytewise whether or not migration 0228 has been applied yet.
const pending = await db.execute<{ table_id: string }>(sql`
SELECT DISTINCT table_id FROM (
SELECT
table_id,
order_key,
LEAD(order_key) OVER (
PARTITION BY table_id ORDER BY position, id
) AS next_key
FROM user_table_rows
WHERE order_key IS NOT NULL
) t
WHERE next_key IS NOT NULL AND order_key COLLATE "C" >= next_key COLLATE "C"
`)
console.log(
`Repair starting — ${pending.length} table(s) with mis-ordered keys${dryRun ? ' [DRY RUN]' : ''}`
)
for (const { table_id: tableId } of pending) {
stats.tables += 1
try {
if (dryRun) {
// Sizing only — count outside any transaction/lock so we never serialize
// live inserts on the table (taking the advisory lock just to count would
// make the dry run the opposite of safe).
const [row] = await db
.select({ rowCount: sql<number>`count(*)`.mapWith(Number) })
.from(userTableRows)
.where(eq(userTableRows.tableId, tableId))
stats.tablesKeyed += 1
stats.rowsKeyed += row.rowCount
console.log(` ${tableId}: would re-key ${row.rowCount} rows`)
continue
}
const keyed = await db.transaction(async (trx) => {
// Serialize with concurrent inserts on this table (same lock the app uses).
await trx.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))`
)
const rows = await trx
.select({ id: userTableRows.id })
.from(userTableRows)
.where(eq(userTableRows.tableId, tableId))
.orderBy(asc(userTableRows.position), asc(userTableRows.id))
if (rows.length === 0) return 0
const keys = nKeysBetween(null, null, rows.length)
// Chunked UPDATE … FROM (VALUES …) mapping id → key (see WRITE_CHUNK_SIZE).
for (let start = 0; start < rows.length; start += WRITE_CHUNK_SIZE) {
const chunk = rows.slice(start, start + WRITE_CHUNK_SIZE)
const values = sql.join(
chunk.map((r, i) => sql`(${r.id}, ${keys[start + i]})`),
sql`, `
)
await trx.execute(sql`
UPDATE user_table_rows AS t
SET order_key = v.order_key
FROM (VALUES ${values}) AS v(id, order_key)
WHERE t.id = v.id AND t.table_id = ${tableId}
`)
}
return rows.length
})
stats.tablesKeyed += 1
stats.rowsKeyed += keyed
console.log(` ${tableId}: re-keyed ${keyed} rows`)
} catch (error) {
stats.failed += 1
console.error(` ${tableId}: FAILED — ${getErrorMessage(error)}`)
}
}
const verb = dryRun ? 'to re-key' : 're-keyed'
console.log(`Repair complete.${dryRun ? ' [DRY RUN — no rows written]' : ''}`)
console.log(` tables scanned: ${stats.tables}`)
console.log(` tables ${verb}: ${stats.tablesKeyed}`)
console.log(` rows ${verb}: ${stats.rowsKeyed}`)
console.log(` failed: ${stats.failed}`)
if (stats.failed > 0) process.exitCode = 1
} finally {
await client.end({ timeout: 5 }).catch(() => {})
}
}
if ((import.meta as { main?: boolean }).main) {
try {
await runRepair()
} catch (error) {
console.error('Repair aborted:', getErrorMessage(error))
process.exitCode = 1
}
}