From fb30fa18fc0ff63b4873b5d881d061aa2fc233ab Mon Sep 17 00:00:00 2001 From: aqib-rx Date: Mon, 1 Jun 2026 16:57:12 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(schema):=20add=20per-dialect=20ui=20me?= =?UTF-8?q?tadata-row=20upsert=20builder=20(D2b=20=C2=A74.12.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schema/ui-schema/metadata-sql.test.ts | 112 ++++++++++ .../domains/schema/ui-schema/metadata-sql.ts | 204 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 packages/nextly/src/domains/schema/ui-schema/metadata-sql.test.ts create mode 100644 packages/nextly/src/domains/schema/ui-schema/metadata-sql.ts diff --git a/packages/nextly/src/domains/schema/ui-schema/metadata-sql.test.ts b/packages/nextly/src/domains/schema/ui-schema/metadata-sql.test.ts new file mode 100644 index 0000000..25d20ac --- /dev/null +++ b/packages/nextly/src/domains/schema/ui-schema/metadata-sql.test.ts @@ -0,0 +1,112 @@ +/** + * @module domains/schema/ui-schema/metadata-sql.test + * @since v0.0.3-alpha (Plan D2b) + */ +import { describe, expect, it } from "vitest"; + +import { uiSchemaManifest } from "../../../schemas/_zod/ui-schema"; +import { calculateSchemaHash } from "../services/schema-hash"; + +import { + buildCollectionMetadataUpsert, + buildComponentMetadataUpsert, + buildSingleMetadataUpsert, +} from "./metadata-sql"; + +const manifest = uiSchemaManifest.parse({ + collections: [ + { + slug: "events", + labels: { singular: "Event", plural: "Events" }, + admin: { useAsTitle: "title" }, + fields: [ + { name: "title", type: "text", required: true }, + { name: "note", type: "text" }, + ], + }, + { slug: "no-labels", fields: [{ name: "x", type: "text" }] }, + ], + singles: [{ slug: "home", fields: [{ name: "hero", type: "text" }] }], + components: [{ slug: "seo", fields: [{ name: "meta_title", type: "text" }] }], +}); + +const events = manifest.collections[0]; +const noLabels = manifest.collections[1]; +const home = manifest.singles[0]; +const seo = manifest.components[0]; + +describe("buildCollectionMetadataUpsert", () => { + it("postgres: INSERT … ON CONFLICT (slug) DO UPDATE with ::jsonb casts", () => { + const sql = buildCollectionMetadataUpsert(events, "postgresql"); + expect(sql).toContain('INSERT INTO "dynamic_collections"'); + expect(sql).toContain('ON CONFLICT ("slug") DO UPDATE SET'); + expect(sql).toContain("::jsonb"); + expect(sql).toContain("'dc_events'"); + expect(sql).toContain("'ui'"); + }); + + it("mysql: INSERT … ON DUPLICATE KEY UPDATE with backtick idents", () => { + const sql = buildCollectionMetadataUpsert(events, "mysql"); + expect(sql).toContain("INSERT INTO `dynamic_collections`"); + expect(sql).toContain("ON DUPLICATE KEY UPDATE"); + expect(sql).toContain("VALUES(`fields`)"); + }); + + it("sqlite: ON CONFLICT(slug) and integer booleans", () => { + const sql = buildCollectionMetadataUpsert(events, "sqlite"); + expect(sql).toContain('ON CONFLICT ("slug") DO UPDATE SET'); + expect(sql).toMatch(/"status"/); + }); + + it("embeds the runtime schema hash for the fields", () => { + const sql = buildCollectionMetadataUpsert(events, "postgresql"); + const hash = calculateSchemaHash( + events.fields as unknown as Parameters[0] + ); + expect(sql).toContain(hash); + }); + + it("derives labels from the slug when omitted", () => { + const sql = buildCollectionMetadataUpsert(noLabels, "postgresql"); + expect(sql).toContain('"labels"'); + expect(sql).toContain("'dc_no_labels'"); + }); + + it("is deterministic (same input → identical SQL)", () => { + expect(buildCollectionMetadataUpsert(events, "postgresql")).toBe( + buildCollectionMetadataUpsert(events, "postgresql") + ); + }); + + it("escapes single quotes in values", () => { + const tricky = uiSchemaManifest.parse({ + collections: [ + { + slug: "quotes", + labels: { singular: "It's", plural: "It's" }, + fields: [{ name: "x", type: "text" }], + }, + ], + }).collections[0]; + const sql = buildCollectionMetadataUpsert(tricky, "postgresql"); + expect(sql).toContain("It''s"); + }); +}); + +describe("buildSingleMetadataUpsert", () => { + it("targets dynamic_singles with a singular label column", () => { + const sql = buildSingleMetadataUpsert(home, "postgresql"); + expect(sql).toContain('INSERT INTO "dynamic_singles"'); + expect(sql).toContain('"label"'); + expect(sql).toContain("'single_home'"); + }); +}); + +describe("buildComponentMetadataUpsert", () => { + it("targets dynamic_components and omits status", () => { + const sql = buildComponentMetadataUpsert(seo, "postgresql"); + expect(sql).toContain('INSERT INTO "dynamic_components"'); + expect(sql).toContain("'comp_seo'"); + expect(sql).not.toContain('"status"'); + }); +}); diff --git a/packages/nextly/src/domains/schema/ui-schema/metadata-sql.ts b/packages/nextly/src/domains/schema/ui-schema/metadata-sql.ts new file mode 100644 index 0000000..c64b418 --- /dev/null +++ b/packages/nextly/src/domains/schema/ui-schema/metadata-sql.ts @@ -0,0 +1,204 @@ +/** + * Per-dialect metadata-row upsert SQL for UI-built entities (spec §4.12.7). + * + * `migrate:create` appends one of these statements per UI-built entity whose + * data table is touched in a migration, so that after `nextly migrate` runs in + * production the collection/single/component appears in the admin UI — the + * `dynamic_collections` / `dynamic_singles` / `dynamic_components` metadata row + * is created/updated by the same committed `.sql` that creates the data table. + * + * The row is built to match what the runtime would write: `schema_hash` reuses + * the runtime `calculateSchemaHash`; `fields` is stored 1:1; labels derive from + * the slug via the shared helpers. `id` is derived deterministically from the + * slug so the committed SQL is byte-stable. + * + * KNOWN LIMITATION (v1): label-only edits produce no DDL operation, so no + * migration is generated and the label change is not propagated until a schema + * change co-occurs. + * + * @module domains/schema/ui-schema/metadata-sql + * @since v0.0.3-alpha (Plan D2b) + */ +import { createHash } from "node:crypto"; + +import type { SupportedDialect } from "@nextlyhq/adapter-drizzle/types"; + +import type { UiSchemaEntity } from "../../../schemas/_zod/ui-schema"; +import { + toPluralLabel, + toSingularLabel, +} from "../../../shared/lib/pluralization"; +import { quoteIdent } from "../pipeline/sql-templates/identifier-quoting"; +import { calculateSchemaHash } from "../services/schema-hash"; + +type Dialect = SupportedDialect; + +/** Deterministic UUID-shaped id from a slug (stable committed SQL). */ +function deterministicId(slug: string): string { + const hex = createHash("sha256").update(`ui:${slug}`).digest("hex"); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join("-"); +} + +/** SQL single-quoted string literal (standard single-quote doubling). */ +function sqlStr(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +/** JSON value literal. PG casts to jsonb; MySQL/SQLite store the JSON text. */ +function jsonLiteral(value: unknown, dialect: Dialect): string { + const lit = sqlStr(JSON.stringify(value)); + return dialect === "postgresql" ? `${lit}::jsonb` : lit; +} + +/** Boolean literal: integer on SQLite, keyword elsewhere. */ +function boolLiteral(value: boolean, dialect: Dialect): string { + if (dialect === "sqlite") return value ? "1" : "0"; + return value ? "true" : "false"; +} + +interface Column { + name: string; + value: string; + /** Whether this column is updated on conflict (mutable). */ + update?: boolean; +} + +/** Assemble an INSERT … upsert for the given dialect. */ +function buildUpsert( + table: string, + columns: Column[], + dialect: Dialect +): string { + const idents = columns.map(c => quoteIdent(c.name, dialect)).join(", "); + const values = columns.map(c => c.value).join(", "); + const insert = `INSERT INTO ${quoteIdent(table, dialect)} (${idents}) VALUES (${values})`; + const updatable = columns.filter(c => c.update); + + if (dialect === "mysql") { + const sets = updatable + .map( + c => + `${quoteIdent(c.name, dialect)} = VALUES(${quoteIdent(c.name, dialect)})` + ) + .join(", "); + return `${insert} ON DUPLICATE KEY UPDATE ${sets}`; + } + + const sets = updatable + .map( + c => + `${quoteIdent(c.name, dialect)} = EXCLUDED.${quoteIdent(c.name, dialect)}` + ) + .join(", "); + return `${insert} ON CONFLICT (${quoteIdent("slug", dialect)}) DO UPDATE SET ${sets}`; +} + +function tableNameFor( + slug: string, + prefix: "dc_" | "single_" | "comp_" +): string { + return `${prefix}${slug.replace(/-/g, "_")}`; +} + +function singular(entity: UiSchemaEntity): string { + return entity.labels?.singular ?? toSingularLabel(entity.slug); +} + +function hashOf(entity: UiSchemaEntity): string { + return calculateSchemaHash( + entity.fields as unknown as Parameters[0] + ); +} + +export function buildCollectionMetadataUpsert( + entity: UiSchemaEntity, + dialect: Dialect +): string { + const labels = { + singular: singular(entity), + plural: entity.labels?.plural ?? toPluralLabel(entity.slug), + }; + const columns: Column[] = [ + { name: "id", value: sqlStr(deterministicId(entity.slug)) }, + { name: "slug", value: sqlStr(entity.slug) }, + { name: "labels", value: jsonLiteral(labels, dialect), update: true }, + { name: "table_name", value: sqlStr(tableNameFor(entity.slug, "dc_")) }, + { + name: "fields", + value: jsonLiteral(entity.fields, dialect), + update: true, + }, + { name: "source", value: sqlStr("ui") }, + { name: "schema_hash", value: sqlStr(hashOf(entity)), update: true }, + { + name: "status", + value: boolLiteral(entity.status === true, dialect), + update: true, + }, + { name: "migration_status", value: sqlStr("applied") }, + ]; + if (entity.admin !== undefined) { + columns.push({ + name: "admin", + value: jsonLiteral(entity.admin, dialect), + update: true, + }); + } + return buildUpsert("dynamic_collections", columns, dialect); +} + +export function buildSingleMetadataUpsert( + entity: UiSchemaEntity, + dialect: Dialect +): string { + const columns: Column[] = [ + { name: "id", value: sqlStr(deterministicId(entity.slug)) }, + { name: "slug", value: sqlStr(entity.slug) }, + { name: "label", value: sqlStr(singular(entity)), update: true }, + { + name: "table_name", + value: sqlStr(tableNameFor(entity.slug, "single_")), + }, + { + name: "fields", + value: jsonLiteral(entity.fields, dialect), + update: true, + }, + { name: "source", value: sqlStr("ui") }, + { name: "schema_hash", value: sqlStr(hashOf(entity)), update: true }, + { + name: "status", + value: boolLiteral(entity.status === true, dialect), + update: true, + }, + { name: "migration_status", value: sqlStr("applied") }, + ]; + return buildUpsert("dynamic_singles", columns, dialect); +} + +export function buildComponentMetadataUpsert( + entity: UiSchemaEntity, + dialect: Dialect +): string { + const columns: Column[] = [ + { name: "id", value: sqlStr(deterministicId(entity.slug)) }, + { name: "slug", value: sqlStr(entity.slug) }, + { name: "label", value: sqlStr(singular(entity)), update: true }, + { name: "table_name", value: sqlStr(tableNameFor(entity.slug, "comp_")) }, + { + name: "fields", + value: jsonLiteral(entity.fields, dialect), + update: true, + }, + { name: "source", value: sqlStr("ui") }, + { name: "schema_hash", value: sqlStr(hashOf(entity)), update: true }, + { name: "migration_status", value: sqlStr("applied") }, + ]; + return buildUpsert("dynamic_components", columns, dialect); +} From 231e74292d9202208a8a762a7b492eb654bd3e35 Mon Sep 17 00:00:00 2001 From: aqib-rx Date: Mon, 1 Jun 2026 16:59:39 +0500 Subject: [PATCH 2/2] =?UTF-8?q?feat(cli):=20migrate:create=20emits=20ui=20?= =?UTF-8?q?metadata-row=20upserts=20(D2b=20=C2=A74.12.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nextly/src/cli/commands/migrate-create.ts | 34 +++++++++++++++++++ .../migrate-create/__tests__/generate.test.ts | 33 ++++++++++++++++++ .../domains/schema/migrate-create/generate.ts | 31 ++++++++++++++--- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/nextly/src/cli/commands/migrate-create.ts b/packages/nextly/src/cli/commands/migrate-create.ts index 5a42ee2..281e2d8 100644 --- a/packages/nextly/src/cli/commands/migrate-create.ts +++ b/packages/nextly/src/cli/commands/migrate-create.ts @@ -62,6 +62,11 @@ import { PromptCancelledError } from "../../domains/schema/migrate-create/prompt 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 { + buildCollectionMetadataUpsert, + buildComponentMetadataUpsert, + buildSingleMetadataUpsert, +} from "../../domains/schema/ui-schema/metadata-sql"; import { createContext, type CommandContext } from "../program"; import { getDialectDisplayName, validateDatabaseEnv } from "../utils/adapter"; import { loadConfig, type LoadConfigResult } from "../utils/config-loader"; @@ -247,6 +252,34 @@ export async function runMigrateCreate( } const { collections, singles, components } = merged; + // §4.12.7: per-dialect metadata-row upserts for UI-built entities that + // survived the merge (code-first wins → shadowed UI slugs are skipped). + const dropped = new Set(merged.droppedUiSlugs); + const tn = (slug: string, prefix: "dc_" | "single_" | "comp_") => + `${prefix}${slug.replace(/-/g, "_")}`; + const metadataUpserts: { tableName: string; sql: string }[] = []; + for (const c of manifest.collections) { + if (dropped.has(c.slug)) continue; + metadataUpserts.push({ + tableName: tn(c.slug, "dc_"), + sql: buildCollectionMetadataUpsert(c, dialect), + }); + } + for (const s of manifest.singles) { + if (dropped.has(s.slug)) continue; + metadataUpserts.push({ + tableName: tn(s.slug, "single_"), + sql: buildSingleMetadataUpsert(s, dialect), + }); + } + for (const cp of manifest.components) { + if (dropped.has(cp.slug)) continue; + metadataUpserts.push({ + tableName: tn(cp.slug, "comp_"), + sql: buildComponentMetadataUpsert(cp, dialect), + }); + } + if ( collections.length === 0 && singles.length === 0 && @@ -269,6 +302,7 @@ export async function runMigrateCreate( collections, singles, components, + metadataUpserts, nonInteractive, autoAcceptRenames: options.acceptRenames === true, }); diff --git a/packages/nextly/src/domains/schema/migrate-create/__tests__/generate.test.ts b/packages/nextly/src/domains/schema/migrate-create/__tests__/generate.test.ts index 5206f8d..14f1d38 100644 --- a/packages/nextly/src/domains/schema/migrate-create/__tests__/generate.test.ts +++ b/packages/nextly/src/domains/schema/migrate-create/__tests__/generate.test.ts @@ -74,6 +74,39 @@ describe("generateMigration", () => { expect(sql).toContain("-- Generated at: 2026-04-29T15:45:00.123Z"); }); + it("appends a metadata upsert only when its table is touched (§4.12.7)", async () => { + const result = await generateMigration({ + name: "create_posts", + dialect: "postgresql", + migrationsDir, + collections: [POSTS_V1], + singles: [], + components: [], + nonInteractive: true, + now: NOW, + metadataUpserts: [ + { + tableName: "dc_posts", + sql: 'INSERT INTO "dynamic_collections" /*posts*/', + }, + { + tableName: "dc_unrelated", + sql: 'INSERT INTO "dynamic_collections" /*nope*/', + }, + ], + }); + expect(result).not.toBeNull(); + const sql = await readFile(result!.sqlPath, "utf-8"); + // dc_posts is created → its upsert is appended. + expect(sql).toContain("/*posts*/"); + // dc_unrelated has no operation → its upsert is omitted. + expect(sql).not.toContain("/*nope*/"); + // The upsert follows the CREATE TABLE DDL. + expect(sql.indexOf("/*posts*/")).toBeGreaterThan( + sql.indexOf('CREATE TABLE "dc_posts"') + ); + }); + it("returns null when config matches latest snapshot (no changes)", async () => { // Seed a snapshot matching POSTS_V1. const desired = buildDesiredSnapshotFromConfigForTest( diff --git a/packages/nextly/src/domains/schema/migrate-create/generate.ts b/packages/nextly/src/domains/schema/migrate-create/generate.ts index e10b4e5..2f4a888 100644 --- a/packages/nextly/src/domains/schema/migrate-create/generate.ts +++ b/packages/nextly/src/domains/schema/migrate-create/generate.ts @@ -37,11 +37,7 @@ import type { RenameCandidate } from "../pipeline/pushschema-pipeline-interfaces import { RegexRenameDetector } from "../pipeline/rename-detector"; import { generateSQL } from "../pipeline/sql-templates/index"; -import { - formatMigrationFile, - formatTimestamp, - slugify, -} from "./format-file"; +import { formatMigrationFile, formatTimestamp, slugify } from "./format-file"; import { promptRenames, type RenameDecision } from "./prompt-renames"; import { EMPTY_SNAPSHOT, @@ -90,6 +86,11 @@ export interface GenerateArgs { collections: MinimalConfigEntity[]; singles: MinimalConfigEntity[]; components: MinimalConfigEntity[]; + /** + * UI-built metadata-row upserts (spec §4.12.7), keyed by data-table name. + * Each is appended to the generated SQL when an operation touches its table. + */ + metadataUpserts?: { tableName: string; sql: string }[]; /** Skip interactive prompts (non-TTY / CI). */ nonInteractive?: boolean; /** Only meaningful with nonInteractive=true. Default = decline. */ @@ -110,6 +111,18 @@ export interface GenerateResult { renamesAccepted: number; } +/** The table an operation acts on (for correlating metadata upserts). */ +function operationTableName(op: Operation): string { + switch (op.type) { + case "add_table": + return op.table.name; + case "rename_table": + return op.toName; + default: + return op.tableName; + } +} + /** * Run the migrate:create orchestration. Returns null if no operations * remain (config matches latest snapshot — exit code 2 in the CLI). @@ -156,6 +169,14 @@ export async function generateMigration( // 7. Generate SQL per op. const sqlStatements = operations.map(op => generateSQL(op, args.dialect)); + // 7b. Append UI metadata-row upserts for any touched UI-built table (§4.12.7). + if (args.metadataUpserts && args.metadataUpserts.length > 0) { + const touched = new Set(operations.map(operationTableName)); + for (const m of args.metadataUpserts) { + if (touched.has(m.tableName)) sqlStatements.push(m.sql); + } + } + // 8. Compose file content + write both files. const collectionSlugs = args.collections.map(c => c.slug).sort(); const singleSlugs = args.singles.map(c => c.slug).sort();