import { Type } from "@sinclair/typebox"; import { expect, it } from "bun:test"; import { ServerScripts } from "../multiplayerPolicies"; import { ServerScriptManager } from "../serverScripts"; import { createUnsafeQuickJSModule } from "../serverSimulator"; const testEnvironment = { target: "test" as const, mode: "simulation" as const, label: "server-test", }; function createManager(options?: { getState?: () => any; applyState?: (options: { nextState: any }) => Promise; }) { let state: any = {}; let sharedData: Record = {}; return new ServerScriptManager({ getQuickJSModule: () => Promise.resolve(createUnsafeQuickJSModule()), environment: testEnvironment, getState: options?.getState ?? (() => state), getSharedConnectionData: () => sharedData, setSharedConnectionData: (data) => { sharedData = data; }, drainInputQueue: () => [], applyState: options?.applyState ?? (async ({ nextState }) => { state = nextState; }), log: () => {}, }); } it("runs migration script when schemaMigration trigger matches fromVersions", async () => { const schema = ServerScripts( Type.Object( { items: Type.Array( Type.Object({ id: Type.String(), name: Type.String(), }) ), }, { version: 2 } ), [ { id: "migrate-v1-to-v2", code: ` export default function(ctx) { if (ctx.trigger?.type !== "schemaMigration") return; // Rename 'title' to 'name' return { items: (ctx.state.oldItems ?? []).map(item => ({ id: item.id, name: item.title, })) }; } `, on: { schemaMigration: { fromVersions: [1] }, }, }, ] ); const manager = createManager(); await manager.updateSchema(schema); const oldState = { oldItems: [ { id: "1", title: "First" }, { id: "2", title: "Second" }, ], }; const result = await manager.handleSchemaMigration(1, 2, oldState); expect(result).toEqual({ items: [ { id: "1", name: "First" }, { id: "2", name: "Second" }, ], }); }); it("skips migration script when fromVersion doesn't match", async () => { const schema = ServerScripts( Type.Object({ value: Type.Number() }, { version: 3 }), [ { id: "migrate-v1-only", code: ` export default function(ctx) { return { value: ctx.state.value * 10 }; } `, on: { schemaMigration: { fromVersions: [1] }, }, }, ] ); const manager = createManager(); await manager.updateSchema(schema); // Migrating from v2 to v3 should not run the v1-only script const result = await manager.handleSchemaMigration(2, 3, { value: 5 }); expect(result).toBeUndefined(); }); it("runs migration script with schemaMigration: true for any version", async () => { const schema = ServerScripts( Type.Object({ count: Type.Number() }, { version: 5 }), [ { id: "always-migrate", code: ` export default function(ctx) { if (ctx.trigger?.type !== "schemaMigration") return; return { count: ctx.state.count + 100 }; } `, on: { schemaMigration: true, }, }, ] ); const manager = createManager(); await manager.updateSchema(schema); // Should run for any fromVersion const result1 = await manager.handleSchemaMigration(1, 5, { count: 1 }); expect(result1).toEqual({ count: 101 }); }); it("chains multiple migration scripts in order", async () => { const schema = ServerScripts( Type.Object({ value: Type.Number() }, { version: 3 }), [ { id: "first-migration", code: ` export default function(ctx) { if (ctx.trigger?.type !== "schemaMigration") return; return { value: ctx.state.value * 2 }; } `, on: { schemaMigration: true, }, }, { id: "second-migration", code: ` export default function(ctx) { if (ctx.trigger?.type !== "schemaMigration") return; return { value: ctx.state.value + 10 }; } `, on: { schemaMigration: true, }, }, ] ); const manager = createManager(); await manager.updateSchema(schema); const result = await manager.handleSchemaMigration(1, 3, { value: 5 }); // First script: 5 * 2 = 10 // Second script: 10 + 10 = 20 expect(result).toEqual({ value: 20 }); }); it("provides fromVersion and toVersion in trigger context", async () => { const schema = ServerScripts( Type.Object({ value: Type.Number() }, { version: 4 }), [ { id: "capture-trigger", code: ` export default function(ctx) { if (ctx.trigger?.type !== "schemaMigration") return; // Return the trigger info so we can verify it return { fromVersion: ctx.trigger.fromVersion, toVersion: ctx.trigger.toVersion, value: ctx.state.value, }; } `, on: { schemaMigration: true, }, }, ] ); const manager = createManager(); await manager.updateSchema(schema); const result = await manager.handleSchemaMigration(2, 4, { value: 42 }); expect(result).toEqual({ fromVersion: 2, toVersion: 4, value: 42, }); }); it("continues to next script if one returns undefined", async () => { const schema = ServerScripts( Type.Object({ value: Type.Number() }, { version: 2 }), [ { id: "skip-migration", code: ` export default function(ctx) { // Return undefined - should not affect state return undefined; } `, on: { schemaMigration: true, }, }, { id: "actual-migration", code: ` export default function(ctx) { return { value: ctx.state.value + 1 }; } `, on: { schemaMigration: true, }, }, ] ); const manager = createManager(); await manager.updateSchema(schema); const result = await manager.handleSchemaMigration(1, 2, { value: 10 }); expect(result).toEqual({ value: 11 }); }); it("returns undefined when no migration scripts are defined", async () => { const schema = ServerScripts( Type.Object({ value: Type.Number() }, { version: 2 }), [ { id: "state-change-only", code: ` export default function(ctx) { return { value: ctx.state.value * 2 }; } `, on: { stateChange: true, }, }, ] ); const manager = createManager(); await manager.updateSchema(schema); const result = await manager.handleSchemaMigration(1, 2, { value: 5 }); // No migration scripts, so undefined expect(result).toBeUndefined(); }); it("treats missing schema version as version 0", async () => { // When migrating from an unversioned schema to a versioned one, // the fromVersion is 0 (not undefined) const schema = ServerScripts( Type.Object({ value: Type.Number() }, { version: 1 }), [ { id: "migrate-from-unversioned", code: ` export default function(ctx) { if (ctx.trigger?.type !== "schemaMigration") return; // Only run when migrating from version 0 (unversioned) if (ctx.trigger.fromVersion !== 0) return; return { value: ctx.state.value + 1000 }; } `, on: { schemaMigration: { fromVersions: [0] }, }, }, ] ); const manager = createManager(); await manager.updateSchema(schema); // Migrating from unversioned (0) to v1 const result = await manager.handleSchemaMigration(0, 1, { value: 5 }); expect(result).toEqual({ value: 1005 }); }); it("matches multiple fromVersions", async () => { const schema = ServerScripts( Type.Object({ value: Type.Number() }, { version: 5 }), [ { id: "migrate-from-1-or-2", code: ` export default function(ctx) { return { value: ctx.state.value + 100 }; } `, on: { schemaMigration: { fromVersions: [1, 2] }, }, }, ] ); const manager = createManager(); await manager.updateSchema(schema); // Should run for version 1 const result1 = await manager.handleSchemaMigration(1, 5, { value: 1 }); expect(result1).toEqual({ value: 101 }); // Should run for version 2 const result2 = await manager.handleSchemaMigration(2, 5, { value: 2 }); expect(result2).toEqual({ value: 102 }); // Should NOT run for version 3 const result3 = await manager.handleSchemaMigration(3, 5, { value: 3 }); expect(result3).toBeUndefined(); });