From 74490964ee70b778d16c10f4b6d11673043b02a1 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 11:51:44 +0530 Subject: [PATCH] feat(cli): migrate api entries into the apis block Co-Authored-By: Claude Opus 5 --- packages/cli/src/migrations/apis-block.ts | 221 +++++++++++++++++++ packages/cli/src/update.ts | 36 +++ packages/cli/test/migrate-apis-block.test.ts | 121 ++++++++++ 3 files changed, 378 insertions(+) create mode 100644 packages/cli/src/migrations/apis-block.ts create mode 100644 packages/cli/test/migrate-apis-block.test.ts diff --git a/packages/cli/src/migrations/apis-block.ts b/packages/cli/src/migrations/apis-block.ts new file mode 100644 index 00000000..2df2f924 --- /dev/null +++ b/packages/cli/src/migrations/apis-block.ts @@ -0,0 +1,221 @@ +/** + * Migration: move `api` entries out of legacy `ssr { … }` / `client { … }` + * data blocks into a single page-level `apis { }` block. + * + * IMPORTANT: this is a text-level transform, not a parse-and-rewrite. The + * `@wrnexus/syntax` parser deliberately THROWS a `ParseError` on `ssr { … }` + * / `client { … }` data blocks (see `packages/syntax/src/parser.ts`, the + * `case "ssr": case "client": case "server":` handling) — that legacy syntax + * is exactly what this migration exists to read, so it cannot be reached by + * parsing first. Instead this scans the source with the same balanced-brace + * technique used throughout `update.ts` (`findMatching`), and only parses + * the RESULT, as a validity check. + */ + +import { parse } from "@wrnexus/syntax"; + +/** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */ +function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number { + let depth = 0; + let quote = ""; + for (let index = open; index < source.length; index++) { + const char = source[index]!; + if (quote) { + if (char === "\\") index++; + else if (char === quote) quote = ""; + continue; + } + if (char === '"' || char === "'" || char === "`") quote = char; + else if (char === openChar) depth++; + else if (char === closeChar && --depth === 0) return index; + } + return -1; +} + +interface ModeBlock { + mode: "ssr" | "client"; + /** Start of the `ssr`/`client` keyword. */ + start: number; + /** One past the block's closing `}`. */ + end: number; + bodyStart: number; + bodyEnd: number; +} + +/** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */ +function findModeBlocks(source: string): ModeBlock[] { + const blocks: ModeBlock[] = []; + const header = /\b(ssr|client)\s*\{/g; + let match: RegExpExecArray | null; + while ((match = header.exec(source))) { + const open = match.index + match[0].length - 1; + const close = findMatching(source, open); + if (close < 0) { + throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`); + } + blocks.push({ + mode: match[1] as "ssr" | "client", + start: match.index, + end: close + 1, + bodyStart: open + 1, + bodyEnd: close, + }); + header.lastIndex = close + 1; + } + return blocks; +} + +interface ApiEntry { + name: string; + method: string; + path: string; + /** `name METHOD path { … }` text, without the leading `api` keyword. */ + text: string; + /** Offsets relative to the block body the entry was found in. */ + localStart: number; + localEnd: number; +} + +/** Find `api { … }` entries inside a mode-block body. */ +function findApiEntries(body: string): ApiEntry[] { + const entries: ApiEntry[] = []; + const header = /\bapi\s+([A-Za-z_$][\w$]*)\s+([A-Za-z]+)\s+(\S+?)\s*\{/g; + let match: RegExpExecArray | null; + while ((match = header.exec(body))) { + const open = match.index + match[0].length - 1; + const close = findMatching(body, open); + if (close < 0) { + throw new Error(`unterminated api entry '${match[1]}' at offset ${match.index}`); + } + const localStart = match.index; + const localEnd = close + 1; + const text = body.slice(localStart, localEnd).replace(/^api\s+/, ""); + entries.push({ + name: match[1]!, + method: match[2]!.toUpperCase(), + path: match[3]!, + text, + localStart, + localEnd, + }); + header.lastIndex = localEnd; + } + return entries; +} + +interface Edit { + start: number; + end: number; + replacement: string; +} + +function applyEdits(source: string, edits: Edit[]): string { + const sorted = [...edits].sort((a, b) => a.start - b.start); + let output = ""; + let cursor = 0; + for (const edit of sorted) { + output += source.slice(cursor, edit.start) + edit.replacement; + cursor = edit.end; + } + output += source.slice(cursor); + return output; +} + +export function migrateApisBlock( + source: string, +): { source: string; changed: boolean } | { skip: string } { + const blocks = findModeBlocks(source); + if (blocks.length === 0) return { source, changed: false }; + + const allEntries: ApiEntry[] = []; + const nameMode = new Map(); + const perBlockEntries = new Map(); + + for (const block of blocks) { + const body = source.slice(block.bodyStart, block.bodyEnd); + const entries = findApiEntries(body); + if (entries.length === 0) continue; + perBlockEntries.set(block, entries); + for (const entry of entries) { + const existingMode = nameMode.get(entry.name); + if (existingMode && existingMode !== block.mode) { + return { + skip: `api '${entry.name}' is declared in both ssr and client — merge them by hand before this migration can run`, + }; + } + nameMode.set(entry.name, block.mode); + allEntries.push(entry); + } + } + + if (allEntries.length === 0) return { source, changed: false }; + + // Locate an existing page-level `apis { }` block, if any. + const apisMatch = /\bapis\s*\{/.exec(source); + let existingApis: { bodyStart: number; bodyEnd: number } | null = null; + if (apisMatch) { + const open = apisMatch.index + apisMatch[0].length - 1; + const close = findMatching(source, open); + if (close < 0) throw new Error(`unterminated "apis {" block at offset ${apisMatch.index}`); + existingApis = { bodyStart: open + 1, bodyEnd: close }; + } + + const designatedBlock = blocks.find((block) => perBlockEntries.has(block)); + + const edits: Edit[] = []; + + for (const block of blocks) { + const entries = perBlockEntries.get(block); + if (!entries) continue; // block had no api entries; leave it untouched entirely + + const body = source.slice(block.bodyStart, block.bodyEnd); + let remainder = body; + for (const entry of [...entries].reverse()) { + remainder = remainder.slice(0, entry.localStart) + remainder.slice(entry.localEnd); + } + if (remainder.trim() !== "") { + // A bare `ssr { … }` / `client { … }` block is no longer valid syntax at + // all once its api entries are gone -- only "state" and hydrate forms + // survive. Leftover content (e.g. `functions { }`) belongs to the + // move-mode-functions migration; migrating api entries alone here would + // strand it inside a block shape the parser rejects. Skip rather than + // partially rewrite. + return { + skip: `${block.mode} { … } mixes api entries with other content (e.g. functions) that must be migrated first`, + }; + } + + if (!existingApis && block === designatedBlock) { + const apisBody = allEntries.map((entry) => ` ${entry.text}`).join("\n\n"); + const apisBlockText = `apis {\n${apisBody}\n }`; + edits.push({ start: block.start, end: block.end, replacement: apisBlockText }); + } else { + edits.push({ start: block.start, end: block.end, replacement: "" }); + } + } + + if (existingApis) { + const existingBody = source.slice(existingApis.bodyStart, existingApis.bodyEnd); + const additions = allEntries.map((entry) => ` ${entry.text}`).join("\n\n"); + const mergedBody = `${existingBody.replace(/\s*$/, "")}\n\n${additions}\n `; + edits.push({ + start: existingApis.bodyStart, + end: existingApis.bodyEnd, + replacement: mergedBody, + }); + } + + let after = applyEdits(source, edits); + after = after.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n"); + + if (after === source) return { source, changed: false }; + + try { + parse(after); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`apis-block migration produced source that failed to parse: ${message}`); + } + + return { source: after, changed: true }; +} diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 202f2e3e..342ba661 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -34,6 +34,7 @@ import { pathToFileURL } from "node:url"; import { formatWrn, parse } from "@wrnexus/syntax"; import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts"; import { inspectProject, type DoctorCheck } from "./doctor.ts"; +import { migrateApisBlock } from "./migrations/apis-block.ts"; import { generateApplicationTypes } from "./types.ts"; /** The version of the CLI currently running (its own package.json). */ @@ -868,6 +869,41 @@ const MIGRATIONS: Migration[] = [ if (!ctx.dryRun) writeFileSync(file, after, "utf8"); }, }, + { + version: "0.9.0", + id: "move-api-blocks", + description: + "Move api entries out of legacy ssr/client data blocks into a page-level apis block", + apply(ctx) { + const appDirectory = join(ctx.appRoot, "app"); + if (!existsSync(appDirectory)) return; + + for (const file of walkProjectFiles(appDirectory, ".wrn")) { + const relativeFile = relative(ctx.appRoot, file).replace(/\\/g, "/"); + const before = readFileSync(file, "utf8"); + + let result: ReturnType; + try { + result = migrateApisBlock(before); + } catch (error) { + const message = error instanceof Error ? error.message.split("\n", 1)[0] : String(error); + ctx.report.parseFailures.push(`${relativeFile}: ${message}`); + ctx.log(`! ${relativeFile}: left unchanged because the apis-block migration failed`); + continue; + } + + if ("skip" in result) { + ctx.report.needsReview.push(`${relativeFile}: ${result.skip}`); + continue; + } + if (!result.changed) continue; + + ctx.report.changedAutomatically.push(`${relativeFile}: moved api entries into apis { }`); + ctx.log(`~ ${relativeFile}: moved api entries into apis { }`); + if (!ctx.dryRun) writeFileSync(file, result.source, "utf8"); + } + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ diff --git a/packages/cli/test/migrate-apis-block.test.ts b/packages/cli/test/migrate-apis-block.test.ts new file mode 100644 index 00000000..7c3cee0f --- /dev/null +++ b/packages/cli/test/migrate-apis-block.test.ts @@ -0,0 +1,121 @@ +import { expect, test } from "bun:test"; +import { migrateApisBlock } from "../src/migrations/apis-block.ts"; + +const SOURCE = `page Search { + client { + api searchUsers POST /api/users { + request { body { name?: string } } + response { return data.users } + error { return [] } + } + } + + view {
x
} +} +`; + +test("a client api entry moves into an apis block", () => { + const result = migrateApisBlock(SOURCE) as { source: string; changed: boolean }; + + expect(result.changed).toBe(true); + expect(result.source).toContain("apis {"); + expect(result.source).toContain("searchUsers POST /api/users"); + expect(result.source).not.toContain("client {\n api"); +}); + +test("the sections survive unchanged", () => { + const result = migrateApisBlock(SOURCE) as { source: string }; + + expect(result.source).toContain("return data.users"); + expect(result.source).toContain("return []"); +}); + +test("running it on migrated source changes nothing", () => { + const once = (migrateApisBlock(SOURCE) as { source: string }).source; + const twice = migrateApisBlock(once) as { source: string; changed: boolean }; + + expect(twice.changed).toBe(false); + expect(twice.source).toBe(once); +}); + +test("a name declared in both modes is skipped with a reason", () => { + const clash = `page P { + ssr { api dup GET /api/a { response { return data } } } + client { api dup GET /api/a { response { return data } } } + view {
x
} +} +`; + const result = migrateApisBlock(clash) as { skip: string }; + + expect(result.skip).toContain("dup"); +}); + +test("a page with an existing apis block merges legacy entries instead of producing two apis blocks", () => { + const source = `page P { + apis { + existing GET /api/existing { + response { return data } + } + } + + client { + api added POST /api/added { + response { return data.value } + } + } + + view {
x
} +} +`; + const result = migrateApisBlock(source) as { source: string; changed: boolean }; + + expect(result.changed).toBe(true); + const apisCount = (result.source.match(/\bapis\s*\{/g) ?? []).length; + expect(apisCount).toBe(1); + expect(result.source).toContain("existing GET /api/existing"); + expect(result.source).toContain("added POST /api/added"); + expect(result.source).not.toContain("client {"); +}); + +test("a mode block mixing api entries with other content is skipped, not partially rewritten", () => { + // A bare `ssr { … }` block is invalid syntax once api entries are removed + // (only `state` and hydrate forms survive) -- leftover content such as + // `functions { }` belongs to the move-mode-functions migration, so this + // file cannot be completed by this migration alone. + const source = `page P { + ssr { + api foo GET /api/foo { + response { return data } + } + + functions { + function helper() { return 1 } + } + } + + view {
x
} +} +`; + const result = migrateApisBlock(source) as { skip: string }; + + expect(result.skip).toContain("ssr"); + expect(result.skip).toContain("functions"); +}); + +test("a brace inside a string literal in a section body does not truncate the entry", () => { + const source = `page P { + client { + api foo GET /api/foo { + response { return { text: "a } b" } } + } + } + + view {
x
} +} +`; + const result = migrateApisBlock(source) as { source: string; changed: boolean }; + + expect(result.changed).toBe(true); + expect(result.source).toContain('text: "a } b"'); + expect(result.source).toContain("apis {"); +});