import { pgTable, uuid, text, integer, smallint, boolean, timestamp, primaryKey, bigserial } from "drizzle-orm/pg-core"; // One row per registered MC server. Phase 1 has no admin registration API yet (Phase 6) — // rows are created by `bun run seed` from MCMAPPER_SEED_SERVER_NAME/_TOKEN env vars. export const servers = pgTable("servers", { id: uuid("id").defaultRandom().primaryKey(), name: text("name").notNull(), token: text("token").notNull().unique(), authMode: text("auth_mode").notNull().default("offline"), // Phase 3: web chat is open to anonymous (unlinked) visitors by default; an admin can require // linking per-server. No admin UI to flip this yet (Phase 6) — set directly in the DB for now. anonymousChatAllowed: boolean("anonymous_chat_allowed").notNull().default(true), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // A linked player identity. `serverId` is null for online-mode accounts (uuid is a real Mojang // UUID, safe to merge into one global account across every online-mode server this backend // manages) and set for offline-mode accounts (uuid is just a hash of the username, only // trustworthy scoped to the one server that issued it) — see the plan's "Identity & Linking" // section. The actual uniqueness rules (one global account per online uuid; one account per // (server, uuid) for offline) are partial unique indexes in the migration SQL, not expressible // in drizzle's schema DSL in a way worth fighting for one column. export const accounts = pgTable("accounts", { id: uuid("id").defaultRandom().primaryKey(), mcUuid: text("mc_uuid").notNull(), username: text("username").notNull(), authMode: text("auth_mode").notNull(), serverId: uuid("server_id").references(() => servers.id, { onDelete: "cascade" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // A persistent, revocable web session. The token is handed to the client in the redeem response // body (not an httpOnly cookie) and sent back via the `X-MCMapper-Session` header — a deliberate // Phase 3 MVP simplification (see api/src/link.ts's doc comment for the tradeoff) worth // revisiting once there's an actually security-sensitive surface behind it (e.g. admin actions). export const sessions = pgTable("sessions", { token: text("token").primaryKey(), accountId: uuid("account_id") .notNull() .references(() => accounts.id, { onDelete: "cascade" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // Chat history, both directions. `source` is 'game' (from the mod's ChatBridge.OutboundSink) or // 'web' (from a browser's /ws/chat connection). export const chatMessages = pgTable("chat_messages", { id: bigserial("id", { mode: "number" }).primaryKey(), serverId: uuid("server_id") .notNull() .references(() => servers.id, { onDelete: "cascade" }), username: text("username").notNull(), message: text("message").notNull(), source: text("source").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // Column-granularity world state: the topmost non-air block per (dimension, x, z), plus its // height. Kept deliberately separate from `chunkSections` below — cheap to write/read for 2D // tile rendering, which never needs full voxel data. export const chunkColumns = pgTable( "chunk_columns", { serverId: uuid("server_id") .notNull() .references(() => servers.id, { onDelete: "cascade" }), dimension: integer("dimension").notNull(), x: integer("x").notNull(), z: integer("z").notNull(), blockId: integer("block_id").notNull(), blockMeta: integer("block_meta").notNull(), height: smallint("height").notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z] })], ); // Full-voxel storage for one 16x16x16 section (sectionY = worldY / 16), for 3D meshing — // additive to `chunkColumns`, not a replacement (see that table's comment). `blocks` is the // same base64 the mod sends over the wire: 4096 little-endian u16 blockStateIds, indexed by // `(ly*16 + lz)*16 + lx` within the section. Stored as base64 text rather than real bytea to // avoid postgres-js/drizzle binary-column plumbing for what's still an MVP — worth revisiting // if storage size ever matters (base64 is ~33% larger than raw bytes). export const chunkSections = pgTable( "chunk_sections", { serverId: uuid("server_id") .notNull() .references(() => servers.id, { onDelete: "cascade" }), dimension: integer("dimension").notNull(), x: integer("x").notNull(), z: integer("z").notNull(), sectionY: integer("section_y").notNull(), blocks: text("blocks").notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z, table.sectionY] }), ], ); // Metadata pointer to a rendered mesh buffer in MinIO, mirroring `tilePointers` but for 3D // meshes — one row per rendered section (a chunk with N non-empty sections gets N mesh rows, // each loaded as its own Babylon mesh; see worker/src/mesh/mod.rs for why section boundaries // aren't merged in Phase 2). export const meshPointers = pgTable( "mesh_pointers", { serverId: uuid("server_id") .notNull() .references(() => servers.id, { onDelete: "cascade" }), dimension: integer("dimension").notNull(), x: integer("x").notNull(), z: integer("z").notNull(), sectionY: integer("section_y").notNull(), storageKey: text("storage_key").notNull(), contentHash: text("content_hash").notNull(), renderedAt: timestamp("rendered_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z, table.sectionY] }), ], ); // Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres. // One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds // multi-resolution tiles. export const tilePointers = pgTable( "tile_pointers", { serverId: uuid("server_id") .notNull() .references(() => servers.id, { onDelete: "cascade" }), dimension: integer("dimension").notNull(), zoom: integer("zoom").notNull().default(0), tileX: integer("tile_x").notNull(), tileZ: integer("tile_z").notNull(), storageKey: text("storage_key").notNull(), contentHash: text("content_hash").notNull(), renderedAt: timestamp("rendered_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ primaryKey({ columns: [table.serverId, table.dimension, table.zoom, table.tileX, table.tileZ] }), ], );