Skip to content

Commit bc88ac2

Browse files
committed
Merge pull request #9 from xiaolai/post-deploy-fixes
Post-deploy fixes: pin search_vec, add /office/policy to sidebar, doc push hazard
2 parents 7725b70 + f16de06 commit bc88ac2

4 files changed

Lines changed: 115 additions & 14 deletions

File tree

.claude/rules/db-migrations.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Database migrations — never use `drizzle-kit push` against prod
2+
3+
The Neon-backed `web/` app has hand-authored SQL migrations in
4+
`web/src/db/migrations/*.sql`, plus DB-level features (FTS-generated
5+
columns, partial unique indexes, triggers) that **Drizzle's schema
6+
DSL cannot fully represent**. Running `pnpm drizzle-kit push` against
7+
a database with those features will **drop** them, because push
8+
diffs the DB against the schema files and treats anything not in the
9+
schema as something to remove.
10+
11+
This is not theoretical. On 2026-05-06, `drizzle-kit push` was used to
12+
apply migrations 0018–0021 against prod (because `drizzle-kit migrate`
13+
was hanging over the HTTP-only Neon driver). The push **dropped
14+
`submissions.search_vec`** (the FTS-generated column added by
15+
migration `0003_fts`) and the matching `idx_submissions_search_vec`
16+
GIN index, breaking site search until restored manually.
17+
18+
## The rule
19+
20+
For ANY production schema change:
21+
22+
1. **Add a numbered migration file** under
23+
`web/src/db/migrations/<NNNN>_<slug>.sql` with the exact DDL.
24+
2. **Apply it via `psql` or via the Neon dashboard SQL editor**
25+
pointed at the prod connection string. Never push against prod.
26+
3. **Update `_journal.json`** so the migration is recorded.
27+
4. **Mirror the change** in the relevant `web/src/db/schema/*.ts`
28+
file so type inference works. Use `customType` for DB features
29+
Drizzle's DSL can't express (FTS columns, generated columns,
30+
trigger-maintained columns). The schema declaration is the
31+
"leave this alone" signal to push, not the source of truth.
32+
33+
## Why not just `drizzle-kit migrate`?
34+
35+
Migrate uses transactions, which require websockets. The
36+
`@neondatabase/serverless` HTTP driver doesn't support them, so
37+
`migrate` hangs indefinitely under the default config. Switching the
38+
driver for migrations alone is more work than just using `psql`.
39+
40+
If a future contributor needs migrate to work, the path is:
41+
42+
- Set up a websocket-capable Neon connection in a one-off node
43+
script that imports `@neondatabase/serverless` with
44+
`neonConfig.webSocketConstructor = ws` set.
45+
- Use the `migrate()` helper from `drizzle-orm/neon-serverless/migrator`.
46+
47+
But for one-off prod migrations, `psql` is faster and more auditable.
48+
49+
## Existing things push will want to drop
50+
51+
These DB-level features are not represented in
52+
`web/src/db/schema/*.ts` (or are represented as opaque types push
53+
can match against):
54+
55+
- `submissions.search_vec``tsvector GENERATED ALWAYS AS (…) STORED`,
56+
declared as opaque `tsvector` in `schema/content.ts` so push
57+
matches.
58+
- `idx_submissions_search_vec` — GIN index over `search_vec`. Push
59+
will recreate this with the same name once the column is in
60+
schema.
61+
- The `score_after_vote_change` trigger and any other triggers
62+
added by `0001_triggers` and friends — push doesn't manage
63+
triggers, but won't drop them either as long as they're attached
64+
to existing tables.
65+
66+
If you add a new generated column or trigger-maintained column,
67+
update this rule and add the matching `customType` declaration in
68+
the schema.

web/src/app/(reader)/office/policy/page.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import Link from "next/link";
2-
import { ArrowLeft, Cpu, Shield } from "lucide-react";
2+
import { Cpu, Shield } from "lucide-react";
33
import { count, gte, sql } from "drizzle-orm";
44

55
import { db } from "@/db/client";
66
import { policyDecisions } from "@/db/schema";
77
import { POLICY_CATEGORIES } from "@/lib/moderation";
88
import { getActiveSystemPrompt } from "@/lib/moderation/prompt-store";
9+
import { OfficeSidebar } from "@/components/prototype/OfficeSidebar";
910

1011
export const dynamic = "force-dynamic";
1112

@@ -71,16 +72,9 @@ export default async function OfficePolicyPage() {
7172
const { version: activePromptVersion } = await getActiveSystemPrompt();
7273

7374
return (
74-
<div className="proto-page-narrow">
75-
<nav className="office-breadcrumb">
76-
<Link href="/office">
77-
<span className="proto-inline-icon" aria-hidden>
78-
<ArrowLeft size={12} />
79-
</span>{" "}
80-
the office
81-
</Link>
82-
</nav>
83-
75+
<div className="proto-page-aside">
76+
<OfficeSidebar current="policy" />
77+
<div className="proto-page-aside-content">
8478
<header className="proto-section">
8579
<div className="office-persona-head">
8680
<h1>Policy moderation</h1>
@@ -189,6 +183,7 @@ export default async function OfficePolicyPage() {
189183
</li>
190184
</ul>
191185
</section>
186+
</div>
192187
</div>
193188
);
194189
}

web/src/components/prototype/OfficeSidebar.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import Link from "next/link";
2-
import { ScrollText, Volume2, BookOpen } from "lucide-react";
2+
import { ScrollText, Volume2, BookOpen, Shield } from "lucide-react";
33

4-
export type OfficeSidebarPage = "office" | "transparency" | "voice" | "rubric";
4+
export type OfficeSidebarPage =
5+
| "office"
6+
| "transparency"
7+
| "voice"
8+
| "rubric"
9+
| "policy";
510

6-
/** Shared left-rail navigation for the four /office area pages.
11+
/** Shared left-rail navigation for the /office area pages.
712
* Pass the current page key so the active link gets aria-current="page"
813
* (which the .proto-page-aside-nav stylesheet picks up for the accent
914
* border + accent-ink color). */
@@ -36,6 +41,11 @@ export function OfficeSidebar({ current }: { current: OfficeSidebarPage }) {
3641
<BookOpen size={14} aria-hidden /> The rubric
3742
</Link>
3843
</li>
44+
<li>
45+
<Link href="/office/policy" aria-current={ariaCurrent("policy")}>
46+
<Shield size={14} aria-hidden /> Policy moderation
47+
</Link>
48+
</li>
3949
</ul>
4050
</nav>
4151
);

web/src/db/schema/content.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import {
14+
customType,
1415
index,
1516
integer,
1617
jsonb,
@@ -28,6 +29,28 @@ import {
2829
} from "./enums";
2930
import { users } from "./users";
3031

32+
/**
33+
* tsvector custom type for the FTS column on submissions. The
34+
* actual column DDL is `tsvector GENERATED ALWAYS AS (…) STORED`
35+
* (see migration 0003_fts.sql) — Drizzle has no first-class
36+
* support for generated columns, so we declare the column here
37+
* with `tsvector` only and rely on the migration's GENERATED
38+
* clause for population.
39+
*
40+
* Without this declaration, `drizzle-kit push` sees a column that
41+
* exists in the DB but not in the schema and DROPS it (which
42+
* happened on 2026-05-06 when push was used to apply migrations
43+
* 0018–0021 — search_vec went with it). Declaring it here keeps
44+
* push's diff happy.
45+
*
46+
* If push ever tries to "fix" this column by dropping the GENERATED
47+
* clause, that's a bigger problem — switch to migrate / psql for
48+
* production schema changes.
49+
*/
50+
const tsvector = customType<{ data: string; driverData: string }>({
51+
dataType: () => "tsvector",
52+
});
53+
3154
export const submissions = pgTable(
3255
"submissions",
3356
{
@@ -64,6 +87,11 @@ export const submissions = pgTable(
6487
// discriminator if/when both coexist. Null for organic user posts.
6588
submitterKind: submitterKindEnum("submitter_kind").notNull().default("user"),
6689
sourceId: text("source_id"),
90+
// Full-text search column — `tsvector GENERATED ALWAYS AS (…)
91+
// STORED` per migration 0003_fts.sql. Declared here as `tsvector`
92+
// only so drizzle-kit push doesn't see a phantom column to drop.
93+
// The GENERATED clause is owned by the migration, not the schema.
94+
searchVec: tsvector("search_vec"),
6795
},
6896
(t) => [
6997
index("idx_submissions_state_created").on(t.state, t.createdAt.desc()),

0 commit comments

Comments
 (0)