Step 5.3: add database schema and migrations
CI / check (push) Successful in 1m9s
Details
CI / check (push) Successful in 1m9s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c809e10d7e
commit
5754b7ca25
|
|
@ -7,6 +7,21 @@ on:
|
|||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
# Test database for server integration tests.
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: deadhead
|
||||
POSTGRES_PASSWORD: deadhead
|
||||
POSTGRES_DB: deadhead_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U deadhead -d deadhead_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 5s
|
||||
--health-retries 30
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://deadhead:deadhead@postgres:5432/deadhead_test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pnpm-lock.yaml
|
||||
memory-bank/
|
||||
.pnpm-store/
|
||||
apps/server/drizzle/
|
||||
|
|
|
|||
13
DECISIONS.md
13
DECISIONS.md
|
|
@ -212,3 +212,16 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
|
||||
- **The injectable clock arrives now, not in Step 7.2.** `buildApp(config, { clock })` takes a `Clock` (`() => RealTime`) and defaults to `Date.now`. Every endpoint will read "now" through it (decision 0.5.18). Step 7.2 still adds the test-only clock-offset endpoint.
|
||||
- **Game time uses the default `content.config`**, via sim `realToGame`.
|
||||
|
||||
## Step 5.3
|
||||
|
||||
- **Every game table references `players.id`.** Players and companies are one-to-one; `companies` holds credits and is the row that money-changing endpoints lock (Step 6.14).
|
||||
- **Times are `bigint` Unix ms** (Drizzle `mode: "number"`), like sim and the API. World seeds are `bigint` because unsigned 32-bit values overflow `integer`.
|
||||
- **Status enums use `in_flight`** in the database, as the plan words it; the API uses `in-flight`, so the server maps between them.
|
||||
- **Dispatches store the full sim `DispatchRecord` as JSONB**, plus the columns queries need (`status`, `arrival_at`, `resolved_at`, `acknowledged_at`, `tutorial`, `ship_id`, `pilot_id`). The applied `Resolution` is kept as JSONB for the summary. A tutorial dispatch has `ship_id` null, because the loaner is not a ship row.
|
||||
- **Pilot skills are a JSONB `{ skillId: level }` map**, plus a `skill_points_spent` counter. Level is derived from XP, not stored.
|
||||
- **Contract boards store their contracts as JSONB**, keyed by player, kind (`bay` / `tutorial`), location (the bay or the loaner's location) and period.
|
||||
- **Auth tables are not here yet;** Better Auth's tables and the link from players to users arrive in Step 5.5.
|
||||
- **Migrations are generated SQL** (`drizzle-kit generate`) committed in `apps/server/drizzle/`, which Prettier ignores. They are applied with the drizzle-orm migrator (`db:migrate`, or `runMigrations(url)` in tests).
|
||||
- **Server test files run one at a time** (`apps/server/vitest.config.ts`) because integration tests share one test database.
|
||||
- **CI runs a `postgres:18` service container** and points `TEST_DATABASE_URL` at it; locally the default is the compose database `deadhead_test`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
// `pnpm --filter @deadhead/server db:generate` writes SQL migrations to ./drizzle.
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
});
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
CREATE TYPE "public"."activity_status" AS ENUM('idle', 'in_flight');--> statement-breakpoint
|
||||
CREATE TYPE "public"."board_kind" AS ENUM('bay', 'tutorial');--> statement-breakpoint
|
||||
CREATE TYPE "public"."dispatch_status" AS ENUM('in_flight', 'resolved');--> statement-breakpoint
|
||||
CREATE TABLE "accepted_contracts" (
|
||||
"player_id" text NOT NULL,
|
||||
"contract_id" text NOT NULL,
|
||||
"period_index" integer NOT NULL,
|
||||
"dispatch_id" text NOT NULL,
|
||||
"accepted_at" bigint NOT NULL,
|
||||
CONSTRAINT "accepted_contracts_player_id_contract_id_pk" PRIMARY KEY("player_id","contract_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "bays" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"player_id" text NOT NULL,
|
||||
"location_id" text NOT NULL,
|
||||
"created_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "companies" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"player_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"credits" bigint NOT NULL,
|
||||
CONSTRAINT "companies_player_id_unique" UNIQUE("player_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "contract_boards" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"player_id" text NOT NULL,
|
||||
"kind" "board_kind" NOT NULL,
|
||||
"location_id" text NOT NULL,
|
||||
"period_index" integer NOT NULL,
|
||||
"contracts" jsonb NOT NULL,
|
||||
"created_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "discovered_locations" (
|
||||
"player_id" text NOT NULL,
|
||||
"location_id" text NOT NULL,
|
||||
"discovered_at" bigint NOT NULL,
|
||||
CONSTRAINT "discovered_locations_player_id_location_id_pk" PRIMARY KEY("player_id","location_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "dispatches" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"player_id" text NOT NULL,
|
||||
"ship_id" text,
|
||||
"pilot_id" text NOT NULL,
|
||||
"tutorial" boolean NOT NULL,
|
||||
"record" jsonb NOT NULL,
|
||||
"arrival_at" bigint NOT NULL,
|
||||
"status" "dispatch_status" DEFAULT 'in_flight' NOT NULL,
|
||||
"resolution" jsonb,
|
||||
"resolved_at" bigint,
|
||||
"acknowledged_at" bigint
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pilots" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"player_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"is_avatar" boolean DEFAULT false NOT NULL,
|
||||
"xp" integer DEFAULT 0 NOT NULL,
|
||||
"skills" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"skill_points_spent" integer DEFAULT 0 NOT NULL,
|
||||
"assigned_ship_id" text,
|
||||
"location_id" text,
|
||||
"status" "activity_status" DEFAULT 'idle' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
CONSTRAINT "pilots_assigned_ship_id_unique" UNIQUE("assigned_ship_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "players" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"world_seed" bigint NOT NULL,
|
||||
"tutorial_active" boolean DEFAULT true NOT NULL,
|
||||
"loaner_location_id" text,
|
||||
"created_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ships" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"player_id" text NOT NULL,
|
||||
"model_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"status" "activity_status" DEFAULT 'idle' NOT NULL,
|
||||
"location_id" text,
|
||||
"created_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "accepted_contracts" ADD CONSTRAINT "accepted_contracts_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "accepted_contracts" ADD CONSTRAINT "accepted_contracts_dispatch_id_dispatches_id_fk" FOREIGN KEY ("dispatch_id") REFERENCES "public"."dispatches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "bays" ADD CONSTRAINT "bays_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD CONSTRAINT "companies_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contract_boards" ADD CONSTRAINT "contract_boards_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "discovered_locations" ADD CONSTRAINT "discovered_locations_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "dispatches" ADD CONSTRAINT "dispatches_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "dispatches" ADD CONSTRAINT "dispatches_ship_id_ships_id_fk" FOREIGN KEY ("ship_id") REFERENCES "public"."ships"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "dispatches" ADD CONSTRAINT "dispatches_pilot_id_pilots_id_fk" FOREIGN KEY ("pilot_id") REFERENCES "public"."pilots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pilots" ADD CONSTRAINT "pilots_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pilots" ADD CONSTRAINT "pilots_assigned_ship_id_ships_id_fk" FOREIGN KEY ("assigned_ship_id") REFERENCES "public"."ships"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ships" ADD CONSTRAINT "ships_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "bays_player_location_idx" ON "bays" USING btree ("player_id","location_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "contract_boards_unique_idx" ON "contract_boards" USING btree ("player_id","kind","location_id","period_index");--> statement-breakpoint
|
||||
CREATE INDEX "dispatches_due_idx" ON "dispatches" USING btree ("status","arrival_at");--> statement-breakpoint
|
||||
CREATE INDEX "dispatches_player_status_idx" ON "dispatches" USING btree ("player_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "pilots_player_idx" ON "pilots" USING btree ("player_id");--> statement-breakpoint
|
||||
CREATE INDEX "ships_player_idx" ON "ships" USING btree ("player_id");
|
||||
|
|
@ -0,0 +1,842 @@
|
|||
{
|
||||
"id": "1390d91d-5047-43d3-984f-06f82cb52ab5",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accepted_contracts": {
|
||||
"name": "accepted_contracts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"contract_id": {
|
||||
"name": "contract_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"period_index": {
|
||||
"name": "period_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"dispatch_id": {
|
||||
"name": "dispatch_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"accepted_at": {
|
||||
"name": "accepted_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accepted_contracts_player_id_players_id_fk": {
|
||||
"name": "accepted_contracts_player_id_players_id_fk",
|
||||
"tableFrom": "accepted_contracts",
|
||||
"tableTo": "players",
|
||||
"columnsFrom": [
|
||||
"player_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"accepted_contracts_dispatch_id_dispatches_id_fk": {
|
||||
"name": "accepted_contracts_dispatch_id_dispatches_id_fk",
|
||||
"tableFrom": "accepted_contracts",
|
||||
"tableTo": "dispatches",
|
||||
"columnsFrom": [
|
||||
"dispatch_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"accepted_contracts_player_id_contract_id_pk": {
|
||||
"name": "accepted_contracts_player_id_contract_id_pk",
|
||||
"columns": [
|
||||
"player_id",
|
||||
"contract_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.bays": {
|
||||
"name": "bays",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_id": {
|
||||
"name": "location_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"bays_player_location_idx": {
|
||||
"name": "bays_player_location_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "player_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "location_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"bays_player_id_players_id_fk": {
|
||||
"name": "bays_player_id_players_id_fk",
|
||||
"tableFrom": "bays",
|
||||
"tableTo": "players",
|
||||
"columnsFrom": [
|
||||
"player_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.companies": {
|
||||
"name": "companies",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"credits": {
|
||||
"name": "credits",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"companies_player_id_players_id_fk": {
|
||||
"name": "companies_player_id_players_id_fk",
|
||||
"tableFrom": "companies",
|
||||
"tableTo": "players",
|
||||
"columnsFrom": [
|
||||
"player_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"companies_player_id_unique": {
|
||||
"name": "companies_player_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"player_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.contract_boards": {
|
||||
"name": "contract_boards",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "board_kind",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_id": {
|
||||
"name": "location_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"period_index": {
|
||||
"name": "period_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"contracts": {
|
||||
"name": "contracts",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"contract_boards_unique_idx": {
|
||||
"name": "contract_boards_unique_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "player_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "kind",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "location_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "period_index",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"contract_boards_player_id_players_id_fk": {
|
||||
"name": "contract_boards_player_id_players_id_fk",
|
||||
"tableFrom": "contract_boards",
|
||||
"tableTo": "players",
|
||||
"columnsFrom": [
|
||||
"player_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.discovered_locations": {
|
||||
"name": "discovered_locations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_id": {
|
||||
"name": "location_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"discovered_at": {
|
||||
"name": "discovered_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"discovered_locations_player_id_players_id_fk": {
|
||||
"name": "discovered_locations_player_id_players_id_fk",
|
||||
"tableFrom": "discovered_locations",
|
||||
"tableTo": "players",
|
||||
"columnsFrom": [
|
||||
"player_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"discovered_locations_player_id_location_id_pk": {
|
||||
"name": "discovered_locations_player_id_location_id_pk",
|
||||
"columns": [
|
||||
"player_id",
|
||||
"location_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.dispatches": {
|
||||
"name": "dispatches",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ship_id": {
|
||||
"name": "ship_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pilot_id": {
|
||||
"name": "pilot_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tutorial": {
|
||||
"name": "tutorial",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"record": {
|
||||
"name": "record",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"arrival_at": {
|
||||
"name": "arrival_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "dispatch_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'in_flight'"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"acknowledged_at": {
|
||||
"name": "acknowledged_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"dispatches_due_idx": {
|
||||
"name": "dispatches_due_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "arrival_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"dispatches_player_status_idx": {
|
||||
"name": "dispatches_player_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "player_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"dispatches_player_id_players_id_fk": {
|
||||
"name": "dispatches_player_id_players_id_fk",
|
||||
"tableFrom": "dispatches",
|
||||
"tableTo": "players",
|
||||
"columnsFrom": [
|
||||
"player_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"dispatches_ship_id_ships_id_fk": {
|
||||
"name": "dispatches_ship_id_ships_id_fk",
|
||||
"tableFrom": "dispatches",
|
||||
"tableTo": "ships",
|
||||
"columnsFrom": [
|
||||
"ship_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"dispatches_pilot_id_pilots_id_fk": {
|
||||
"name": "dispatches_pilot_id_pilots_id_fk",
|
||||
"tableFrom": "dispatches",
|
||||
"tableTo": "pilots",
|
||||
"columnsFrom": [
|
||||
"pilot_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pilots": {
|
||||
"name": "pilots",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_avatar": {
|
||||
"name": "is_avatar",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"xp": {
|
||||
"name": "xp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"skills": {
|
||||
"name": "skills",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"skill_points_spent": {
|
||||
"name": "skill_points_spent",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"assigned_ship_id": {
|
||||
"name": "assigned_ship_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"location_id": {
|
||||
"name": "location_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "activity_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"pilots_player_idx": {
|
||||
"name": "pilots_player_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "player_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"pilots_player_id_players_id_fk": {
|
||||
"name": "pilots_player_id_players_id_fk",
|
||||
"tableFrom": "pilots",
|
||||
"tableTo": "players",
|
||||
"columnsFrom": [
|
||||
"player_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"pilots_assigned_ship_id_ships_id_fk": {
|
||||
"name": "pilots_assigned_ship_id_ships_id_fk",
|
||||
"tableFrom": "pilots",
|
||||
"tableTo": "ships",
|
||||
"columnsFrom": [
|
||||
"assigned_ship_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"pilots_assigned_ship_id_unique": {
|
||||
"name": "pilots_assigned_ship_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"assigned_ship_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.players": {
|
||||
"name": "players",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"world_seed": {
|
||||
"name": "world_seed",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tutorial_active": {
|
||||
"name": "tutorial_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"loaner_location_id": {
|
||||
"name": "loaner_location_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ships": {
|
||||
"name": "ships",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"model_id": {
|
||||
"name": "model_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "activity_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"location_id": {
|
||||
"name": "location_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ships_player_idx": {
|
||||
"name": "ships_player_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "player_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ships_player_id_players_id_fk": {
|
||||
"name": "ships_player_id_players_id_fk",
|
||||
"tableFrom": "ships",
|
||||
"tableTo": "players",
|
||||
"columnsFrom": [
|
||||
"player_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.activity_status": {
|
||||
"name": "activity_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"idle",
|
||||
"in_flight"
|
||||
]
|
||||
},
|
||||
"public.board_kind": {
|
||||
"name": "board_kind",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"bay",
|
||||
"tutorial"
|
||||
]
|
||||
},
|
||||
"public.dispatch_status": {
|
||||
"name": "dispatch_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"in_flight",
|
||||
"resolved"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1790991444530,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -8,16 +8,22 @@
|
|||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "tsx src/main.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate-cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deadhead/content": "workspace:*",
|
||||
"@deadhead/schema": "workspace:*",
|
||||
"@deadhead/sim": "workspace:*",
|
||||
"drizzle-orm": "^0.45.3",
|
||||
"fastify": "^5.12.5",
|
||||
"pg": "^8.23.1",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.23.1",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"tsx": "^4.23.15"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import pg from "pg";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export interface Database {
|
||||
db: Db;
|
||||
pool: pg.Pool;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createDatabase(url: string): Database {
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
return { db: drizzle(pool, { schema }), pool, close: () => pool.end() };
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runMigrations } from "./migrate";
|
||||
|
||||
// `pnpm --filter @deadhead/server db:migrate` migrates DATABASE_URL.
|
||||
const envFile = fileURLToPath(new URL("../../../../.env", import.meta.url));
|
||||
if (existsSync(envFile)) process.loadEnvFile(envFile);
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
await runMigrations(url);
|
||||
console.log("Migrations applied");
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { createDatabase } from "./client";
|
||||
|
||||
export const MIGRATIONS_FOLDER = fileURLToPath(new URL("../../drizzle", import.meta.url));
|
||||
|
||||
/** Applies pending migrations. Already-applied ones are skipped, so it is safe to rerun. */
|
||||
export async function runMigrations(url: string): Promise<void> {
|
||||
const database = createDatabase(url);
|
||||
try {
|
||||
await migrate(database.db, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// All times are Unix milliseconds (bigint), matching sim and the API.
|
||||
const time = (name: string) => bigint(name, { mode: "number" });
|
||||
|
||||
export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]);
|
||||
export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]);
|
||||
export const boardKind = pgEnum("board_kind", ["bay", "tutorial"]);
|
||||
|
||||
export const players = pgTable("players", {
|
||||
id: text("id").primaryKey(),
|
||||
/** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */
|
||||
worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
|
||||
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
||||
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
|
||||
loanerLocationId: text("loaner_location_id"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const companies = pgTable("companies", {
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
|
||||
credits: bigint("credits", { mode: "number" }).notNull(),
|
||||
});
|
||||
|
||||
export const ships = pgTable(
|
||||
"ships",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
modelId: text("model_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
status: activityStatus("status").notNull().default("idle"),
|
||||
/** Null while in flight. */
|
||||
locationId: text("location_id"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [index("ships_player_idx").on(t.playerId)],
|
||||
);
|
||||
|
||||
export const pilots = pgTable(
|
||||
"pilots",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
isAvatar: boolean("is_avatar").notNull().default(false),
|
||||
xp: integer("xp").notNull().default(0),
|
||||
skills: jsonb("skills").$type<Record<string, number>>().notNull().default({}),
|
||||
skillPointsSpent: integer("skill_points_spent").notNull().default(0),
|
||||
/** One pilot per ship (decision 0.5.2). */
|
||||
assignedShipId: text("assigned_ship_id")
|
||||
.unique()
|
||||
.references(() => ships.id, { onDelete: "set null" }),
|
||||
/** Only unassigned pilots store a location (decision 0.5.27). */
|
||||
locationId: text("location_id"),
|
||||
status: activityStatus("status").notNull().default("idle"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [index("pilots_player_idx").on(t.playerId)],
|
||||
);
|
||||
|
||||
export const bays = pgTable(
|
||||
"bays",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
locationId: text("location_id").notNull(),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [uniqueIndex("bays_player_location_idx").on(t.playerId, t.locationId)],
|
||||
);
|
||||
|
||||
export const discoveredLocations = pgTable(
|
||||
"discovered_locations",
|
||||
{
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
locationId: text("location_id").notNull(),
|
||||
discoveredAt: time("discovered_at").notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.playerId, t.locationId] })],
|
||||
);
|
||||
|
||||
/**
|
||||
* One stored board per bay per period, and one tutorial board per period per
|
||||
* loaner location (decisions 0.5.5 and 0.5.21). `locationId` is the bay's or
|
||||
* the loaner's location.
|
||||
*/
|
||||
export const contractBoards = pgTable(
|
||||
"contract_boards",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
kind: boardKind("kind").notNull(),
|
||||
locationId: text("location_id").notNull(),
|
||||
periodIndex: integer("period_index").notNull(),
|
||||
contracts: jsonb("contracts").$type<Contract[]>().notNull(),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("contract_boards_unique_idx").on(t.playerId, t.kind, t.locationId, t.periodIndex),
|
||||
],
|
||||
);
|
||||
|
||||
export const dispatches = pgTable(
|
||||
"dispatches",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
/** Null for a tutorial run (the loaner is not a ship row). */
|
||||
shipId: text("ship_id").references(() => ships.id, { onDelete: "set null" }),
|
||||
pilotId: text("pilot_id")
|
||||
.notNull()
|
||||
.references(() => pilots.id, { onDelete: "cascade" }),
|
||||
tutorial: boolean("tutorial").notNull(),
|
||||
/** The full sim record, including a snapshot of the contract. */
|
||||
record: jsonb("record").$type<DispatchRecord>().notNull(),
|
||||
arrivalAt: time("arrival_at").notNull(),
|
||||
status: dispatchStatus("status").notNull().default("in_flight"),
|
||||
/** Applied effects, kept for the "while you were away" summary. */
|
||||
resolution: jsonb("resolution").$type<Resolution>(),
|
||||
resolvedAt: time("resolved_at"),
|
||||
acknowledgedAt: time("acknowledged_at"),
|
||||
},
|
||||
(t) => [
|
||||
index("dispatches_due_idx").on(t.status, t.arrivalAt),
|
||||
index("dispatches_player_status_idx").on(t.playerId, t.status),
|
||||
],
|
||||
);
|
||||
|
||||
export const acceptedContracts = pgTable(
|
||||
"accepted_contracts",
|
||||
{
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
contractId: text("contract_id").notNull(),
|
||||
periodIndex: integer("period_index").notNull(),
|
||||
dispatchId: text("dispatch_id")
|
||||
.notNull()
|
||||
.references(() => dispatches.id, { onDelete: "cascade" }),
|
||||
acceptedAt: time("accepted_at").notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
|
||||
);
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import pg from "pg";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { runMigrations } from "../src/db/migrate";
|
||||
import { testConfig } from "./helpers";
|
||||
|
||||
const url = testConfig().databaseUrl;
|
||||
const client = new pg.Client({ connectionString: url });
|
||||
|
||||
const tables = async () =>
|
||||
(
|
||||
await client.query<{ table_name: string }>(
|
||||
"select table_name from information_schema.tables where table_schema = 'public' order by table_name",
|
||||
)
|
||||
).rows.map((r) => r.table_name);
|
||||
|
||||
const appliedMigrations = async () =>
|
||||
Number(
|
||||
(await client.query<{ n: string }>("select count(*) as n from drizzle.__drizzle_migrations"))
|
||||
.rows[0]!.n,
|
||||
);
|
||||
|
||||
describe("database migrations", () => {
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
// Start from an empty database.
|
||||
await client.query("drop schema if exists drizzle cascade");
|
||||
await client.query("drop schema public cascade");
|
||||
await client.query("create schema public");
|
||||
});
|
||||
afterAll(() => client.end());
|
||||
|
||||
it("creates every table on an empty database", async () => {
|
||||
await runMigrations(url);
|
||||
expect(await tables()).toEqual([
|
||||
"accepted_contracts",
|
||||
"bays",
|
||||
"companies",
|
||||
"contract_boards",
|
||||
"discovered_locations",
|
||||
"dispatches",
|
||||
"pilots",
|
||||
"players",
|
||||
"ships",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op when run a second time", async () => {
|
||||
const before = { tables: await tables(), migrations: await appliedMigrations() };
|
||||
await runMigrations(url);
|
||||
expect({ tables: await tables(), migrations: await appliedMigrations() }).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { defineProject } from "vitest/config";
|
||||
|
||||
// Integration tests share one test database, so test files run one at a time.
|
||||
export default defineProject({
|
||||
test: {
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -58,3 +58,200 @@ The `routes` catalog is the single source for both server and client. Each entry
|
|||
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
|
||||
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
|
||||
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |
|
||||
|
||||
## Database (`apps/server/src/db`)
|
||||
|
||||
Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/drizzle/`, generated with `pnpm --filter @deadhead/server db:generate` and applied with `db:migrate` (uses `DATABASE_URL`; rerunning is a no-op). Every game table hangs off `players.id`; times are bigint Unix ms.
|
||||
|
||||
| Table | Purpose | Key constraints |
|
||||
|---|---|---|
|
||||
| `players` | World seed, tutorial flag, loaner location | pk `id` |
|
||||
| `companies` | Name and credits (the row locked for money changes) | unique `player_id` |
|
||||
| `ships` | Owned ships: model, name, status, location (null in flight) | — |
|
||||
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | unique `assigned_ship_id` (one pilot per ship) |
|
||||
| `bays` | Leased bays | unique (`player_id`, `location_id`) |
|
||||
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |
|
||||
| `contract_boards` | Stored bay boards and tutorial boards per period (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`) |
|
||||
| `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) |
|
||||
| `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) |
|
||||
|
||||
### Drizzle schema
|
||||
|
||||
Copied from `apps/server/src/db/schema.ts`; update both together.
|
||||
|
||||
```ts
|
||||
import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// All times are Unix milliseconds (bigint), matching sim and the API.
|
||||
const time = (name: string) => bigint(name, { mode: "number" });
|
||||
|
||||
export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]);
|
||||
export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]);
|
||||
export const boardKind = pgEnum("board_kind", ["bay", "tutorial"]);
|
||||
|
||||
export const players = pgTable("players", {
|
||||
id: text("id").primaryKey(),
|
||||
/** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */
|
||||
worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
|
||||
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
||||
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
|
||||
loanerLocationId: text("loaner_location_id"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const companies = pgTable("companies", {
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
|
||||
credits: bigint("credits", { mode: "number" }).notNull(),
|
||||
});
|
||||
|
||||
export const ships = pgTable(
|
||||
"ships",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
modelId: text("model_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
status: activityStatus("status").notNull().default("idle"),
|
||||
/** Null while in flight. */
|
||||
locationId: text("location_id"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [index("ships_player_idx").on(t.playerId)],
|
||||
);
|
||||
|
||||
export const pilots = pgTable(
|
||||
"pilots",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
isAvatar: boolean("is_avatar").notNull().default(false),
|
||||
xp: integer("xp").notNull().default(0),
|
||||
skills: jsonb("skills").$type<Record<string, number>>().notNull().default({}),
|
||||
skillPointsSpent: integer("skill_points_spent").notNull().default(0),
|
||||
/** One pilot per ship (decision 0.5.2). */
|
||||
assignedShipId: text("assigned_ship_id")
|
||||
.unique()
|
||||
.references(() => ships.id, { onDelete: "set null" }),
|
||||
/** Only unassigned pilots store a location (decision 0.5.27). */
|
||||
locationId: text("location_id"),
|
||||
status: activityStatus("status").notNull().default("idle"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [index("pilots_player_idx").on(t.playerId)],
|
||||
);
|
||||
|
||||
export const bays = pgTable(
|
||||
"bays",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
locationId: text("location_id").notNull(),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [uniqueIndex("bays_player_location_idx").on(t.playerId, t.locationId)],
|
||||
);
|
||||
|
||||
export const discoveredLocations = pgTable(
|
||||
"discovered_locations",
|
||||
{
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
locationId: text("location_id").notNull(),
|
||||
discoveredAt: time("discovered_at").notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.playerId, t.locationId] })],
|
||||
);
|
||||
|
||||
/**
|
||||
* One stored board per bay per period, and one tutorial board per period per
|
||||
* loaner location (decisions 0.5.5 and 0.5.21). `locationId` is the bay's or
|
||||
* the loaner's location.
|
||||
*/
|
||||
export const contractBoards = pgTable(
|
||||
"contract_boards",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
kind: boardKind("kind").notNull(),
|
||||
locationId: text("location_id").notNull(),
|
||||
periodIndex: integer("period_index").notNull(),
|
||||
contracts: jsonb("contracts").$type<Contract[]>().notNull(),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("contract_boards_unique_idx").on(t.playerId, t.kind, t.locationId, t.periodIndex),
|
||||
],
|
||||
);
|
||||
|
||||
export const dispatches = pgTable(
|
||||
"dispatches",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
/** Null for a tutorial run (the loaner is not a ship row). */
|
||||
shipId: text("ship_id").references(() => ships.id, { onDelete: "set null" }),
|
||||
pilotId: text("pilot_id")
|
||||
.notNull()
|
||||
.references(() => pilots.id, { onDelete: "cascade" }),
|
||||
tutorial: boolean("tutorial").notNull(),
|
||||
/** The full sim record, including a snapshot of the contract. */
|
||||
record: jsonb("record").$type<DispatchRecord>().notNull(),
|
||||
arrivalAt: time("arrival_at").notNull(),
|
||||
status: dispatchStatus("status").notNull().default("in_flight"),
|
||||
/** Applied effects, kept for the "while you were away" summary. */
|
||||
resolution: jsonb("resolution").$type<Resolution>(),
|
||||
resolvedAt: time("resolved_at"),
|
||||
acknowledgedAt: time("acknowledged_at"),
|
||||
},
|
||||
(t) => [
|
||||
index("dispatches_due_idx").on(t.status, t.arrivalAt),
|
||||
index("dispatches_player_status_idx").on(t.playerId, t.status),
|
||||
],
|
||||
);
|
||||
|
||||
export const acceptedContracts = pgTable(
|
||||
"accepted_contracts",
|
||||
{
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
contractId: text("contract_id").notNull(),
|
||||
periodIndex: integer("period_index").notNull(),
|
||||
dispatchId: text("dispatch_id")
|
||||
.notNull()
|
||||
.references(() => dispatches.id, { onDelete: "cascade" }),
|
||||
acceptedAt: time("accepted_at").notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
|
||||
);
|
||||
```
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
- [x] **Step 5.1 — Fastify skeleton.** `apps/server`: Zod-validated env config (`src/config.ts`), `buildApp` with pino JSON logging and `GET /api/health` (`src/app.ts`), entry point `src/main.ts` (loads repo-root `.env` if present). `pnpm --filter @deadhead/server dev|start` run it with tsx. Integration test starts the app on an ephemeral port and calls health over HTTP.
|
||||
- [x] **Step 5.2 — Server time endpoint.** `GET /api/time` returns `{ realTime, gameTime }` from an injectable `Clock` (`src/clock.ts`, passed via `buildApp(config, { clock })`).
|
||||
- [ ] Step 5.3 — Database schema and migrations
|
||||
- [x] **Step 5.3 — Database schema and migrations.** Drizzle schema (`apps/server/src/db/schema.ts`, copied into `architecture.md`), first migration `drizzle/0000_init.sql`, `db:generate` / `db:migrate` scripts. Migration test runs against an emptied test database twice. CI now has a Postgres 18 service; server tests run one file at a time.
|
||||
- [ ] Step 5.4 — Test database harness
|
||||
- [ ] Step 5.5 — Guest accounts
|
||||
- [ ] Step 5.6 — Request validation from the route catalog
|
||||
|
|
|
|||
783
pnpm-lock.yaml
783
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue