Files
Kevin Codex 869ebb91dc fix(profiles): cap zalgo text, keep tags only in real flags, review leftovers
Deploy blockers (both from 658b50d's cleaning rewrite)
- Length: the grapheme cut alone let one letter carry thousands of combining
  marks ("a" + 3000 x U+0301 saved as a 3,000-character name; a bio kept
  4,001). Each character now keeps at most 4 combining marks and 16 code
  points (the longest emoji, a family with skin tones, is 11), and a field at
  most 4 code points per allowed character. Real diacritics and emoji are
  kept; an emoji at the limit is kept or dropped whole, never split.
- Tags: tag characters survive only inside the three subdivision flags emoji
  have (England, Scotland, Wales). Any other black flag + tags rendered as a
  plain flag while carrying hidden ASCII ("ignoreallprevinstruc" got
  through), the kind of text that steers an LLM reading names or bios.
- Pasted line breaks and tabs in a one-line field become a space instead of
  gluing words together.
- Tests: zalgo name and bio under their caps, fake flags stripped, the three
  real flags unchanged, 👨‍💻 / 🏳️‍🌈 / a skin-tone family at the exact limit,
  and the idempotency fuzz still holds.

Review leftovers
- migrate.mjs: a failed concurrent build names the index and the likely
  cause (55P03: an older transaction is still open, or another session holds
  the table; 57014: past the 20 min timeout). Still exits 1; retrying the
  deploy is safe.
- The availability check takes the form's X handle, so a verified owner's
  claim matches the save's rule (only while keeping that handle).
- The as-sent signature candidate counts only when the fields hold no C0/C1
  controls, bidi marks, overrides or isolates, or line / paragraph
  separators (the bio may keep line breaks); otherwise only the cleaned text
  is accepted.
- approve_x takes each released wallet's profile lock (in wallet order)
  before touching its rows, as that wallet's own verify does.
- deleteProfile's doc says when a name is held (kept a day or more).
2026-10-09 11:47:34 +08:00

125 lines
6.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Apply db/schema.sql to DATABASE_URL inside ONE transaction. The file is
* idempotent (IF NOT EXISTS / CREATE OR REPLACE / ADD COLUMN IF NOT EXISTS), so
* this runs on every deploy as the Fly release_command (fly.toml) and locally
* after `docker run … postgres:16`. Records one bb_migrations row whenever the
* sha256 of the file differs from the last applied one.
*
* The whole file is sent as a single simple-protocol query (`sql.unsafe` with
* no parameters) — Postgres splits the statements itself, so `$$ … $$` function
* bodies never need client-side parsing. Any error rolls everything back and
* exits 1 (Fly then aborts the deploy).
*
* DATABASE_URL=postgres://… node scripts/migrate.mjs
*
* Then db/concurrent-indexes.sql: indexes on the big, busy tables, built one by
* one AFTER the transaction commits with CREATE INDEX CONCURRENTLY, so no read
* or write on those tables waits for a build (inside the transaction a build
* would hold the exclusive lock an ALTER TABLE already took). An index a failed
* build left invalid is dropped and built again. A failed build exits 1 too.
*
* Only depends on `postgres` + node builtins so it runs from the standalone
* image (Dockerfile copies db/ + this file; next.config.ts keeps `postgres`
* external so it is traced into .next/standalone/node_modules).
*/
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { hostname } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import postgres from "postgres";
// Fresh checkouts: pick DATABASE_URL up from .env.local / .env (never overriding a real env var).
if (!process.env.DATABASE_URL) {
const { readFileSync, existsSync } = await import("node:fs");
for (const f of [".env.local", ".env"]) {
if (!existsSync(f)) continue;
const m = readFileSync(f, "utf8").match(/^\s*DATABASE_URL\s*=\s*"?([^"\n#]+)"?/m);
if (m) {
process.env.DATABASE_URL = m[1].trim();
break;
}
}
}
const url = process.env.DATABASE_URL?.trim();
if (!url) {
console.error("migrate: DATABASE_URL is not set");
process.exit(2);
}
const here = path.dirname(fileURLToPath(import.meta.url));
const file = process.env.BASEBID_SCHEMA_FILE || path.resolve(here, "../db/schema.sql");
const text = await readFile(file, "utf8");
const concurrentFile = process.env.BASEBID_CONCURRENT_FILE || path.resolve(path.dirname(file), "concurrent-indexes.sql");
const concurrentText = await readFile(concurrentFile, "utf8").catch(() => "");
// one CREATE INDEX CONCURRENTLY IF NOT EXISTS <name> per statement (comments dropped)
const concurrent = concurrentText
.replace(/--[^\n]*/g, "")
.split(";")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const m = /^CREATE INDEX CONCURRENTLY IF NOT EXISTS (\w+) ON /i.exec(s);
if (!m) throw new Error(`concurrent-indexes.sql: not a CREATE INDEX CONCURRENTLY IF NOT EXISTS statement: ${s.slice(0, 80)}`);
return { name: m[1], sql: s };
});
const hash = createHash("sha256").update(text).update(concurrentText).digest("hex");
const appliedBy = process.env.FLY_IMAGE_REF || process.env.FLY_MACHINE_ID || hostname();
const sql = postgres(url, { max: 1, connect_timeout: 15, idle_timeout: 5, onnotice: () => {} });
const t0 = Date.now();
let exitCode = 0;
try {
const { changed, history, tables } = await sql.begin(async (tx) => {
// an ALTER TABLE takes an exclusive lock even when the column exists: never queue behind a long read (and block
// every read behind us) for more than a few seconds; a failed release just fails the deploy, which can be retried
await tx`SET LOCAL lock_timeout = '10s'`;
await tx.unsafe(text);
const [last] = await tx`select schema_sha256 from bb_migrations order by id desc limit 1`;
const changed = last?.schema_sha256 !== hash;
if (changed) {
await tx`insert into bb_migrations (schema_sha256, applied_by) values (${hash}, ${appliedBy})`;
}
const [{ history }] = await tx`select count(*)::int as history from bb_migrations`;
const [{ tables }] = await tx`
select count(*)::int as tables from information_schema.tables
where table_schema = current_schema() and table_name like 'bb\\_%'`;
return { changed, history, tables };
});
// outside any transaction: each build lets reads and writes go on; a long-held lock elsewhere fails it after 30 s
await sql`SET lock_timeout = '30s'`;
await sql`SET statement_timeout = '20min'`;
for (const ix of concurrent) {
const [state] = await sql`select i.indisvalid as valid from pg_class c join pg_index i on i.indexrelid = c.oid where c.relname = ${ix.name} and c.relnamespace = current_schema()::regnamespace`;
if (state && state.valid) continue;
const t1 = Date.now();
try {
if (state) await sql.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${ix.name}`); // left invalid by a failed build
await sql.unsafe(ix.sql);
} catch (err) {
// say which index and why: the schema transaction is already in, so retrying the deploy is safe
const e = /** @type {{ message?: string; code?: string }} */ (err);
const why =
e?.code === "55P03"
? "waited 30 s for a lock: an older transaction is still open, or another session holds the table (retry the deploy)"
: e?.code === "57014"
? "the build ran past the 20 min statement timeout (retry the deploy)"
: "";
throw Object.assign(new Error(`building ${ix.name} concurrently: ${e?.message ?? err}${why ? ` — ${why}` : ""}`), { code: e?.code });
}
console.log(`migrate: built ${ix.name} concurrently in ${Date.now() - t1}ms`);
}
console.log(
`migrate: ok schema=${hash.slice(0, 12)} ${changed ? "applied (new hash)" : "re-applied (unchanged)"} ` +
`tables=${tables} history=${history} ${Date.now() - t0}ms`,
);
} catch (err) {
const e = /** @type {{ message?: string; position?: string; code?: string }} */ (err);
console.error(`migrate: FAILED ${e?.code ?? ""} ${e?.message ?? err}${e?.position ? ` (at char ${e.position})` : ""}`);
exitCode = 1;
} finally {
await sql.end({ timeout: 5 }).catch(() => {});
}
process.exit(exitCode);