Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion server/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions server/db/relations.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
26 changes: 26 additions & 0 deletions styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading