Phase 1: generate database types and type the Supabase clients

Types are generated from the live database's API schema by
scripts/gen-types.mjs (npm run gen:types), in the same shape as
supabase gen types, so no Supabase CLI login is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:20:31 +02:00
co-authored by Claude Opus 5.5
parent b134f87326
commit bc8e4f3f5a
6 changed files with 546 additions and 4 deletions
+2 -1
View File
@@ -9,7 +9,8 @@
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"check:rls": "node --env-file=.env.local scripts/check-rls.mjs"
"check:rls": "node --env-file=.env.local scripts/check-rls.mjs",
"gen:types": "node --env-file=.env.local scripts/gen-types.mjs"
},
"dependencies": {
"@supabase/ssr": "^0.12.7",
+140
View File
@@ -0,0 +1,140 @@
// Generates src/lib/database.types.ts from the live database's API schema, in the same
// shape as `supabase gen types typescript` — without needing a Supabase CLI login.
// Run with: npm run gen:types (after every migration)
import { writeFileSync } from "node:fs";
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !serviceKey) {
console.error("Missing Supabase env vars. Fill in .env.local first.");
process.exit(1);
}
// Foreign keys whose constraint name isn't Postgres' default `<table>_<column>_fkey`.
const FK_NAME_OVERRIDES = { "events.current_game_id": "events_current_game_fk" };
// Columns with a DB default that the API schema doesn't report (it omits array defaults).
const EXTRA_DEFAULTS = new Set(["games.placement_points"]);
const response = await fetch(`${url}/rest/v1/`, {
headers: { apikey: serviceKey, Authorization: `Bearer ${serviceKey}` },
});
if (!response.ok) {
console.error(`Could not read the schema: HTTP ${response.status}`);
process.exit(1);
}
const { definitions = {} } = await response.json();
const tables = Object.keys(definitions).sort();
if (tables.length === 0) {
console.error("No tables found. Has the migration been applied?");
process.exit(1);
}
const enums = {};
function tsType(column) {
if (column.enum && column.format?.startsWith("public.")) {
const name = column.format.slice("public.".length);
enums[name] = column.enum;
return `Database["public"]["Enums"]["${name}"]`;
}
if (column.type === "array") return `${tsType(column.items)}[]`;
if (column.format === "json" || column.format === "jsonb") return "Json";
if (column.type === "integer" || column.type === "number") return "number";
if (column.type === "boolean") return "boolean";
return "string";
}
function tableBlock(table) {
const { properties, required = [] } = definitions[table];
const columns = Object.keys(properties).sort();
const row = [];
const insert = [];
const update = [];
const relationships = [];
for (const name of columns) {
const column = properties[name];
const nullable = !required.includes(name);
const type = tsType(column) + (nullable ? " | null" : "");
const optionalOnInsert =
nullable || column.default !== undefined || EXTRA_DEFAULTS.has(`${table}.${name}`);
row.push(` ${name}: ${type};`);
insert.push(` ${name}${optionalOnInsert ? "?" : ""}: ${type};`);
update.push(` ${name}?: ${type};`);
const fk = column.description?.match(/<fk table='(\w+)' column='(\w+)'\/>/);
if (fk) {
const fkName = FK_NAME_OVERRIDES[`${table}.${name}`] ?? `${table}_${name}_fkey`;
relationships.push(
[
" {",
` foreignKeyName: "${fkName}";`,
` columns: ["${name}"];`,
" isOneToOne: false;",
` referencedRelation: "${fk[1]}";`,
` referencedColumns: ["${fk[2]}"];`,
" },",
].join("\n"),
);
}
}
return [
` ${table}: {`,
" Row: {",
...row,
" };",
" Insert: {",
...insert,
" };",
" Update: {",
...update,
" };",
relationships.length ? ` Relationships: [\n${relationships.join("\n")}\n ];` : " Relationships: [];",
" };",
].join("\n");
}
const tableBlocks = tables.map(tableBlock).join("\n");
const enumBlock = Object.keys(enums)
.sort()
.map((name) => ` ${name}: ${enums[name].map((value) => `"${value}"`).join(" | ")};`)
.join("\n");
const output = `// GENERATED by scripts/gen-types.mjs from the live database — do not edit by hand.
// Regenerate with: npm run gen:types
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export type Database = {
public: {
Tables: {
${tableBlocks}
};
Views: {
[_ in never]: never;
};
Functions: {
[_ in never]: never;
};
Enums: {
${enumBlock}
};
CompositeTypes: {
[_ in never]: never;
};
};
};
export type Tables<T extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][T]["Row"];
export type TablesInsert<T extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][T]["Insert"];
export type TablesUpdate<T extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][T]["Update"];
export type Enums<T extends keyof Database["public"]["Enums"]> = Database["public"]["Enums"][T];
`;
writeFileSync(new URL("../src/lib/database.types.ts", import.meta.url), output);
console.log(`Wrote src/lib/database.types.ts (${tables.length} tables, ${Object.keys(enums).length} enums).`);
+398
View File
@@ -0,0 +1,398 @@
// GENERATED by scripts/gen-types.mjs from the live database — do not edit by hand.
// Regenerate with: npm run gen:types
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export type Database = {
public: {
Tables: {
adjustments: {
Row: {
created_at: string;
event_id: string;
game_id: string | null;
id: string;
points: number;
reason: string | null;
team_id: string;
};
Insert: {
created_at?: string;
event_id: string;
game_id?: string | null;
id?: string;
points: number;
reason?: string | null;
team_id: string;
};
Update: {
created_at?: string;
event_id?: string;
game_id?: string | null;
id?: string;
points?: number;
reason?: string | null;
team_id?: string;
};
Relationships: [
{
foreignKeyName: "adjustments_event_id_fkey";
columns: ["event_id"];
isOneToOne: false;
referencedRelation: "events";
referencedColumns: ["id"];
},
{
foreignKeyName: "adjustments_game_id_fkey";
columns: ["game_id"];
isOneToOne: false;
referencedRelation: "games";
referencedColumns: ["id"];
},
{
foreignKeyName: "adjustments_team_id_fkey";
columns: ["team_id"];
isOneToOne: false;
referencedRelation: "teams";
referencedColumns: ["id"];
},
];
};
criteria: {
Row: {
description: string | null;
game_id: string;
id: string;
input_type: Database["public"]["Enums"]["criterion_input"];
label: string;
max_value: number;
min_value: number;
sort_order: number;
step: number;
weight: number;
};
Insert: {
description?: string | null;
game_id: string;
id?: string;
input_type?: Database["public"]["Enums"]["criterion_input"];
label: string;
max_value?: number;
min_value?: number;
sort_order?: number;
step?: number;
weight?: number;
};
Update: {
description?: string | null;
game_id?: string;
id?: string;
input_type?: Database["public"]["Enums"]["criterion_input"];
label?: string;
max_value?: number;
min_value?: number;
sort_order?: number;
step?: number;
weight?: number;
};
Relationships: [
{
foreignKeyName: "criteria_game_id_fkey";
columns: ["game_id"];
isOneToOne: false;
referencedRelation: "games";
referencedColumns: ["id"];
},
];
};
events: {
Row: {
created_at: string;
current_game_id: string | null;
event_date: string | null;
frozen_snapshot: Json | null;
id: string;
name: string;
owner_id: string;
reveal_active: boolean;
reveal_count: number;
scoreboard_token: string;
scoreboard_visibility: Database["public"]["Enums"]["scoreboard_visibility"];
updated_at: string;
};
Insert: {
created_at?: string;
current_game_id?: string | null;
event_date?: string | null;
frozen_snapshot?: Json | null;
id?: string;
name: string;
owner_id: string;
reveal_active?: boolean;
reveal_count?: number;
scoreboard_token?: string;
scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"];
updated_at?: string;
};
Update: {
created_at?: string;
current_game_id?: string | null;
event_date?: string | null;
frozen_snapshot?: Json | null;
id?: string;
name?: string;
owner_id?: string;
reveal_active?: boolean;
reveal_count?: number;
scoreboard_token?: string;
scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"];
updated_at?: string;
};
Relationships: [
{
foreignKeyName: "events_current_game_fk";
columns: ["current_game_id"];
isOneToOne: false;
referencedRelation: "games";
referencedColumns: ["id"];
},
];
};
games: {
Row: {
admin_notes: string | null;
created_at: string;
description: string | null;
event_id: string;
id: string;
judge_notes: string | null;
judging_locked: boolean;
multiplier: number;
open_link_enabled: boolean;
open_token: string;
placement_points: number[];
points_mode: Database["public"]["Enums"]["points_mode"];
scoring_mode: Database["public"]["Enums"]["scoring_mode"];
sort_order: number;
spotlight_team_id: string | null;
status: Database["public"]["Enums"]["game_status"];
title: string;
updated_at: string;
};
Insert: {
admin_notes?: string | null;
created_at?: string;
description?: string | null;
event_id: string;
id?: string;
judge_notes?: string | null;
judging_locked?: boolean;
multiplier?: number;
open_link_enabled?: boolean;
open_token?: string;
placement_points?: number[];
points_mode?: Database["public"]["Enums"]["points_mode"];
scoring_mode?: Database["public"]["Enums"]["scoring_mode"];
sort_order?: number;
spotlight_team_id?: string | null;
status?: Database["public"]["Enums"]["game_status"];
title: string;
updated_at?: string;
};
Update: {
admin_notes?: string | null;
created_at?: string;
description?: string | null;
event_id?: string;
id?: string;
judge_notes?: string | null;
judging_locked?: boolean;
multiplier?: number;
open_link_enabled?: boolean;
open_token?: string;
placement_points?: number[];
points_mode?: Database["public"]["Enums"]["points_mode"];
scoring_mode?: Database["public"]["Enums"]["scoring_mode"];
sort_order?: number;
spotlight_team_id?: string | null;
status?: Database["public"]["Enums"]["game_status"];
title?: string;
updated_at?: string;
};
Relationships: [
{
foreignKeyName: "games_event_id_fkey";
columns: ["event_id"];
isOneToOne: false;
referencedRelation: "events";
referencedColumns: ["id"];
},
{
foreignKeyName: "games_spotlight_team_id_fkey";
columns: ["spotlight_team_id"];
isOneToOne: false;
referencedRelation: "teams";
referencedColumns: ["id"];
},
];
};
judges: {
Row: {
created_at: string;
game_id: string;
id: string;
name: string;
revoked: boolean;
source: Database["public"]["Enums"]["judge_source"];
token: string;
};
Insert: {
created_at?: string;
game_id: string;
id?: string;
name: string;
revoked?: boolean;
source?: Database["public"]["Enums"]["judge_source"];
token?: string;
};
Update: {
created_at?: string;
game_id?: string;
id?: string;
name?: string;
revoked?: boolean;
source?: Database["public"]["Enums"]["judge_source"];
token?: string;
};
Relationships: [
{
foreignKeyName: "judges_game_id_fkey";
columns: ["game_id"];
isOneToOne: false;
referencedRelation: "games";
referencedColumns: ["id"];
},
];
};
scores: {
Row: {
criterion_id: string;
game_id: string;
id: string;
judge_id: string | null;
team_id: string;
updated_at: string;
value: number;
};
Insert: {
criterion_id: string;
game_id: string;
id?: string;
judge_id?: string | null;
team_id: string;
updated_at?: string;
value: number;
};
Update: {
criterion_id?: string;
game_id?: string;
id?: string;
judge_id?: string | null;
team_id?: string;
updated_at?: string;
value?: number;
};
Relationships: [
{
foreignKeyName: "scores_criterion_id_fkey";
columns: ["criterion_id"];
isOneToOne: false;
referencedRelation: "criteria";
referencedColumns: ["id"];
},
{
foreignKeyName: "scores_game_id_fkey";
columns: ["game_id"];
isOneToOne: false;
referencedRelation: "games";
referencedColumns: ["id"];
},
{
foreignKeyName: "scores_judge_id_fkey";
columns: ["judge_id"];
isOneToOne: false;
referencedRelation: "judges";
referencedColumns: ["id"];
},
{
foreignKeyName: "scores_team_id_fkey";
columns: ["team_id"];
isOneToOne: false;
referencedRelation: "teams";
referencedColumns: ["id"];
},
];
};
teams: {
Row: {
color: string;
created_at: string;
event_id: string;
id: string;
name: string;
sort_order: number;
};
Insert: {
color?: string;
created_at?: string;
event_id: string;
id?: string;
name: string;
sort_order?: number;
};
Update: {
color?: string;
created_at?: string;
event_id?: string;
id?: string;
name?: string;
sort_order?: number;
};
Relationships: [
{
foreignKeyName: "teams_event_id_fkey";
columns: ["event_id"];
isOneToOne: false;
referencedRelation: "events";
referencedColumns: ["id"];
},
];
};
};
Views: {
[_ in never]: never;
};
Functions: {
[_ in never]: never;
};
Enums: {
criterion_input: "blocks" | "number";
game_status: "upcoming" | "live" | "done";
judge_source: "nominated" | "open_link";
points_mode: "raw" | "placement";
scoreboard_visibility: "visible" | "frozen" | "hidden";
scoring_mode: "judged" | "direct";
};
CompositeTypes: {
[_ in never]: never;
};
};
};
export type Tables<T extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][T]["Row"];
export type TablesInsert<T extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][T]["Insert"];
export type TablesUpdate<T extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][T]["Update"];
export type Enums<T extends keyof Database["public"]["Enums"]> = Database["public"]["Enums"][T];
+2 -1
View File
@@ -2,6 +2,7 @@ import "server-only";
import { createClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/database.types";
import { serverEnv } from "@/lib/env.server";
/**
@@ -9,7 +10,7 @@ import { serverEnv } from "@/lib/env.server";
* AFTER the scoreboard/judge token has been validated, and scope every query by that token.
*/
export function createAdminClient() {
return createClient(serverEnv.NEXT_PUBLIC_SUPABASE_URL, serverEnv.SUPABASE_SERVICE_ROLE_KEY, {
return createClient<Database>(serverEnv.NEXT_PUBLIC_SUPABASE_URL, serverEnv.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/lib/database.types";
import { publicEnv } from "@/lib/env.public";
/** Anon-key client for the browser: Realtime subscriptions + the admin auth session. */
export function createClient() {
return createBrowserClient(
return createBrowserClient<Database>(
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
);
+2 -1
View File
@@ -3,13 +3,14 @@ import "server-only";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/lib/database.types";
import { publicEnv } from "@/lib/env.public";
/** Cookie-based client for admin server components/actions. RLS applies as the signed-in admin. */
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
return createServerClient<Database>(
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{