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,
|
||||
"tag": "0005_broad_mariko_yashida",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1776270391189,
|
||||
"tag": "0006_certain_adam_destine",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
primaryKey,
|
||||
index,
|
||||
boolean,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { sql, relations } from "drizzle-orm";
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
CEFR_LEVELS,
|
||||
SUPPORTED_DECK_TYPES,
|
||||
DIFFICULTY_LEVELS,
|
||||
LOBBY_STATUSES,
|
||||
} from "@lila/shared";
|
||||
|
||||
export const terms = pgTable(
|
||||
|
|
@ -252,12 +254,53 @@ export const accountRelations = relations(account, ({ one }) => ({
|
|||
user: one(user, { fields: [account.userId], references: [user.id] }),
|
||||
}));
|
||||
|
||||
/*
|
||||
* INTENTIONAL DESIGN DECISIONS — see decisions.md for full reasoning
|
||||
*
|
||||
* source + source_id (terms): idempotency key per import pipeline
|
||||
* display_name UNIQUE (users): multiplayer requires distinguishable names
|
||||
* UNIQUE(term_id, language_code, text): allows synonyms, prevents exact duplicates
|
||||
* updated_at omitted: misleading without a trigger to maintain it
|
||||
* FK indexes: all FK columns covered, no sequential scans on joins
|
||||
*/
|
||||
export const lobbies = pgTable(
|
||||
"lobbies",
|
||||
{
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
code: varchar({ length: 10 }).notNull().unique(),
|
||||
hostUserId: text("host_user_id")
|
||||
.notNull()
|
||||
.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 { fileURLToPath } from "url";
|
||||
import { dirname } from "path";
|
||||
import * as schema from "./db/schema.js";
|
||||
|
||||
config({
|
||||
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";
|
||||
|
|
|
|||
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 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