Phase 2 harness wiring: differ --right-app web, e2e target app, dev-controls, cookbook

- differ can serve apps/web on the right side (--right-app web); impersonation no longer follows app-specific redirects
- e2e: E2E_TARGET_APP=web spawns apps/web servers per worker against the shared dbs; leaderboards spec + page object added; expectRouterIdle exported
- apps/web dev-controls endpoints: /auth/impersonate(+stop), /theme (404-redirect GET parity), /refresh-caches, /end-season, /run-routine, /sidenav
- HydrationTestIndicator implements the hydrated/router-idle contract (counts in-flight remote fetches)
- codemods: route-map (routes.ts -> SvelteKit route scaffolds, manifest-aware, idempotent) and remote-scaffold (loader->query, _action branches->commands)
- MIGRATION.md cookbook seeded with the golden read-path pattern and slice learnings; manifest: leaderboards migrated
This commit is contained in:
Kalle
2026-08-16 11:59:13 +03:00
parent 2aaf937ff9
commit 35ffc136be
27 changed files with 1062 additions and 16 deletions

195
MIGRATION.md Normal file
View File

@@ -0,0 +1,195 @@
# Migration cookbook
The pattern book for the React → Svelte migration (`svelte-big-bang.md`). Every
recurring pattern gets a before/after entry here; migrating agents follow the
book and never improvise. A pattern missing from the book is an escalation —
the resolution becomes a new entry. Entries are born in the vertical slices
(`/leaderboards` first) and grow from there.
Conventions used by every entry:
- `apps/web` imports its own code via `#lib/...` subpath imports **with explicit
file extensions** (`#lib/utils/urls.ts`, `#lib/components/Main.svelte`) — Kit 3
subpath imports are unambiguous or they don't resolve.
- Server-only code: `*.server.ts` filename suffix, or anything under
`src/lib/server/`. Remote functions: `<feature>.remote.ts`.
- One component per `.svelte` file; feature components live in
`src/lib/features/<feature>/components/`, generic primitives in
`@sendou/components` (`packages/components`), app-wide pieces in
`src/lib/components/`.
- Route files are thin shells: a `+page.svelte` composes feature components and
wires meta tags; everything real lives in the feature folder.
---
## Read path (loader → remote query)
The golden pattern, from `/leaderboards`.
**Before** (React Router): `loaders/leaderboards.server.ts` exports a `loader`
that parses search params from the request URL and returns data;
`useLoaderData()` in the route component.
**After** (SvelteKit): the loader body becomes a `query()` in
`leaderboards.remote.ts`. Remote queries cannot read the URL — the page parses
the URL client/SSR-side and passes the decoded values as query args, validated
by a valibot schema:
```ts
// leaderboards.remote.ts
import { query } from "$app/server";
import { getUser } from "#lib/features/auth/user.server.ts";
import { leaderboardsQuerySchema } from "./leaderboards-schemas.ts";
export const getLeaderboards = query(
leaderboardsQuerySchema,
async ({ type, season }) => {
const user = getUser(); // event.locals via getRequestEvent()
// ...same body as the React loader
},
);
```
```svelte
<!-- Leaderboards.svelte -->
<script lang="ts">
const params = searchParamsState(leaderboardsSearchParams);
const data = $derived(await getLeaderboards(params.current));
</script>
```
`$derived(await ...)` needs `compilerOptions.experimental.async` (on). When the
params change the derived re-awaits; identical args hit the query cache, so a
URL write that decodes to the same values refetches nothing — this replaces the
React `shouldRevalidate` machinery entirely.
Auth: `requireUser()` / `getUser()` / `actorId()` from
`#lib/features/auth/user.server.ts` read `event.locals.user`, resolved once per
request in `hooks.server.ts`. No AsyncLocalStorage.
## Write path (action → command), first shape
The full write-path pattern lands with the `/scrims` slice; `/leaderboards`
established the command shape for fixed-field mutations:
- Each `_action` branch of a React action union becomes its own `command()`
with its own valibot schema (the `_action` discriminator disappears).
- **Server-driven refresh is the default**: the handler calls
`getX(args).refresh()` / `.set(result)` for exactly the queries it
invalidated.
- When the server genuinely can't know which query instance the client holds
(filter/pagination args — the leaderboards case), the **client** rides the
refresh on the mutation round trip instead:
```ts
skipTeam({ season, identifier }).updates(getLeaderboards(queryArgs));
```
Never leave a mutation without one of the two — the refresh-everything default
must not ship.
## Search params
`app/modules/search-params` is ported at
`#lib/modules/search-params/search-params.ts` with valibot schemas
(`SP.param(v.picklist(...))` etc.). Component state comes from
`searchParamsState(definition)` (`search-params-state.svelte.ts`):
`params.current` is reactive to the URL; `params.set(updates)` writes through a
replace navigation (`goto` with `reset: false`), shallow for `loader: false`
params. Every definition still registers an `assertRoundTrips` test.
`SP.custom` takes an explicit `ParamCodec` (`{ decode, encode }`) instead of a
zod codec.
## Validation (zod → valibot)
- `z.enum([...])` → `v.picklist([...])`
- `z.number().int().min(1)` → `v.pipe(v.number(), v.integer(), v.minValue(1))`
- `z.string().regex(...).pipe(z.custom<T>())` →
`v.pipe(v.string(), v.regex(...), v.transform((s) => s as T))`
- `.nullable()` → `v.nullable(...)`
- Schemas with 2+ consumers get promoted to `@sendou/schemas` (none yet).
## Components
| React | Svelte |
|---|---|
| `useState` | `$state` |
| derived-in-render | `$derived` / `$derived.by` |
| `useEffect` | usually delete; else `$effect` |
| props / `children` / render props | `$props()` / snippets (`{@render children()}`) |
| `clsx(...)` in `className` | `class={[ ... ]}` array form (built-in clsx) |
| `React.cloneElement(icon, {className})` | wrapper `<span class="...">{@render icon()}</span>` + `:global(svg)` sizing |
| ref callbacks | `{@attach fn}` attachments (return value = cleanup) |
| `useHydrated()` | `browser` from `$app/env`, or just render client-only branches after an `$effect` sets a flag |
| controlled/uncontrolled prop pairs | same pattern; mark the mount-time branch with `svelte-ignore state_referenced_locally` |
| module-level caches keyed by `i18n.language` | usually unnecessary — paraglide messages are plain function calls |
| react-aria `Tabs/TabList/Tab/TabPanel` | `@sendou/components` `Tabs/TabList/Tab/TabPanel` (handrolled ARIA tabs, context-based) |
| react-aria `MenuTrigger/Menu/MenuItem` | `@sendou/components` `Menu/MenuItem` — trigger is a snippet receiving `{ "aria-expanded", "aria-haspopup", onclick }` to spread |
| react-aria `DialogTrigger/Popover/Dialog` | `@sendou/components` `Popover`, same trigger-snippet contract |
| react-aria `Select` + `Autocomplete` + `Virtualizer` | `@sendou/components` `Select/SelectItem/SelectItemSection` — filtering happens at the **data level** in the caller (see `WeaponSelect.svelte`), no virtualization (a few hundred items render fine) |
| `useLoaderData()` | `await query()` (see read path) |
| `<Link to>` | plain `<a href>` |
| `useUser()` / `useHasRole()` | `loggedInUser()` / `hasRole()` from `#lib/features/auth/user-state.ts` (reads `page.data.user`) |
| meta functions | `<MetaTags …/>` (`#lib/components/MetaTags.svelte`) inside the `+page.svelte` |
## CSS
- Each component's `.module.css` contents move into its `<style>` block
verbatim; `styles.foo` references become plain `class="foo"`.
- **Shared module classes** (one `.module.css` imported by several components,
e.g. the leaderboard table styles from `top-search.module.css`): declare them
once in the feature's top component under a scoped wrapper class with
`:global(...)` descendants (`.leaderboards :global(.tableRow) { ... }`);
child components just use the plain class names. Same technique for
cross-component selectors inside one logical component family
(`Tabs.svelte` styling `.tabList`/`.tabContainer` rendered by its children).
- Global styles (`vars.css`, `utils.css`, `normalize.css`, `common.css`,
`flags.css`, `fonts.css`) are imported once in `+layout.svelte`.
- A `<Main className="stack lg">` in React becomes `<Main>` wrapping a
`<div class="stack lg <feature>">` — the wrapper also carries the feature's
scoped `:global` styles. Visually identical; noted for the differ.
## i18n
- `t("ns:some.key")` → `m.ns_some_key()` (`import { m } from
"#lib/paraglide/messages.js"`): namespace prefix + dots/dashes → underscores.
- Dynamic key families (weapon names by id, mode names) go through the typed
helpers in `#lib/modules/i18n/messages.ts` (`mainWeaponName(id)`,
`modeLongName(mode)`, `weaponCategoryName(name)`, or `dynamicMessage(key)`).
- Current language: `getLocale()` from `#lib/paraglide/runtime.js` (replaces
`i18n.language`).
- `handle.i18n` namespace lists are gone — messages are tree-shaken imports.
## Infra
- Session cookies (`__session`, `theme`, `sidenav`) are byte-compatible with
the React app: `readSessionCookie`/`writeSessionCookie` in
`#lib/features/auth/session.server.ts` reimplement React Router's
base64-JSON + HMAC-SHA256 format with the same `SESSION_SECRET`.
- Repository files port verbatim: only the import paths change
(`~/db/sql` → `#lib/server/db/sql.ts`, `~/utils/kysely.server` →
`#lib/server/kysely.ts`, `~/utils/dates` → `#lib/utils/dates.ts`).
- `#lib/server/db/tables.ts` is a **trimmed** copy of the React `tables.ts` —
it grows with each migrated feature; copy table interfaces verbatim, omit
columns whose payload types would drag in unported feature graphs (they get
added back when that feature migrates).
- cachified + the LRU cache work unchanged (`#lib/server/cache.ts`).
- Dev-controls endpoints (`/auth/impersonate`, `/theme`, `/refresh-caches`,
`/end-season`, `/run-routine`, `/sidenav`) are `+server.ts` route handlers
gated on `DANGEROUS_CAN_ACCESS_DEV_CONTROLS`.
- The e2e/differ app contract (`data-testid="hydrated"`, `data-router-idle`)
is `#lib/components/HydrationTestIndicator.svelte`, rendered under
`IS_E2E_TEST_RUN` only; it counts in-flight remote fetches for the busy
state.
## One-offs ledger (discoveries)
- Kit 3 moved `Handle` to `@sveltejs/kit/hooks` and `getRequestEvent` to
`$app/server`; `event.request` is read-only in handle hooks (paraglide's
middleware pattern of reassigning it is unnecessary without URL locale
strategies).
- React's `/theme` GET responds with a 404-status redirect — kept verbatim via
a manual `Response` (Kit's `redirect()` only allows 3xx).
- The differ impersonates with `maxRedirects: 0` so the redirect target may be
a not-yet-migrated route.

View File

@@ -10,6 +10,12 @@ import {
} from "./helpers/playwright";
const DEBUG = process.env.E2E_DEBUG === "true";
/** `E2E_TARGET_APP=web` serves `apps/web` (the SvelteKit app) instead of this
* React app, for running migrated specs against the other side of the
* migration (`svelte-big-bang.md`). Factories keep writing through this app's
* code either way — the database files are shared. */
const TARGET_APP = process.env.E2E_TARGET_APP === "web" ? "web" : "web-react";
const WEB_APP_DIR = new URL("../../web", import.meta.url).pathname;
const SERVER_PROCESSES: ChildProcess[] = [];
const MINIO_MARKER_FILE = ".e2e-minio-started";
const STORAGE_BUCKET = "sendou";
@@ -127,6 +133,21 @@ async function globalSetup(config: FullConfig) {
: "Built the application",
);
if (TARGET_APP === "web") {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("Building apps/web (E2E_TARGET_APP=web)...");
execSync("pnpm run build", {
stdio: "inherit",
cwd: WEB_APP_DIR,
env: {
...process.env,
NODE_ENV: "production",
VITE_E2E_TEST_RUN: "true",
VITE_SITE_DOMAIN: `http://localhost:${E2E_BASE_PORT}`,
},
});
}
// Prepare databases and start servers for each worker
const serverPromises: Promise<void>[] = [];
@@ -156,12 +177,19 @@ async function globalSetup(config: FullConfig) {
// above already migrated, making the script's `migrate up` step redundant
const serverProcess = spawn(
process.execPath,
["./node_modules/@react-router/serve/bin.cjs", "./build/server/index.js"],
TARGET_APP === "web"
? [`${WEB_APP_DIR}/build/index.js`]
: [
"./node_modules/@react-router/serve/bin.cjs",
"./build/server/index.js",
],
{
env: {
...process.env,
NODE_ENV: "production",
DB_PATH: dbPath,
// the db files live in this app's directory either way (the
// factories and migrations run through this app's code)
DB_PATH: TARGET_APP === "web" ? `${process.cwd()}/${dbPath}` : dbPath,
PORT: String(port),
DISCORD_CLIENT_ID: "123",
DISCORD_CLIENT_SECRET: "secret",

View File

@@ -315,7 +315,7 @@ export async function waitForPOSTResponse(page: Page, cb: () => Promise<void>) {
}
/** Waits until no navigation, revalidation or fetcher is in flight. */
async function expectRouterIdle(page: Page) {
export async function expectRouterIdle(page: Page) {
// A submit's redirect plus the target page's loaders can exceed the default
// expect timeout when the full suite is loading all workers.
try {

View File

@@ -0,0 +1,93 @@
import { expect, test } from "./helpers/playwright";
import { LeaderboardsPage } from "./pages/leaderboards/leaderboards-page";
const HIGH_POWER_NAME = "TopPlayer";
const LOW_POWER_NAME = "RunnerUp";
const RM_ONLY_NAME = "RainmakerMain";
test.describe("Leaderboards", () => {
test("shows the season player leaderboard ordered by SP", async ({
page,
factories,
}) => {
const better = await factories.UserFactory.create({
discordName: "BetterPlayer",
});
const worse = await factories.UserFactory.create({
discordName: "WorsePlayer",
});
await factories.SkillFactory.create(
{ userId: better.id, mu: 30 },
{ matchesCount: 10 },
);
await factories.SkillFactory.create(
{ userId: worse.id, mu: 20 },
{ matchesCount: 10 },
);
const leaderboards = new LeaderboardsPage(page);
await leaderboards.goto();
const betterRow = page.getByRole("link", { name: /BetterPlayer/ });
const worseRow = page.getByRole("link", { name: /WorsePlayer/ });
await expect(betterRow).toBeVisible();
await expect(worseRow).toBeVisible();
const betterBox = await betterRow.boundingBox();
const worseBox = await worseRow.boundingBox();
expect(betterBox!.y).toBeLessThan(worseBox!.y);
});
test("filters the X Battle leaderboard by mode", async ({
page,
factories,
}) => {
const zonesPlayer = await factories.UserFactory.create();
const rainmakerPlayer = await factories.UserFactory.create();
await factories.XRankPlacementFactory.create({
playerUserId: zonesPlayer.id,
name: HIGH_POWER_NAME,
mode: "SZ",
power: 3200,
weaponSplId: 40,
});
await factories.XRankPlacementFactory.create({
playerUserId: zonesPlayer.id,
name: LOW_POWER_NAME,
playerSplId: `runner-up-${zonesPlayer.id}`,
mode: "SZ",
power: 3000,
weaponSplId: 1000,
});
await factories.XRankPlacementFactory.create({
playerUserId: rainmakerPlayer.id,
name: RM_ONLY_NAME,
mode: "RM",
power: 2900,
weaponSplId: 2000,
});
const leaderboards = new LeaderboardsPage(page);
await leaderboards.goto("?type=XP-ALL");
await expect(page.getByText(HIGH_POWER_NAME)).toBeVisible();
await expect(page.getByText(RM_ONLY_NAME)).toBeVisible();
await leaderboards.selectModeChip("Rainmaker");
await expect(page.getByText(RM_ONLY_NAME)).toBeVisible();
await expect(page.getByText(HIGH_POWER_NAME)).not.toBeVisible();
expect(page.url()).toContain("type=XP-MODE-RM");
});
test("switching to the X Battle tab clears the season param", async ({
page,
}) => {
const leaderboards = new LeaderboardsPage(page);
await leaderboards.goto("?season=1");
await leaderboards.selectTab("xpTab");
expect(page.url()).toContain("type=XP-ALL");
expect(page.url()).not.toContain("season=");
});
});

View File

@@ -0,0 +1,42 @@
import type { Page } from "@playwright/test";
import { LEADERBOARDS_PAGE } from "~/utils/urls";
import { expectRouterIdle, navigate } from "../../helpers/playwright";
/** `/leaderboards` */
export class LeaderboardsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
playersTab: this.page.getByRole("tab", { name: "Players" }),
teamsTab: this.page.getByRole("tab", { name: "Teams" }),
xpTab: this.page.getByRole("tab", { name: "X Battle" }),
weaponSelect: this.page.getByTestId("weapon-select"),
rows: this.page.locator("main a[href]").filter({
has: this.page.locator("img"),
}),
};
}
async goto(search = "") {
await navigate({
page: this.page,
url: `${LEADERBOARDS_PAGE}${search}`,
});
}
async selectTab(tab: "playersTab" | "teamsTab" | "xpTab") {
await this.locators[tab].click();
await expectRouterIdle(this.page);
}
async selectModeChip(label: string) {
await this.page
.getByRole("radiogroup")
.getByText(label, { exact: true })
.click();
await expectRouterIdle(this.page);
}
}

View File

@@ -23,10 +23,12 @@
"@sendou/components": "workspace:*",
"@sendou/in-game-lists": "workspace:*",
"@sendou/utils": "workspace:*",
"@types/nprogress": "^0.2.3",
"date-fns": "4.4.0",
"fflate": "^0.8.3",
"kysely": "0.29.0",
"lru-cache": "^11.2.1",
"nprogress": "^0.2.0",
"remeda": "2.39.0",
"sql-formatter": "^15.8.2",
"valibot": "^1.4.2"

View File

@@ -0,0 +1,57 @@
<script lang="ts" module>
let pendingRemoteRequests = $state(0);
let fetchPatched = false;
// remote function calls go over plain fetch; counting the in-flight ones is
// what lets e2e (and the differ) wait for "router idle" after interactions
function patchFetchOnce() {
if (fetchPatched || typeof window === "undefined") return;
fetchPatched = true;
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const isAppRequest = url.includes("/_app/") || url.startsWith("/");
if (isAppRequest) pendingRemoteRequests++;
try {
return await originalFetch(input, init);
} finally {
if (isAppRequest) pendingRemoteRequests--;
}
};
}
</script>
<script lang="ts">
import { navigating } from "$app/state";
let hydrated = $state(false);
$effect(() => {
patchFetchOnce();
hydrated = true;
});
const busy = $derived.by(() => {
const parts: string[] = [];
if (navigating.to) parts.push(`nav:${navigating.type ?? "navigating"}`);
if (pendingRemoteRequests > 0) {
parts.push(`remote:${pendingRemoteRequests}`);
}
return parts;
});
</script>
{#if hydrated}
<div
style="display: none"
data-testid="hydrated"
data-router-idle={busy.length === 0 ? "true" : undefined}
data-router-busy={busy.length === 0 ? undefined : busy.join(" | ")}
></div>
{/if}

View File

@@ -0,0 +1,31 @@
import { db } from "#lib/server/db/sql.ts";
import { concatUserSubmittedImagePrefix } from "#lib/server/kysely.ts";
import { databaseTimestampNow } from "#lib/utils/dates.ts";
const SIDEBAR_VISIBLE_SECONDS = 6 * 60 * 60;
/** External streams that should currently show in the sidebar (started under 6h ago or upcoming). */
export function findAllForSidebar() {
return db
.selectFrom("ExternalStream")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"ExternalStream.avatarImgId",
)
.select((eb) => [
"ExternalStream.id",
"ExternalStream.name",
"ExternalStream.url",
"ExternalStream.startsAt",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
])
.where(
"ExternalStream.startsAt",
">=",
databaseTimestampNow() - SIDEBAR_VISIBLE_SECONDS,
)
.execute();
}

View File

@@ -0,0 +1,4 @@
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
export const DANGEROUS_CAN_ACCESS_DEV_CONTROLS =
process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN;

View File

@@ -0,0 +1,46 @@
import { error, redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
import {
AUTH_COOKIE_NAME,
authCookieOptions,
IMPERSONATED_SESSION_KEY,
readSessionCookie,
writeSessionCookie,
} from "#lib/features/auth/session.server.ts";
export const POST: RequestHandler = async ({ request, url, cookies, locals }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
const user = locals.user;
const canImpersonate =
user?.roles.includes("ADMIN") || user?.roles.includes("DEV");
if (!canImpersonate) {
error(403, "Forbidden");
}
}
const rawId = url.searchParams.get("id");
const userId = Number(rawId);
if (!rawId || Number.isNaN(userId)) {
error(400, "Invalid user id");
}
const session = readSessionCookie(cookies.get(AUTH_COOKIE_NAME)) ?? {};
session[IMPERSONATED_SESSION_KEY] = userId;
cookies.set(
AUTH_COOKIE_NAME,
writeSessionCookie(session),
authCookieOptions,
);
const formData = await request.formData().catch(() => null);
const returnTo = formData?.get("returnTo");
const redirectTo =
typeof returnTo === "string" &&
returnTo.startsWith("/") &&
!returnTo.startsWith("//")
? returnTo
: "/admin";
redirect(303, redirectTo);
};

View File

@@ -0,0 +1,21 @@
import { redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import {
AUTH_COOKIE_NAME,
authCookieOptions,
IMPERSONATED_SESSION_KEY,
readSessionCookie,
writeSessionCookie,
} from "#lib/features/auth/session.server.ts";
export const POST: RequestHandler = ({ cookies }) => {
const session = readSessionCookie(cookies.get(AUTH_COOKIE_NAME)) ?? {};
delete session[IMPERSONATED_SESSION_KEY];
cookies.set(
AUTH_COOKIE_NAME,
writeSessionCookie(session),
authCookieOptions,
);
redirect(303, "/admin");
};

View File

@@ -0,0 +1,14 @@
import { error, json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
export const POST: RequestHandler = () => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
error(400, "Dev controls are not available");
}
Seasons.DANGEROUS_setSeasonEndedOverride(true);
return json({ success: true });
};

View File

@@ -0,0 +1,22 @@
import { error, json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
import { refreshBannedCache } from "#lib/features/ban/banned.server.ts";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
import { cache } from "#lib/server/cache.ts";
export const POST: RequestHandler = async ({ request }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
error(400, "Dev controls are not available");
}
const form = new URLSearchParams(await request.text());
if (form.get("resetDevOverrides") === "true") {
Seasons.DANGEROUS_setSeasonEndedOverride(false);
}
cache.clear();
await refreshBannedCache();
return json({ success: true });
};

View File

@@ -0,0 +1,24 @@
import { error, json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
// grows as routines are ported with their features (svelte-big-bang.md phase 4+)
const routines = new Map<string, () => Promise<void>>();
export const POST: RequestHandler = async ({ request }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
error(400, "Dev controls are not available");
}
const form = new URLSearchParams(await request.text());
const name = form.get("name");
const routine = name ? routines.get(name) : undefined;
if (!routine) {
error(400, `Unknown routine: ${name ?? "(missing)"}`);
}
await routine();
return json({ success: true });
};

View File

@@ -0,0 +1,29 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import {
readSessionCookie,
writeSessionCookie,
} from "#lib/features/auth/session.server.ts";
import { ServerConfig } from "#lib/server/config.ts";
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
const SIDENAV_COOKIE_NAME = "sidenav";
const TEN_YEARS_IN_SECONDS = 315_360_000;
export const POST: RequestHandler = async ({ request, cookies }) => {
const form = new URLSearchParams(await request.text());
const collapsed = form.get("collapsed") === "true";
const session =
readSessionCookie(cookies.get(SIDENAV_COOKIE_NAME)) ?? {};
session.collapsed = collapsed;
cookies.set(SIDENAV_COOKIE_NAME, writeSessionCookie(session), {
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
sameSite: "lax",
path: "/",
httpOnly: true,
maxAge: TEN_YEARS_IN_SECONDS,
});
return json({ success: true });
};

View File

@@ -0,0 +1,32 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import {
clearTheme,
isTheme,
setTheme,
} from "#lib/features/theme/theme.server.ts";
export const POST: RequestHandler = async ({ request, cookies }) => {
const form = new URLSearchParams(await request.text());
const theme = form.get("theme");
if (theme === "auto") {
clearTheme(cookies);
return json({ success: true });
}
if (!isTheme(theme)) {
return json({
success: false,
message: `theme value of ${theme ?? "null"} is not a valid theme`,
});
}
setTheme(cookies, theme);
return json({ success: true });
};
// matches the React route's loader: a 404-status redirect home
export const GET: RequestHandler = () => {
return new Response(null, { status: 404, headers: { Location: "/" } });
};

View File

@@ -76,7 +76,8 @@
"status": "pending"
},
"leaderboards": {
"status": "pending"
"status": "migrated",
"notes": "phase 2 vertical slice; verified pending differ + e2e run"
},
"lfg": {
"status": "pending"
@@ -218,7 +219,7 @@
"feature": "front-page"
},
"features/layout/routes/sidenav.ts": {
"status": "pending",
"status": "migrated",
"path": "/sidenav",
"feature": "layout"
},
@@ -729,9 +730,10 @@
"feature": "top-search"
},
"features/leaderboards/routes/leaderboards.tsx": {
"status": "pending",
"status": "migrated",
"path": "/leaderboards",
"feature": "leaderboards"
"feature": "leaderboards",
"notes": "ported by hand as the phase 2 golden slice; loader -> getLeaderboards query, action -> skipTeam/unskipTeam commands"
},
"features/links/routes/links.tsx": {
"status": "pending",
@@ -1099,7 +1101,7 @@
"feature": "user-page"
},
"features/theme/routes/theme.ts": {
"status": "pending",
"status": "migrated",
"path": "/theme",
"feature": "theme"
},

6
pnpm-lock.yaml generated
View File

@@ -34,6 +34,9 @@ importers:
'@sendou/utils':
specifier: workspace:*
version: link:../../packages/utils
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
date-fns:
specifier: 4.4.0
version: 4.4.0
@@ -46,6 +49,9 @@ importers:
lru-cache:
specifier: ^11.2.1
version: 11.5.2
nprogress:
specifier: ^0.2.0
version: 0.2.0
remeda:
specifier: 2.39.0
version: 2.39.0

View File

@@ -5,6 +5,8 @@
"type": "module",
"scripts": {
"mono-split": "node src/mono-split.ts",
"route-map": "node src/route-map.ts",
"remote-scaffold": "node src/remote-scaffold.ts",
"manifest:generate": "node src/manifest.ts generate",
"manifest:report": "node src/manifest.ts report"
},

View File

@@ -0,0 +1,174 @@
/**
* remote-scaffold — scaffolds a feature's `<feature>.remote.ts` from its React
* Router loader and action files: every `loaders/*.server.ts` becomes a
* `query()` stub, every `_action("NAME")` branch of the action schema becomes
* its own `command()` stub. The original server code is referenced (not
* copied); the migrating agent ports the bodies per MIGRATION.md and converts
* the schemas with zod-to-valibot. Idempotent: an existing remote file is
* never overwritten.
*
* Usage:
* node src/remote-scaffold.ts --feature leaderboards write the scaffold
* node src/remote-scaffold.ts --feature leaderboards --print print instead of writing
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { parseArgs } from "node:util";
const REPO_ROOT = new URL("../../..", import.meta.url).pathname;
const APP_ROOT = join(REPO_ROOT, "apps/web-react");
const WEB_LIB_ROOT = join(REPO_ROOT, "apps/web/src/lib");
const MANIFEST_PATH = join(REPO_ROOT, "migration-manifest.json");
const { values } = parseArgs({
options: {
feature: { type: "string" },
print: { type: "boolean", default: false },
},
});
if (!values.feature) {
process.stderr.write(
"usage: remote-scaffold.ts --feature <name> [--print]\n",
);
process.exit(1);
}
const feature = values.feature;
const featureDir = join(APP_ROOT, "app/features", feature);
if (!existsSync(featureDir)) {
process.stderr.write(`remote-scaffold: no feature named ${feature}\n`);
process.exit(1);
}
const loaderFiles = listServerFiles(join(featureDir, "loaders"));
const actionFiles = listServerFiles(join(featureDir, "actions"));
const queries = loaderFiles.map((file) => ({
file: `app/features/${feature}/loaders/${file}`,
name: `get${pascalCase(file.replace(".server.ts", ""))}`,
}));
const commands = actionFiles.flatMap((file) => {
const source = readFileSync(
join(featureDir, "actions", file),
"utf8",
);
const schemaImports = source.match(/from "[^"]*-schemas"/g) ?? [];
const schemaSources = [
source,
...schemaImports.flatMap(() => {
const schemaFile = join(featureDir, `${feature}-schemas.ts`);
return existsSync(schemaFile) ? [readFileSync(schemaFile, "utf8")] : [];
}),
].join("\n");
const actionNames = [
...new Set(
[...schemaSources.matchAll(/_action\("([A-Z0-9_]+)"\)/g)].map(
(match) => match[1],
),
),
];
return actionNames.map((actionName) => ({
file: `app/features/${feature}/actions/${file}`,
actionName,
name: camelCase(actionName),
}));
});
if (queries.length === 0 && commands.length === 0) {
process.stderr.write(
`remote-scaffold: ${feature} has no loaders/ or actions/ files\n`,
);
process.exit(1);
}
const scaffold = `import * as v from "valibot";
import { command, query } from "$app/server";
${queries
.map(
(query) => `export const ${query.name} = query(
// @MIGRATE args schema: the search params / route params the loader read,
// converted to valibot (zod-to-valibot)
v.object({}),
async (args) => {
// @MIGRATE port the loader body from ${query.file}
// (see MIGRATION.md "Read path"; auth via getUser()/requireUser())
throw new Error("@MIGRATE ${query.name} not implemented");
},
);
`,
)
.join("\n")}${commands
.map(
(cmd) => `
export const ${cmd.name} = command(
// @MIGRATE fields schema of the ${cmd.actionName} branch (zod-to-valibot)
v.object({}),
async (args) => {
// @MIGRATE port the ${cmd.actionName} branch from ${cmd.file}
// (see MIGRATION.md "Write path"; refresh the queries this invalidates)
throw new Error("@MIGRATE ${cmd.name} not implemented");
},
);
`,
)
.join("")}`;
if (values.print) {
process.stdout.write(scaffold);
process.exit(0);
}
const targetDir = join(WEB_LIB_ROOT, "features", feature);
const target = join(targetDir, `${feature}.remote.ts`);
if (existsSync(target)) {
process.stdout.write(
`remote-scaffold: ${target.replace(`${REPO_ROOT}/`, "")} already exists, leaving it alone\n`,
);
process.exit(0);
}
mkdirSync(targetDir, { recursive: true });
writeFileSync(target, scaffold);
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
const featureRow = manifest.features?.[feature];
if (featureRow && featureRow.status === "pending") {
featureRow.status = "scaffolded";
writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, "\t")}\n`);
}
process.stdout.write(
`remote-scaffold: wrote ${target.replace(`${REPO_ROOT}/`, "")} (${queries.length} queries, ${commands.length} commands)\n`,
);
function listServerFiles(dir: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((file) => file.endsWith(".server.ts"))
.sort();
}
function pascalCase(value: string): string {
return value
.split(/[-_.]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
function camelCase(value: string): string {
const pascal = pascalCase(value.toLowerCase());
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
}

View File

@@ -0,0 +1,172 @@
/**
* route-map — maps React Router routes (`app/routes.ts`) to SvelteKit route
* directories in `apps/web/src/routes`, generating thin scaffold files for
* routes that don't exist yet. Idempotent: existing files are never touched.
*
* Usage:
* node src/route-map.ts --path /leaderboards scaffold one URL path
* node src/route-map.ts --all scaffold every non-excluded route
* node src/route-map.ts --list print the path → directory mapping
*
* `.tsx` route files scaffold a `+page.svelte` (with an @MIGRATE marker), plain
* `.ts` route files a `+server.ts`. Scaffolded rows advance to `scaffolded` in
* the migration manifest with `codemod: "route-map"`.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { parseArgs } from "node:util";
const REPO_ROOT = new URL("../../..", import.meta.url).pathname;
const APP_ROOT = join(REPO_ROOT, "apps/web-react");
const WEB_ROUTES_ROOT = join(REPO_ROOT, "apps/web/src/routes");
const MANIFEST_PATH = join(REPO_ROOT, "migration-manifest.json");
interface RouteConfigEntry {
path?: string;
file: string;
index?: boolean;
children?: RouteConfigEntry[];
}
interface ManifestRouteRow {
status: string;
path: string;
feature: string;
codemod?: string;
notes?: string;
}
interface Manifest {
features: Record<string, { status: string; notes?: string }>;
routes: Record<string, ManifestRouteRow>;
}
const { values } = parseArgs({
options: {
path: { type: "string" },
all: { type: "boolean", default: false },
list: { type: "boolean", default: false },
},
});
const routeModule = await import(join(APP_ROOT, "app/routes.ts"));
const routes = [...flattenRoutes(routeModule.default, "")];
if (values.list) {
for (const route of routes) {
process.stdout.write(
`${route.path.padEnd(45)} → src/routes/${reactPathToSvelteDir(route.path)}/${route.file.endsWith(".tsx") ? "+page.svelte" : "+server.ts"}\n`,
);
}
process.exit(0);
}
const selected = values.all
? routes
: routes.filter((route) => route.path === values.path);
if (selected.length === 0) {
process.stderr.write(
values.path
? `route-map: no route with path ${values.path}\n`
: "usage: route-map.ts <--path /url | --all | --list>\n",
);
process.exit(1);
}
const manifest: Manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
let scaffolded = 0;
let skipped = 0;
for (const route of selected) {
const dir = reactPathToSvelteDir(route.path);
const isPage = route.file.endsWith(".tsx");
const target = join(
WEB_ROUTES_ROOT,
dir,
isPage ? "+page.svelte" : "+server.ts",
);
if (existsSync(target)) {
skipped++;
continue;
}
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, isPage ? pageScaffold(route) : serverScaffold(route));
scaffolded++;
const row = manifest.routes[route.file];
if (row) {
row.codemod = "route-map";
if (row.status === "pending") {
row.status = "scaffolded";
}
}
}
writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, "\t")}\n`);
process.stdout.write(
`route-map: ${scaffolded} scaffolded, ${skipped} already existed\n`,
);
function pageScaffold(route: { file: string; path: string }) {
const feature = route.file.split("/")[1] ?? "unknown";
return `<script lang="ts">
// @MIGRATE port the route component from apps/web-react/app/${route.file}
// (thin shell: compose components from #lib/features/${feature}/components/
// and wire the feature's remote functions; see MIGRATION.md)
</script>
<h1>@MIGRATE ${route.path}</h1>
`;
}
function serverScaffold(route: { file: string; path: string }) {
return `import type { RequestHandler } from "./$types";
// @MIGRATE port the resource route from apps/web-react/app/${route.file}
export const GET: RequestHandler = () => {
throw new Error("@MIGRATE ${route.path} not implemented");
};
`;
}
function reactPathToSvelteDir(urlPath: string): string {
if (urlPath === "/") return "";
return urlPath
.replace(/^\//, "")
.split("/")
.map((segment) => {
if (segment === "*") return "[...rest]";
if (segment.endsWith("?") && segment.startsWith(":")) {
return `[[${segment.slice(1, -1)}]]`;
}
if (segment.startsWith(":")) return `[${segment.slice(1)}]`;
return segment;
})
.join("/");
}
function* flattenRoutes(
entries: RouteConfigEntry[],
parentPath: string,
): Generator<{ file: string; path: string }> {
for (const entry of entries) {
const path = entry.index
? parentPath || "/"
: joinPaths(parentPath, entry.path);
yield { file: entry.file, path };
if (entry.children) {
yield* flattenRoutes(entry.children, path);
}
}
}
function joinPaths(parent: string, child: string | undefined): string {
if (!child) return parent || "/";
if (child.startsWith("/")) return child;
return `${parent.replace(/\/$/, "")}/${child}`;
}

View File

@@ -25,8 +25,13 @@ pnpm run differ -- --filter /leaderboards
pnpm run differ -- --max-rows 20 --themes light --viewports desktop
pnpm run differ -- --seed-now 2026-08-16T12:00:00Z --concurrency 2
pnpm run differ -- --skip-prepare # reuse the previous build + seeded dbs
pnpm run differ -- --right-app web --filter /leaderboards # React vs Svelte
```
`--right-app web` serves `apps/web` (the SvelteKit app) on the right side;
the default (`web-react`) is the React-vs-React control run. The left side is
always the React oracle.
Heads-up: a run holds two production servers plus a Chromium instance — a
couple of GB of memory. Run it from a plain terminal, not nested under other
memory-heavy tooling, and leave `--concurrency` at its default (1) on smaller

View File

@@ -28,9 +28,16 @@ const { values } = parseArgs({
"seed-now": { type: "string" },
"skip-prepare": { type: "boolean", default: false },
out: { type: "string" },
"right-app": { type: "string", default: "web-react" },
},
});
if (values["right-app"] !== "web-react" && values["right-app"] !== "web") {
throw new Error(
`--right-app must be "web-react" or "web", got "${values["right-app"]}"`,
);
}
const seedNow = values["seed-now"]
? new Date(values["seed-now"]).getTime()
: Date.now();
@@ -64,6 +71,8 @@ const e2eBasePort = Number(process.env.PORT || envPort || 5173) + 500;
const config: RunConfig = {
repoRoot: REPO_ROOT,
webReactDir,
webDir: path.join(REPO_ROOT, "apps", "web"),
rightApp: values["right-app"],
bakedSiteDomain: `http://localhost:${e2eBasePort}`,
outDir,
seedNow,

View File

@@ -165,10 +165,14 @@ async function createContexts(
};
});
// the redirect target is app-specific (and may not exist on a
// partially migrated right side), so only the impersonation
// response itself is checked
const impersonate = await context.request.post(
`/auth/impersonate?id=${census.adminUserId}`,
{ maxRedirects: 0 },
);
if (!impersonate.ok()) {
if (impersonate.status() >= 400) {
throw new Error(
`Impersonation on ${server.baseURL} failed with status ${impersonate.status()}`,
);

View File

@@ -22,6 +22,15 @@ export function prepare(config: RunConfig): Census {
E2E_BUILD_SITE_DOMAIN: config.bakedSiteDomain,
});
if (config.rightApp === "web") {
log("Building apps/web (right side)...");
runIn(config.webDir, "pnpm", ["run", "build"], {
VITE_E2E_TEST_RUN: "true",
VITE_SITE_DOMAIN: config.bakedSiteDomain,
NODE_ENV: "production",
});
}
log(`Seeding ${SEED_DB} at ${new Date(config.seedNow).toISOString()}...`);
for (const dbFile of [SEED_DB, LEFT_DB, RIGHT_DB]) {
deleteDbFiles(path.join(config.webReactDir, dbFile));
@@ -97,9 +106,18 @@ function run(
command: string,
args: string[],
env: Record<string, string>,
) {
runIn(config.webReactDir, command, args, env);
}
function runIn(
cwd: string,
command: string,
args: string[],
env: Record<string, string>,
) {
const result = spawnSync(command, args, {
cwd: config.webReactDir,
cwd,
stdio: "inherit",
env: { ...process.env, ...env },
});

View File

@@ -20,22 +20,32 @@ export async function startServers(
const servers = (
[
{ name: "left", port: config.leftPort, dbFile: dbFiles.left },
{ name: "right", port: config.rightPort, dbFile: dbFiles.right },
{ name: "left", app: "web-react", port: config.leftPort, dbFile: dbFiles.left },
{ name: "right", app: config.rightApp, port: config.rightPort, dbFile: dbFiles.right },
] as const
).map(({ name, port, dbFile }): AppServer => {
).map(({ name, app, port, dbFile }): AppServer => {
const isSvelteApp = app === "web";
const serverProcess = spawn(
process.execPath,
["./node_modules/@react-router/serve/bin.cjs", "./build/server/index.js"],
isSvelteApp
? ["./build/index.js"]
: [
"./node_modules/@react-router/serve/bin.cjs",
"./build/server/index.js",
],
{
cwd: config.webReactDir,
cwd: isSvelteApp ? config.webDir : config.webReactDir,
env: {
...process.env,
NODE_ENV: "production",
// bounded heap so two servers plus the browser cannot push the
// machine into memory-pressure kills
NODE_OPTIONS: "--max-old-space-size=1024",
DB_PATH: dbFile,
// the db files live in the web-react dir either way (they are
// produced by its seed scripts)
DB_PATH: isSvelteApp
? `${config.webReactDir}/${dbFile}`
: dbFile,
PORT: String(port),
DISCORD_CLIENT_ID: "123",
DISCORD_CLIENT_SECRET: "secret",

View File

@@ -21,6 +21,10 @@ export interface Viewport {
export interface RunConfig {
repoRoot: string;
webReactDir: string;
/** Directory of the SvelteKit app (`apps/web`). */
webDir: string;
/** Which app serves the right side; the left side is always `web-react` (the oracle). */
rightApp: "web-react" | "web";
/** The site domain baked into the shared e2e build; must be derived the
* same way as `E2E_BASE_PORT` so the e2e build gets reused. */
bakedSiteDomain: string;