From 4d23b735ba49c8d582b199534eb8c2c29826aef0 Mon Sep 17 00:00:00 2001 From: Mason James <297610+masonjames@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:19:15 -0400 Subject: [PATCH 1/2] feat(mcp): add safe schema update tools --- .changeset/add-mcp-schema-updates.md | 5 + .../src/content/docs/reference/mcp-server.mdx | 45 ++- .../api/schema/collections/[slug]/index.ts | 8 +- packages/core/src/mcp/server.ts | 105 ++++++ packages/core/src/schema/types.ts | 2 +- .../core/tests/integration/mcp/schema.test.ts | 301 ++++++++++++++++++ 6 files changed, 457 insertions(+), 9 deletions(-) create mode 100644 .changeset/add-mcp-schema-updates.md diff --git a/.changeset/add-mcp-schema-updates.md b/.changeset/add-mcp-schema-updates.md new file mode 100644 index 0000000000..e1561dc400 --- /dev/null +++ b/.changeset/add-mcp-schema-updates.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds MCP tools for updating collections and fields without deleting and recreating their schemas. diff --git a/docs/src/content/docs/reference/mcp-server.mdx b/docs/src/content/docs/reference/mcp-server.mdx index 68b8eb45c2..900ec0aa1f 100644 --- a/docs/src/content/docs/reference/mcp-server.mdx +++ b/docs/src/content/docs/reference/mcp-server.mdx @@ -36,7 +36,7 @@ Tokens are scoped to limit what operations a client can perform. Scopes are requ | `media:read` | List and get media items. | | `media:write` | Register (create), update, and delete media metadata. | | `schema:read` | List collections and get collection schemas. | -| `schema:write` | Create and delete collections and fields. | +| `schema:write` | Create, update, and delete collections and fields. | | `taxonomies:manage` | Create, update, and delete taxonomy terms. | | `menus:manage` | Create, update, and delete navigation menus and their items. | | `settings:read` | Read site-wide settings. | @@ -324,6 +324,27 @@ Create a new content collection. This creates a database table and schema defini **Scope:** `schema:write` | **Minimum role:** Admin +#### `schema_update_collection` + +Update an existing collection without deleting its table, fields, or content. Only provided settings change; omitted settings keep their current values. The collection slug cannot be changed. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `slug` | `string` | Yes | Collection slug to update | +| `label` | `string` | No | New plural display name | +| `labelSingular` | `string` | No | New singular display name | +| `description` | `string` | No | New collection description | +| `icon` | `string` | No | New admin UI icon name | +| `supports` | `string[]` | No | Complete feature list to enable; omitted preserves the current list | +| `urlPattern` | `string \| null` | No | New public URL pattern; `null` clears it | +| `hasSeo` | `boolean` | No | Whether the collection supports SEO metadata | +| `commentsEnabled` | `boolean` | No | Whether comments are enabled | +| `commentsModeration` | `string` | No | Moderation policy: `all`, `first_time`, or `none` | +| `commentsClosedAfterDays` | `integer` | No | Close comments after this many days; `0` keeps them open | +| `commentsAutoApproveUsers` | `boolean` | No | Automatically approve comments from authenticated users | + +**Scope:** `schema:write` | **Minimum role:** Admin + #### `schema_delete_collection` Delete a collection and its database table. This is irreversible and deletes all content in the collection. @@ -359,6 +380,28 @@ For `select` and `multiSelect` types, provide allowed values in `validation.opti **Scope:** `schema:write` | **Minimum role:** Admin +#### `schema_update_field` + +Update an existing field without deleting its column or stored values. Only provided settings change; omitted settings keep their current values. Type changes are accepted only when the underlying column type remains compatible. Changes that require a physical content migration return `FIELD_TYPE_COLUMN_CHANGE` with migration guidance. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `collection` | `string` | Yes | Collection containing the field | +| `fieldSlug` | `string` | Yes | Field slug to update | +| `label` | `string` | No | New display name | +| `type` | `string` | No | New field type; must preserve the underlying column type | +| `required` | `boolean` | No | Whether the field is required | +| `unique` | `boolean` | No | Whether field values must be unique | +| `defaultValue` | `any` | No | Default value for new content | +| `validation` | `object \| null` | No | Validation constraints; `null` clears them | +| `widget` | `string` | No | Admin editor widget name | +| `options` | `object` | No | Widget configuration | +| `sortOrder` | `integer` | No | Field order in the content editor | +| `searchable` | `boolean` | No | Whether full-text search indexes the field | +| `translatable` | `boolean` | No | Whether values vary by locale | + +**Scope:** `schema:write` | **Minimum role:** Admin + #### `schema_delete_field` Remove a field from a collection. This drops the column and deletes all data in that field. Irreversible. diff --git a/packages/core/src/astro/routes/api/schema/collections/[slug]/index.ts b/packages/core/src/astro/routes/api/schema/collections/[slug]/index.ts index 63caf6b189..f50e6ffb82 100644 --- a/packages/core/src/astro/routes/api/schema/collections/[slug]/index.ts +++ b/packages/core/src/astro/routes/api/schema/collections/[slug]/index.ts @@ -17,7 +17,6 @@ import { } from "#api/index.js"; import { parseBody, parseQuery, isParseError } from "#api/parse.js"; import { collectionGetQuery, updateCollectionBody } from "#api/schemas.js"; -import type { UpdateCollectionInput } from "#schema/types.js"; export const prerender = false; @@ -53,12 +52,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => { const body = await parseBody(request, updateCollectionBody); if (isParseError(body)) return body; - const result = await handleSchemaCollectionUpdate( - emdash.db, - slug, - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- parseBody validates via Zod - body as UpdateCollectionInput, - ); + const result = await handleSchemaCollectionUpdate(emdash.db, slug, body); emdash.invalidateUrlPatternCache(); return unwrapResult(result); }; diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index e25338de24..cf1bffae4a 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -20,6 +20,8 @@ import { CONTENT_TYPE_RE, contentBylineInputSchema, contentSeoInput, + updateCollectionBody, + updateFieldBody, } from "#api/schemas.js"; import type { MediaUsageRepairRequest } from "../api/schemas/media-usage.js"; @@ -107,6 +109,59 @@ const mediaUsageRepairToolSchema = z } }); +const schemaUpdateCollectionToolSchema = z.object({ + slug: z.string().describe("Collection slug to update; the slug itself cannot be changed"), + label: updateCollectionBody.shape.label.describe("New plural display name"), + labelSingular: updateCollectionBody.shape.labelSingular.describe("New singular display name"), + description: updateCollectionBody.shape.description.describe("New collection description"), + icon: updateCollectionBody.shape.icon.describe("New admin UI icon name"), + supports: updateCollectionBody.shape.supports.describe( + "Complete feature list to enable; omit to preserve the current list", + ), + urlPattern: updateCollectionBody.shape.urlPattern.describe( + "New public URL pattern; pass null to clear it", + ), + hasSeo: updateCollectionBody.shape.hasSeo.describe( + "Whether the collection supports SEO metadata", + ), + commentsEnabled: updateCollectionBody.shape.commentsEnabled.describe( + "Whether comments are enabled for this collection", + ), + commentsModeration: updateCollectionBody.shape.commentsModeration.describe( + "Comment moderation policy", + ), + commentsClosedAfterDays: updateCollectionBody.shape.commentsClosedAfterDays.describe( + "Close comments after this many days; 0 keeps them open", + ), + commentsAutoApproveUsers: updateCollectionBody.shape.commentsAutoApproveUsers.describe( + "Whether comments from authenticated users are automatically approved", + ), +}); + +const schemaUpdateFieldToolSchema = z.object({ + collection: z.string().describe("Collection slug containing the field"), + fieldSlug: z.string().describe("Field slug to update; the slug itself cannot be changed"), + label: updateFieldBody.shape.label.describe("New display name"), + type: updateFieldBody.shape.type.describe( + "New field type; only changes that preserve the underlying column type are allowed", + ), + required: updateFieldBody.shape.required.describe("Whether the field is required"), + unique: updateFieldBody.shape.unique.describe("Whether field values must be unique"), + defaultValue: updateFieldBody.shape.defaultValue.describe("Default value for new content"), + validation: updateFieldBody.shape.validation.describe( + "Validation constraints; pass null to clear existing constraints", + ), + widget: updateFieldBody.shape.widget.describe("Admin editor widget name"), + options: updateFieldBody.shape.options.describe("Widget configuration"), + sortOrder: updateFieldBody.shape.sortOrder.describe("Field order in the content editor"), + searchable: updateFieldBody.shape.searchable.describe( + "Whether full-text search indexes the field", + ), + translatable: updateFieldBody.shape.translatable.describe( + "Whether values vary by locale instead of syncing across a translation group", + ), +}); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -1737,6 +1792,32 @@ export function createMcpServer( }, ); + server.registerTool( + "schema_update_collection", + { + title: "Update Collection", + description: + "Update an existing collection without deleting its table, fields, or content. " + + "Only provided settings change; omitted settings keep their current values. " + + "The collection slug cannot be changed.", + inputSchema: schemaUpdateCollectionToolSchema, + }, + async (args, extra) => { + requireScope(extra, "schema:write"); + requireRole(extra, Role.ADMIN); + const ec = getEmDash(extra); + const { slug, ...input } = args; + try { + const { handleSchemaCollectionUpdate } = await import("../api/handlers/schema.js"); + const result = await handleSchemaCollectionUpdate(ec.db, slug, input); + if (result.success) ec.invalidateUrlPatternCache(); + return unwrap(result); + } catch (error) { + return respondHandlerError(error, "SCHEMA_UPDATE_ERROR"); + } + }, + ); + server.registerTool( "schema_create_field", { @@ -1868,6 +1949,30 @@ export function createMcpServer( }, ); + server.registerTool( + "schema_update_field", + { + title: "Update Field", + description: + "Update an existing field without deleting its column or stored values. Only " + + "provided settings change; omitted settings keep their current values. Type " + + "changes that require a physical column migration are rejected with migration guidance.", + inputSchema: schemaUpdateFieldToolSchema, + }, + async (args, extra) => { + requireScope(extra, "schema:write"); + requireRole(extra, Role.ADMIN); + const ec = getEmDash(extra); + const { collection, fieldSlug, ...input } = args; + try { + const { handleSchemaFieldUpdate } = await import("../api/handlers/schema.js"); + return unwrap(await handleSchemaFieldUpdate(ec.db, collection, fieldSlug, input)); + } catch (error) { + return respondHandlerError(error, "SCHEMA_FIELD_UPDATE_ERROR"); + } + }, + ); + // ===================================================================== // Media tools // ===================================================================== diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6d5055e981..a4fce579fc 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -231,7 +231,7 @@ export interface UpdateCollectionInput { description?: string; icon?: string; supports?: CollectionSupport[]; - urlPattern?: string; + urlPattern?: string | null; hasSeo?: boolean; commentsEnabled?: boolean; commentsModeration?: "all" | "first_time" | "none"; diff --git a/packages/core/tests/integration/mcp/schema.test.ts b/packages/core/tests/integration/mcp/schema.test.ts index 4162fc541c..35967b3f11 100644 --- a/packages/core/tests/integration/mcp/schema.test.ts +++ b/packages/core/tests/integration/mcp/schema.test.ts @@ -5,8 +5,10 @@ * - schema_list_collections * - schema_get_collection * - schema_create_collection (also bug #11 — supports default) + * - schema_update_collection * - schema_delete_collection * - schema_create_field + * - schema_update_field * - schema_delete_field * * For each tool: happy path, edge cases (empty, missing, duplicate, @@ -299,6 +301,159 @@ describe("schema_create_collection", () => { }); }); +// --------------------------------------------------------------------------- +// schema_update_collection +// --------------------------------------------------------------------------- + +describe("schema_update_collection", () => { + let db: Kysely; + let harness: McpHarness; + + beforeEach(async () => { + db = await setupTestDatabase(); + const registry = new SchemaRegistry(db); + await registry.createCollection({ + slug: "post", + label: "Posts", + labelSingular: "Post", + supports: ["drafts", "revisions"], + }); + await registry.createField("post", { slug: "title", label: "Title", type: "string" }); + harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); + }); + + afterEach(async () => { + if (harness) await harness.cleanup(); + await teardownTestDatabase(db); + }); + + it("updates collection settings without recreating the collection or losing content", async () => { + const registry = new SchemaRegistry(db); + const before = await registry.getCollectionWithFields("post"); + await harness.client.callTool({ + name: "content_create", + arguments: { collection: "post", data: { title: "Kept content" } }, + }); + + const result = await harness.client.callTool({ + name: "schema_update_collection", + arguments: { + slug: "post", + label: "Articles", + labelSingular: "Article", + description: "Long-form writing", + supports: ["drafts", "revisions", "preview"], + urlPattern: "/articles/{slug}", + hasSeo: true, + commentsEnabled: true, + commentsModeration: "first_time", + commentsClosedAfterDays: 30, + commentsAutoApproveUsers: true, + }, + }); + + expect(result.isError, extractText(result)).toBeFalsy(); + const { item } = extractJson<{ + item: { + id: string; + label: string; + labelSingular: string | null; + description: string | null; + supports: string[]; + urlPattern: string | null; + hasSeo: boolean; + commentsEnabled: boolean; + commentsModeration: string; + commentsClosedAfterDays: number; + commentsAutoApproveUsers: boolean; + }; + }>(result); + expect(item).toMatchObject({ + id: before?.id, + label: "Articles", + labelSingular: "Article", + description: "Long-form writing", + urlPattern: "/articles/{slug}", + hasSeo: true, + commentsEnabled: true, + commentsModeration: "first_time", + commentsClosedAfterDays: 30, + commentsAutoApproveUsers: true, + }); + expect(item.supports).toEqual(["drafts", "revisions", "preview"]); + + const clearUrlPattern = await harness.client.callTool({ + name: "schema_update_collection", + arguments: { slug: "post", urlPattern: null }, + }); + expect(clearUrlPattern.isError, extractText(clearUrlPattern)).toBeFalsy(); + expect( + extractJson<{ item: { urlPattern?: string } }>(clearUrlPattern).item.urlPattern, + ).toBeUndefined(); + + const after = await registry.getCollectionWithFields("post"); + expect(after?.id).toBe(before?.id); + expect(after?.urlPattern).toBeUndefined(); + expect(after?.fields.map((field) => field.slug)).toContain("title"); + + const content = await harness.client.callTool({ + name: "content_list", + arguments: { collection: "post" }, + }); + const { items } = extractJson<{ items: Array<{ data: { title?: string } }> }>(content); + expect(items.some((entry) => entry.data.title === "Kept content")).toBe(true); + }); + + it("returns the canonical not-found error", async () => { + const result = await harness.client.callTool({ + name: "schema_update_collection", + arguments: { slug: "missing", label: "Missing" }, + }); + + expect(result.isError).toBe(true); + expect(extractText(result)).toContain("[COLLECTION_NOT_FOUND]"); + expect(extractText(result)).toContain('Collection "missing" not found'); + }); + + it("requires the schema:write token scope", async () => { + await harness.cleanup(); + harness = await connectMcpHarness({ + db, + userId: ADMIN_ID, + userRole: Role.ADMIN, + tokenScopes: ["schema:read"], + }); + + const result = await harness.client.callTool({ + name: "schema_update_collection", + arguments: { slug: "post", label: "Blocked" }, + }); + + expect(result.isError).toBe(true); + expect(extractText(result)).toContain("[INSUFFICIENT_SCOPE]"); + expect((await new SchemaRegistry(db).getCollection("post"))?.label).toBe("Posts"); + }); + + it("requires the ADMIN role even with schema:write scope", async () => { + await harness.cleanup(); + harness = await connectMcpHarness({ + db, + userId: EDITOR_ID, + userRole: Role.EDITOR, + tokenScopes: ["schema:write"], + }); + + const result = await harness.client.callTool({ + name: "schema_update_collection", + arguments: { slug: "post", label: "Blocked" }, + }); + + expect(result.isError).toBe(true); + expect(extractText(result)).toContain("[INSUFFICIENT_PERMISSIONS]"); + expect((await new SchemaRegistry(db).getCollection("post"))?.label).toBe("Posts"); + }); +}); + // --------------------------------------------------------------------------- // schema_delete_collection // --------------------------------------------------------------------------- @@ -572,6 +727,152 @@ describe("schema_create_field", () => { }); }); +// --------------------------------------------------------------------------- +// schema_update_field +// --------------------------------------------------------------------------- + +describe("schema_update_field", () => { + let db: Kysely; + let harness: McpHarness; + + beforeEach(async () => { + db = await setupTestDatabase(); + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "post", label: "Posts" }); + await registry.createField("post", { slug: "body", label: "Body", type: "text" }); + harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); + await harness.client.callTool({ + name: "content_create", + arguments: { collection: "post", data: { body: "Kept body" } }, + }); + }); + + afterEach(async () => { + if (harness) await harness.cleanup(); + await teardownTestDatabase(db); + }); + + it("updates field metadata and a storage-compatible type without losing values", async () => { + const result = await harness.client.callTool({ + name: "schema_update_field", + arguments: { + collection: "post", + fieldSlug: "body", + label: "Summary", + type: "string", + required: true, + validation: { minLength: 1, maxLength: 160 }, + widget: "text", + sortOrder: 4, + translatable: false, + }, + }); + + expect(result.isError, extractText(result)).toBeFalsy(); + const { item } = extractJson<{ + item: { + slug: string; + label: string; + type: string; + required: boolean; + validation: { minLength: number; maxLength: number } | null; + widget: string | null; + sortOrder: number; + translatable: boolean; + }; + }>(result); + expect(item).toMatchObject({ + slug: "body", + label: "Summary", + type: "string", + required: true, + validation: { minLength: 1, maxLength: 160 }, + widget: "text", + sortOrder: 4, + translatable: false, + }); + + const content = await harness.client.callTool({ + name: "content_list", + arguments: { collection: "post" }, + }); + const { items } = extractJson<{ items: Array<{ data: { body?: string } }> }>(content); + expect(items.some((entry) => entry.data.body === "Kept body")).toBe(true); + }); + + it("rejects a type change that needs a content migration and preserves the field", async () => { + const result = await harness.client.callTool({ + name: "schema_update_field", + arguments: { + collection: "post", + fieldSlug: "body", + type: "portableText", + }, + }); + + expect(result.isError).toBe(true); + const text = extractText(result); + expect(text).toContain("[FIELD_TYPE_COLUMN_CHANGE]"); + expect(text).toMatch(/manual content migration/i); + expect(text).toContain('from type "text" to "portableText"'); + + const field = await new SchemaRegistry(db).getField("post", "body"); + expect(field?.type).toBe("text"); + }); + + it("uses the REST validation contract for update input", async () => { + const result = await harness.client.callTool({ + name: "schema_update_field", + arguments: { + collection: "post", + fieldSlug: "body", + label: "", + sortOrder: -1, + }, + }); + + expect(result.isError).toBe(true); + expect(extractText(result)).toMatch(/invalid|too small|greater than or equal to/i); + expect(extractText(result)).not.toMatch(/tool .* not found/i); + const field = await new SchemaRegistry(db).getField("post", "body"); + expect(field?.label).toBe("Body"); + expect(field?.sortOrder).toBe(0); + }); + + it("requires schema:write scope and ADMIN role", async () => { + await harness.cleanup(); + harness = await connectMcpHarness({ + db, + userId: EDITOR_ID, + userRole: Role.EDITOR, + tokenScopes: ["schema:write"], + }); + + const editorResult = await harness.client.callTool({ + name: "schema_update_field", + arguments: { collection: "post", fieldSlug: "body", label: "Blocked" }, + }); + expect(editorResult.isError).toBe(true); + expect(extractText(editorResult)).toContain("[INSUFFICIENT_PERMISSIONS]"); + + await harness.cleanup(); + harness = await connectMcpHarness({ + db, + userId: ADMIN_ID, + userRole: Role.ADMIN, + tokenScopes: ["schema:read"], + }); + + const scopeResult = await harness.client.callTool({ + name: "schema_update_field", + arguments: { collection: "post", fieldSlug: "body", label: "Blocked" }, + }); + expect(scopeResult.isError).toBe(true); + expect(extractText(scopeResult)).toContain("[INSUFFICIENT_SCOPE]"); + expect((await new SchemaRegistry(db).getField("post", "body"))?.label).toBe("Body"); + }); +}); + // --------------------------------------------------------------------------- // schema_delete_field // --------------------------------------------------------------------------- From b6287032594eeae120b6301d047e5370d4638ea2 Mon Sep 17 00:00:00 2001 From: Mason James <297610+masonjames@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:18:02 -0400 Subject: [PATCH 2/2] fix(mcp): harden schema update safety --- .changeset/add-mcp-schema-updates.md | 2 +- .../src/content/docs/reference/mcp-server.mdx | 10 +- packages/core/src/api/handlers/schema.ts | 12 +- packages/core/src/api/schemas/schema.ts | 45 ++- packages/core/src/mcp/server.ts | 18 +- packages/core/src/query.ts | 16 +- packages/core/src/schema/registry.ts | 278 +++++++++--------- packages/core/src/schema/types.ts | 10 +- packages/core/src/schema/url-pattern.ts | 11 + .../core/tests/integration/mcp/schema.test.ts | 124 +++++++- .../core/tests/unit/schema/registry.test.ts | 4 +- 11 files changed, 346 insertions(+), 184 deletions(-) create mode 100644 packages/core/src/schema/url-pattern.ts diff --git a/.changeset/add-mcp-schema-updates.md b/.changeset/add-mcp-schema-updates.md index e1561dc400..d8061f4a15 100644 --- a/.changeset/add-mcp-schema-updates.md +++ b/.changeset/add-mcp-schema-updates.md @@ -2,4 +2,4 @@ "emdash": minor --- -Adds MCP tools for updating collections and fields without deleting and recreating their schemas. +Adds MCP tools for safely updating collections and fields without deleting and recreating their schemas. diff --git a/docs/src/content/docs/reference/mcp-server.mdx b/docs/src/content/docs/reference/mcp-server.mdx index 900ec0aa1f..3999137952 100644 --- a/docs/src/content/docs/reference/mcp-server.mdx +++ b/docs/src/content/docs/reference/mcp-server.mdx @@ -382,23 +382,23 @@ For `select` and `multiSelect` types, provide allowed values in `validation.opti #### `schema_update_field` -Update an existing field without deleting its column or stored values. Only provided settings change; omitted settings keep their current values. Type changes are accepted only when the underlying column type remains compatible. Changes that require a physical content migration return `FIELD_TYPE_COLUMN_CHANGE` with migration guidance. +Update an existing field without deleting its column or stored values. Only provided settings change; omitted settings keep their current values. `string`, `text`, and `slug` may be changed between one another. Other type changes and settings that require content or column migration are rejected with migration guidance. Invalid regular expressions and contradictory validation ranges are rejected before any schema metadata changes. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `collection` | `string` | Yes | Collection containing the field | | `fieldSlug` | `string` | Yes | Field slug to update | | `label` | `string` | No | New display name | -| `type` | `string` | No | New field type; must preserve the underlying column type | -| `required` | `boolean` | No | Whether the field is required | -| `unique` | `boolean` | No | Whether field values must be unique | +| `type` | `string` | No | New field type; only `string`, `text`, and `slug` aliases can be changed in place | +| `required` | `boolean` | No | Whether the field is required; changing it requires a manual content migration | +| `unique` | `boolean` | No | Whether field values must be unique; changing it requires a manual content migration | | `defaultValue` | `any` | No | Default value for new content | | `validation` | `object \| null` | No | Validation constraints; `null` clears them | | `widget` | `string` | No | Admin editor widget name | | `options` | `object` | No | Widget configuration | | `sortOrder` | `integer` | No | Field order in the content editor | | `searchable` | `boolean` | No | Whether full-text search indexes the field | -| `translatable` | `boolean` | No | Whether values vary by locale | +| `translatable` | `boolean` | No | Whether values vary by locale; changing to `false` requires a manual content migration | **Scope:** `schema:write` | **Minimum role:** Admin diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 2426a47d39..027f97e425 100644 --- a/packages/core/src/api/handlers/schema.ts +++ b/packages/core/src/api/handlers/schema.ts @@ -9,6 +9,7 @@ import { invalidateCollectionCache } from "../../object-cache/index.js"; import { SchemaRegistry, SchemaError, + invalidateSchemaCache, type Collection, type Field, type CreateCollectionInput, @@ -19,6 +20,11 @@ import { } from "../../schema/index.js"; import type { ApiResult } from "../types.js"; +function invalidateFieldCaches(collectionSlug: string): void { + invalidateCollectionCache(collectionSlug); + invalidateSchemaCache(collectionSlug); +} + export interface CollectionListResponse { items: Collection[]; } @@ -317,7 +323,7 @@ export async function handleSchemaFieldCreate( const item = await registry.createField(collectionSlug, input); // Content snapshots embed field values; a column change invalidates them. - invalidateCollectionCache(collectionSlug); + invalidateFieldCaches(collectionSlug); return { success: true, @@ -357,7 +363,7 @@ export async function handleSchemaFieldUpdate( const registry = new SchemaRegistry(db); const item = await registry.updateField(collectionSlug, fieldSlug, input); - invalidateCollectionCache(collectionSlug); + invalidateFieldCaches(collectionSlug); return { success: true, @@ -396,7 +402,7 @@ export async function handleSchemaFieldDelete( const registry = new SchemaRegistry(db); await registry.deleteField(collectionSlug, fieldSlug); - invalidateCollectionCache(collectionSlug); + invalidateFieldCaches(collectionSlug); return { success: true, diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index f2922289e8..2efc02bd25 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { compileUrlPattern } from "../../schema/url-pattern.js"; import { slugPattern } from "./common.js"; // --------------------------------------------------------------------------- @@ -49,6 +50,17 @@ const repeaterSubFieldSchema = z.object({ options: z.array(z.string()).optional(), }); +const urlPatternValue = z.string().superRefine((pattern, ctx) => { + try { + compileUrlPattern(pattern); + } catch { + ctx.addIssue({ + code: "custom", + message: "Invalid URL pattern", + }); + } +}); + const fieldValidation = z .object({ required: z.boolean().optional(), @@ -71,6 +83,35 @@ const fieldValidation = z .max(64, "allowedMimeTypes may contain at most 64 entries") .optional(), }) + .superRefine((validation, ctx) => { + for (const [minimum, maximum] of [ + ["min", "max"], + ["minLength", "maxLength"], + ["minItems", "maxItems"], + ] as const) { + const minimumValue = validation[minimum]; + const maximumValue = validation[maximum]; + if (minimumValue !== undefined && maximumValue !== undefined && minimumValue > maximumValue) { + ctx.addIssue({ + code: "custom", + path: [maximum], + message: `${maximum} must be greater than or equal to ${minimum}`, + }); + } + } + + if (validation.pattern !== undefined) { + try { + RegExp(validation.pattern); + } catch { + ctx.addIssue({ + code: "custom", + path: ["pattern"], + message: "Invalid validation pattern", + }); + } + } + }) .optional(); const fieldWidgetOptions = z.record(z.string(), z.unknown()).optional(); @@ -84,7 +125,7 @@ export const createCollectionBody = z icon: z.string().optional(), supports: z.array(collectionSupportValues).optional(), source: z.string().regex(collectionSourcePattern).optional(), - urlPattern: z.string().optional(), + urlPattern: urlPatternValue.optional(), hasSeo: z.boolean().optional(), }) .meta({ id: "CreateCollectionBody" }); @@ -96,7 +137,7 @@ export const updateCollectionBody = z description: z.string().optional(), icon: z.string().optional(), supports: z.array(collectionSupportValues).optional(), - urlPattern: z.string().nullish(), + urlPattern: urlPatternValue.nullish(), hasSeo: z.boolean().optional(), commentsEnabled: z.boolean().optional(), commentsModeration: z.enum(["all", "first_time", "none"]).optional(), diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index cf1bffae4a..dcd4a2a2e1 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -143,10 +143,14 @@ const schemaUpdateFieldToolSchema = z.object({ fieldSlug: z.string().describe("Field slug to update; the slug itself cannot be changed"), label: updateFieldBody.shape.label.describe("New display name"), type: updateFieldBody.shape.type.describe( - "New field type; only changes that preserve the underlying column type are allowed", + "New field type; only string, text, and slug aliases can be changed in place", + ), + required: updateFieldBody.shape.required.describe( + "Whether the field is required; changing this requires a manual content migration", + ), + unique: updateFieldBody.shape.unique.describe( + "Whether field values must be unique; changing this requires a manual content migration", ), - required: updateFieldBody.shape.required.describe("Whether the field is required"), - unique: updateFieldBody.shape.unique.describe("Whether field values must be unique"), defaultValue: updateFieldBody.shape.defaultValue.describe("Default value for new content"), validation: updateFieldBody.shape.validation.describe( "Validation constraints; pass null to clear existing constraints", @@ -158,7 +162,7 @@ const schemaUpdateFieldToolSchema = z.object({ "Whether full-text search indexes the field", ), translatable: updateFieldBody.shape.translatable.describe( - "Whether values vary by locale instead of syncing across a translation group", + "Whether values vary by locale; changing to false requires a manual content migration", ), }); @@ -1801,6 +1805,7 @@ export function createMcpServer( "Only provided settings change; omitted settings keep their current values. " + "The collection slug cannot be changed.", inputSchema: schemaUpdateCollectionToolSchema, + annotations: { destructiveHint: false }, }, async (args, extra) => { requireScope(extra, "schema:write"); @@ -1955,9 +1960,10 @@ export function createMcpServer( title: "Update Field", description: "Update an existing field without deleting its column or stored values. Only " + - "provided settings change; omitted settings keep their current values. Type " + - "changes that require a physical column migration are rejected with migration guidance.", + "provided settings change; omitted settings keep their current values. Changes " + + "that require a content or column migration are rejected with migration guidance.", inputSchema: schemaUpdateFieldToolSchema, + annotations: { destructiveHint: false }, }, async (args, extra) => { requireScope(extra, "schema:write"); diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 87ecdb8a06..531e1d9506 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -40,6 +40,7 @@ import { } from "./object-cache/index.js"; import { requestCached } from "./request-cache.js"; import { getRequestContext } from "./request-context.js"; +import { compileUrlPattern } from "./schema/url-pattern.js"; import type { TaxonomyTerm } from "./taxonomies/types.js"; import { isMissingTableError } from "./utils/db-errors.js"; import { @@ -1298,19 +1299,6 @@ export interface ResolvePathResult> { params: Record; } -/** Matches `{paramName}` placeholders in URL patterns */ -const URL_PARAM_PATTERN = /\{(\w+)\}/g; - -/** Convert a URL pattern like "/blog/{slug}" to a regex and param name list */ -function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } { - const paramNames: string[] = []; - const regexStr = pattern.replace(URL_PARAM_PATTERN, (_match, name: string) => { - paramNames.push(name); - return "([^/]+)"; - }); - return { regex: new RegExp(`^${regexStr}$`), paramNames }; -} - /** Cached compiled URL patterns for resolveEmDashPath */ interface CachedPattern { slug: string; @@ -1365,7 +1353,7 @@ export async function resolveEmDashPath>( cachedUrlPatterns = []; for (const collection of collections) { if (!collection.urlPattern) continue; - const { regex, paramNames } = patternToRegex(collection.urlPattern); + const { regex, paramNames } = compileUrlPattern(collection.urlPattern); cachedUrlPatterns.push({ slug: collection.slug, regex, paramNames }); } } diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index a2608f45a2..803fa4969c 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -1,4 +1,11 @@ -import type { ColumnDataType, CreateTableBuilder, Insertable, Kysely, Selectable } from "kysely"; +import type { + ColumnDataType, + CreateTableBuilder, + Insertable, + Kysely, + Selectable, + Updateable, +} from "kysely"; import { sql } from "kysely"; import { ulid } from "ulidx"; @@ -44,6 +51,7 @@ const COLUMN_TYPE_TO_DATA_TYPE = { INTEGER: "integer", JSON: "json", } satisfies Record; +const TEXT_ALIAS_FIELD_TYPES: ReadonlySet = new Set(["string", "text", "slug"]); /** Valid collection source prefixes/values */ const VALID_SOURCES: ReadonlySet = new Set(["manual", "discovered", "seed"]); @@ -384,63 +392,46 @@ export class SchemaRegistry { * Update a collection */ async updateCollection(slug: string, input: UpdateCollectionInput): Promise { - const existing = await this.getCollection(slug); - if (!existing) { - throw new SchemaError(`Collection "${slug}" not found`, "COLLECTION_NOT_FOUND"); - } - - const now = new Date().toISOString(); - - // Derive hasSeo from supports array if supports is being updated and hasSeo not explicitly set - const supportsArray = input.supports ?? existing.supports; - const hasSeo = - input.hasSeo !== undefined - ? input.hasSeo - : input.supports !== undefined - ? supportsArray.includes("seo") - : existing.hasSeo; - return withTransaction(this.db, async (trx) => { - await trx - .updateTable("_emdash_collections") - .set({ - label: input.label ?? existing.label, - label_singular: input.labelSingular ?? existing.labelSingular ?? null, - description: input.description ?? existing.description ?? null, - icon: input.icon ?? existing.icon ?? null, - supports: input.supports - ? JSON.stringify(input.supports) - : JSON.stringify(existing.supports), - url_pattern: - input.urlPattern !== undefined - ? (input.urlPattern ?? null) - : (existing.urlPattern ?? null), - has_seo: hasSeo ? 1 : 0, - comments_enabled: - input.commentsEnabled !== undefined - ? input.commentsEnabled - ? 1 - : 0 - : existing.commentsEnabled - ? 1 - : 0, - comments_moderation: input.commentsModeration ?? existing.commentsModeration, - comments_closed_after_days: - input.commentsClosedAfterDays !== undefined - ? input.commentsClosedAfterDays - : existing.commentsClosedAfterDays, - comments_auto_approve_users: - input.commentsAutoApproveUsers !== undefined - ? input.commentsAutoApproveUsers - ? 1 - : 0 - : existing.commentsAutoApproveUsers - ? 1 - : 0, - updated_at: now, - }) + const existingRow = await trx + .selectFrom("_emdash_collections") .where("slug", "=", slug) - .execute(); + .selectAll() + .executeTakeFirst(); + if (!existingRow) { + throw new SchemaError(`Collection "${slug}" not found`, "COLLECTION_NOT_FOUND"); + } + const existing = this.mapCollectionRow(existingRow); + const updates: Updateable = {}; + + if (input.label !== undefined) updates.label = input.label; + if (input.labelSingular !== undefined) updates.label_singular = input.labelSingular; + if (input.description !== undefined) updates.description = input.description; + if (input.icon !== undefined) updates.icon = input.icon; + if (input.supports !== undefined) updates.supports = JSON.stringify(input.supports); + if (input.urlPattern !== undefined) updates.url_pattern = input.urlPattern; + if (input.hasSeo !== undefined) updates.has_seo = input.hasSeo ? 1 : 0; + if (input.commentsEnabled !== undefined) { + updates.comments_enabled = input.commentsEnabled ? 1 : 0; + } + if (input.commentsModeration !== undefined) { + updates.comments_moderation = input.commentsModeration; + } + if (input.commentsClosedAfterDays !== undefined) { + updates.comments_closed_after_days = input.commentsClosedAfterDays; + } + if (input.commentsAutoApproveUsers !== undefined) { + updates.comments_auto_approve_users = input.commentsAutoApproveUsers ? 1 : 0; + } + + if (Object.keys(updates).length > 0) { + updates.updated_at = new Date().toISOString(); + await trx + .updateTable("_emdash_collections") + .set(updates) + .where("id", "=", existing.id) + .execute(); + } const row = await trx .selectFrom("_emdash_collections") @@ -667,87 +658,92 @@ export class SchemaRegistry { fieldSlug: string, input: UpdateFieldInput, ): Promise { - const field = await this.getField(collectionSlug, fieldSlug); - if (!field) { - throw new SchemaError( - `Field "${fieldSlug}" not found in collection "${collectionSlug}"`, - "FIELD_NOT_FOUND", - ); - } - - // `input.validation === undefined` means "no change" (keep existing); - // an explicit `null` clears the column. - const nextValidation = input.validation === undefined ? field.validation : input.validation; - - // A field-type change is only safe when the underlying column type stays - // the same. There is no in-place column migration (only addColumn/ - // dropColumn), so a change that would alter the SQLite column affinity - // (e.g. `text` TEXT -> `portableText` JSON) is rejected rather than - // silently rewriting only the metadata — which would leave `column_type` - // pointing at a column type the real `ec_*` column doesn't have (#1397). - let nextType = field.type; - let nextColumnType = field.columnType; - if (input.type !== undefined && input.type !== field.type) { - const newColumnType = FIELD_TYPE_TO_COLUMN[input.type]; - if (newColumnType !== field.columnType) { - throw new SchemaError( - `Cannot change field "${fieldSlug}" in collection "${collectionSlug}" from type ` + - `"${field.type}" to "${input.type}": the underlying column type would change from ` + - `${field.columnType} to ${newColumnType}, which requires a manual content migration. ` + - `Drop and re-create the field, or migrate the column data, before changing its type.`, - "FIELD_TYPE_COLUMN_CHANGE", - ); - } - nextType = input.type; - nextColumnType = newColumnType; - } - let schemaMutated = false; try { const updatedField = await withTransaction(this.db, async (trx) => { - await trx - .updateTable("_emdash_fields") - .set({ - type: nextType, - column_type: nextColumnType, - label: input.label ?? field.label, - required: - input.required !== undefined ? (input.required ? 1 : 0) : field.required ? 1 : 0, - unique: input.unique !== undefined ? (input.unique ? 1 : 0) : field.unique ? 1 : 0, - searchable: - input.searchable !== undefined - ? input.searchable - ? 1 - : 0 - : field.searchable - ? 1 - : 0, - translatable: - input.translatable !== undefined - ? input.translatable - ? 1 - : 0 - : field.translatable - ? 1 - : 0, - default_value: - input.defaultValue !== undefined - ? JSON.stringify(input.defaultValue) - : field.defaultValue !== undefined - ? JSON.stringify(field.defaultValue) - : null, - validation: nextValidation ? JSON.stringify(nextValidation) : null, - widget: input.widget ?? field.widget ?? null, - options: input.options - ? JSON.stringify(input.options) - : field.options - ? JSON.stringify(field.options) - : null, - sort_order: input.sortOrder ?? field.sortOrder, - }) - .where("id", "=", field.id) - .execute(); - schemaMutated = true; + const collectionRow = await trx + .selectFrom("_emdash_collections") + .where("slug", "=", collectionSlug) + .select("id") + .executeTakeFirst(); + const fieldRow = collectionRow + ? await trx + .selectFrom("_emdash_fields") + .where("collection_id", "=", collectionRow.id) + .where("slug", "=", fieldSlug) + .selectAll() + .executeTakeFirst() + : undefined; + if (!fieldRow) { + throw new SchemaError( + `Field "${fieldSlug}" not found in collection "${collectionSlug}"`, + "FIELD_NOT_FOUND", + ); + } + const field = this.mapFieldRow(fieldRow); + const updates: Updateable = {}; + + if (input.type !== undefined && input.type !== field.type) { + const newColumnType = FIELD_TYPE_TO_COLUMN[input.type]; + if (newColumnType !== field.columnType) { + throw new SchemaError( + `Cannot change field "${fieldSlug}" in collection "${collectionSlug}" from type ` + + `"${field.type}" to "${input.type}": the underlying column type would change from ` + + `${field.columnType} to ${newColumnType}, which requires a manual content migration.`, + "FIELD_TYPE_COLUMN_CHANGE", + ); + } + if (!TEXT_ALIAS_FIELD_TYPES.has(field.type) || !TEXT_ALIAS_FIELD_TYPES.has(input.type)) { + throw new SchemaError( + `Cannot change field "${fieldSlug}" in collection "${collectionSlug}" from type ` + + `"${field.type}" to "${input.type}" without a manual content migration.`, + "FIELD_TYPE_CHANGE_REQUIRES_MIGRATION", + ); + } + updates.type = input.type; + updates.column_type = newColumnType; + } + + if (input.required !== undefined && input.required !== field.required) { + throw new SchemaError( + `Changing required for field "${fieldSlug}" requires a manual content migration.`, + "FIELD_UPDATE_REQUIRES_MIGRATION", + ); + } + if (input.unique !== undefined && input.unique !== field.unique) { + throw new SchemaError( + `Changing unique for field "${fieldSlug}" requires a manual content migration.`, + "FIELD_UPDATE_REQUIRES_MIGRATION", + ); + } + if (input.translatable === false && field.translatable) { + throw new SchemaError( + `Changing field "${fieldSlug}" to non-translatable requires a manual content migration.`, + "FIELD_UPDATE_REQUIRES_MIGRATION", + ); + } + + if (input.label !== undefined) updates.label = input.label; + if (input.required !== undefined) updates.required = input.required ? 1 : 0; + if (input.unique !== undefined) updates.unique = input.unique ? 1 : 0; + if (input.searchable !== undefined) updates.searchable = input.searchable ? 1 : 0; + if (input.translatable !== undefined) { + updates.translatable = input.translatable ? 1 : 0; + } + if (input.defaultValue !== undefined) { + updates.default_value = JSON.stringify(input.defaultValue); + } + if (input.validation !== undefined) { + updates.validation = input.validation ? JSON.stringify(input.validation) : null; + } + if (input.widget !== undefined) updates.widget = input.widget; + if (input.options !== undefined) updates.options = JSON.stringify(input.options); + if (input.sortOrder !== undefined) updates.sort_order = input.sortOrder; + + if (Object.keys(updates).length > 0) { + await trx.updateTable("_emdash_fields").set(updates).where("id", "=", field.id).execute(); + schemaMutated = true; + } // Read the updated field via trx (not this.db) to avoid connection mutex deadlock const updatedRow = await trx @@ -765,18 +761,20 @@ export class SchemaRegistry { // If searchable changed, sync FTS state for this collection const searchableChanged = - input.searchable !== undefined && input.searchable !== field.searchable; + schemaMutated && input.searchable !== undefined && input.searchable !== field.searchable; if (searchableChanged) { await this.syncSearchState(collectionSlug, trx); } return updated; }); - await markContentMediaUsageCollectionStaleSafely( - this.db, - collectionSlug, - "CONTENT_USAGE_STALE", - ); + if (schemaMutated) { + await markContentMediaUsageCollectionStaleSafely( + this.db, + collectionSlug, + "CONTENT_USAGE_STALE", + ); + } return updatedField; } catch (error) { if (schemaMutated) { diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index a4fce579fc..943b006b6d 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -265,13 +265,9 @@ export interface CreateFieldInput { export interface UpdateFieldInput { label?: string; /** - * Change the field's type. Only type changes that keep the same underlying - * column type (per `FIELD_TYPE_TO_COLUMN`) are allowed — e.g. `string` to - * `slug` (both TEXT). A change that would alter the column affinity (e.g. - * `text` TEXT to `portableText` JSON) is rejected, because there is no - * in-place column migration and silently rewriting the metadata would - * desync `column_type` from the real `ec_*` column. Omit to keep the - * current type. + * Change the field's type. Only storage-compatible text aliases (`string`, + * `text`, and `slug`) can be changed in place. Other changes require an + * explicit content migration. Omit to keep the current type. */ type?: FieldType; required?: boolean; diff --git a/packages/core/src/schema/url-pattern.ts b/packages/core/src/schema/url-pattern.ts new file mode 100644 index 0000000000..107419fbdf --- /dev/null +++ b/packages/core/src/schema/url-pattern.ts @@ -0,0 +1,11 @@ +const URL_PARAM_PATTERN = /\{(\w+)\}/g; + +export function compileUrlPattern(pattern: string): { regex: RegExp; paramNames: string[] } { + const paramNames: string[] = []; + const regexSource = pattern.replace(URL_PARAM_PATTERN, (_match, name: string) => { + paramNames.push(name); + return "([^/]+)"; + }); + + return { regex: new RegExp(`^${regexSource}$`), paramNames }; +} diff --git a/packages/core/tests/integration/mcp/schema.test.ts b/packages/core/tests/integration/mcp/schema.test.ts index 35967b3f11..aa94545ead 100644 --- a/packages/core/tests/integration/mcp/schema.test.ts +++ b/packages/core/tests/integration/mcp/schema.test.ts @@ -24,6 +24,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Database } from "../../../src/database/types.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { validateContent } from "../../../src/schema/zod-generator.js"; import { connectMcpHarness, extractJson, @@ -317,6 +318,7 @@ describe("schema_update_collection", () => { label: "Posts", labelSingular: "Post", supports: ["drafts", "revisions"], + hasSeo: true, }); await registry.createField("post", { slug: "title", label: "Title", type: "string" }); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); @@ -415,6 +417,40 @@ describe("schema_update_collection", () => { expect(extractText(result)).toContain('Collection "missing" not found'); }); + it("rejects malformed URL patterns without changing the collection", async () => { + const result = await harness.client.callTool({ + name: "schema_update_collection", + arguments: { slug: "post", urlPattern: "(" }, + }); + + expect(result.isError).toBe(true); + expect(extractText(result)).toMatch(/url pattern|regular expression|invalid/i); + expect((await new SchemaRegistry(db).getCollection("post"))?.urlPattern).toBeUndefined(); + }); + + it("preserves SEO when supports changes without hasSeo", async () => { + const result = await harness.client.callTool({ + name: "schema_update_collection", + arguments: { slug: "post", supports: ["drafts", "preview"] }, + }); + + expect(result.isError, extractText(result)).toBeFalsy(); + expect(extractJson<{ item: { hasSeo: boolean } }>(result).item.hasSeo).toBe(true); + expect((await new SchemaRegistry(db).getCollection("post"))?.hasSeo).toBe(true); + }); + + it("preserves concurrent partial collection updates", async () => { + const registry = new SchemaRegistry(db); + await Promise.all([ + registry.updateCollection("post", { label: "Articles" }), + registry.updateCollection("post", { description: "Concurrent description" }), + ]); + + const collection = await registry.getCollection("post"); + expect(collection?.label).toBe("Articles"); + expect(collection?.description).toBe("Concurrent description"); + }); + it("requires the schema:write token scope", async () => { await harness.cleanup(); harness = await connectMcpHarness({ @@ -760,11 +796,9 @@ describe("schema_update_field", () => { fieldSlug: "body", label: "Summary", type: "string", - required: true, validation: { minLength: 1, maxLength: 160 }, widget: "text", sortOrder: 4, - translatable: false, }, }); @@ -785,11 +819,11 @@ describe("schema_update_field", () => { slug: "body", label: "Summary", type: "string", - required: true, + required: false, validation: { minLength: 1, maxLength: 160 }, widget: "text", sortOrder: 4, - translatable: false, + translatable: true, }); const content = await harness.client.callTool({ @@ -820,6 +854,88 @@ describe("schema_update_field", () => { expect(field?.type).toBe("text"); }); + it("rejects a semantically incompatible type with the same column affinity", async () => { + const result = await harness.client.callTool({ + name: "schema_update_field", + arguments: { + collection: "post", + fieldSlug: "body", + type: "datetime", + }, + }); + + expect(result.isError).toBe(true); + expect(extractText(result)).toContain("[FIELD_TYPE_CHANGE_REQUIRES_MIGRATION]"); + expect(extractText(result)).toMatch(/manual content migration/i); + expect((await new SchemaRegistry(db).getField("post", "body"))?.type).toBe("text"); + }); + + it("rejects field invariant changes that require data or column migration", async () => { + for (const change of [{ required: true }, { unique: true }, { translatable: false }]) { + const result = await harness.client.callTool({ + name: "schema_update_field", + arguments: { collection: "post", fieldSlug: "body", ...change }, + }); + expect(result.isError).toBe(true); + expect(extractText(result)).toContain("[FIELD_UPDATE_REQUIRES_MIGRATION]"); + expect(extractText(result)).toMatch(/manual content migration/i); + } + + const field = await new SchemaRegistry(db).getField("post", "body"); + expect(field).toMatchObject({ required: false, unique: false, translatable: true }); + }); + + it("rejects invalid validation rules without changing the field", async () => { + for (const validation of [ + { pattern: "[" }, + { minLength: 5, maxLength: 1 }, + { min: 10, max: 1 }, + { minItems: 4, maxItems: 2 }, + ]) { + const result = await harness.client.callTool({ + name: "schema_update_field", + arguments: { collection: "post", fieldSlug: "body", validation }, + }); + expect(result.isError).toBe(true); + expect(extractText(result)).toMatch(/invalid|minimum|maximum|pattern/i); + } + + expect((await new SchemaRegistry(db).getField("post", "body"))?.validation).toBeUndefined(); + }); + + it("invalidates the generated content schema after a field update", async () => { + const registry = new SchemaRegistry(db); + const before = await registry.getCollectionWithFields("post"); + expect(before).not.toBeNull(); + expect(validateContent(before!, { body: "primes the cache" }).success).toBe(true); + + const update = await harness.client.callTool({ + name: "schema_update_field", + arguments: { + collection: "post", + fieldSlug: "body", + validation: { maxLength: 1 }, + }, + }); + expect(update.isError, extractText(update)).toBeFalsy(); + + const after = await registry.getCollectionWithFields("post"); + expect(after).not.toBeNull(); + expect(validateContent(after!, { body: "too long" }).success).toBe(false); + }); + + it("preserves concurrent partial field updates", async () => { + const registry = new SchemaRegistry(db); + await Promise.all([ + registry.updateField("post", "body", { label: "Summary" }), + registry.updateField("post", "body", { sortOrder: 7 }), + ]); + + const field = await registry.getField("post", "body"); + expect(field?.label).toBe("Summary"); + expect(field?.sortOrder).toBe(7); + }); + it("uses the REST validation contract for update input", async () => { const result = await harness.client.callTool({ name: "schema_update_field", diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index 0991d0d9ce..bfcb0ed4e2 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -263,12 +263,12 @@ describe("SchemaRegistry", () => { const updated = await registry.updateField("posts", "title", { label: "Post Title", - required: true, + sortOrder: 3, widget: "text", }); expect(updated.label).toBe("Post Title"); - expect(updated.required).toBe(true); + expect(updated.sortOrder).toBe(3); expect(updated.widget).toBe("text"); });