From 37cb113f7ab58af42bca0f810601776009e67d5e Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 14 Jun 2026 14:19:22 +0100 Subject: [PATCH 1/3] fix(db): stop /feed.xml crashing on "cannot infer relation posts.tags" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema barrel re-exports four tables under camelCase aliases for app ergonomics (post_votes as postVotes, etc.). Passing the whole module namespace to drizzle put two keys per table into its tableNamesMap; the collision made drizzle attach each join table's relations to the alias key and leave the canonical key with none. Relational queries that walk a many-relation on those tables (with: { tags }, with: { votes }) then threw "There is not enough information to infer relation posts.tags" — which is exactly what /feed.xml does, so the RSS feed 500'd. Strip the aliases from the schema object handed to drizzle so each table is registered once; app code still imports the aliases from the barrel unchanged. Adds a regression test that builds the /feed.xml query. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/db/index.ts | 15 +++++++++++++- server/db/relations.test.ts | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 server/db/relations.test.ts 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..1cec94e6f --- /dev/null +++ b/server/db/relations.test.ts @@ -0,0 +1,41 @@ +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 the join tables (no alias collision)", () => { + const schema = db._.schema; + expect(Object.keys(schema.post_tags.relations)).toContain("post"); + expect(Object.keys(schema.post_tags.relations)).toContain("tag"); + expect(Object.keys(schema.post_votes.relations)).toContain("post"); + }); + + it("builds the nested relational query used by /feed.xml", () => { + expect(() => + db.query.posts + .findMany({ + columns: { title: true }, + with: { + author: { columns: { username: true } }, + tags: { with: { tag: true } }, + }, + limit: 1, + }) + .toSQL(), + ).not.toThrow(); + }); +}); From c823a39f8e5b272df69ed3e1ac2c2683b069cb53 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 14 Jun 2026 14:19:22 +0100 Subject: [PATCH 2/3] fix(ui): recolour the form-input focus ring from blue to brand accent @tailwindcss/forms paints a hardcoded blue (#2563eb) focus ring and border on every form control, so text inputs lit up blue while links and buttons already used the mint :focus-visible ring. Override the plugin's ring colour and border to the accent token for all form controls, keeping an accessible (now on-brand, thin) focus indicator instead of the off-palette blue. Co-Authored-By: Claude Opus 4.8 (1M context) --- styles/globals.css | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/styles/globals.css b/styles/globals.css index dadd11d1c..a6421723f 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -161,6 +161,31 @@ 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 thin ring doubles as the keyboard focus indicator, so + a11y is preserved — 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, +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; From 3010ae465a0ba764826aeb4b032d9bd6e8b5df72 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 14 Jun 2026 14:23:26 +0100 Subject: [PATCH 3/3] test(db)+fix(ui): broaden relation guard to all 4 aliases; cover typeless inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review hardening: - relations test now asserts comment_votes and feed_sources relations register too (not just post_tags/post_votes), and builds a query walking votes + source — covering all four stripped aliases, not just two. - focus-ring override now also targets bare `input:not([type])`, which @tailwindcss/forms styles but the typed-selector list missed. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/db/relations.test.ts | 19 +++++++++++++------ styles/globals.css | 5 +++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/server/db/relations.test.ts b/server/db/relations.test.ts index 1cec94e6f..450edab89 100644 --- a/server/db/relations.test.ts +++ b/server/db/relations.test.ts @@ -17,21 +17,28 @@ describe("posts relational config", () => { ({ db } = await import("@/server/db")); }); - it("registers relations on the join tables (no alias collision)", () => { + it("registers relations on all four aliased join tables (no collision)", () => { const schema = db._.schema; - expect(Object.keys(schema.post_tags.relations)).toContain("post"); - expect(Object.keys(schema.post_tags.relations)).toContain("tag"); + // 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 the nested relational query used by /feed.xml", () => { + it("builds relational queries that walk the previously-broken relations", () => { expect(() => db.query.posts .findMany({ columns: { title: true }, with: { - author: { columns: { username: true } }, - tags: { with: { tag: true } }, + 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, }) diff --git a/styles/globals.css b/styles/globals.css index a6421723f..a7041570c 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -165,8 +165,8 @@ body { 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 thin ring doubles as the keyboard focus indicator, so - a11y is preserved — only the colour changes. */ + @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, @@ -180,6 +180,7 @@ body { [type="time"]:focus, [type="week"]:focus, [multiple]:focus, +input:not([type]):focus, textarea:focus, select:focus { --tw-ring-color: rgb(var(--color-accent));