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
51 changes: 48 additions & 3 deletions packages/nextly/src/cli/commands/migrate-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ import type {
Operation,
} from "../../domains/schema/pipeline/diff/types";
import type { SupportedDialect } from "../../domains/schema/services/schema-generator";
import { validateCrossFile } from "../../domains/schema/ui-schema/cross-file";
import { loadUiSchema } from "../../domains/schema/ui-schema/loader";
import { mergeUiEntities } from "../../domains/schema/ui-schema/merge";
import { createContext, type CommandContext } from "../program";
import { validateDatabaseEnv } from "../utils/adapter";
import { loadConfig, type LoadConfigResult } from "../utils/config-loader";
Expand Down Expand Up @@ -132,10 +135,52 @@ export async function runMigrateCheck(
const cwd = options.cwd ?? process.cwd();
const migrationsDir = resolve(cwd, configResult.config.db.migrationsDir);

// Layer 5: load + validate ui-schema.json (intra-file via loadUiSchema's Zod).
let manifest;
try {
manifest = await loadUiSchema({
projectRoot: cwd,
uiSchemaFile: configResult.config.db.uiSchemaFile,
});
} catch (error) {
logger.error(
`UI_SCHEMA_INVALID: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}

// Cross-file checks (slug collision, relation targets).
const crossIssues = validateCrossFile({
codeCollectionSlugs: configResult.config.collections.map(
(c: { slug: string }) => c.slug
),
manifest,
});
if (crossIssues.length > 0) {
for (const issue of crossIssues) {
logger.error(`${issue.code}: ${issue.message}`);
}
process.exit(1);
}

// Merge code + UI entities (code-first wins) for the drift comparison.
const merged = mergeUiEntities({
codeCollections: toMinimalEntities(configResult.config.collections, "dc_"),
codeSingles: toMinimalEntities(
configResult.config.singles ?? [],
"single_"
),
codeComponents: toMinimalEntities(
configResult.config.components ?? [],
"comp_"
),
manifest,
});

const desiredSnapshot = buildDesiredSnapshotFromConfig(
toMinimalEntities(configResult.config.collections, "dc_"),
toMinimalEntities(configResult.config.singles ?? [], "single_"),
toMinimalEntities(configResult.config.components ?? [], "comp_"),
merged.collections,
merged.singles,
merged.components,
dialect
);

Expand Down
41 changes: 34 additions & 7 deletions packages/nextly/src/cli/commands/migrate-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,10 @@ import {
} from "../../domains/schema/migrate-create/generate";
import { PromptCancelledError } from "../../domains/schema/migrate-create/prompt-renames";
import type { SupportedDialect } from "../../domains/schema/services/schema-generator";
import { loadUiSchema } from "../../domains/schema/ui-schema/loader";
import { mergeUiEntities } from "../../domains/schema/ui-schema/merge";
import { createContext, type CommandContext } from "../program";
import {
getDialectDisplayName,
validateDatabaseEnv,
} from "../utils/adapter";
import { getDialectDisplayName, validateDatabaseEnv } from "../utils/adapter";
import { loadConfig, type LoadConfigResult } from "../utils/config-loader";
import { formatDuration } from "../utils/logger";

Expand Down Expand Up @@ -210,16 +209,44 @@ export async function runMigrateCreate(
}

// Convert config entries to the minimal shape the orchestrator needs.
const collections = toMinimalEntities(configResult.config.collections, "dc_");
const singles = toMinimalEntities(
const codeCollections = toMinimalEntities(
configResult.config.collections,
"dc_"
);
const codeSingles = toMinimalEntities(
configResult.config.singles ?? [],
"single_"
);
const components = toMinimalEntities(
const codeComponents = toMinimalEntities(
configResult.config.components ?? [],
"comp_"
);

// Load + merge UI-built entities (code-first wins on slug collision).
let manifest;
try {
manifest = await loadUiSchema({
projectRoot: cwd,
uiSchemaFile: configResult.config.db.uiSchemaFile,
});
} catch (error) {
logger.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}

const merged = mergeUiEntities({
codeCollections,
codeSingles,
codeComponents,
manifest,
});
for (const slug of merged.droppedUiSlugs) {
logger.warn(
`ui-schema.json entry '${slug}' is shadowed by a code-first collection of the same slug; the code definition wins.`
);
}
const { collections, singles, components } = merged;

if (
collections.length === 0 &&
singles.length === 0 &&
Expand Down
94 changes: 94 additions & 0 deletions packages/nextly/src/domains/schema/ui-schema/cross-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* @module domains/schema/ui-schema/cross-file.test
* @since v0.0.3-alpha (Plan D2)
*/
import { describe, expect, it } from "vitest";

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

import { validateCrossFile } from "./cross-file";

describe("validateCrossFile", () => {
it("returns no issues for a clean config + manifest", () => {
const manifest = uiSchemaManifest.parse({
collections: [
{
slug: "events",
fields: [
{ name: "title", type: "text" },
{ name: "owner", type: "relationship", relationTo: "users" },
],
},
],
});
const issues = validateCrossFile({
codeCollectionSlugs: ["posts"],
manifest,
});
expect(issues).toEqual([]);
});

it("flags a slug present in both code and UI", () => {
const manifest = uiSchemaManifest.parse({
collections: [{ slug: "posts", fields: [{ name: "x", type: "text" }] }],
});
const issues = validateCrossFile({
codeCollectionSlugs: ["posts"],
manifest,
});
expect(issues.some(i => i.code === "NEXTLY_SCHEMA_SLUG_COLLISION")).toBe(
true
);
});

it("flags a relationTo that points at no known target", () => {
const manifest = uiSchemaManifest.parse({
collections: [
{
slug: "events",
fields: [
{ name: "owner", type: "relationship", relationTo: "ghosts" },
],
},
],
});
const issues = validateCrossFile({
codeCollectionSlugs: [],
manifest,
});
expect(
issues.some(i => i.code === "NEXTLY_SCHEMA_RELATION_TARGET_MISSING")
).toBe(true);
});

it("accepts relationTo pointing at a core table (users/media)", () => {
const manifest = uiSchemaManifest.parse({
collections: [
{
slug: "events",
fields: [{ name: "hero", type: "upload", relationTo: "media" }],
},
],
});
expect(validateCrossFile({ codeCollectionSlugs: [], manifest })).toEqual(
[]
);
});

it("accepts relationTo pointing at another UI collection", () => {
const manifest = uiSchemaManifest.parse({
collections: [
{ slug: "venues", fields: [{ name: "name", type: "text" }] },
{
slug: "events",
fields: [
{ name: "venue", type: "relationship", relationTo: "venues" },
],
},
],
});
expect(validateCrossFile({ codeCollectionSlugs: [], manifest })).toEqual(
[]
);
});
});
77 changes: 77 additions & 0 deletions packages/nextly/src/domains/schema/ui-schema/cross-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Cross-file validation for `ui-schema.json` vs `nextly.config.ts`
* (spec §4.14.5/4.14.6). These checks span both schema sources, so they
* live outside the per-file Zod schema (D1) and run at CI / dev-API time.
*
* - Slug collision: no slug appears in both code config and the manifest.
* - Relation target: every `relationTo` points at a real collection —
* code-first, UI-built, or a known core table (`users`, `media`).
*
* Pure: returns a list of issues; callers decide how to surface them
* (migrate:check prints + exits; the D3 dev API maps to NextlyError).
*
* @module domains/schema/ui-schema/cross-file
* @since v0.0.3-alpha (Plan D2)
*/
import type { UiSchemaManifest } from "../../../schemas/_zod/ui-schema";

/** Core tables a UI field may relate to without being a user collection. */
const CORE_RELATION_TARGETS = new Set(["users", "media"]);

export interface CrossFileIssue {
code:
| "NEXTLY_SCHEMA_SLUG_COLLISION"
| "NEXTLY_SCHEMA_RELATION_TARGET_MISSING";
message: string;
}

export interface ValidateCrossFileArgs {
/** Slugs of code-first collections (from `nextly.config.ts`). */
codeCollectionSlugs: string[];
manifest: UiSchemaManifest;
}

export function validateCrossFile(
args: ValidateCrossFileArgs
): CrossFileIssue[] {
const issues: CrossFileIssue[] = [];
const codeSlugs = new Set(args.codeCollectionSlugs);

// Every entity across the manifest, for slug-collision detection.
const manifestEntities = [
...args.manifest.collections,
...args.manifest.singles,
...args.manifest.components,
];
for (const e of manifestEntities) {
if (codeSlugs.has(e.slug)) {
issues.push({
code: "NEXTLY_SCHEMA_SLUG_COLLISION",
message: `slug '${e.slug}' is defined in both nextly.config.ts and ui-schema.json`,
});
}
}

// Valid relation targets: code collections + UI collections + core tables.
const validTargets = new Set<string>([
...args.codeCollectionSlugs,
...args.manifest.collections.map(c => c.slug),
...CORE_RELATION_TARGETS,
]);
for (const e of manifestEntities) {
for (const f of e.fields) {
if (
(f.type === "relationship" || f.type === "upload") &&
f.relationTo !== undefined &&
!validTargets.has(f.relationTo)
) {
issues.push({
code: "NEXTLY_SCHEMA_RELATION_TARGET_MISSING",
message: `${e.slug}.${f.name} relates to unknown target '${f.relationTo}'`,
});
}
}
}

return issues;
}
70 changes: 70 additions & 0 deletions packages/nextly/src/domains/schema/ui-schema/merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @module domains/schema/ui-schema/merge.test
* @since v0.0.3-alpha (Plan D2)
*/
import { describe, expect, it } from "vitest";

import { uiSchemaManifest } from "../../../schemas/_zod/ui-schema";
import type { MinimalConfigEntity } from "../migrate-create/generate";

import { mergeUiEntities } from "./merge";

const codeCollection: MinimalConfigEntity = {
slug: "posts",
tableName: "dc_posts",
fields: [{ name: "title", type: "text", required: true }],
status: false,
};

describe("mergeUiEntities", () => {
it("appends UI-only entities after code entities", () => {
const manifest = uiSchemaManifest.parse({
collections: [
{ slug: "events", fields: [{ name: "title", type: "text" }] },
],
});
const r = mergeUiEntities({
codeCollections: [codeCollection],
codeSingles: [],
codeComponents: [],
manifest,
});
expect(r.collections.map(c => c.slug)).toEqual(["posts", "events"]);
expect(r.collections[1].tableName).toBe("dc_events");
expect(r.droppedUiSlugs).toEqual([]);
});

it("drops a UI entity whose slug collides with code (code-first wins)", () => {
const manifest = uiSchemaManifest.parse({
collections: [
{ slug: "posts", fields: [{ name: "body", type: "text" }] },
],
});
const r = mergeUiEntities({
codeCollections: [codeCollection],
codeSingles: [],
codeComponents: [],
manifest,
});
expect(r.collections).toHaveLength(1);
expect(r.collections[0].fields.map(f => f.name)).toContain("title"); // code kept
expect(r.droppedUiSlugs).toEqual(["posts"]);
});

it("maps singles/components to their prefixes", () => {
const manifest = uiSchemaManifest.parse({
singles: [{ slug: "home", fields: [{ name: "hero", type: "text" }] }],
components: [
{ slug: "seo", fields: [{ name: "meta_title", type: "text" }] },
],
});
const r = mergeUiEntities({
codeCollections: [],
codeSingles: [],
codeComponents: [],
manifest,
});
expect(r.singles[0].tableName).toBe("single_home");
expect(r.components[0].tableName).toBe("comp_seo");
});
});
Loading
Loading