diff --git a/packages/nextly/src/cli/commands/migrate-check.ts b/packages/nextly/src/cli/commands/migrate-check.ts index 330135f..03c9de7 100644 --- a/packages/nextly/src/cli/commands/migrate-check.ts +++ b/packages/nextly/src/cli/commands/migrate-check.ts @@ -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"; @@ -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 ); diff --git a/packages/nextly/src/cli/commands/migrate-create.ts b/packages/nextly/src/cli/commands/migrate-create.ts index 900fb1c..5a42ee2 100644 --- a/packages/nextly/src/cli/commands/migrate-create.ts +++ b/packages/nextly/src/cli/commands/migrate-create.ts @@ -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"; @@ -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 && diff --git a/packages/nextly/src/domains/schema/ui-schema/cross-file.test.ts b/packages/nextly/src/domains/schema/ui-schema/cross-file.test.ts new file mode 100644 index 0000000..161ae29 --- /dev/null +++ b/packages/nextly/src/domains/schema/ui-schema/cross-file.test.ts @@ -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( + [] + ); + }); +}); diff --git a/packages/nextly/src/domains/schema/ui-schema/cross-file.ts b/packages/nextly/src/domains/schema/ui-schema/cross-file.ts new file mode 100644 index 0000000..7fa287b --- /dev/null +++ b/packages/nextly/src/domains/schema/ui-schema/cross-file.ts @@ -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([ + ...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; +} diff --git a/packages/nextly/src/domains/schema/ui-schema/merge.test.ts b/packages/nextly/src/domains/schema/ui-schema/merge.test.ts new file mode 100644 index 0000000..7b72113 --- /dev/null +++ b/packages/nextly/src/domains/schema/ui-schema/merge.test.ts @@ -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"); + }); +}); diff --git a/packages/nextly/src/domains/schema/ui-schema/merge.ts b/packages/nextly/src/domains/schema/ui-schema/merge.ts new file mode 100644 index 0000000..6f2e322 --- /dev/null +++ b/packages/nextly/src/domains/schema/ui-schema/merge.ts @@ -0,0 +1,92 @@ +/** + * Merge code-first and UI-built entities into one set per type (spec §4.11). + * + * Code-first wins on slug collision: a UI entity whose slug already exists in + * the code config is dropped (and reported via `droppedUiSlugs` so the caller + * can warn). The result feeds the existing `buildDesiredSnapshotFromConfig` / + * `generateMigration` — no new snapshot logic. + * + * @module domains/schema/ui-schema/merge + * @since v0.0.3-alpha (Plan D2) + */ +import type { + UiSchemaEntity, + UiSchemaManifest, +} from "../../../schemas/_zod/ui-schema"; +import type { MinimalConfigEntity } from "../migrate-create/generate"; + +export interface MergeUiEntitiesArgs { + codeCollections: MinimalConfigEntity[]; + codeSingles: MinimalConfigEntity[]; + codeComponents: MinimalConfigEntity[]; + manifest: UiSchemaManifest; +} + +export interface MergeUiEntitiesResult { + collections: MinimalConfigEntity[]; + singles: MinimalConfigEntity[]; + components: MinimalConfigEntity[]; + /** UI slugs dropped because a code entity already owns that slug. */ + droppedUiSlugs: string[]; +} + +function uiToMinimal( + entity: UiSchemaEntity, + prefix: "dc_" | "single_" | "comp_" +): MinimalConfigEntity { + return { + slug: entity.slug, + tableName: `${prefix}${entity.slug.replace(/-/g, "_")}`, + fields: entity.fields.map(f => ({ + name: f.name, + type: f.type, + required: f.required, + })), + status: entity.status === true, + }; +} + +function mergeType( + code: MinimalConfigEntity[], + ui: UiSchemaEntity[], + prefix: "dc_" | "single_" | "comp_", + dropped: string[] +): MinimalConfigEntity[] { + const codeSlugs = new Set(code.map(c => c.slug)); + const merged = [...code]; + for (const e of ui) { + if (codeSlugs.has(e.slug)) { + dropped.push(e.slug); + continue; + } + merged.push(uiToMinimal(e, prefix)); + } + return merged; +} + +export function mergeUiEntities( + args: MergeUiEntitiesArgs +): MergeUiEntitiesResult { + const droppedUiSlugs: string[] = []; + return { + collections: mergeType( + args.codeCollections, + args.manifest.collections, + "dc_", + droppedUiSlugs + ), + singles: mergeType( + args.codeSingles, + args.manifest.singles, + "single_", + droppedUiSlugs + ), + components: mergeType( + args.codeComponents, + args.manifest.components, + "comp_", + droppedUiSlugs + ), + droppedUiSlugs, + }; +} diff --git a/packages/nextly/src/errors/error-codes.ts b/packages/nextly/src/errors/error-codes.ts index a64790f..f3ad281 100644 --- a/packages/nextly/src/errors/error-codes.ts +++ b/packages/nextly/src/errors/error-codes.ts @@ -45,6 +45,8 @@ export const NEXTLY_ERROR_STATUS = { NEXTLY_MIGRATION_RESOLVE_PRECONDITION: 409, // Plan D — UI schema support. NEXTLY_UI_SCHEMA_INVALID: 400, + NEXTLY_SCHEMA_SLUG_COLLISION: 409, + NEXTLY_SCHEMA_RELATION_TARGET_MISSING: 400, } as const; export type NextlyErrorCode = keyof typeof NEXTLY_ERROR_STATUS;