feat(db): add lobbies and lobby_players tables + model
This commit is contained in:
parent
a7be7152cc
commit
47a68c0315
8 changed files with 1310 additions and 10 deletions
21
packages/db/drizzle/0006_certain_adam_destine.sql
Normal file
21
packages/db/drizzle/0006_certain_adam_destine.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
CREATE TABLE "lobbies" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"code" varchar(10) NOT NULL,
|
||||||
|
"host_user_id" text NOT NULL,
|
||||||
|
"status" varchar(20) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "lobbies_code_unique" UNIQUE("code"),
|
||||||
|
CONSTRAINT "lobby_status_check" CHECK ("lobbies"."status" IN ('waiting', 'in_progress', 'finished'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "lobby_players" (
|
||||||
|
"lobby_id" uuid NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "lobby_players_lobby_id_user_id_pk" PRIMARY KEY("lobby_id","user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "lobbies" ADD CONSTRAINT "lobbies_host_user_id_user_id_fk" FOREIGN KEY ("host_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_lobby_id_lobbies_id_fk" FOREIGN KEY ("lobby_id") REFERENCES "public"."lobbies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
1081
packages/db/drizzle/meta/0006_snapshot.json
Normal file
1081
packages/db/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,13 @@
|
||||||
"when": 1776154563168,
|
"when": 1776154563168,
|
||||||
"tag": "0005_broad_mariko_yashida",
|
"tag": "0005_broad_mariko_yashida",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776270391189,
|
||||||
|
"tag": "0006_certain_adam_destine",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
primaryKey,
|
primaryKey,
|
||||||
index,
|
index,
|
||||||
boolean,
|
boolean,
|
||||||
|
integer,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import { sql, relations } from "drizzle-orm";
|
import { sql, relations } from "drizzle-orm";
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
CEFR_LEVELS,
|
CEFR_LEVELS,
|
||||||
SUPPORTED_DECK_TYPES,
|
SUPPORTED_DECK_TYPES,
|
||||||
DIFFICULTY_LEVELS,
|
DIFFICULTY_LEVELS,
|
||||||
|
LOBBY_STATUSES,
|
||||||
} from "@lila/shared";
|
} from "@lila/shared";
|
||||||
|
|
||||||
export const terms = pgTable(
|
export const terms = pgTable(
|
||||||
|
|
@ -252,12 +254,53 @@ export const accountRelations = relations(account, ({ one }) => ({
|
||||||
user: one(user, { fields: [account.userId], references: [user.id] }),
|
user: one(user, { fields: [account.userId], references: [user.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/*
|
export const lobbies = pgTable(
|
||||||
* INTENTIONAL DESIGN DECISIONS — see decisions.md for full reasoning
|
"lobbies",
|
||||||
*
|
{
|
||||||
* source + source_id (terms): idempotency key per import pipeline
|
id: uuid().primaryKey().defaultRandom(),
|
||||||
* display_name UNIQUE (users): multiplayer requires distinguishable names
|
code: varchar({ length: 10 }).notNull().unique(),
|
||||||
* UNIQUE(term_id, language_code, text): allows synonyms, prevents exact duplicates
|
hostUserId: text("host_user_id")
|
||||||
* updated_at omitted: misleading without a trigger to maintain it
|
.notNull()
|
||||||
* FK indexes: all FK columns covered, no sequential scans on joins
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
*/
|
status: varchar({ length: 20 }).notNull().default("waiting"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check(
|
||||||
|
"lobby_status_check",
|
||||||
|
sql`${table.status} IN (${sql.raw(LOBBY_STATUSES.map((s) => `'${s}'`).join(", "))})`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const lobby_players = pgTable(
|
||||||
|
"lobby_players",
|
||||||
|
{
|
||||||
|
lobbyId: uuid("lobby_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => lobbies.id, { onDelete: "cascade" }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
score: integer().notNull().default(0),
|
||||||
|
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.lobbyId, table.userId] })],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const lobbyRelations = relations(lobbies, ({ one, many }) => ({
|
||||||
|
host: one(user, { fields: [lobbies.hostUserId], references: [user.id] }),
|
||||||
|
players: many(lobby_players),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const lobbyPlayersRelations = relations(lobby_players, ({ one }) => ({
|
||||||
|
lobby: one(lobbies, {
|
||||||
|
fields: [lobby_players.lobbyId],
|
||||||
|
references: [lobbies.id],
|
||||||
|
}),
|
||||||
|
user: one(user, { fields: [lobby_players.userId], references: [user.id] }),
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
|
import * as schema from "./db/schema.js";
|
||||||
|
|
||||||
config({
|
config({
|
||||||
path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"),
|
path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const db = drizzle(process.env["DATABASE_URL"]!);
|
export const db = drizzle(process.env["DATABASE_URL"]!, { schema });
|
||||||
|
|
||||||
export * from "./models/termModel.js";
|
export * from "./models/termModel.js";
|
||||||
|
|
|
||||||
122
packages/db/src/models/lobbyModel.ts
Normal file
122
packages/db/src/models/lobbyModel.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { db } from "@lila/db";
|
||||||
|
import { lobbies, lobby_players } from "@lila/db/schema";
|
||||||
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { LobbyStatus } from "@lila/shared";
|
||||||
|
|
||||||
|
export type Lobby = typeof lobbies.$inferSelect;
|
||||||
|
export type LobbyPlayer = typeof lobby_players.$inferSelect;
|
||||||
|
export type LobbyWithPlayers = Lobby & {
|
||||||
|
players: (LobbyPlayer & { user: { id: string; name: string } })[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createLobby = async (
|
||||||
|
code: string,
|
||||||
|
hostUserId: string,
|
||||||
|
): Promise<Lobby> => {
|
||||||
|
const [newLobby] = await db
|
||||||
|
.insert(lobbies)
|
||||||
|
.values({ code, hostUserId, status: "waiting" })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!newLobby) {
|
||||||
|
throw new Error("Failed to create lobby");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newLobby;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLobbyByCodeWithPlayers = async (
|
||||||
|
code: string,
|
||||||
|
): Promise<LobbyWithPlayers | undefined> => {
|
||||||
|
return db.query.lobbies.findFirst({
|
||||||
|
where: eq(lobbies.code, code),
|
||||||
|
with: {
|
||||||
|
players: { with: { user: { columns: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateLobbyStatus = async (
|
||||||
|
lobbyId: string,
|
||||||
|
status: LobbyStatus,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.update(lobbies).set({ status }).where(eq(lobbies.id, lobbyId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteLobby = async (lobbyId: string): Promise<void> => {
|
||||||
|
await db.delete(lobbies).where(eq(lobbies.id, lobbyId));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically inserts a player into a lobby. Returns the new player row,
|
||||||
|
* or undefined if the insert was skipped because:
|
||||||
|
* - the lobby is at capacity, or
|
||||||
|
* - the lobby is not in 'waiting' status, or
|
||||||
|
* - the user is already in the lobby (PK conflict).
|
||||||
|
*
|
||||||
|
* Callers are expected to pre-check these conditions against a hydrated
|
||||||
|
* lobby state to produce specific error messages; the undefined return
|
||||||
|
* is a safety net for concurrent races.
|
||||||
|
*/
|
||||||
|
export const addPlayer = async (
|
||||||
|
lobbyId: string,
|
||||||
|
userId: string,
|
||||||
|
maxPlayers: number,
|
||||||
|
): Promise<LobbyPlayer | undefined> => {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO lobby_players (lobby_id, user_id)
|
||||||
|
SELECT ${lobbyId}::uuid, ${userId}
|
||||||
|
WHERE (
|
||||||
|
SELECT COUNT(*) FROM lobby_players WHERE lobby_id = ${lobbyId}::uuid
|
||||||
|
) < ${maxPlayers}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM lobbies WHERE id = ${lobbyId}::uuid AND status = 'waiting'
|
||||||
|
)
|
||||||
|
ON CONFLICT (lobby_id, user_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!result.rowCount) return undefined;
|
||||||
|
const [player] = await db
|
||||||
|
.select()
|
||||||
|
.from(lobby_players)
|
||||||
|
.where(
|
||||||
|
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return player;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removePlayer = async (
|
||||||
|
lobbyId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db
|
||||||
|
.delete(lobby_players)
|
||||||
|
.where(
|
||||||
|
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finishGame = async (
|
||||||
|
lobbyId: string,
|
||||||
|
scoresByUser: Map<string, number>,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const [userId, score] of scoresByUser) {
|
||||||
|
await tx
|
||||||
|
.update(lobby_players)
|
||||||
|
.set({ score })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(lobby_players.lobbyId, lobbyId),
|
||||||
|
eq(lobby_players.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx
|
||||||
|
.update(lobbies)
|
||||||
|
.set({ status: "finished" })
|
||||||
|
.where(eq(lobbies.id, lobbyId));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -13,3 +13,6 @@ export const SUPPORTED_DECK_TYPES = ["grammar", "media"] as const;
|
||||||
|
|
||||||
export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const;
|
export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const;
|
||||||
export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number];
|
export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number];
|
||||||
|
|
||||||
|
export const LOBBY_STATUSES = ["waiting", "in_progress", "finished"] as const;
|
||||||
|
export type LobbyStatus = (typeof LOBBY_STATUSES)[number];
|
||||||
|
|
|
||||||
22
packages/shared/src/schemas/lobby.ts
Normal file
22
packages/shared/src/schemas/lobby.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { LOBBY_STATUSES } from "../constants.js";
|
||||||
|
|
||||||
|
export const LobbyPlayerSchema = z.object({
|
||||||
|
lobbyId: z.uuid(),
|
||||||
|
userId: z.string(),
|
||||||
|
score: z.number().int().min(0),
|
||||||
|
user: z.object({ id: z.string(), name: z.string() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LobbyPlayer = z.infer<typeof LobbyPlayerSchema>;
|
||||||
|
|
||||||
|
export const LobbySchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
code: z.string().min(1).max(10),
|
||||||
|
hostUserId: z.string(),
|
||||||
|
status: z.enum(LOBBY_STATUSES),
|
||||||
|
createdAt: z.iso.datetime(),
|
||||||
|
players: z.array(LobbyPlayerSchema),
|
||||||
|
});
|
||||||
|
export type Lobby = z.infer<typeof LobbySchema>;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue