Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/nextly/src/domains/schema/ui-schema/mutate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* @module domains/schema/ui-schema/mutate.test
* @since v0.0.3-alpha (Plan D3)
*/
import { describe, expect, it } from "vitest";

import { uiSchemaManifest } from "../../../schemas/_zod/ui-schema";

import { mutateManifest } from "./mutate";

const base = uiSchemaManifest.parse({
collections: [{ slug: "events", fields: [{ name: "title", type: "text" }] }],
});

describe("mutateManifest", () => {
it("upserts a new collection (append)", () => {
const next = mutateManifest(base, {
type: "upsert",
kind: "collections",
entity: { slug: "venues", fields: [{ name: "name", type: "text" }] },
});
expect(next.collections.map(c => c.slug)).toEqual(["events", "venues"]);
});

it("upserts an existing collection (replace by slug)", () => {
const next = mutateManifest(base, {
type: "upsert",
kind: "collections",
entity: {
slug: "events",
fields: [
{ name: "title", type: "text" },
{ name: "venue", type: "text" },
],
},
});
expect(next.collections).toHaveLength(1);
expect(next.collections[0].fields.map(f => f.name)).toEqual([
"title",
"venue",
]);
});

it("deletes a collection by slug", () => {
const next = mutateManifest(base, {
type: "delete",
kind: "collections",
slug: "events",
});
expect(next.collections).toEqual([]);
});

it("delete is idempotent when the slug is absent", () => {
const next = mutateManifest(base, {
type: "delete",
kind: "collections",
slug: "ghost",
});
expect(next.collections.map(c => c.slug)).toEqual(["events"]);
});

it("throws NEXTLY_UI_SCHEMA_INVALID when the upsert is invalid", () => {
expect(() =>
mutateManifest(base, {
type: "upsert",
kind: "collections",
entity: { slug: "Bad Slug", fields: [] },
})
).toThrowError(
expect.objectContaining({ code: "NEXTLY_UI_SCHEMA_INVALID" })
);
});

it("upserts a single and a component", () => {
const withSingle = mutateManifest(base, {
type: "upsert",
kind: "singles",
entity: { slug: "home", fields: [{ name: "hero", type: "text" }] },
});
expect(withSingle.singles.map(s => s.slug)).toEqual(["home"]);
const withComponent = mutateManifest(withSingle, {
type: "upsert",
kind: "components",
entity: { slug: "seo", fields: [{ name: "meta_title", type: "text" }] },
});
expect(withComponent.components.map(c => c.slug)).toEqual(["seo"]);
});
});
72 changes: 72 additions & 0 deletions packages/nextly/src/domains/schema/ui-schema/mutate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* In-memory `ui-schema.json` mutations for the dev write API (spec §4.12.3).
*
* `mutateManifest` applies one upsert/delete by slug and re-validates the whole
* manifest through the shared Zod schema (validation Layer 4) — throwing
* NEXTLY_UI_SCHEMA_INVALID before the caller writes anything. Pure: no HTTP, no
* filesystem. The HTTP handler (route-handler/dev-schema-handler.ts) loads the
* current manifest, calls this, then serializes + writes the result.
*
* @module domains/schema/ui-schema/mutate
* @since v0.0.3-alpha (Plan D3)
*/
import { NextlyError } from "../../../errors";
import {
parseUiSchema,
type UiSchemaManifest,
} from "../../../schemas/_zod/ui-schema";

export type ManifestKind = "collections" | "singles" | "components";

export type ManifestMutation =
| { type: "upsert"; kind: ManifestKind; entity: unknown }
| { type: "delete"; kind: ManifestKind; slug: string };

function slugOf(entity: unknown): string | undefined {
const s = (entity as { slug?: unknown }).slug;
return typeof s === "string" ? s : undefined;
}

/**
* Apply a mutation to a validated manifest and re-validate the result.
* Throws NEXTLY_UI_SCHEMA_INVALID (400) when the change would make the file
* invalid — the caller then leaves the file untouched.
*/
export function mutateManifest(
current: UiSchemaManifest,
mutation: ManifestMutation
): UiSchemaManifest {
const draft: Record<string, unknown> = {
$schema: current.$schema,
version: current.version,
collections: [...current.collections],
singles: [...current.singles],
components: [...current.components],
};

const list = [...(draft[mutation.kind] as unknown[])];
if (mutation.type === "upsert") {
const slug = slugOf(mutation.entity);
const idx =
slug === undefined ? -1 : list.findIndex(e => slugOf(e) === slug);
if (idx >= 0) list[idx] = mutation.entity;
else list.push(mutation.entity);
} else {
const filtered = list.filter(e => slugOf(e) !== mutation.slug);
list.length = 0;
list.push(...filtered);
}
draft[mutation.kind] = list;

const result = parseUiSchema(draft);
if (!result.success) {
const issues = result.error.issues
.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`)
.join("; ");
throw new NextlyError({
code: "NEXTLY_UI_SCHEMA_INVALID",
publicMessage: `ui-schema change rejected: ${issues}`,
});
}
return result.data;
}
120 changes: 120 additions & 0 deletions packages/nextly/src/route-handler/dev-schema-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @module route-handler/dev-schema-handler.test
* @since v0.0.3-alpha (Plan D3)
*/
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { setHandlerConfig } from "./auth-handler";
import { handleDevSchemaRequest } from "./dev-schema-handler";

let dir: string;
const ORIGINAL_ENV = process.env.NODE_ENV;

function req(method: string, body?: unknown): Request {
return new Request("http://x/admin/api/_dev/schema", {
method,
body: body === undefined ? undefined : JSON.stringify(body),
});
}

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "dev-schema-"));
vi.spyOn(process, "cwd").mockReturnValue(dir);
process.env.NODE_ENV = "development";
setHandlerConfig({
db: { uiSchemaFile: "./ui-schema.json" },
} as unknown as Parameters<typeof setHandlerConfig>[0]);
});

afterEach(() => {
vi.restoreAllMocks();
process.env.NODE_ENV = ORIGINAL_ENV;
});

describe("handleDevSchemaRequest", () => {
it("POST collection writes the manifest and returns 200", async () => {
const res = await handleDevSchemaRequest(
req("POST", {
slug: "events",
fields: [{ name: "title", type: "text" }],
}),
["_dev", "schema", "collection"],
"POST"
);
expect(res.status).toBe(200);
const written = JSON.parse(
await readFile(join(dir, "ui-schema.json"), "utf-8")
);
expect(written.collections[0].slug).toBe("events");
});

it("rejects an invalid entity with 400 and leaves the file untouched", async () => {
await writeFile(
join(dir, "ui-schema.json"),
JSON.stringify({
version: 1,
collections: [],
singles: [],
components: [],
})
);
const before = await readFile(join(dir, "ui-schema.json"), "utf-8");
const res = await handleDevSchemaRequest(
req("POST", { slug: "Bad Slug", fields: [] }),
["_dev", "schema", "collection"],
"POST"
);
expect(res.status).toBe(400);
expect(await readFile(join(dir, "ui-schema.json"), "utf-8")).toBe(before);
});

it("DELETE removes a collection by slug", async () => {
await writeFile(
join(dir, "ui-schema.json"),
JSON.stringify({
version: 1,
collections: [
{ slug: "events", fields: [{ name: "title", type: "text" }] },
],
singles: [],
components: [],
})
);
const res = await handleDevSchemaRequest(
req("DELETE"),
["_dev", "schema", "collection", "events"],
"DELETE"
);
expect(res.status).toBe(200);
const written = JSON.parse(
await readFile(join(dir, "ui-schema.json"), "utf-8")
);
expect(written.collections).toEqual([]);
});

it("404s an unknown kind", async () => {
const res = await handleDevSchemaRequest(
req("POST", {}),
["_dev", "schema", "widget"],
"POST"
);
expect(res.status).toBe(404);
});

it("404s when NODE_ENV is not development (defense in depth)", async () => {
process.env.NODE_ENV = "production";
const res = await handleDevSchemaRequest(
req("POST", {
slug: "events",
fields: [{ name: "title", type: "text" }],
}),
["_dev", "schema", "collection"],
"POST"
);
expect(res.status).toBe(404);
});
});
105 changes: 105 additions & 0 deletions packages/nextly/src/route-handler/dev-schema-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Dev-only write API for `ui-schema.json` (spec §4.12.3).
*
* Endpoints (mounted only when NODE_ENV === "development"; see routeHandler):
* POST /admin/api/_dev/schema/collection upsert a collection
* DELETE /admin/api/_dev/schema/collection/:slug remove a collection
* POST /admin/api/_dev/schema/single upsert a single
* DELETE /admin/api/_dev/schema/single/:slug remove a single
* POST /admin/api/_dev/schema/component upsert a component
* DELETE /admin/api/_dev/schema/component/:slug remove a component
*
* Each write loads the current manifest, applies the change, re-validates the
* whole manifest (Layer 4 — `mutateManifest` throws NEXTLY_UI_SCHEMA_INVALID on
* failure), then serializes via the deterministic writer and writes the file.
*
* @module route-handler/dev-schema-handler
* @since v0.0.3-alpha (Plan D3)
*/
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";

import { respondAction } from "../api/response-shapes";
import { withErrorHandler } from "../api/with-error-handler";
import { serializeUiSchema } from "../cli/utils/ui-schema-writer";
import { loadUiSchema } from "../domains/schema/ui-schema/loader";
import {
mutateManifest,
type ManifestKind,
} from "../domains/schema/ui-schema/mutate";
import { NextlyError } from "../errors";

import { getHandlerConfig } from "./auth-handler";

const KIND_BY_SEGMENT: Record<string, ManifestKind> = {
collection: "collections",
single: "singles",
component: "components",
};

function notFound(): Response {
return new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}

/**
* Handle `/admin/api/_dev/schema/*`. `params` is the route-handler segment
* array, e.g. `["_dev", "schema", "collection", "events"]`.
*/
export const handleDevSchemaRequest = withErrorHandler(
async (
req: Request,
params: string[],
method: "POST" | "DELETE"
): Promise<Response> => {
// Defense in depth — the routeHandler branch already gates on NODE_ENV.
if (process.env.NODE_ENV !== "development") return notFound();
if (params[1] !== "schema") return notFound();

const kind = KIND_BY_SEGMENT[params[2] ?? ""];
if (!kind) return notFound();

const config = getHandlerConfig();
if (!config) {
throw new NextlyError({
code: "INTERNAL_ERROR",
publicMessage: "Nextly config not initialized.",
});
}
const projectRoot = process.cwd();
const uiSchemaFile = config.db.uiSchemaFile;
const current = await loadUiSchema({ projectRoot, uiSchemaFile });

let next;
if (method === "POST" && params[3] === undefined) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
throw new NextlyError({
code: "VALIDATION_ERROR",
publicMessage: "Invalid JSON in request body.",
});
}
next = mutateManifest(current, { type: "upsert", kind, entity: body });
} else if (method === "DELETE" && params[3] !== undefined) {
next = mutateManifest(current, {
type: "delete",
kind,
slug: params[3],
});
} else {
return notFound();
}

await writeFile(
resolve(projectRoot, uiSchemaFile),
serializeUiSchema(next),
"utf-8"
);
return respondAction("ui-schema updated", { kind });
}
);
Loading
Loading