Step 14.2: save document schema and migrations
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6cdffa2a43
commit
c473580c56
|
|
@ -1345,3 +1345,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
|
||||
- **The device clock is a small class with injected device time**, so tests set the device clock forward and back. It remembers the latest time it handed out; the save stores that (`lastTimeSeen`) and `restore` applies it on load, so restarting the app with the device clock set back still can't rewind the game.
|
||||
- **The developer offset only moves forward** (`advance`), like the old developer route; there is no "set offset" any more, because moving back would be clamped anyway.
|
||||
|
||||
## Step 14.2
|
||||
|
||||
- **The save mirrors the database, one company per document**: the same fields as the tables, minus player ids (there's one player per save) and minus the backup-code and login fields, which CR-014 removes. Statuses use the API's words (`in-flight`), not the database enum's (`in_flight`).
|
||||
- **Version 1 needs no migration**: it is the first local format and the CR allows resetting dev data. The CR's "identity migration for version 1" is covered by the framework itself (a version-1 save passes through unchanged and validates); the chaining is tested with made-up versions 1 → 2 → 3.
|
||||
- **The stored contract, dispatch record and resolution are checked against the sim's types at compile time**, so a sim change that the save doesn't follow fails the build instead of a player's load.
|
||||
- **`migrateSave` never throws**: unknown shapes, missing or future versions and invalid fields all come back as a readable message (Zod's pretty error for field problems), for the storage fallback (14.3) and import (14.10).
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ The `routes` catalog is the single source for both server and client. Each entry
|
|||
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
|
||||
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |
|
||||
|
||||
## Save document (`packages/schema/src/save.ts`, CR-014)
|
||||
|
||||
One document per company on the device, validated on every load and save: `saveVersion` (currently 1), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map and shop fields), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the database tables below without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing.
|
||||
|
||||
## 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). Contracts are JSONB snapshots (`contract_boards.contracts`, `dispatches.record`); `0005_cr007_goods` backfills pre-CR-007 contracts with a default good of their class, the origin's first shipper and no Priority. Every game table hangs off `players.id`; times are bigint Unix ms.
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
|
|||
- **Follow-ups to preserve:** Earth launch cost 2.1; base pay +50 %; developer tools for pilots, ships and discovery (become developer-only engine commands); deadhead.belowland.com as an allowed Vite host.
|
||||
- **Not started:** the Fixed Map and Fuel Tanks request (recorded after this phase as CR-015).
|
||||
- [x] **Step 14.1 — Engine package and clock.** New `packages/engine` (depends on content, schema, sim; picked up as a Vitest project). `Clock` interface (`now()`) and `DeviceClock`: device time (injected, `Date.now` by default) plus a developer offset, clamped to the last time it handed out; `lastTimeSeen` for the save, `restore(lastTimeSeen)` after loading one, `advance(ms)` for the developer tools (forward only). Tests (`packages/engine/test/clock.test.ts`): offset applied; device clock set back returns the last time seen; a forward move shows at once; a save's last time seen wins over a device that is behind; the offset can't go backward.
|
||||
- [ ] Step 14.2 — Save document schema and migrations
|
||||
- [x] **Step 14.2 — Save document schema and migrations.** `packages/schema/src/save.ts`: the save document (every database table for one company, without player ids, plus `saveVersion`, `saveId`, `lastTimeSeen`), with compile-time checks that its contract, dispatch record and resolution match the sim types. Migration framework: `SAVE_MIGRATIONS` (ordered version-to-version steps; none yet, version 1 is the first format), `applyMigrations` (steps a save up one version at a time), `migrateSave` (migrate, then validate; never throws, gives a readable error). Tests (`packages/schema/test/save.test.ts`, fixture `save-fixture.ts` reused later): a fully populated save validates; a wrong field is rejected naming `company.credits`; non-saves and saves from a newer build are refused; a version-1 save round-trips through JSON and migration unchanged; migrations chain in order and a missing step is reported.
|
||||
- [ ] Step 14.3 — Storage layer
|
||||
- [ ] Step 14.4 — Mutation queue and tab lock
|
||||
- [ ] Step 14.5 — Port the game logic
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export * from "./entities";
|
|||
export * from "./errors";
|
||||
export * from "./requests";
|
||||
export * from "./routes";
|
||||
export * from "./save";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
import type { Contract as SimContract, DispatchRecord, Resolution } from "@deadhead/sim";
|
||||
import { z } from "zod";
|
||||
import { contractSchema, idSchema, timeSchema } from "./entities";
|
||||
|
||||
/**
|
||||
* The save document (CR-014): everything the game database held, for the one
|
||||
* company on this device. Validated on every load and every save; each
|
||||
* `saveVersion` change ships with a migration (see `migrateSave`).
|
||||
*/
|
||||
|
||||
const credits = z.number().int();
|
||||
const activity = z.enum(["idle", "in-flight"]);
|
||||
|
||||
/** The sim's dispatch record, stored as it was created. */
|
||||
export const dispatchRecordSchema = z.object({
|
||||
id: idSchema,
|
||||
shipId: idSchema,
|
||||
pilotId: idSchema,
|
||||
contract: contractSchema.nullable(),
|
||||
originId: idSchema,
|
||||
destinationId: idSchema,
|
||||
tutorial: z.boolean(),
|
||||
departureAt: timeSchema,
|
||||
arrivalAt: timeSchema,
|
||||
distanceMillionKm: z.number().nonnegative(),
|
||||
fuelCost: credits.nonnegative(),
|
||||
expectedPayout: credits,
|
||||
seed: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
/** What resolving a dispatch did, kept for the "while you were away" summary. */
|
||||
export const resolutionSchema = z.object({
|
||||
credits,
|
||||
pilotPay: credits.nonnegative(),
|
||||
loanRepayment: credits.nonnegative(),
|
||||
pilotXp: z.number().int().nonnegative(),
|
||||
shipLocationId: idSchema,
|
||||
discoversDestination: z.boolean(),
|
||||
pilotDutyHours: z.number().nonnegative(),
|
||||
pilotRestEndsAt: timeSchema.nullable(),
|
||||
});
|
||||
|
||||
export const savedShipSchema = z.object({
|
||||
id: idSchema,
|
||||
modelId: idSchema,
|
||||
name: z.string().min(1),
|
||||
status: activity,
|
||||
/** Null while in flight. */
|
||||
locationId: idSchema.nullable(),
|
||||
/** Installed upgrade levels (CR-009); a missing upgrade is level 0. */
|
||||
upgrades: z.record(idSchema, z.number().int().positive()),
|
||||
/** In the upgrade shop until this time (decision 0.5.66); null or past: not. */
|
||||
inShopUntil: timeSchema.nullable(),
|
||||
/** The latest shop job, kept until the player has seen it. */
|
||||
shopUpgradeId: idSchema.nullable(),
|
||||
shopLevel: z.number().int().positive().nullable(),
|
||||
/** Handed over in the deal rather than bought (CR-013). */
|
||||
fromDeal: z.boolean(),
|
||||
createdAt: timeSchema,
|
||||
});
|
||||
|
||||
export const savedPilotSchema = z.object({
|
||||
id: idSchema,
|
||||
name: z.string().min(1),
|
||||
isAvatar: z.boolean(),
|
||||
isVeteran: z.boolean(),
|
||||
xp: z.number().int().nonnegative(),
|
||||
skills: z.record(idSchema, z.number().int().nonnegative()),
|
||||
skillPointsSpent: z.number().int().nonnegative(),
|
||||
assignedShipId: idSchema.nullable(),
|
||||
/** Only unassigned pilots store a location (decision 0.5.27). */
|
||||
locationId: idSchema.nullable(),
|
||||
status: activity,
|
||||
dutyHours: z.number().nonnegative(),
|
||||
restEndsAt: timeSchema.nullable(),
|
||||
createdAt: timeSchema,
|
||||
});
|
||||
|
||||
export const savedBoardSchema = z.object({
|
||||
id: idSchema,
|
||||
kind: z.enum(["location", "tutorial"]),
|
||||
locationId: idSchema,
|
||||
periodIndex: z.number().int(),
|
||||
/** Tutorial boards: runs completed when generated; 0 for location boards. */
|
||||
tutorialRun: z.number().int().nonnegative(),
|
||||
contracts: z.array(contractSchema),
|
||||
createdAt: timeSchema,
|
||||
});
|
||||
|
||||
export const savedDispatchSchema = z.object({
|
||||
id: idSchema,
|
||||
/** Null for a tutorial run (the loaner is not an owned ship). */
|
||||
shipId: idSchema.nullable(),
|
||||
pilotId: idSchema,
|
||||
tutorial: z.boolean(),
|
||||
record: dispatchRecordSchema,
|
||||
arrivalAt: timeSchema,
|
||||
status: z.enum(["in-flight", "resolved"]),
|
||||
resolution: resolutionSchema.nullable(),
|
||||
resolvedAt: timeSchema.nullable(),
|
||||
acknowledgedAt: timeSchema.nullable(),
|
||||
});
|
||||
|
||||
/** The save version this build writes. Bump it with a new migration. */
|
||||
export const CURRENT_SAVE_VERSION = 1;
|
||||
|
||||
export const saveDocumentSchema = z.object({
|
||||
saveVersion: z.literal(CURRENT_SAVE_VERSION),
|
||||
/** Identifies this company's save (debug info, notifications). */
|
||||
saveId: idSchema,
|
||||
/** The latest time the game has seen (decision 0.5.88): the clock never goes below it. */
|
||||
lastTimeSeen: timeSchema,
|
||||
player: z.object({
|
||||
/** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */
|
||||
worldSeed: z.number().int().nonnegative().max(0xffff_ffff),
|
||||
tutorialActive: z.boolean(),
|
||||
loanerLocationId: idSchema.nullable(),
|
||||
tutorialRunsCompleted: z.number().int().nonnegative(),
|
||||
dealAccepted: z.boolean(),
|
||||
createdAt: timeSchema,
|
||||
}),
|
||||
company: z.object({
|
||||
id: idSchema,
|
||||
name: z.string().min(1),
|
||||
credits,
|
||||
loanBalance: credits.nonnegative(),
|
||||
}),
|
||||
ships: z.array(savedShipSchema),
|
||||
pilots: z.array(savedPilotSchema),
|
||||
bays: z.array(z.object({ id: idSchema, locationId: idSchema, createdAt: timeSchema })),
|
||||
discoveredLocations: z.array(z.object({ locationId: idSchema, discoveredAt: timeSchema })),
|
||||
contractBoards: z.array(savedBoardSchema),
|
||||
dispatches: z.array(savedDispatchSchema),
|
||||
acceptedContracts: z.array(
|
||||
z.object({
|
||||
contractId: idSchema,
|
||||
periodIndex: z.number().int(),
|
||||
dispatchId: idSchema,
|
||||
acceptedAt: timeSchema,
|
||||
}),
|
||||
),
|
||||
claimedGoals: z.array(z.object({ goalId: idSchema, claimedAt: timeSchema })),
|
||||
});
|
||||
export type SaveDocument = z.infer<typeof saveDocumentSchema>;
|
||||
|
||||
// The saved shapes are the sim's own: a change on either side fails the build.
|
||||
type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
||||
const _sameContract: Same<z.infer<typeof contractSchema>, SimContract> = true;
|
||||
const _sameRecord: Same<z.infer<typeof dispatchRecordSchema>, DispatchRecord> = true;
|
||||
const _sameResolution: Same<z.infer<typeof resolutionSchema>, Resolution> = true;
|
||||
void [_sameContract, _sameRecord, _sameResolution];
|
||||
|
||||
/** One step: a save of version `from` to version `from + 1`. */
|
||||
export interface SaveMigration {
|
||||
from: number;
|
||||
migrate(save: Record<string, unknown>): Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every migration, in order. Version 1 is the first local save format, so
|
||||
* there is nothing before it yet; a format change adds `{ from: 1, … }` here
|
||||
* and bumps `CURRENT_SAVE_VERSION`.
|
||||
*/
|
||||
export const SAVE_MIGRATIONS: readonly SaveMigration[] = [];
|
||||
|
||||
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };
|
||||
type Stepped = { ok: true; save: Record<string, unknown> } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Steps a save from its version up to `current`, one migration at a time.
|
||||
* Doesn't validate the result; `migrateSave` does.
|
||||
*/
|
||||
export function applyMigrations(
|
||||
raw: unknown,
|
||||
migrations: readonly SaveMigration[],
|
||||
current: number,
|
||||
): Stepped {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return { ok: false, error: "The save isn't a save document." };
|
||||
}
|
||||
let save = raw as Record<string, unknown>;
|
||||
let version = save.saveVersion;
|
||||
if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
|
||||
return { ok: false, error: "The save has no valid saveVersion." };
|
||||
}
|
||||
if (version > current) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `The save is from a newer version of the game (save version ${version}, this build reads up to ${current}).`,
|
||||
};
|
||||
}
|
||||
while (version < current) {
|
||||
const step = migrations.find((m) => m.from === version);
|
||||
if (!step) return { ok: false, error: `No migration from save version ${version}.` };
|
||||
save = { ...step.migrate(save), saveVersion: version + 1 };
|
||||
version += 1;
|
||||
}
|
||||
return { ok: true, save };
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a save of any known version to the current one and validates it.
|
||||
* Never throws: an unknown or broken save gives a readable error.
|
||||
*/
|
||||
export function migrateSave(raw: unknown): SaveParse {
|
||||
const stepped = applyMigrations(raw, SAVE_MIGRATIONS, CURRENT_SAVE_VERSION);
|
||||
if (!stepped.ok) return stepped;
|
||||
const parsed = saveDocumentSchema.safeParse(stepped.save);
|
||||
return parsed.success
|
||||
? { ok: true, save: parsed.data }
|
||||
: {
|
||||
ok: false,
|
||||
error: `The save is damaged:
|
||||
${z.prettifyError(parsed.error)}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import type { Contract, SaveDocument } from "../src/index";
|
||||
|
||||
const T = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
/** A contract as the sim builds it. */
|
||||
export const sampleContract: Contract = {
|
||||
id: "earth:1:0",
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "standard",
|
||||
goodId: "consumer-goods",
|
||||
shipper: "Pacific Orbital Logistics",
|
||||
priority: false,
|
||||
deliveryWindowGameHours: null,
|
||||
tier: 0,
|
||||
massTonnes: 12,
|
||||
payout: 1500,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: T,
|
||||
expiresAt: T + 600_000,
|
||||
firstContact: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* A save with every collection and every optional field filled: a mid-game
|
||||
* company after the deal, with an upgraded ship in the shop, a resting pilot,
|
||||
* one flight under way and one resolved and unseen.
|
||||
*/
|
||||
export function fullSave(): SaveDocument {
|
||||
const record = (id: string, arrivalAt: number) => ({
|
||||
id,
|
||||
shipId: "s1",
|
||||
pilotId: "avatar",
|
||||
contract: sampleContract,
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
tutorial: false,
|
||||
departureAt: T,
|
||||
arrivalAt,
|
||||
distanceMillionKm: 0.38,
|
||||
fuelCost: 160,
|
||||
expectedPayout: 1500,
|
||||
seed: 42,
|
||||
});
|
||||
return {
|
||||
saveVersion: 1,
|
||||
saveId: "save-1",
|
||||
lastTimeSeen: T + 3_600_000,
|
||||
player: {
|
||||
worldSeed: 0xdeadbeef,
|
||||
tutorialActive: false,
|
||||
loanerLocationId: null,
|
||||
tutorialRunsCompleted: 4,
|
||||
dealAccepted: true,
|
||||
createdAt: T,
|
||||
},
|
||||
company: { id: "co1", name: "Deadhead Freight", credits: 12_345, loanBalance: 60_000 },
|
||||
ships: [
|
||||
{
|
||||
id: "s1",
|
||||
modelId: "courier",
|
||||
name: "Dusty Mule",
|
||||
status: "in-flight",
|
||||
locationId: null,
|
||||
upgrades: { engines: 2, "fuel-tanks": 1 },
|
||||
inShopUntil: null,
|
||||
shopUpgradeId: null,
|
||||
shopLevel: null,
|
||||
fromDeal: true,
|
||||
createdAt: T,
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
modelId: "clipper",
|
||||
name: "Swift Heron",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
upgrades: {},
|
||||
inShopUntil: T + 7_200_000,
|
||||
shopUpgradeId: "cargo-bay",
|
||||
shopLevel: 1,
|
||||
fromDeal: true,
|
||||
createdAt: T,
|
||||
},
|
||||
],
|
||||
pilots: [
|
||||
{
|
||||
id: "avatar",
|
||||
name: "Captain",
|
||||
isAvatar: true,
|
||||
isVeteran: false,
|
||||
xp: 120,
|
||||
skills: { "long-haul": 1 },
|
||||
skillPointsSpent: 1,
|
||||
assignedShipId: "s1",
|
||||
locationId: null,
|
||||
status: "in-flight",
|
||||
dutyHours: 4.5,
|
||||
restEndsAt: null,
|
||||
createdAt: T,
|
||||
},
|
||||
{
|
||||
id: "veteran",
|
||||
name: "Rosa Kell",
|
||||
isAvatar: false,
|
||||
isVeteran: true,
|
||||
xp: 250,
|
||||
skills: { "expensive-cargo": 1 },
|
||||
skillPointsSpent: 1,
|
||||
assignedShipId: "s2",
|
||||
locationId: null,
|
||||
status: "idle",
|
||||
dutyHours: 0,
|
||||
restEndsAt: T + 3_000_000,
|
||||
createdAt: T,
|
||||
},
|
||||
],
|
||||
bays: [{ id: "b1", locationId: "earth", createdAt: T }],
|
||||
discoveredLocations: [
|
||||
{ locationId: "earth", discoveredAt: T },
|
||||
{ locationId: "moon", discoveredAt: T },
|
||||
],
|
||||
contractBoards: [
|
||||
{
|
||||
id: "board-1",
|
||||
kind: "location",
|
||||
locationId: "earth",
|
||||
periodIndex: 1,
|
||||
tutorialRun: 0,
|
||||
contracts: [sampleContract],
|
||||
createdAt: T,
|
||||
},
|
||||
],
|
||||
dispatches: [
|
||||
{
|
||||
id: "d1",
|
||||
shipId: "s1",
|
||||
pilotId: "avatar",
|
||||
tutorial: false,
|
||||
record: record("d1", T + 9_000_000),
|
||||
arrivalAt: T + 9_000_000,
|
||||
status: "in-flight",
|
||||
resolution: null,
|
||||
resolvedAt: null,
|
||||
acknowledgedAt: null,
|
||||
},
|
||||
{
|
||||
id: "d0",
|
||||
shipId: "s1",
|
||||
pilotId: "avatar",
|
||||
tutorial: false,
|
||||
record: record("d0", T + 200_000),
|
||||
arrivalAt: T + 200_000,
|
||||
status: "resolved",
|
||||
resolution: {
|
||||
credits: 1000,
|
||||
pilotPay: 0,
|
||||
loanRepayment: 340,
|
||||
pilotXp: 10,
|
||||
shipLocationId: "moon",
|
||||
discoversDestination: false,
|
||||
pilotDutyHours: 2.4,
|
||||
pilotRestEndsAt: null,
|
||||
},
|
||||
resolvedAt: T + 200_000,
|
||||
acknowledgedAt: null,
|
||||
},
|
||||
],
|
||||
acceptedContracts: [
|
||||
{ contractId: sampleContract.id, periodIndex: 1, dispatchId: "d1", acceptedAt: T },
|
||||
],
|
||||
claimedGoals: [{ goalId: "first-own-delivery", claimedAt: T + 300_000 }],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyMigrations,
|
||||
CURRENT_SAVE_VERSION,
|
||||
migrateSave,
|
||||
saveDocumentSchema,
|
||||
type SaveMigration,
|
||||
} from "../src/index";
|
||||
import { fullSave } from "./save-fixture";
|
||||
|
||||
describe("save document (Step 14.2)", () => {
|
||||
it("validates a fully populated save", () => {
|
||||
expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true);
|
||||
expect(CURRENT_SAVE_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an invalid save with a readable error naming the field", () => {
|
||||
const broken = fullSave() as unknown as { company: { credits: unknown } };
|
||||
broken.company.credits = "lots";
|
||||
const result = migrateSave(broken);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("The save is damaged");
|
||||
expect(result.error).toContain("company.credits");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects things that aren't saves, and saves from a newer build", () => {
|
||||
expect(migrateSave("hello")).toEqual({ ok: false, error: "The save isn't a save document." });
|
||||
expect(migrateSave({ company: {} })).toEqual({
|
||||
ok: false,
|
||||
error: "The save has no valid saveVersion.",
|
||||
});
|
||||
const future = migrateSave({ ...fullSave(), saveVersion: CURRENT_SAVE_VERSION + 1 });
|
||||
expect(future.ok).toBe(false);
|
||||
if (!future.ok) expect(future.error).toContain("newer version");
|
||||
});
|
||||
|
||||
it("migrates a version-1 save to a valid current save", () => {
|
||||
const result = migrateSave(JSON.parse(JSON.stringify(fullSave())));
|
||||
expect(result).toEqual({ ok: true, save: fullSave() });
|
||||
});
|
||||
|
||||
it("chains migrations in order, one version at a time", () => {
|
||||
const seen: number[] = [];
|
||||
const migrations: SaveMigration[] = [
|
||||
{ from: 2, migrate: (s) => (seen.push(2), { ...s, extra: [...(s.extra as number[]), 2] }) },
|
||||
{ from: 1, migrate: (s) => (seen.push(1), { ...s, extra: [1] }) },
|
||||
];
|
||||
const result = applyMigrations({ saveVersion: 1 }, migrations, 3);
|
||||
expect(result).toEqual({ ok: true, save: { saveVersion: 3, extra: [1, 2] } });
|
||||
expect(seen).toEqual([1, 2]);
|
||||
expect(applyMigrations({ saveVersion: 1 }, [], 2)).toEqual({
|
||||
ok: false,
|
||||
error: "No migration from save version 1.",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue