db+game: schema_version on saves with ordered idempotent backfill (v2 bodies, v3 crew) — no more DB wipes

This commit is contained in:
2026-09-28 23:25:06 +00:00
parent f5cba45b42
commit c7580d92bd
4 changed files with 139 additions and 14 deletions
+10 -1
View File
@@ -24,7 +24,8 @@ CREATE TABLE IF NOT EXISTS saves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seed TEXT NOT NULL,
turn INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
schema_version INTEGER -- which save-schema version this row was backfilled up to (NULL = v1)
);
CREATE TABLE IF NOT EXISTS solar_systems (
@@ -98,6 +99,14 @@ async function open(file: string): Promise<GameDb> {
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
raw.exec(DDL); // no-op for tables that already exist in an older file
// Idempotent evolution for files created by older code: CREATE TABLE IF NOT EXISTS above cannot
// add columns, so check and add any that are missing. This is what makes DB wipes unnecessary —
// old saves open fine and get backfilled by ensureSave (see lib/game/save.ts).
const saveCols = raw.exec('PRAGMA table_info(saves)')[0]?.values.map((v) => v[1]) ?? [];
if (!saveCols.includes('schema_version')) {
raw.exec('ALTER TABLE saves ADD COLUMN schema_version INTEGER');
}
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
// First run: stamp when this DB was created and land the file on disk so the ./db volume
+3
View File
@@ -11,6 +11,9 @@ export const saves = sqliteTable('saves', {
seed: text('seed').notNull(),
turn: integer('turn').notNull().default(0),
createdAt: text('created_at').notNull(),
// Which schema version this save was last brought up to (NULL on saves from before the
// column existed = v1). ensureSave runs ordered idempotent backfill steps for anything below.
schemaVersion: integer('schema_version'),
})
export const solarSystems = sqliteTable('solar_systems', {
+66 -1
View File
@@ -2,10 +2,11 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { openGameDb, type GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
import { getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
const dirs: string[] = []
@@ -300,3 +301,67 @@ describe('explorePlanet crew finds', () => {
expect((await getCrewState(db)).length).toBe(before.length + 1)
})
})
describe('backfill (old saves)', () => {
// Planets/moons of one system in insertion order, nested by parent — ids are excluded so a
// regenerated set compares equal to the original even though autoincrement moved on.
function bodyShape(rows: { id: number; name: string; type: string; hazard: boolean; parentId: number | null }[]) {
return rows
.filter((r) => r.parentId === null)
.map((p) => ({ name: p.name, type: p.type, hazard: p.hazard, moons: rows.filter((m) => m.parentId === p.id).map((m) => [m.name, m.type, m.hazard]) }))
}
it('upgrades a v1 save (no bodies, no crew) in place — deterministic and idempotent', async () => {
const { db } = await freshFile()
await getWorldState(db) // new game: everything seeded at the current version
const systems = await db.db.select().from(schema.solarSystems)
const rowsFor = (systemId: number) => db.db.select().from(schema.planets).where(eq(schema.planets.systemId, systemId))
const beforeBodies: Record<number, unknown> = {}
for (const s of systems) {
const rows = await rowsFor(s.id)
beforeBodies[s.id] = bodyShape(rows.sort((a, b) => a.id - b.id))
}
const crewOf = (rows: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }[]) =>
rows.map((c) => ({ name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) // ids excluded — re-seed moves autoincrement on
const beforeCrew = crewOf(await db.db.select().from(schema.crew))
// Simulate a save created by pre-v0.4 code: no bodies, no crew, no version stamp (NULL).
await db.db.delete(schema.planets)
await db.db.delete(schema.crew)
await db.db.update(schema.saves).set({ schemaVersion: null })
const world = await getWorldState(db) // ensureSave → backfill runs
expect(world.systems).toHaveLength(7)
for (const s of systems) {
const rows = await rowsFor(s.id)
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id]) // identical to fresh generation
}
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
const [save] = await db.db.select().from(schema.saves)
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
// Idempotent: a second pass changes nothing.
const planetCount = (await db.db.select().from(schema.planets)).length
await getWorldState(db)
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length)
for (const s of systems) {
const rows = await rowsFor(s.id)
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id])
}
})
it('leaves an up-to-date save untouched', async () => {
const { db } = await freshFile()
await getWorldState(db)
const planetCount = (await db.db.select().from(schema.planets)).length
const [save] = await db.db.select().from(schema.saves)
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
await getWorldState(db) // second read: no backfill work to do
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
})
})
+60 -12
View File
@@ -45,20 +45,36 @@ function expansionRng(seed: string, turn: number): () => number {
return mulberry32((Number(seed) + turn * 1_000_003) >>> 0)
}
// Inserts one system together with its generated bodies (planets + moons), so a system is
// never left without worlds to explore. Bodies derive from (seed, system id) via bodyRng —
// independent of generation order and every other system.
async function insertSystemWithBodies(db: GameDb, saveId: number, ws: { name: string; x: number; y: number }, seed: string): Promise<number> {
const [row] = await db.db.insert(schema.solarSystems).values({ saveId, name: ws.name, x: ws.x, y: ws.y }).returning({ id: schema.solarSystems.id })
for (const body of generatePlanets(bodyRng(seed, row.id))) {
const [planet] = await db.db.insert(schema.planets).values({ systemId: row.id, parentId: null, name: body.name, type: body.type, hazard: body.hazard }).returning({ id: schema.planets.id })
// The bodies of one system (planets + moons), derived from (seed, system id) via bodyRng —
// independent of generation order and every other system. Shared by new-game creation and the
// v2 backfill so both produce identical rows for a given save+system.
async function insertBodiesForSystem(db: GameDb, systemId: number, seed: string): Promise<void> {
for (const body of generatePlanets(bodyRng(seed, systemId))) {
const [planet] = await db.db.insert(schema.planets).values({ systemId, parentId: null, name: body.name, type: body.type, hazard: body.hazard }).returning({ id: schema.planets.id })
for (const moon of body.moons) {
await db.db.insert(schema.planets).values({ systemId: row.id, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard })
await db.db.insert(schema.planets).values({ systemId, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard })
}
}
}
// Inserts one system together with its generated bodies, so a system is never left without
// worlds to explore.
async function insertSystemWithBodies(db: GameDb, saveId: number, ws: { name: string; x: number; y: number }, seed: string): Promise<number> {
const [row] = await db.db.insert(schema.solarSystems).values({ saveId, name: ws.name, x: ws.x, y: ws.y }).returning({ id: schema.solarSystems.id })
await insertBodiesForSystem(db, row.id, seed)
return row.id
}
// v2 backfill: every system without planets gets its generated bodies. Idempotent — systems
// that already have rows are skipped, so a second run changes nothing.
async function ensureBodiesForAllSystems(db: GameDb, saveId: number, seed: string): Promise<void> {
const systems = await db.db.select().from(schema.solarSystems)
for (const system of systems) {
const rows = await db.db.select({ id: schema.planets.id }).from(schema.planets).where(eq(schema.planets.systemId, system.id))
if (rows.length === 0) await insertBodiesForSystem(db, system.id, seed)
}
}
async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise<void> {
for (const [a, b] of edges) {
const from = resolve(a)
@@ -75,17 +91,29 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi
}
}
// Creates the save + starting region when the DB has none. No-op otherwise — safe to call
// on every request (single player, one process: check-then-insert cannot race in practice).
// What each schema version added to a save. Saves created by older code lack the data newer
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
// current — each step is idempotent (a no-op when its data already exists). When #9/#10 add
// seeding, bump SCHEMA_VERSION and append a step here.
export const SCHEMA_VERSION = 3 // v1: save + start region only · v2: bodies for every system · v3: starting crew
// Creates the save + starting region when the DB has none; backfills older saves otherwise.
// Safe to call on every request (single player, one process: check-then-insert cannot race).
export async function ensureSave(db: GameDb): Promise<void> {
const existing = await db.db.select().from(schema.saves).limit(1)
if (existing.length > 0) return
if (existing.length === 0) {
await createNewGame(db)
return
}
await backfillSave(db, existing[0])
}
async function createNewGame(db: GameDb): Promise<void> {
const seed = newSeed()
const region = generateStartRegion(mulberry32(Number(seed)))
const nowIso = new Date().toISOString()
const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso }).returning()
const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso, schemaVersion: SCHEMA_VERSION }).returning()
// All inserted as undiscovered first; the start is then marked discovered + current.
const startId = await insertSystemWithBodies(db, save.id, region.start, seed)
const neighborIds: number[] = []
@@ -100,6 +128,26 @@ export async function ensureSave(db: GameDb): Promise<void> {
await persist(db)
}
// Ordered idempotent backfill for a save created by older code. A NULL version means v1.
async function backfillSave(db: GameDb, save: { id: number; seed: string; schemaVersion: number | null }): Promise<void> {
const from = save.schemaVersion ?? 1
let version = from
if (version < 2) {
await ensureBodiesForAllSystems(db, save.id, save.seed) // bodies derive from (seed, system id) — identical to fresh generation
version = 2
}
if (version < 3) {
if ((await db.db.select().from(schema.crew)).length === 0) await seedStartingCrew(db, save.id, save.seed)
version = 3
}
if (from !== SCHEMA_VERSION) {
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
await persist(db)
}
}
export async function getWorldState(db: GameDb): Promise<WorldState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)