diff --git a/server/db/index.ts b/server/db/index.ts index d26f96987..831a1e6a2 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -4,7 +4,20 @@ import { type Logger } from "drizzle-orm/logger"; import postgres from "postgres"; import { env } from "@/config/env"; -import * as schema from "@/server/db/schema"; +import * as schemaExports from "@/server/db/schema"; + +// The schema barrel re-exports four tables under camelCase aliases for app-code +// ergonomics (`post_votes as postVotes`, etc.). Those alias keys point at the +// SAME table object as their snake_case originals, so handing the whole module +// namespace to drizzle puts two keys per table into its tableNamesMap. The +// collision makes drizzle attach each join table's relations to the alias key +// and leave the canonical key with none, silently breaking relational queries +// like `with: { tags }` / `with: { votes }` ("not enough information to infer +// relation posts.tags"). Strip the aliases so each table reaches drizzle once; +// app code still imports them from the schema barrel unchanged. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const { postVotes, commentVotes, postTags, feedSources, ...schema } = + schemaExports; /** * Cache the database connection in development. This avoids creating a new connection on every HMR diff --git a/server/db/relations.test.ts b/server/db/relations.test.ts new file mode 100644 index 000000000..450edab89 --- /dev/null +++ b/server/db/relations.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +// Regression guard for the tableNamesMap collision: the schema barrel re-exports +// a few tables under camelCase aliases (postVotes/postTags/...), and feeding both +// keys to drizzle dropped the join tables' relations — which broke `with: { tags }` +// / `with: { votes }` on /feed.xml ("not enough information to infer relation +// posts.tags"). server/db/index.ts strips the aliases before handing the schema +// to drizzle; these tests fail if that regresses. +describe("posts relational config", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let db: any; + + beforeAll(async () => { + process.env.SKIP_ENV_VALIDATION = "1"; + process.env.DATABASE_URL ??= "postgres://user:pass@localhost:5432/test"; + // Dynamic import so the env stubs above land before the module reads them. + ({ db } = await import("@/server/db")); + }); + + it("registers relations on all four aliased join tables (no collision)", () => { + const schema = db._.schema; + // All four tables that have a camelCase alias re-export must keep their + // relations registered under the canonical snake_case key. + expect(Object.keys(schema.post_tags.relations)).toEqual( + expect.arrayContaining(["post", "tag"]), + ); + expect(Object.keys(schema.post_votes.relations)).toContain("post"); + expect(Object.keys(schema.comment_votes.relations)).toContain("comment"); + expect(Object.keys(schema.feed_sources.relations)).toContain("user"); + }); + + it("builds relational queries that walk the previously-broken relations", () => { + expect(() => + db.query.posts + .findMany({ + columns: { title: true }, + with: { + author: { columns: { username: true } }, // used by /feed.xml + tags: { with: { tag: true } }, // post_tags + votes: true, // post_votes + source: true, // feed_sources (one) + }, + limit: 1, + }) + .toSQL(), + ).not.toThrow(); + }); +}); diff --git a/styles/globals.css b/styles/globals.css index dadd11d1c..a7041570c 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -161,6 +161,32 @@ body { outline-offset: 2px; } +/* @tailwindcss/forms paints a hardcoded blue (#2563eb) focus ring + border on + every form control. Recolour both to the brand accent so input focus matches + the mint focus language used everywhere else (links/buttons already get a mint + :focus-visible ring). Unlayered, so it reliably overrides the plugin's + @layer base styles. The plugin's thin ring (shown on :focus, pointer and + keyboard) is kept as the focus indicator — only the colour changes. */ +[type="text"]:focus, +[type="email"]:focus, +[type="url"]:focus, +[type="password"]:focus, +[type="number"]:focus, +[type="date"]:focus, +[type="datetime-local"]:focus, +[type="month"]:focus, +[type="search"]:focus, +[type="tel"]:focus, +[type="time"]:focus, +[type="week"]:focus, +[multiple]:focus, +input:not([type]):focus, +textarea:focus, +select:focus { + --tw-ring-color: rgb(var(--color-accent)); + border-color: rgb(var(--color-accent)); +} + /* ── Eyebrow / kicker: the signature mono "// label" device ── */ .eyebrow { @apply font-mono text-xs uppercase tracking-label text-accent;