From 11449b9a9f1ff59bb39d45adeee8807fcaf94357 Mon Sep 17 00:00:00 2001 From: Kingston Date: Fri, 13 Mar 2026 15:41:08 +0100 Subject: [PATCH 1/4] Fix up MCP navigation for entity usage --- .changeset/mcp-server-actions.md | 5 + .changeset/mcp-server-improvements.md | 5 + .../src/parser/walk-schema-structure.ts | 42 ++- .../parser/walk-schema-structure.unit.test.ts | 20 +- .../tools/entity-service/entity-navigation.ts | 10 + .../tools/entity-service/entity-type-map.ts | 5 +- .../actions/__tests__/action-test-utils.ts | 23 +- .../actions/definition/apply-fix.action.ts | 111 ++++++ .../actions/definition/commit-draft.action.ts | 30 +- .../definition/configure-plugin.action.ts | 119 +++++++ .../definition/definition-actions.int.test.ts | 317 ------------------ .../definition-test-fixtures.test-helper.ts | 116 +++++++ .../definition/disable-plugin.action.ts | 94 ++++++ .../definition/draft-lifecycle.int.test.ts | 261 ++++++++++++++ .../definition/entity-type-blacklist.ts | 21 ++ .../get-entity-schema.action.unit.test.ts | 37 ++ .../definition/get-entity.action.unit.test.ts | 56 ++++ .../src/actions/definition/index.ts | 5 + .../list-entities.action.unit.test.ts | 60 ++++ .../definition/list-entity-types.action.ts | 9 +- .../list-entity-types.action.unit.test.ts | 28 ++ .../actions/definition/list-plugins.action.ts | 71 ++++ .../definition/search-entities.action.ts | 67 ++++ .../search-entities.action.unit.test.ts | 74 ++++ .../stage-create-entity.action.int.test.ts | 127 +++++++ .../definition/stage-create-entity.action.ts | 17 +- .../stage-delete-entity.action.int.test.ts | 83 +++++ .../definition/stage-delete-entity.action.ts | 17 +- .../stage-update-entity.action.int.test.ts | 107 ++++++ .../definition/stage-update-entity.action.ts | 17 +- .../src/actions/definition/validate-draft.ts | 118 +++++++ .../src/actions/registry.ts | 10 + packages/tools/eslint-configs/typescript.js | 10 + 33 files changed, 1702 insertions(+), 390 deletions(-) create mode 100644 .changeset/mcp-server-actions.md create mode 100644 .changeset/mcp-server-improvements.md create mode 100644 packages/project-builder-server/src/actions/definition/apply-fix.action.ts create mode 100644 packages/project-builder-server/src/actions/definition/configure-plugin.action.ts delete mode 100644 packages/project-builder-server/src/actions/definition/definition-actions.int.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/definition-test-fixtures.test-helper.ts create mode 100644 packages/project-builder-server/src/actions/definition/disable-plugin.action.ts create mode 100644 packages/project-builder-server/src/actions/definition/draft-lifecycle.int.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/entity-type-blacklist.ts create mode 100644 packages/project-builder-server/src/actions/definition/get-entity-schema.action.unit.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/get-entity.action.unit.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/list-entities.action.unit.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/list-entity-types.action.unit.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/list-plugins.action.ts create mode 100644 packages/project-builder-server/src/actions/definition/search-entities.action.ts create mode 100644 packages/project-builder-server/src/actions/definition/search-entities.action.unit.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/stage-create-entity.action.int.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/stage-delete-entity.action.int.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/stage-update-entity.action.int.test.ts diff --git a/.changeset/mcp-server-actions.md b/.changeset/mcp-server-actions.md new file mode 100644 index 000000000..f02cc9ecb --- /dev/null +++ b/.changeset/mcp-server-actions.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/project-builder-server': patch +--- + +MCP server improvements: apply fixRefDeletions and applyDefinitionFixes when staging changes, add entity search action, expose auto-fix suggestions with apply-fix action, add plugin management actions (list, configure, disable), blacklist plugin entity type from generic entity operations diff --git a/.changeset/mcp-server-improvements.md b/.changeset/mcp-server-improvements.md new file mode 100644 index 000000000..889553cfa --- /dev/null +++ b/.changeset/mcp-server-improvements.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/project-builder-lib': patch +--- + +Fix entity navigation for discriminated union array children (e.g. admin sections) by stripping leading discriminated-union-array element from relative paths in collectEntityMetadata diff --git a/packages/project-builder-lib/src/parser/walk-schema-structure.ts b/packages/project-builder-lib/src/parser/walk-schema-structure.ts index c219247af..b3be4229b 100644 --- a/packages/project-builder-lib/src/parser/walk-schema-structure.ts +++ b/packages/project-builder-lib/src/parser/walk-schema-structure.ts @@ -7,17 +7,14 @@ import { getSchemaChildren } from './schema-structure.js'; // --------------------------------------------------------------------------- /** - * Represents a single deterministic step in navigating from a parent entity - * (or definition root) to a child entity array. + * Represents a single step in navigating through a schema structure. * - * Only deterministic navigation steps are represented: * - `object-key`: navigate into an object property * - `tuple-index`: navigate into a specific tuple position * - `discriminated-union-array`: enter an array and pick the unique element * matching a discriminator value - * - * Non-deterministic structures (plain arrays, records) are not represented - * in the path — the walker descends into them without adding path elements. + * - `array`: descend into a plain array's element schema (non-deterministic) + * - `record`: descend into a record's value schema (non-deterministic) */ export type SchemaPathElement = | { type: 'object-key'; key: string } @@ -26,7 +23,9 @@ export type SchemaPathElement = type: 'discriminated-union-array'; discriminatorKey: string; value: string; - }; + } + | { type: 'array' } + | { type: 'record' }; // --------------------------------------------------------------------------- // Visitor types @@ -64,12 +63,13 @@ export interface SchemaStructureVisitor { * at every schema node. * * Unlike `walkDataWithSchema`, this operates on the schema alone. - * Only deterministic navigation steps produce path elements: - * - Object keys and tuple indices add path elements - * - Arrays of discriminated unions add `discriminated-union-array` elements - * (one per branch) + * Every structural descent produces a path element: + * - Object keys → `object-key` + * - Tuple indices → `tuple-index` + * - Arrays of discriminated unions → `discriminated-union-array` (one per branch) + * - Plain arrays → `array` + * - Records → `record` * - Discriminated unions on objects are transparent (no path element) - * - Plain arrays and records are descended into without path elements * * Uses a `Set` circular-reference guard with delete-on-backtrack * so the same schema can appear at different paths. @@ -150,8 +150,13 @@ function walkNode( visited, ); } else { - // Plain array — descend without adding a path element - walkNode(children.elementSchema, path, visitors, visited); + // Plain array — descend with an array path element + walkNode( + children.elementSchema, + [...path, { type: 'array' }], + visitors, + visited, + ); } break; } @@ -182,8 +187,13 @@ function walkNode( } case 'record': { - // Non-deterministic — walk without path element - walkNode(children.valueSchema, path, visitors, visited); + // Record — descend with a record path element + walkNode( + children.valueSchema, + [...path, { type: 'record' }], + visitors, + visited, + ); break; } diff --git a/packages/project-builder-lib/src/parser/walk-schema-structure.unit.test.ts b/packages/project-builder-lib/src/parser/walk-schema-structure.unit.test.ts index 76bb962b0..1262675d1 100644 --- a/packages/project-builder-lib/src/parser/walk-schema-structure.unit.test.ts +++ b/packages/project-builder-lib/src/parser/walk-schema-structure.unit.test.ts @@ -89,19 +89,17 @@ describe('walkSchemaStructure — simple schemas', () => { // --------------------------------------------------------------------------- describe('walkSchemaStructure — arrays', () => { - it('walks plain array elements without adding a path element', () => { + it('walks plain array elements with an array path element', () => { const schema = z.object({ tags: z.array(z.string()) }); const visitor = makeRecordingVisitor(); walkSchemaStructure(schema, [visitor]); - // The string inside the array should have the same path as the array field - // (no array marker in path — non-deterministic) expect(visitor.calls).toContainEqual({ path: [{ type: 'object-key', key: 'tags' }], type: 'array', }); expect(visitor.calls).toContainEqual({ - path: [{ type: 'object-key', key: 'tags' }], + path: [{ type: 'object-key', key: 'tags' }, { type: 'array' }], type: 'string', }); }); @@ -268,20 +266,19 @@ describe('walkSchemaStructure — tuples', () => { // --------------------------------------------------------------------------- describe('walkSchemaStructure — records', () => { - it('walks record values without a path element', () => { + it('walks record values with a record path element', () => { const schema = z.object({ data: z.record(z.string(), z.number()), }); const visitor = makeRecordingVisitor(); walkSchemaStructure(schema, [visitor]); - // Record value schema visited at the same path as the record field expect(visitor.calls).toContainEqual({ path: [{ type: 'object-key', key: 'data' }], type: 'record', }); expect(visitor.calls).toContainEqual({ - path: [{ type: 'object-key', key: 'data' }], + path: [{ type: 'object-key', key: 'data' }, { type: 'record' }], type: 'number', }); }); @@ -436,13 +433,10 @@ describe('walkSchemaStructure — entity detection via collectEntityMetadata', ( const childMeta = map.get('du-child'); expect(childMeta).toBeDefined(); - // Child is inside branch 'a' of the discriminated union array + // Child is inside branch 'a' — the leading discriminated-union-array + // element is stripped because it describes the parent's array branch, + // not the path from the parent entity to the child. expect(childMeta?.relativePath).toEqual([ - { - type: 'discriminated-union-array', - discriminatorKey: 'type', - value: 'a', - }, { type: 'object-key', key: 'items' }, ]); expect(childMeta?.parentEntityTypeName).toBe('du-parent'); diff --git a/packages/project-builder-lib/src/tools/entity-service/entity-navigation.ts b/packages/project-builder-lib/src/tools/entity-service/entity-navigation.ts index 94dab5b3a..378178fee 100644 --- a/packages/project-builder-lib/src/tools/entity-service/entity-navigation.ts +++ b/packages/project-builder-lib/src/tools/entity-service/entity-navigation.ts @@ -64,6 +64,16 @@ function navigateToEntityArrayFromSchemaPath( path.push(match); break; } + case 'array': { + throw new Error( + `Cannot navigate through plain array at path "${path.join('.')}": non-deterministic structure`, + ); + } + case 'record': { + throw new Error( + `Cannot navigate through record at path "${path.join('.')}": non-deterministic structure`, + ); + } } } diff --git a/packages/project-builder-lib/src/tools/entity-service/entity-type-map.ts b/packages/project-builder-lib/src/tools/entity-service/entity-type-map.ts index c9c9d61a8..fe4fe5a62 100644 --- a/packages/project-builder-lib/src/tools/entity-service/entity-type-map.ts +++ b/packages/project-builder-lib/src/tools/entity-service/entity-type-map.ts @@ -66,7 +66,10 @@ export function collectEntityMetadata(schema: z.ZodType): EntityTypeMap { } parentEntityTypeName = parentType.name; - relativePath = ctx.path.slice(parentEntry.pathAtEntity.length); + // Strip the first element — it is always an array or + // discriminated-union-array that describes how to enter the parent's + // array, not how to navigate from the parent entity to this child. + relativePath = ctx.path.slice(parentEntry.pathAtEntity.length + 1); } else { // Top-level entity: relative path is the full path relativePath = ctx.path; diff --git a/packages/project-builder-server/src/actions/__tests__/action-test-utils.ts b/packages/project-builder-server/src/actions/__tests__/action-test-utils.ts index 14b17178c..a46bb9052 100644 --- a/packages/project-builder-server/src/actions/__tests__/action-test-utils.ts +++ b/packages/project-builder-server/src/actions/__tests__/action-test-utils.ts @@ -2,11 +2,15 @@ import type { ProjectDefinitionInput, SchemaParserContext, } from '@baseplate-dev/project-builder-lib'; +import type z from 'zod'; import { createTestProjectDefinitionContainer } from '@baseplate-dev/project-builder-lib/testing'; import { createConsoleLogger } from '@baseplate-dev/sync'; -import type { ServiceActionContext } from '#src/actions/types.js'; +import type { + ServiceAction, + ServiceActionContext, +} from '#src/actions/types.js'; import type { EntityServiceContextResult } from '../definition/load-entity-service-context.js'; @@ -58,3 +62,20 @@ export function createTestEntityServiceContext( const entityContext = container.toEntityServiceContext(); return { entityContext, container, parserContext: container.parserContext }; } + +/** + * Invokes a service action for testing, validating input/output schemas + * but skipping CLI output formatting. + */ +export async function invokeServiceActionForTest< + TInputType extends z.ZodType, + TOutputType extends z.ZodType, +>( + action: ServiceAction, + input: z.input, + context: ServiceActionContext, +): Promise> { + const parsedInput = action.inputSchema.parse(input); + const result = await action.handler(parsedInput, context); + return action.outputSchema.parse(result); +} diff --git a/packages/project-builder-server/src/actions/definition/apply-fix.action.ts b/packages/project-builder-server/src/actions/definition/apply-fix.action.ts new file mode 100644 index 000000000..272652218 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/apply-fix.action.ts @@ -0,0 +1,111 @@ +import { + collectDefinitionIssues, + createIssueFixSetter, + ProjectDefinitionContainer, + serializeSchema, +} from '@baseplate-dev/project-builder-lib'; +import { produce } from 'immer'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { + definitionIssueSchema, + fixAndValidateDraftDefinition, + generateFixId, + mapIssueToOutput, +} from './validate-draft.js'; + +const applyFixInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + fixId: z + .string() + .describe( + 'The deterministic fix ID returned by stage actions (e.g., "fix-a1b2c3d4").', + ), +}); + +const applyFixOutputSchema = z.object({ + message: z.string().describe('A summary of the applied fix.'), + issues: z + .array(definitionIssueSchema) + .optional() + .describe('Remaining definition issues after applying the fix.'), +}); + +export const applyFixAction = createServiceAction({ + name: 'apply-fix', + title: 'Apply Fix', + description: + 'Apply an auto-fix for a definition issue in the current draft session.', + inputSchema: applyFixInputSchema, + outputSchema: applyFixOutputSchema, + handler: async (input, context) => { + const { session, parserContext, projectDirectory } = + await getOrCreateDraftSession(input.project, context); + + // Build container from draft definition to collect issues + const container = ProjectDefinitionContainer.fromSerializedConfig( + session.draftDefinition, + parserContext, + ); + + const issues = collectDefinitionIssues(container); + + // Find the issue matching the fix ID + const matchingIssue = issues.find( + (issue) => issue.fix && generateFixId(issue) === input.fixId, + ); + + if (!matchingIssue) { + throw new Error( + `No fixable issue found with ID "${input.fixId}". ` + + 'The fix may no longer be applicable or the ID may be incorrect.', + ); + } + + const setter = createIssueFixSetter(matchingIssue, container); + if (!setter) { + throw new Error( + `Issue "${matchingIssue.message}" has no applicable fix.`, + ); + } + + // Apply the fix to the parsed definition + const fixedDefinition = produce(setter)(container.definition); + + // Serialize back to name-based format via the schema + const fixedSerializedDef = serializeSchema( + container.schema, + fixedDefinition, + ) as Record; + + // Run fixAndValidateDraftDefinition on the result + const { fixedSerializedDefinition, errors, warnings } = + fixAndValidateDraftDefinition(fixedSerializedDef, parserContext); + + if (errors.length > 0) { + const messages = errors.map((e) => e.message).join('; '); + throw new Error( + `Fix applied but resulted in definition errors: ${messages}`, + ); + } + + session.draftDefinition = fixedSerializedDefinition; + await saveDraftSession(projectDirectory, session); + + return { + message: `Applied fix: ${matchingIssue.fix?.label ?? matchingIssue.message}`, + issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, + }; + }, + writeCliOutput: (output) => { + console.info(`✓ ${output.message}`); + if (output.issues) { + for (const issue of output.issues) { + console.warn(` ⚠ ${issue.message}`); + } + } + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/commit-draft.action.ts b/packages/project-builder-server/src/actions/definition/commit-draft.action.ts index 7c9c8c154..c322b2ddd 100644 --- a/packages/project-builder-server/src/actions/definition/commit-draft.action.ts +++ b/packages/project-builder-server/src/actions/definition/commit-draft.action.ts @@ -1,7 +1,3 @@ -import { - collectDefinitionIssues, - ProjectDefinitionContainer, -} from '@baseplate-dev/project-builder-lib'; import { writeFile } from 'node:fs/promises'; import path from 'node:path'; import { z } from 'zod'; @@ -16,7 +12,11 @@ import { loadDefinitionHash, loadDraftSession, } from './draft-session.js'; -import { definitionIssueSchema } from './validate-draft.js'; +import { + definitionIssueSchema, + fixAndValidateDraftDefinition, + mapIssueToOutput, +} from './validate-draft.js'; const commitDraftInputSchema = z.object({ project: z.string().describe('The name or ID of the project.'), @@ -56,7 +56,7 @@ export const commitDraftAction = createServiceAction({ ); } - // Convert the serialized draft back to a proper definition and serialize it + // Convert the serialized draft back to a proper definition and validate const parserContext = await createNodeSchemaParserContext( project, context.logger, @@ -64,19 +64,21 @@ export const commitDraftAction = createServiceAction({ context.cliVersion, ); - const container = ProjectDefinitionContainer.fromSerializedConfig( + const { container, errors, warnings } = fixAndValidateDraftDefinition( session.draftDefinition, parserContext, ); - // Validate the draft definition before committing - const issues = collectDefinitionIssues(container); + if (errors.length > 0) { + const messages = errors.map((e) => e.message).join('; '); + throw new Error(`Commit blocked by definition errors: ${messages}`); + } - if (issues.length > 0) { - const messages = issues - .map((i) => `[${i.severity}] ${i.message}`) - .join('; '); - throw new Error(`Commit blocked by definition issues: ${messages}`); + if (warnings.length > 0) { + return { + message: 'Commit blocked by definition warnings.', + issues: warnings.map(mapIssueToOutput), + }; } const serializedContents = container.toSerializedContents(); diff --git a/packages/project-builder-server/src/actions/definition/configure-plugin.action.ts b/packages/project-builder-server/src/actions/definition/configure-plugin.action.ts new file mode 100644 index 000000000..a8dc50b24 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/configure-plugin.action.ts @@ -0,0 +1,119 @@ +import type { + PluginMetadataWithPaths, + ProjectDefinition, +} from '@baseplate-dev/project-builder-lib'; + +import { + PluginUtils, + ProjectDefinitionContainer, + serializeSchema, +} from '@baseplate-dev/project-builder-lib'; +import { produce } from 'immer'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { + definitionIssueSchema, + fixAndValidateDraftDefinition, + mapIssueToOutput, +} from './validate-draft.js'; + +const configurePluginInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + pluginKey: z.string().describe('The plugin key to enable or configure.'), + config: z + .record(z.string(), z.unknown()) + .optional() + .describe('Optional plugin configuration. Defaults to empty config.'), +}); + +const configurePluginOutputSchema = z.object({ + message: z.string().describe('A summary of the staged change.'), + issues: z + .array(definitionIssueSchema) + .optional() + .describe('Definition issues found after staging.'), +}); + +function findPluginByKey( + plugins: PluginMetadataWithPaths[], + pluginKey: string, +): PluginMetadataWithPaths { + const plugin = plugins.find((p) => p.key === pluginKey); + if (!plugin) { + const available = plugins.map((p) => p.key).join(', '); + throw new Error( + `Plugin "${pluginKey}" not found. Available plugins: ${available}`, + ); + } + return plugin; +} + +export const configurePluginAction = createServiceAction({ + name: 'configure-plugin', + title: 'Configure Plugin', + description: + 'Enable a plugin or update its configuration in the draft session. Changes are not persisted until commit-draft is called.', + inputSchema: configurePluginInputSchema, + outputSchema: configurePluginOutputSchema, + handler: async (input, context) => { + const { session, parserContext, projectDirectory } = + await getOrCreateDraftSession(input.project, context); + + const pluginMetadata = findPluginByKey(context.plugins, input.pluginKey); + + const container = ProjectDefinitionContainer.fromSerializedConfig( + session.draftDefinition, + parserContext, + ); + + const pluginConfig = input.config ?? {}; + + // Apply setPluginConfig via produce + const newDefinition = produce((draft: ProjectDefinition) => { + PluginUtils.setPluginConfig( + draft, + pluginMetadata, + pluginConfig, + container, + ); + })(container.definition); + + // Serialize back to name-based format + const serializedDef = serializeSchema( + container.schema, + newDefinition, + ) as Record; + + // Validate + const { fixedSerializedDefinition, errors, warnings } = + fixAndValidateDraftDefinition(serializedDef, parserContext); + + if (errors.length > 0) { + const messages = errors.map((e) => e.message).join('; '); + throw new Error(`Staging blocked by definition errors: ${messages}`); + } + + session.draftDefinition = fixedSerializedDefinition; + await saveDraftSession(projectDirectory, session); + + const isNew = + PluginUtils.byKey(container.definition, input.pluginKey) == null; + const action = isNew ? 'Enabled' : 'Updated configuration for'; + + return { + message: `${action} plugin "${pluginMetadata.displayName}". Use commit-draft to persist.`, + issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, + }; + }, + writeCliOutput: (output) => { + console.info(`✓ ${output.message}`); + if (output.issues) { + for (const issue of output.issues) { + console.warn(` ⚠ ${issue.message}`); + } + } + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/definition-actions.int.test.ts b/packages/project-builder-server/src/actions/definition/definition-actions.int.test.ts deleted file mode 100644 index c2fe957e5..000000000 --- a/packages/project-builder-server/src/actions/definition/definition-actions.int.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { - createTestFeature, - createTestModel, - createTestScalarField, -} from '@baseplate-dev/project-builder-lib/testing'; -import { vol } from 'memfs'; -import { readFile } from 'node:fs/promises'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { invokeServiceActionAsCli } from '#src/actions/utils/cli.js'; - -import type { DraftSessionContext } from './draft-session.js'; - -import { - createTestActionContext, - createTestEntityServiceContext, -} from '../__tests__/action-test-utils.js'; -import { getOrCreateDraftSession } from './draft-session.js'; -import { getEntitySchemaAction } from './get-entity-schema.action.js'; -import { getEntityAction } from './get-entity.action.js'; -import { listEntitiesAction } from './list-entities.action.js'; -import { listEntityTypesAction } from './list-entity-types.action.js'; -import { loadEntityServiceContext } from './load-entity-service-context.js'; -import { stageCreateEntityAction } from './stage-create-entity.action.js'; -import { stageDeleteEntityAction } from './stage-delete-entity.action.js'; -import { stageUpdateEntityAction } from './stage-update-entity.action.js'; - -vi.mock('./load-entity-service-context.js'); -vi.mock('node:fs/promises'); - -// Only mock getOrCreateDraftSession — let saveDraftSession use memfs -vi.mock('./draft-session.js', async () => { - const actual = await vi.importActual('./draft-session.js'); - return { - ...actual, - getOrCreateDraftSession: vi.fn(), - }; -}); - -// -- Test fixtures ----------------------------------------------------------- - -const blogFeature = createTestFeature({ name: 'blog' }); - -const titleField = createTestScalarField({ name: 'title', type: 'string' }); -const contentField = createTestScalarField({ name: 'content', type: 'string' }); - -const blogPostModel = createTestModel({ - name: 'BlogPost', - featureRef: blogFeature.name, - model: { - fields: [ - createTestScalarField({ - name: 'id', - type: 'uuid', - options: { genUuid: true }, - }), - titleField, - contentField, - ], - primaryKeyFieldRefs: ['id'], - }, -}); - -const testEntityServiceContext = createTestEntityServiceContext({ - features: [blogFeature], - models: [blogPostModel], -}); - -const PROJECT_DIR = '/test-project'; - -const context = createTestActionContext(); - -function createMockDraftSessionContext(): DraftSessionContext { - return { - session: { - sessionId: 'default', - definitionHash: 'test-hash', - draftDefinition: - testEntityServiceContext.entityContext.serializedDefinition, - }, - entityContext: testEntityServiceContext.entityContext, - parserContext: testEntityServiceContext.parserContext, - projectDirectory: PROJECT_DIR, - }; -} - -// -- Setup ------------------------------------------------------------------- - -beforeEach(() => { - vol.reset(); - vi.mocked(loadEntityServiceContext).mockResolvedValue( - testEntityServiceContext, - ); - vi.mocked(getOrCreateDraftSession).mockResolvedValue( - createMockDraftSessionContext(), - ); -}); - -// -- Read action tests ------------------------------------------------------- - -describe('list-entity-types', () => { - it('should return available entity types', async () => { - const result = await invokeServiceActionAsCli( - listEntityTypesAction, - { project: 'test-project' }, - context, - ); - - expect(result.entityTypes).toBeDefined(); - expect(result.entityTypes.length).toBeGreaterThan(0); - - const typeNames = result.entityTypes.map((t: { name: string }) => t.name); - expect(typeNames).toContain('feature'); - expect(typeNames).toContain('model'); - }); -}); - -describe('list-entities', () => { - it('should list features', async () => { - const result = await invokeServiceActionAsCli( - listEntitiesAction, - { project: 'test-project', entityTypeName: 'feature' }, - context, - ); - - expect(result.entities).toHaveLength(1); - expect(result.entities[0]).toMatchObject({ - name: 'blog', - type: 'feature', - }); - }); - - it('should list models', async () => { - const result = await invokeServiceActionAsCli( - listEntitiesAction, - { project: 'test-project', entityTypeName: 'model' }, - context, - ); - - expect(result.entities).toHaveLength(1); - expect(result.entities[0]).toMatchObject({ - name: 'BlogPost', - type: 'model', - }); - }); - - it('should list nested model fields with parent ID', async () => { - const result = await invokeServiceActionAsCli( - listEntitiesAction, - { - project: 'test-project', - entityTypeName: 'model-scalar-field', - parentEntityId: blogPostModel.id, - }, - context, - ); - - const fieldNames = result.entities.map((e: { name: string }) => e.name); - expect(fieldNames).toContain('title'); - expect(fieldNames).toContain('content'); - }); -}); - -describe('get-entity', () => { - it('should retrieve a model by ID', async () => { - const result = await invokeServiceActionAsCli( - getEntityAction, - { project: 'test-project', entityId: blogPostModel.id }, - context, - ); - - expect(result.entity).not.toBeNull(); - expect(result.entity).toHaveProperty('name', 'BlogPost'); - }); - - it('should return null for a nonexistent entity ID', async () => { - const result = await invokeServiceActionAsCli( - getEntityAction, - { project: 'test-project', entityId: 'model:nonexistent' }, - context, - ); - - expect(result.entity).toBeNull(); - }); -}); - -describe('get-entity-schema', () => { - it('should return TypeScript type for a known entity type', async () => { - const result = await invokeServiceActionAsCli( - getEntitySchemaAction, - { project: 'test-project', entityTypeName: 'model' }, - context, - ); - - expect(result.entityTypeName).toBe('model'); - expect(typeof result.schema).toBe('string'); - expect(result.schema).toContain('name'); - }); - - it('should throw for an unknown entity type', async () => { - await expect( - invokeServiceActionAsCli( - getEntitySchemaAction, - { project: 'test-project', entityTypeName: 'nonexistent' }, - context, - ), - ).rejects.toThrow(/Unknown entity type/); - }); -}); - -// -- Write action tests ------------------------------------------------------ - -describe('stage-create-entity', () => { - it('should stage a new feature and write draft files to disk', async () => { - const result = await invokeServiceActionAsCli( - stageCreateEntityAction, - { - project: 'test-project', - entityTypeName: 'feature', - entityData: { name: 'payments' }, - }, - context, - ); - - expect(result.message).toContain('Staged creation'); - - // Verify draft files were written via memfs - const sessionContents = await readFile( - `${PROJECT_DIR}/baseplate/.build/draft-session.json`, - 'utf-8', - ); - const session = JSON.parse(sessionContents) as { - sessionId: string; - definitionHash: string; - }; - expect(session.sessionId).toBe('default'); - expect(session.definitionHash).toBe('test-hash'); - - const defContents = await readFile( - `${PROJECT_DIR}/baseplate/.build/draft-definition.json`, - 'utf-8', - ); - const definition = JSON.parse(defContents) as { - features: { name: string }[]; - }; - // The new feature should be in the draft definition - expect(definition.features.some((f) => f.name === 'payments')).toBe(true); - }); -}); - -describe('stage-update-entity', () => { - it('should stage an entity update and write draft files', async () => { - const result = await invokeServiceActionAsCli( - stageUpdateEntityAction, - { - project: 'test-project', - entityTypeName: 'model', - entityId: blogPostModel.id, - entityData: { - id: blogPostModel.id, - name: 'BlogPostUpdated', - featureRef: 'blog', - model: { - fields: [ - { - name: 'id', - type: 'uuid', - isOptional: false, - options: { genUuid: true }, - }, - ], - primaryKeyFieldRefs: ['id'], - }, - }, - }, - context, - ); - - expect(result.message).toContain('Staged update'); - - const defContents = await readFile( - `${PROJECT_DIR}/baseplate/.build/draft-definition.json`, - 'utf-8', - ); - const definition = JSON.parse(defContents) as { - models: { name: string }[]; - }; - expect(definition.models.some((m) => m.name === 'BlogPostUpdated')).toBe( - true, - ); - }); -}); - -describe('stage-delete-entity', () => { - it('should stage an entity deletion and write draft files', async () => { - const result = await invokeServiceActionAsCli( - stageDeleteEntityAction, - { - project: 'test-project', - entityTypeName: 'feature', - entityId: blogFeature.id, - }, - context, - ); - - expect(result.message).toContain('Staged deletion'); - - const defContents = await readFile( - `${PROJECT_DIR}/baseplate/.build/draft-definition.json`, - 'utf-8', - ); - const definition = JSON.parse(defContents) as { - features: { name: string }[]; - }; - expect(definition.features.some((f) => f.name === 'blog')).toBe(false); - }); -}); diff --git a/packages/project-builder-server/src/actions/definition/definition-test-fixtures.test-helper.ts b/packages/project-builder-server/src/actions/definition/definition-test-fixtures.test-helper.ts new file mode 100644 index 000000000..2fe02dc2b --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/definition-test-fixtures.test-helper.ts @@ -0,0 +1,116 @@ +import type { + ModelConfig, + SchemaParserContext, +} from '@baseplate-dev/project-builder-lib'; + +import { + createTestFeature, + createTestModel, + createTestScalarField, +} from '@baseplate-dev/project-builder-lib/testing'; +import { test as baseTest, vi } from 'vitest'; + +import type { ServiceActionContext } from '#src/actions/types.js'; + +import type { TestEntityServiceContextResult } from '../__tests__/action-test-utils.js'; +import type { DraftSessionContext } from './draft-session.js'; + +import { + createTestActionContext, + createTestEntityServiceContext, +} from '../__tests__/action-test-utils.js'; +import { getOrCreateDraftSession } from './draft-session.js'; +import { loadEntityServiceContext } from './load-entity-service-context.js'; + +// -- Test data builders ------------------------------------------------------- + +function buildTestData(): { + blogPostModel: ModelConfig; + testEntityServiceContext: TestEntityServiceContextResult; +} { + const blogFeature = createTestFeature({ name: 'blog' }); + + const blogPostModel = createTestModel({ + name: 'BlogPost', + featureRef: blogFeature.name, + model: { + fields: [ + createTestScalarField({ + name: 'id', + type: 'uuid', + options: { genUuid: true }, + }), + createTestScalarField({ name: 'title', type: 'string' }), + createTestScalarField({ name: 'content', type: 'string' }), + ], + primaryKeyFieldRefs: ['id'], + }, + }); + + const testEntityServiceContext = createTestEntityServiceContext({ + features: [blogFeature], + models: [blogPostModel], + }); + + return { blogPostModel, testEntityServiceContext }; +} + +// -- Custom test with fixtures ------------------------------------------------ + +/** + * Custom test function that injects definition action fixtures. + * + * Each test file must still declare `vi.mock()` calls at the top level + * (they are hoisted). This fixture handles the per-test mock setup. + */ +interface TestData { + blogPostModel: ModelConfig; + testEntityServiceContext: TestEntityServiceContextResult; +} + +export const test = baseTest.extend<{ + testData: TestData; + context: ServiceActionContext; + blogPostModel: ModelConfig; + testEntityServiceContext: TestEntityServiceContextResult; + parserContext: SchemaParserContext; + projectDir: string; +}>({ + testData: async ({}, use) => { + await use(buildTestData()); + }, + blogPostModel: async ({ testData }, use) => { + await use(testData.blogPostModel); + }, + testEntityServiceContext: async ({ testData }, use) => { + await use(testData.testEntityServiceContext); + }, + parserContext: async ({ testEntityServiceContext }, use) => { + await use(testEntityServiceContext.parserContext); + }, + projectDir: async ({}, use) => { + await use('/test-project'); + }, + context: async ({ testEntityServiceContext }, use) => { + const ctx = createTestActionContext(); + + const draftSessionContext: DraftSessionContext = { + session: { + sessionId: 'default', + definitionHash: 'test-hash', + draftDefinition: + testEntityServiceContext.entityContext.serializedDefinition, + }, + entityContext: testEntityServiceContext.entityContext, + parserContext: testEntityServiceContext.parserContext, + projectDirectory: '/test-project', + }; + + vi.mocked(loadEntityServiceContext).mockResolvedValue( + testEntityServiceContext, + ); + vi.mocked(getOrCreateDraftSession).mockResolvedValue(draftSessionContext); + + await use(ctx); + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/disable-plugin.action.ts b/packages/project-builder-server/src/actions/definition/disable-plugin.action.ts new file mode 100644 index 000000000..0ed0c18ac --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/disable-plugin.action.ts @@ -0,0 +1,94 @@ +import type { ProjectDefinition } from '@baseplate-dev/project-builder-lib'; + +import { + PluginUtils, + ProjectDefinitionContainer, + serializeSchema, +} from '@baseplate-dev/project-builder-lib'; +import { produce } from 'immer'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { + definitionIssueSchema, + fixAndValidateDraftDefinition, + mapIssueToOutput, +} from './validate-draft.js'; + +const disablePluginInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + pluginKey: z.string().describe('The plugin key to disable.'), +}); + +const disablePluginOutputSchema = z.object({ + message: z.string().describe('A summary of the staged change.'), + issues: z + .array(definitionIssueSchema) + .optional() + .describe('Definition issues found after staging.'), +}); + +export const disablePluginAction = createServiceAction({ + name: 'disable-plugin', + title: 'Disable Plugin', + description: + 'Disable a plugin in the draft session. Also disables any plugins managed by this plugin. Changes are not persisted until commit-draft is called.', + inputSchema: disablePluginInputSchema, + outputSchema: disablePluginOutputSchema, + handler: async (input, context) => { + const { session, parserContext, projectDirectory } = + await getOrCreateDraftSession(input.project, context); + + const container = ProjectDefinitionContainer.fromSerializedConfig( + session.draftDefinition, + parserContext, + ); + + // Verify the plugin is currently enabled + const existingPlugin = PluginUtils.byKey( + container.definition, + input.pluginKey, + ); + if (!existingPlugin) { + throw new Error(`Plugin "${input.pluginKey}" is not currently enabled.`); + } + + // Apply disablePlugin via produce + const newDefinition = produce((draft: ProjectDefinition) => { + PluginUtils.disablePlugin(draft, input.pluginKey, parserContext); + })(container.definition); + + // Serialize back to name-based format + const serializedDef = serializeSchema( + container.schema, + newDefinition, + ) as Record; + + // Validate + const { fixedSerializedDefinition, errors, warnings } = + fixAndValidateDraftDefinition(serializedDef, parserContext); + + if (errors.length > 0) { + const messages = errors.map((e) => e.message).join('; '); + throw new Error(`Staging blocked by definition errors: ${messages}`); + } + + session.draftDefinition = fixedSerializedDefinition; + await saveDraftSession(projectDirectory, session); + + return { + message: `Disabled plugin "${input.pluginKey}". Use commit-draft to persist.`, + issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, + }; + }, + writeCliOutput: (output) => { + console.info(`✓ ${output.message}`); + if (output.issues) { + for (const issue of output.issues) { + console.warn(` ⚠ ${issue.message}`); + } + } + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/draft-lifecycle.int.test.ts b/packages/project-builder-server/src/actions/definition/draft-lifecycle.int.test.ts new file mode 100644 index 000000000..785ab7f5f --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/draft-lifecycle.int.test.ts @@ -0,0 +1,261 @@ +import { + createTestFeature, + createTestModel, + createTestRelationField, + createTestScalarField, +} from '@baseplate-dev/project-builder-lib/testing'; +import { vol } from 'memfs'; +import { readFile } from 'node:fs/promises'; +import { beforeEach, describe, expect, vi } from 'vitest'; + +import type { DraftSessionContext } from './draft-session.js'; + +import { + createTestEntityServiceContext, + invokeServiceActionForTest, +} from '../__tests__/action-test-utils.js'; +import { applyFixAction } from './apply-fix.action.js'; +import { commitDraftAction } from './commit-draft.action.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { discardDraftAction } from './discard-draft.action.js'; +import { + getOrCreateDraftSession, + loadDefinitionHash, +} from './draft-session.js'; +import { showDraftAction } from './show-draft.action.js'; +import { stageCreateEntityAction } from './stage-create-entity.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('node:fs/promises'); + +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { + ...actual, + getOrCreateDraftSession: vi.fn(), + loadDefinitionHash: vi.fn(), + }; +}); + +vi.mock('#src/plugins/node-plugin-store.js', () => ({ + createNodeSchemaParserContext: vi.fn(), +})); + +beforeEach(() => { + vol.reset(); + vi.mocked(loadDefinitionHash).mockResolvedValue('test-hash'); +}); + +describe('draft lifecycle', () => { + test('stage → show-draft → commit', async ({ + context, + projectDir, + parserContext, + }) => { + const { createNodeSchemaParserContext } = + await import('#src/plugins/node-plugin-store.js'); + vi.mocked(createNodeSchemaParserContext).mockResolvedValue(parserContext); + + // 1. Stage a new feature + const stageResult = await invokeServiceActionForTest( + stageCreateEntityAction, + { + project: 'test-project', + entityTypeName: 'feature', + entityData: { name: 'payments' }, + }, + context, + ); + expect(stageResult.message).toContain('Staged creation'); + + // 2. Show draft — should reflect the staged change + const showResult = await invokeServiceActionForTest( + showDraftAction, + { project: 'test-project' }, + context, + ); + expect(showResult.hasDraft).toBe(true); + expect(showResult.sessionId).toBe('default'); + const { changes } = showResult; + expect(changes).toBeDefined(); + expect(changes?.length).toBeGreaterThan(0); + + const addedFeature = changes?.find( + (c: { label: string; type: string }) => + c.type === 'added' && c.label.includes('payments'), + ); + expect(addedFeature).toBeDefined(); + + // 3. Commit the draft + const commitResult = await invokeServiceActionForTest( + commitDraftAction, + { project: 'test-project' }, + context, + ); + expect(commitResult.message).toContain('committed successfully'); + + // 4. Verify project-definition.json was written + const defContents = await readFile( + `${projectDir}/baseplate/project-definition.json`, + 'utf-8', + ); + expect(defContents).toBeDefined(); + expect(defContents.length).toBeGreaterThan(0); + + // 5. Verify draft session files are cleaned up + await expect( + readFile(`${projectDir}/baseplate/.build/draft-session.json`, 'utf-8'), + ).rejects.toThrow(); + }); + + test('stage → apply-fix → verify fix applied', async ({ + context, + projectDir, + }) => { + // Build fixtures with a relation type mismatch: + // User.id is uuid, Post.userId is int — mismatch that produces a fixable warning + const feature = createTestFeature({ name: 'core' }); + const userModel = createTestModel({ + name: 'User', + featureRef: feature.name, + model: { + fields: [ + createTestScalarField({ + name: 'id', + type: 'uuid', + options: { genUuid: true }, + }), + ], + primaryKeyFieldRefs: ['id'], + }, + }); + const postModel = createTestModel({ + name: 'Post', + featureRef: feature.name, + model: { + fields: [ + createTestScalarField({ + name: 'id', + type: 'uuid', + options: { genUuid: true }, + }), + createTestScalarField({ name: 'userId', type: 'int' }), + ], + primaryKeyFieldRefs: ['id'], + relations: [ + createTestRelationField({ + name: 'user', + modelRef: 'User', + references: [{ localRef: 'userId', foreignRef: 'id' }], + }), + ], + }, + }); + + const mismatchContext = createTestEntityServiceContext({ + features: [feature], + models: [userModel, postModel], + }); + + const draftSessionContext: DraftSessionContext = { + session: { + sessionId: 'default', + definitionHash: 'test-hash', + draftDefinition: mismatchContext.entityContext.serializedDefinition, + }, + entityContext: mismatchContext.entityContext, + parserContext: mismatchContext.parserContext, + projectDirectory: '/test-project', + }; + vi.mocked(getOrCreateDraftSession).mockResolvedValue(draftSessionContext); + + // 1. Stage a feature — triggers validation which should detect the mismatch + const stageResult = await invokeServiceActionForTest( + stageCreateEntityAction, + { + project: 'test-project', + entityTypeName: 'feature', + entityData: { name: 'payments' }, + }, + context, + ); + expect(stageResult.message).toContain('Staged creation'); + expect(stageResult.issues).toBeDefined(); + + // Find the relation type mismatch warning with a fix + const mismatchIssue = stageResult.issues?.find( + (issue: { fixId?: string; message: string }) => + issue.fixId && issue.message.includes('type mismatch'), + ); + if (!mismatchIssue?.fixId) { + throw new Error('Expected a fixable type mismatch issue'); + } + expect(mismatchIssue.fixLabel).toContain("Change 'userId' type to 'uuid'"); + + // 2. Apply the fix + const fixResult = await invokeServiceActionForTest( + applyFixAction, + { project: 'test-project', fixId: mismatchIssue.fixId }, + context, + ); + expect(fixResult.message).toContain('Applied fix'); + + // 3. Verify the fix was applied — userId should now be uuid + const defContents = await readFile( + `${projectDir}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + models: { + name: string; + model: { fields: { name: string; type: string }[] }; + }[]; + }; + const post = definition.models.find((m) => m.name === 'Post'); + const userIdField = post?.model.fields.find((f) => f.name === 'userId'); + expect(userIdField?.type).toBe('uuid'); + }); + + test('stage → discard', async ({ context, projectDir }) => { + // 1. Stage a change + await invokeServiceActionForTest( + stageCreateEntityAction, + { + project: 'test-project', + entityTypeName: 'feature', + entityData: { name: 'payments' }, + }, + context, + ); + + // Verify draft files exist + const sessionContents = await readFile( + `${projectDir}/baseplate/.build/draft-session.json`, + 'utf-8', + ); + expect(sessionContents).toBeDefined(); + + // 2. Discard + const discardResult = await invokeServiceActionForTest( + discardDraftAction, + { project: 'test-project' }, + context, + ); + expect(discardResult.message).toContain('discarded'); + + // 3. Verify draft session files are deleted + await expect( + readFile(`${projectDir}/baseplate/.build/draft-session.json`, 'utf-8'), + ).rejects.toThrow(); + }); + + test('discard with no draft', async ({ context }) => { + const result = await invokeServiceActionForTest( + discardDraftAction, + { project: 'test-project' }, + context, + ); + + expect(result.message).toContain('No draft session to discard'); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/entity-type-blacklist.ts b/packages/project-builder-server/src/actions/definition/entity-type-blacklist.ts new file mode 100644 index 000000000..73496b828 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/entity-type-blacklist.ts @@ -0,0 +1,21 @@ +/** + * Entity types that are blocked from generic entity operations + * (list-entity-types, stage-create, stage-update, stage-delete). + * + * Plugins require special lifecycle handling (migrations, implementation + * store setup) and must be managed via the dedicated plugin actions + * (configure-plugin, disable-plugin). + */ +export const BLACKLISTED_ENTITY_TYPES = new Set(['plugin']); + +/** + * Throws an error if the given entity type is blacklisted. + */ +export function assertEntityTypeNotBlacklisted(entityTypeName: string): void { + if (BLACKLISTED_ENTITY_TYPES.has(entityTypeName)) { + throw new Error( + `Entity type "${entityTypeName}" cannot be managed via generic entity operations. ` + + 'Use configure-plugin or disable-plugin instead.', + ); + } +} diff --git a/packages/project-builder-server/src/actions/definition/get-entity-schema.action.unit.test.ts b/packages/project-builder-server/src/actions/definition/get-entity-schema.action.unit.test.ts new file mode 100644 index 000000000..08cbf9d7f --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/get-entity-schema.action.unit.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { getEntitySchemaAction } from './get-entity-schema.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { ...actual, getOrCreateDraftSession: vi.fn() }; +}); + +describe('get-entity-schema', () => { + test('should return TypeScript type for a known entity type', async ({ + context, + }) => { + const result = await invokeServiceActionForTest( + getEntitySchemaAction, + { project: 'test-project', entityTypeName: 'model' }, + context, + ); + + expect(result.entityTypeName).toBe('model'); + expect(typeof result.schema).toBe('string'); + expect(result.schema).toContain('name'); + }); + + test('should throw for an unknown entity type', async ({ context }) => { + await expect( + invokeServiceActionForTest( + getEntitySchemaAction, + { project: 'test-project', entityTypeName: 'nonexistent' }, + context, + ), + ).rejects.toThrow(/Unknown entity type/); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/get-entity.action.unit.test.ts b/packages/project-builder-server/src/actions/definition/get-entity.action.unit.test.ts new file mode 100644 index 000000000..8bdf043e5 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/get-entity.action.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { getEntityAction } from './get-entity.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { ...actual, getOrCreateDraftSession: vi.fn() }; +}); + +describe('get-entity', () => { + test('should retrieve a model by ID', async ({ context, blogPostModel }) => { + const result = await invokeServiceActionForTest( + getEntityAction, + { project: 'test-project', entityId: blogPostModel.id }, + context, + ); + + expect(result.entity).not.toBeNull(); + expect(result.entity).toHaveProperty('name', 'BlogPost'); + }); + + test('should retrieve a nested scalar field by ID', async ({ + context, + blogPostModel, + }) => { + const titleField = blogPostModel.model.fields.find( + (f) => f.name === 'title', + ); + if (!titleField) throw new Error('title field not found in fixture'); + + const result = await invokeServiceActionForTest( + getEntityAction, + { project: 'test-project', entityId: titleField.id }, + context, + ); + + expect(result.entity).not.toBeNull(); + expect(result.entity).toHaveProperty('name', 'title'); + expect(result.entity).toHaveProperty('type', 'string'); + }); + + test('should return null for a nonexistent entity ID', async ({ + context, + }) => { + const result = await invokeServiceActionForTest( + getEntityAction, + { project: 'test-project', entityId: 'model:nonexistent' }, + context, + ); + + expect(result.entity).toBeNull(); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/index.ts b/packages/project-builder-server/src/actions/definition/index.ts index 4af43f23a..e7bb49572 100644 --- a/packages/project-builder-server/src/actions/definition/index.ts +++ b/packages/project-builder-server/src/actions/definition/index.ts @@ -1,9 +1,14 @@ +export { applyFixAction } from './apply-fix.action.js'; export { commitDraftAction } from './commit-draft.action.js'; +export { configurePluginAction } from './configure-plugin.action.js'; +export { disablePluginAction } from './disable-plugin.action.js'; export { discardDraftAction } from './discard-draft.action.js'; export { getEntitySchemaAction } from './get-entity-schema.action.js'; export { getEntityAction } from './get-entity.action.js'; export { listEntitiesAction } from './list-entities.action.js'; export { listEntityTypesAction } from './list-entity-types.action.js'; +export { listPluginsAction } from './list-plugins.action.js'; +export { searchEntitiesAction } from './search-entities.action.js'; export { showDraftAction } from './show-draft.action.js'; export { stageCreateEntityAction } from './stage-create-entity.action.js'; export { stageDeleteEntityAction } from './stage-delete-entity.action.js'; diff --git a/packages/project-builder-server/src/actions/definition/list-entities.action.unit.test.ts b/packages/project-builder-server/src/actions/definition/list-entities.action.unit.test.ts new file mode 100644 index 000000000..0c284f3c8 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/list-entities.action.unit.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { listEntitiesAction } from './list-entities.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { ...actual, getOrCreateDraftSession: vi.fn() }; +}); + +describe('list-entities', () => { + test('should list features', async ({ context }) => { + const result = await invokeServiceActionForTest( + listEntitiesAction, + { project: 'test-project', entityTypeName: 'feature' }, + context, + ); + + expect(result.entities).toHaveLength(1); + expect(result.entities[0]).toMatchObject({ + name: 'blog', + type: 'feature', + }); + }); + + test('should list models', async ({ context }) => { + const result = await invokeServiceActionForTest( + listEntitiesAction, + { project: 'test-project', entityTypeName: 'model' }, + context, + ); + + expect(result.entities).toHaveLength(1); + expect(result.entities[0]).toMatchObject({ + name: 'BlogPost', + type: 'model', + }); + }); + + test('should list nested model fields with parent ID', async ({ + context, + blogPostModel, + }) => { + const result = await invokeServiceActionForTest( + listEntitiesAction, + { + project: 'test-project', + entityTypeName: 'model-scalar-field', + parentEntityId: blogPostModel.id, + }, + context, + ); + + const fieldNames = result.entities.map((e: { name: string }) => e.name); + expect(fieldNames).toContain('title'); + expect(fieldNames).toContain('content'); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/list-entity-types.action.ts b/packages/project-builder-server/src/actions/definition/list-entity-types.action.ts index fc22db642..ea9967d34 100644 --- a/packages/project-builder-server/src/actions/definition/list-entity-types.action.ts +++ b/packages/project-builder-server/src/actions/definition/list-entity-types.action.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; +import { BLACKLISTED_ENTITY_TYPES } from './entity-type-blacklist.js'; import { loadEntityServiceContext } from './load-entity-service-context.js'; const listEntityTypesInputSchema = z.object({ @@ -43,12 +44,12 @@ export const listEntityTypesAction = createServiceAction({ context, ); - const entityTypes = [...entityContext.entityTypeMap.entries()].map( - ([name, metadata]) => ({ + const entityTypes = [...entityContext.entityTypeMap.entries()] + .filter(([name]) => !BLACKLISTED_ENTITY_TYPES.has(name)) + .map(([name, metadata]) => ({ name, parentEntityTypeName: metadata.parentEntityTypeName ?? null, - }), - ); + })); return { entityTypes }; }, diff --git a/packages/project-builder-server/src/actions/definition/list-entity-types.action.unit.test.ts b/packages/project-builder-server/src/actions/definition/list-entity-types.action.unit.test.ts new file mode 100644 index 000000000..126b88c14 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/list-entity-types.action.unit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { listEntityTypesAction } from './list-entity-types.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { ...actual, getOrCreateDraftSession: vi.fn() }; +}); + +describe('list-entity-types', () => { + test('should return available entity types', async ({ context }) => { + const result = await invokeServiceActionForTest( + listEntityTypesAction, + { project: 'test-project' }, + context, + ); + + expect(result.entityTypes).toBeDefined(); + expect(result.entityTypes.length).toBeGreaterThan(0); + + const typeNames = result.entityTypes.map((t: { name: string }) => t.name); + expect(typeNames).toContain('feature'); + expect(typeNames).toContain('model'); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/list-plugins.action.ts b/packages/project-builder-server/src/actions/definition/list-plugins.action.ts new file mode 100644 index 000000000..00c2ae8dc --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/list-plugins.action.ts @@ -0,0 +1,71 @@ +import { PluginUtils } from '@baseplate-dev/project-builder-lib'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { loadEntityServiceContext } from './load-entity-service-context.js'; + +const listPluginsInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), +}); + +const pluginInfoSchema = z.object({ + key: z.string().describe('The unique plugin key.'), + name: z.string().describe('The plugin name.'), + displayName: z.string().describe('Human-readable display name.'), + description: z.string().describe('Plugin description.'), + packageName: z.string().describe('The npm package name.'), + version: z.string().describe('The plugin version.'), + enabled: z.boolean().describe('Whether the plugin is currently enabled.'), + managedBy: z + .string() + .optional() + .describe( + 'Fully qualified name of the plugin that manages this one, if any.', + ), +}); + +const listPluginsOutputSchema = z.object({ + plugins: z.array(pluginInfoSchema).describe('Available plugins.'), +}); + +export const listPluginsAction = createServiceAction({ + name: 'list-plugins', + title: 'List Plugins', + description: + 'List available plugins and their enabled/disabled status in the project.', + inputSchema: listPluginsInputSchema, + outputSchema: listPluginsOutputSchema, + writeCliOutput: (output) => { + for (const plugin of output.plugins) { + const status = plugin.enabled ? '✓' : '○'; + const managed = plugin.managedBy + ? ` (managed by ${plugin.managedBy})` + : ''; + console.info( + ` ${status} ${plugin.displayName} [${plugin.key}]${managed}`, + ); + } + }, + handler: async (input, context) => { + const { container } = await loadEntityServiceContext( + input.project, + context, + ); + + const plugins = context.plugins + .filter((p) => !p.hidden) + .map((plugin) => ({ + key: plugin.key, + name: plugin.name, + displayName: plugin.displayName, + description: plugin.description, + packageName: plugin.packageName, + version: plugin.version, + enabled: PluginUtils.byKey(container.definition, plugin.key) != null, + managedBy: plugin.managedBy, + })); + + return { plugins }; + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/search-entities.action.ts b/packages/project-builder-server/src/actions/definition/search-entities.action.ts new file mode 100644 index 000000000..b1f10d628 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/search-entities.action.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { loadEntityServiceContext } from './load-entity-service-context.js'; + +const searchEntitiesInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + query: z + .string() + .describe('Case-insensitive substring to match against entity names.'), + entityTypeName: z + .string() + .optional() + .describe('Restrict search to a specific entity type.'), +}); + +const entityStubSchema = z.object({ + id: z.string().describe('The entity ID.'), + name: z.string().describe('The entity name.'), + type: z.string().describe('The entity type name.'), +}); + +const searchEntitiesOutputSchema = z.object({ + results: z.array(entityStubSchema).describe('Matching entities.'), +}); + +export const searchEntitiesAction = createServiceAction({ + name: 'search-entities', + title: 'Search Entities', + description: + 'Search entities by name across the project definition. Returns matching entity stubs.', + inputSchema: searchEntitiesInputSchema, + outputSchema: searchEntitiesOutputSchema, + writeCliOutput: (output) => { + if (output.results.length === 0) { + console.info(' No matching entities found.'); + return; + } + for (const entity of output.results) { + console.info(` ${entity.name} (${entity.type}) [${entity.id}]`); + } + }, + handler: async (input, context) => { + const { container } = await loadEntityServiceContext( + input.project, + context, + ); + + const queryLower = input.query.toLowerCase(); + + const results = container.entities + .filter((entity) => { + if (input.entityTypeName && entity.type.name !== input.entityTypeName) { + return false; + } + return entity.name.toLowerCase().includes(queryLower); + }) + .map((entity) => ({ + id: entity.id, + name: entity.name, + type: entity.type.name, + })); + + return { results }; + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/search-entities.action.unit.test.ts b/packages/project-builder-server/src/actions/definition/search-entities.action.unit.test.ts new file mode 100644 index 000000000..bb4f882b4 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/search-entities.action.unit.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { searchEntitiesAction } from './search-entities.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { ...actual, getOrCreateDraftSession: vi.fn() }; +}); + +describe('search-entities', () => { + test('should find a model by name substring', async ({ context }) => { + const result = await invokeServiceActionForTest( + searchEntitiesAction, + { project: 'test-project', query: 'Blog' }, + context, + ); + + const names = result.results.map((r: { name: string }) => r.name); + expect(names).toContain('BlogPost'); + }); + + test('should find a nested scalar field by name', async ({ context }) => { + const result = await invokeServiceActionForTest( + searchEntitiesAction, + { project: 'test-project', query: 'title' }, + context, + ); + + expect(result.results).toHaveLength(1); + expect(result.results[0]).toMatchObject({ + name: 'title', + type: 'model-scalar-field', + }); + }); + + test('should filter by entity type', async ({ context }) => { + const result = await invokeServiceActionForTest( + searchEntitiesAction, + { + project: 'test-project', + query: 'BlogPost', + entityTypeName: 'feature', + }, + context, + ); + + // BlogPost is a model, not a feature — should not match + expect(result.results).toHaveLength(0); + }); + + test('should return empty results for no match', async ({ context }) => { + const result = await invokeServiceActionForTest( + searchEntitiesAction, + { project: 'test-project', query: 'nonexistent' }, + context, + ); + + expect(result.results).toHaveLength(0); + }); + + test('should be case-insensitive', async ({ context }) => { + const result = await invokeServiceActionForTest( + searchEntitiesAction, + { project: 'test-project', query: 'blogpost' }, + context, + ); + + const names = result.results.map((r: { name: string }) => r.name); + expect(names).toContain('BlogPost'); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/stage-create-entity.action.int.test.ts b/packages/project-builder-server/src/actions/definition/stage-create-entity.action.int.test.ts new file mode 100644 index 000000000..6abe1d92c --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/stage-create-entity.action.int.test.ts @@ -0,0 +1,127 @@ +import { vol } from 'memfs'; +import { readFile } from 'node:fs/promises'; +import { beforeEach, describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { stageCreateEntityAction } from './stage-create-entity.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('node:fs/promises'); + +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { ...actual, getOrCreateDraftSession: vi.fn() }; +}); + +beforeEach(() => { + vol.reset(); +}); + +describe('stage-create-entity', () => { + test('should stage a new feature and write draft files to disk', async ({ + context, + projectDir, + }) => { + const result = await invokeServiceActionForTest( + stageCreateEntityAction, + { + project: 'test-project', + entityTypeName: 'feature', + entityData: { name: 'payments' }, + }, + context, + ); + + expect(result.message).toContain('Staged creation'); + + const sessionContents = await readFile( + `${projectDir}/baseplate/.build/draft-session.json`, + 'utf-8', + ); + const session = JSON.parse(sessionContents) as { + sessionId: string; + definitionHash: string; + }; + expect(session.sessionId).toBe('default'); + expect(session.definitionHash).toBe('test-hash'); + + const defContents = await readFile( + `${projectDir}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + features: { name: string }[]; + }; + expect(definition.features.some((f) => f.name === 'payments')).toBe(true); + }); + + test('should stage a new model', async ({ context, projectDir }) => { + const result = await invokeServiceActionForTest( + stageCreateEntityAction, + { + project: 'test-project', + entityTypeName: 'model', + entityData: { + name: 'Comment', + featureRef: 'blog', + model: { + fields: [ + { + name: 'id', + type: 'uuid', + isOptional: false, + options: { genUuid: true }, + }, + ], + primaryKeyFieldRefs: ['id'], + }, + }, + }, + context, + ); + + expect(result.message).toContain('Staged creation'); + + const defContents = await readFile( + `${projectDir}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + models: { name: string }[]; + }; + expect(definition.models.some((m) => m.name === 'Comment')).toBe(true); + }); + + test('should stage a new nested scalar field on a model', async ({ + context, + blogPostModel, + projectDir, + }) => { + const result = await invokeServiceActionForTest( + stageCreateEntityAction, + { + project: 'test-project', + entityTypeName: 'model-scalar-field', + entityData: { name: 'summary', type: 'string' }, + parentEntityId: blogPostModel.id, + }, + context, + ); + + expect(result.message).toContain('Staged creation'); + + const defContents = await readFile( + `${projectDir}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + models: { model: { fields: { name: string }[] } }[]; + }; + const blogPost = definition.models.find( + (m: { model: { fields: { name: string }[] } }) => + m.model.fields.some((f) => f.name === 'summary'), + ); + expect(blogPost).toBeDefined(); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts b/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts index 7990a4060..6215ec186 100644 --- a/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts +++ b/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts @@ -4,9 +4,11 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { assertEntityTypeNotBlacklisted } from './entity-type-blacklist.js'; import { definitionIssueSchema, - validateDraftDefinition, + fixAndValidateDraftDefinition, + mapIssueToOutput, } from './validate-draft.js'; const stageCreateEntityInputSchema = z.object({ @@ -41,6 +43,8 @@ export const stageCreateEntityAction = createServiceAction({ inputSchema: stageCreateEntityInputSchema, outputSchema: stageCreateEntityOutputSchema, handler: async (input, context) => { + assertEntityTypeNotBlacklisted(input.entityTypeName); + const { session, entityContext, parserContext, projectDirectory } = await getOrCreateDraftSession(input.project, context); @@ -53,23 +57,20 @@ export const stageCreateEntityAction = createServiceAction({ entityContext, ); - session.draftDefinition = newDefinition; - - const { errors, warnings } = validateDraftDefinition( - newDefinition, - parserContext, - ); + const { fixedSerializedDefinition, errors, warnings } = + fixAndValidateDraftDefinition(newDefinition, parserContext); if (errors.length > 0) { const messages = errors.map((e) => e.message).join('; '); throw new Error(`Staging blocked by definition errors: ${messages}`); } + session.draftDefinition = fixedSerializedDefinition; await saveDraftSession(projectDirectory, session); return { message: `Staged creation of ${input.entityTypeName} entity. Use commit-draft to persist.`, - issues: warnings.length > 0 ? warnings : undefined, + issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, }; }, writeCliOutput: (output) => { diff --git a/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.int.test.ts b/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.int.test.ts new file mode 100644 index 000000000..b787fad65 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.int.test.ts @@ -0,0 +1,83 @@ +import { vol } from 'memfs'; +import { readFile } from 'node:fs/promises'; +import { beforeEach, describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { stageDeleteEntityAction } from './stage-delete-entity.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('node:fs/promises'); + +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { ...actual, getOrCreateDraftSession: vi.fn() }; +}); + +beforeEach(() => { + vol.reset(); +}); + +describe('stage-delete-entity', () => { + test('should stage a model deletion and write draft files', async ({ + context, + blogPostModel, + projectDir, + }) => { + const result = await invokeServiceActionForTest( + stageDeleteEntityAction, + { + project: 'test-project', + entityTypeName: 'model', + entityId: blogPostModel.id, + }, + context, + ); + + expect(result.message).toContain('Staged deletion'); + + const defContents = await readFile( + `${projectDir}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + models: { name: string }[]; + }; + expect(definition.models.some((m) => m.name === 'BlogPost')).toBe(false); + }); + + test('should stage a nested scalar field deletion', async ({ + context, + blogPostModel, + projectDir, + }) => { + const titleField = blogPostModel.model.fields.find( + (f) => f.name === 'title', + ); + if (!titleField) throw new Error('title field not found in fixture'); + + const result = await invokeServiceActionForTest( + stageDeleteEntityAction, + { + project: 'test-project', + entityTypeName: 'model-scalar-field', + entityId: titleField.id, + }, + context, + ); + + expect(result.message).toContain('Staged deletion'); + + const defContents = await readFile( + `${projectDir}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + models: { model: { fields: { name: string }[] } }[]; + }; + const blogPost = definition.models[0]; + const fieldNames = blogPost.model.fields.map((f) => f.name); + expect(fieldNames).not.toContain('title'); + expect(fieldNames).toContain('content'); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts b/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts index 4fe176aab..24d66b8cc 100644 --- a/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts +++ b/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts @@ -4,9 +4,11 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { assertEntityTypeNotBlacklisted } from './entity-type-blacklist.js'; import { definitionIssueSchema, - validateDraftDefinition, + fixAndValidateDraftDefinition, + mapIssueToOutput, } from './validate-draft.js'; const stageDeleteEntityInputSchema = z.object({ @@ -35,6 +37,8 @@ export const stageDeleteEntityAction = createServiceAction({ inputSchema: stageDeleteEntityInputSchema, outputSchema: stageDeleteEntityOutputSchema, handler: async (input, context) => { + assertEntityTypeNotBlacklisted(input.entityTypeName); + const { session, entityContext, parserContext, projectDirectory } = await getOrCreateDraftSession(input.project, context); @@ -46,23 +50,20 @@ export const stageDeleteEntityAction = createServiceAction({ entityContext, ); - session.draftDefinition = newDefinition; - - const { errors, warnings } = validateDraftDefinition( - newDefinition, - parserContext, - ); + const { fixedSerializedDefinition, errors, warnings } = + fixAndValidateDraftDefinition(newDefinition, parserContext); if (errors.length > 0) { const messages = errors.map((e) => e.message).join('; '); throw new Error(`Staging blocked by definition errors: ${messages}`); } + session.draftDefinition = fixedSerializedDefinition; await saveDraftSession(projectDirectory, session); return { message: `Staged deletion of ${input.entityTypeName} entity "${input.entityId}". Use commit-draft to persist.`, - issues: warnings.length > 0 ? warnings : undefined, + issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, }; }, writeCliOutput: (output) => { diff --git a/packages/project-builder-server/src/actions/definition/stage-update-entity.action.int.test.ts b/packages/project-builder-server/src/actions/definition/stage-update-entity.action.int.test.ts new file mode 100644 index 000000000..ac30ff5e3 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/stage-update-entity.action.int.test.ts @@ -0,0 +1,107 @@ +import { vol } from 'memfs'; +import { readFile } from 'node:fs/promises'; +import { beforeEach, describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { stageUpdateEntityAction } from './stage-update-entity.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('node:fs/promises'); + +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { ...actual, getOrCreateDraftSession: vi.fn() }; +}); + +beforeEach(() => { + vol.reset(); +}); + +describe('stage-update-entity', () => { + test('should stage a model update and write draft files', async ({ + context, + blogPostModel, + projectDir, + }) => { + const result = await invokeServiceActionForTest( + stageUpdateEntityAction, + { + project: 'test-project', + entityTypeName: 'model', + entityId: blogPostModel.id, + entityData: { + id: blogPostModel.id, + name: 'BlogPostUpdated', + featureRef: 'blog', + model: { + fields: [ + { + name: 'id', + type: 'uuid', + isOptional: false, + options: { genUuid: true }, + }, + ], + primaryKeyFieldRefs: ['id'], + }, + }, + }, + context, + ); + + expect(result.message).toContain('Staged update'); + + const defContents = await readFile( + `${projectDir}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + models: { name: string }[]; + }; + expect(definition.models.some((m) => m.name === 'BlogPostUpdated')).toBe( + true, + ); + }); + + test('should stage a nested scalar field update', async ({ + context, + blogPostModel, + projectDir, + }) => { + const titleField = blogPostModel.model.fields.find( + (f) => f.name === 'title', + ); + if (!titleField) throw new Error('title field not found in fixture'); + + const result = await invokeServiceActionForTest( + stageUpdateEntityAction, + { + project: 'test-project', + entityTypeName: 'model-scalar-field', + entityId: titleField.id, + entityData: { + id: titleField.id, + name: 'headline', + type: 'string', + isOptional: false, + }, + }, + context, + ); + + expect(result.message).toContain('Staged update'); + + const defContents = await readFile( + `${projectDir}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + models: { model: { fields: { name: string }[] } }[]; + }; + const blogPost = definition.models[0]; + const fieldNames = blogPost.model.fields.map((f) => f.name); + expect(fieldNames).toContain('headline'); + expect(fieldNames).not.toContain('title'); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts b/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts index 67cfc79be..bd7059ae4 100644 --- a/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts +++ b/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts @@ -4,9 +4,11 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { assertEntityTypeNotBlacklisted } from './entity-type-blacklist.js'; import { definitionIssueSchema, - validateDraftDefinition, + fixAndValidateDraftDefinition, + mapIssueToOutput, } from './validate-draft.js'; const stageUpdateEntityInputSchema = z.object({ @@ -38,6 +40,8 @@ export const stageUpdateEntityAction = createServiceAction({ inputSchema: stageUpdateEntityInputSchema, outputSchema: stageUpdateEntityOutputSchema, handler: async (input, context) => { + assertEntityTypeNotBlacklisted(input.entityTypeName); + const { session, entityContext, parserContext, projectDirectory } = await getOrCreateDraftSession(input.project, context); @@ -50,23 +54,20 @@ export const stageUpdateEntityAction = createServiceAction({ entityContext, ); - session.draftDefinition = newDefinition; - - const { errors, warnings } = validateDraftDefinition( - newDefinition, - parserContext, - ); + const { fixedSerializedDefinition, errors, warnings } = + fixAndValidateDraftDefinition(newDefinition, parserContext); if (errors.length > 0) { const messages = errors.map((e) => e.message).join('; '); throw new Error(`Staging blocked by definition errors: ${messages}`); } + session.draftDefinition = fixedSerializedDefinition; await saveDraftSession(projectDirectory, session); return { message: `Staged update of ${input.entityTypeName} entity "${input.entityId}". Use commit-draft to persist.`, - issues: warnings.length > 0 ? warnings : undefined, + issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, }; }, writeCliOutput: (output) => { diff --git a/packages/project-builder-server/src/actions/definition/validate-draft.ts b/packages/project-builder-server/src/actions/definition/validate-draft.ts index 0beda7174..a4958072a 100644 --- a/packages/project-builder-server/src/actions/definition/validate-draft.ts +++ b/packages/project-builder-server/src/actions/definition/validate-draft.ts @@ -1,10 +1,13 @@ import type { + DefinitionIssue, PartitionedIssues, SchemaParserContext, } from '@baseplate-dev/project-builder-lib'; import { + applyDefinitionFixes, collectDefinitionIssues, + fixRefDeletions, partitionIssuesBySeverity, ProjectDefinitionContainer, } from '@baseplate-dev/project-builder-lib'; @@ -26,8 +29,48 @@ export const definitionIssueSchema = z.object({ .describe( "Issue severity: 'error' blocks the operation, 'warning' does not.", ), + fixLabel: z + .string() + .optional() + .describe('Label for an available auto-fix, if one exists.'), + fixId: z + .string() + .optional() + .describe('Deterministic ID for this fix, used with the apply-fix action.'), }); +/** + * Generates a deterministic fix ID from an issue's identifying properties. + */ +export function generateFixId(issue: DefinitionIssue): string { + const key = [issue.entityId ?? '', issue.path.join('.'), issue.message].join( + '|', + ); + + // Simple hash — djb2 algorithm for a short, stable identifier + let hash = 5381; + for (let i = 0; i < key.length; i++) { + hash = (hash * 33) ^ (key.codePointAt(i) ?? 0); + } + return `fix-${(hash >>> 0).toString(16).padStart(8, '0')}`; +} + +/** + * Maps a DefinitionIssue to the output schema shape, including fix metadata. + */ +export function mapIssueToOutput( + issue: DefinitionIssue, +): z.infer { + return { + message: issue.message, + entityId: issue.entityId, + path: issue.path, + severity: issue.severity, + fixLabel: issue.fix?.label, + fixId: issue.fix ? generateFixId(issue) : undefined, + }; +} + /** * Validates a draft definition by collecting all definition issues * (both field-level and definition-level) and partitioning them by severity. @@ -45,3 +88,78 @@ export function validateDraftDefinition( return partitionIssuesBySeverity(issues); } + +export interface FixAndValidateResult { + /** The fixed serialized (name-based) definition. */ + fixedSerializedDefinition: Record; + /** The container built from the fixed definition. */ + container: ProjectDefinitionContainer; + /** Errors that block the operation. */ + errors: DefinitionIssue[]; + /** Warnings that don't block the operation. */ + warnings: DefinitionIssue[]; +} + +/** + * Applies auto-fixes, fixes dangling references, then validates the definition. + * + * Mirrors the web UI save pipeline: + * 1. applyDefinitionFixes — clears disabled services, etc. + * 2. fixRefDeletions — cascades reference deletions + * 3. collectDefinitionIssues — partitions into errors/warnings + */ +export function fixAndValidateDraftDefinition( + draftDefinition: Record, + parserContext: SchemaParserContext, +): FixAndValidateResult { + const container = ProjectDefinitionContainer.fromSerializedConfig( + draftDefinition, + parserContext, + ); + + // Step 1: Apply auto-fixes from registered validators + const fixedDefinition = applyDefinitionFixes( + container.schema, + container.definition, + ); + + // Step 2: Fix dangling references + const refResult = fixRefDeletions(container.schema, fixedDefinition); + + if (refResult.type === 'failure') { + // RESTRICT issues — report as errors + const errors: DefinitionIssue[] = refResult.issues.map((issue) => ({ + message: `Cannot delete: referenced by ${issue.ref.path.join('.')} (onDelete: RESTRICT)`, + path: issue.ref.path, + severity: 'error' as const, + })); + + return { + fixedSerializedDefinition: draftDefinition, + container, + errors, + warnings: [], + }; + } + + // Step 3: Build a new container from the fixed refPayload and validate + const fixedContainer = new ProjectDefinitionContainer( + refResult.refPayload, + container.parserContext, + container.pluginStore, + container.schema, + ); + + const issues = collectDefinitionIssues(fixedContainer); + const { errors, warnings } = partitionIssuesBySeverity(issues); + + const fixedSerializedDefinition = + fixedContainer.toEntityServiceContext().serializedDefinition; + + return { + fixedSerializedDefinition, + container: fixedContainer, + errors, + warnings, + }; +} diff --git a/packages/project-builder-server/src/actions/registry.ts b/packages/project-builder-server/src/actions/registry.ts index 704c5a043..96f762ea0 100644 --- a/packages/project-builder-server/src/actions/registry.ts +++ b/packages/project-builder-server/src/actions/registry.ts @@ -1,10 +1,15 @@ import { + applyFixAction, commitDraftAction, + configurePluginAction, + disablePluginAction, discardDraftAction, getEntityAction, getEntitySchemaAction, listEntitiesAction, listEntityTypesAction, + listPluginsAction, + searchEntitiesAction, showDraftAction, stageCreateEntityAction, stageDeleteEntityAction, @@ -45,14 +50,19 @@ export const USER_SERVICE_ACTIONS = [ syncFileAction, listEntitiesAction, listEntityTypesAction, + searchEntitiesAction, getEntityAction, getEntitySchemaAction, stageCreateEntityAction, stageUpdateEntityAction, stageDeleteEntityAction, + applyFixAction, commitDraftAction, discardDraftAction, showDraftAction, + listPluginsAction, + configurePluginAction, + disablePluginAction, ]; export const ALL_SERVICE_ACTIONS = [ diff --git a/packages/tools/eslint-configs/typescript.js b/packages/tools/eslint-configs/typescript.js index 0b1d89e54..08bf1595d 100644 --- a/packages/tools/eslint-configs/typescript.js +++ b/packages/tools/eslint-configs/typescript.js @@ -69,6 +69,11 @@ export function generateTypescriptEslintConfig(options = {}) { 'prefer-arrow-callback': ['error', { allowNamedFunctions: true }], // Disallow renaming imports, exports, or destructured variables to the same name. 'no-useless-rename': 'error', + // Allow empty patterns in function parameters which are used in test fixtures + 'no-empty-pattern': [ + 'error', + { allowObjectPatternsAsParameters: true }, + ], }, }, @@ -281,6 +286,11 @@ export function generateTypescriptEslintConfig(options = {}) { ...vitest.configs.recommended.rules, // Helpful in dev but should flag as errors when linting 'vitest/no-focused-tests': 'error', + // Allow custom test functions created via test.extend() + 'vitest/no-standalone-expect': [ + 'error', + { additionalTestBlockFunctions: ['test'] }, + ], }, settings: { vitest: { From d0890b8b2a8f28447ed69ae99c3ab751c721d3c6 Mon Sep 17 00:00:00 2001 From: Kingston Date: Fri, 13 Mar 2026 16:19:14 +0100 Subject: [PATCH 2/4] PR feedback --- .../actions/definition/apply-fix.action.ts | 34 ++--- .../definition/configure-plugin.action.ts | 31 ++--- .../configure-plugin.action.unit.test.ts | 54 ++++++++ .../definition/disable-plugin.action.ts | 31 ++--- .../disable-plugin.action.unit.test.ts | 26 ++++ .../list-plugins.action.unit.test.ts | 101 +++++++++++++++ .../definition/stage-create-entity.action.ts | 30 ++--- .../definition/stage-delete-entity.action.ts | 30 ++--- .../definition/stage-update-entity.action.ts | 30 ++--- .../src/actions/definition/validate-draft.ts | 65 ++++++++++ .../definition/validate-draft.unit.test.ts | 120 ++++++++++++++++++ 11 files changed, 427 insertions(+), 125 deletions(-) create mode 100644 packages/project-builder-server/src/actions/definition/configure-plugin.action.unit.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/disable-plugin.action.unit.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/list-plugins.action.unit.test.ts create mode 100644 packages/project-builder-server/src/actions/definition/validate-draft.unit.test.ts diff --git a/packages/project-builder-server/src/actions/definition/apply-fix.action.ts b/packages/project-builder-server/src/actions/definition/apply-fix.action.ts index 272652218..d4eda7879 100644 --- a/packages/project-builder-server/src/actions/definition/apply-fix.action.ts +++ b/packages/project-builder-server/src/actions/definition/apply-fix.action.ts @@ -9,12 +9,13 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; -import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { getOrCreateDraftSession } from './draft-session.js'; import { definitionIssueSchema, - fixAndValidateDraftDefinition, generateFixId, mapIssueToOutput, + validateAndSaveDraft, + writeIssuesCliOutput, } from './validate-draft.js'; const applyFixInputSchema = z.object({ @@ -81,31 +82,18 @@ export const applyFixAction = createServiceAction({ fixedDefinition, ) as Record; - // Run fixAndValidateDraftDefinition on the result - const { fixedSerializedDefinition, errors, warnings } = - fixAndValidateDraftDefinition(fixedSerializedDef, parserContext); - - if (errors.length > 0) { - const messages = errors.map((e) => e.message).join('; '); - throw new Error( - `Fix applied but resulted in definition errors: ${messages}`, - ); - } - - session.draftDefinition = fixedSerializedDefinition; - await saveDraftSession(projectDirectory, session); + const { warnings } = await validateAndSaveDraft( + fixedSerializedDef, + parserContext, + session, + projectDirectory, + 'Fix applied but resulted in definition errors', + ); return { message: `Applied fix: ${matchingIssue.fix?.label ?? matchingIssue.message}`, issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, }; }, - writeCliOutput: (output) => { - console.info(`✓ ${output.message}`); - if (output.issues) { - for (const issue of output.issues) { - console.warn(` ⚠ ${issue.message}`); - } - } - }, + writeCliOutput: writeIssuesCliOutput, }); diff --git a/packages/project-builder-server/src/actions/definition/configure-plugin.action.ts b/packages/project-builder-server/src/actions/definition/configure-plugin.action.ts index a8dc50b24..273673eb4 100644 --- a/packages/project-builder-server/src/actions/definition/configure-plugin.action.ts +++ b/packages/project-builder-server/src/actions/definition/configure-plugin.action.ts @@ -13,11 +13,12 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; -import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { getOrCreateDraftSession } from './draft-session.js'; import { definitionIssueSchema, - fixAndValidateDraftDefinition, mapIssueToOutput, + validateAndSaveDraft, + writeIssuesCliOutput, } from './validate-draft.js'; const configurePluginInputSchema = z.object({ @@ -87,17 +88,12 @@ export const configurePluginAction = createServiceAction({ newDefinition, ) as Record; - // Validate - const { fixedSerializedDefinition, errors, warnings } = - fixAndValidateDraftDefinition(serializedDef, parserContext); - - if (errors.length > 0) { - const messages = errors.map((e) => e.message).join('; '); - throw new Error(`Staging blocked by definition errors: ${messages}`); - } - - session.draftDefinition = fixedSerializedDefinition; - await saveDraftSession(projectDirectory, session); + const { warnings } = await validateAndSaveDraft( + serializedDef, + parserContext, + session, + projectDirectory, + ); const isNew = PluginUtils.byKey(container.definition, input.pluginKey) == null; @@ -108,12 +104,5 @@ export const configurePluginAction = createServiceAction({ issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, }; }, - writeCliOutput: (output) => { - console.info(`✓ ${output.message}`); - if (output.issues) { - for (const issue of output.issues) { - console.warn(` ⚠ ${issue.message}`); - } - } - }, + writeCliOutput: writeIssuesCliOutput, }); diff --git a/packages/project-builder-server/src/actions/definition/configure-plugin.action.unit.test.ts b/packages/project-builder-server/src/actions/definition/configure-plugin.action.unit.test.ts new file mode 100644 index 000000000..5ce9307ad --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/configure-plugin.action.unit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { configurePluginAction } from './configure-plugin.action.js'; +import { test } from './definition-test-fixtures.test-helper.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { + ...actual, + getOrCreateDraftSession: vi.fn(), + }; +}); + +describe('configurePluginAction', () => { + test('throws when plugin key is not found', async ({ context }) => { + context.plugins = []; + + await expect( + invokeServiceActionForTest( + configurePluginAction, + { project: 'test-project', pluginKey: 'nonexistent' }, + context, + ), + ).rejects.toThrow('Plugin "nonexistent" not found'); + }); + + test('error message lists available plugins', async ({ context }) => { + context.plugins = [ + { + key: 'auth', + name: 'auth', + displayName: 'Auth', + description: 'Auth plugin', + version: '1.0.0', + packageName: '@baseplate-dev/plugin-auth', + fullyQualifiedName: '@baseplate-dev/plugin-auth:auth', + pluginDirectory: '/plugins/auth', + webBuildDirectory: '/plugins/auth/web', + nodeModulePaths: [], + webModulePaths: [], + }, + ]; + + await expect( + invokeServiceActionForTest( + configurePluginAction, + { project: 'test-project', pluginKey: 'nonexistent' }, + context, + ), + ).rejects.toThrow('Available plugins: auth'); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/disable-plugin.action.ts b/packages/project-builder-server/src/actions/definition/disable-plugin.action.ts index 0ed0c18ac..154b7c93f 100644 --- a/packages/project-builder-server/src/actions/definition/disable-plugin.action.ts +++ b/packages/project-builder-server/src/actions/definition/disable-plugin.action.ts @@ -10,11 +10,12 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; -import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { getOrCreateDraftSession } from './draft-session.js'; import { definitionIssueSchema, - fixAndValidateDraftDefinition, mapIssueToOutput, + validateAndSaveDraft, + writeIssuesCliOutput, } from './validate-draft.js'; const disablePluginInputSchema = z.object({ @@ -66,29 +67,17 @@ export const disablePluginAction = createServiceAction({ newDefinition, ) as Record; - // Validate - const { fixedSerializedDefinition, errors, warnings } = - fixAndValidateDraftDefinition(serializedDef, parserContext); - - if (errors.length > 0) { - const messages = errors.map((e) => e.message).join('; '); - throw new Error(`Staging blocked by definition errors: ${messages}`); - } - - session.draftDefinition = fixedSerializedDefinition; - await saveDraftSession(projectDirectory, session); + const { warnings } = await validateAndSaveDraft( + serializedDef, + parserContext, + session, + projectDirectory, + ); return { message: `Disabled plugin "${input.pluginKey}". Use commit-draft to persist.`, issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, }; }, - writeCliOutput: (output) => { - console.info(`✓ ${output.message}`); - if (output.issues) { - for (const issue of output.issues) { - console.warn(` ⚠ ${issue.message}`); - } - } - }, + writeCliOutput: writeIssuesCliOutput, }); diff --git a/packages/project-builder-server/src/actions/definition/disable-plugin.action.unit.test.ts b/packages/project-builder-server/src/actions/definition/disable-plugin.action.unit.test.ts new file mode 100644 index 000000000..37b014ef1 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/disable-plugin.action.unit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { disablePluginAction } from './disable-plugin.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { + ...actual, + getOrCreateDraftSession: vi.fn(), + }; +}); + +describe('disablePluginAction', () => { + test('throws when plugin is not currently enabled', async ({ context }) => { + await expect( + invokeServiceActionForTest( + disablePluginAction, + { project: 'test-project', pluginKey: 'nonexistent' }, + context, + ), + ).rejects.toThrow('Plugin "nonexistent" is not currently enabled'); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/list-plugins.action.unit.test.ts b/packages/project-builder-server/src/actions/definition/list-plugins.action.unit.test.ts new file mode 100644 index 000000000..17a9b72ff --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/list-plugins.action.unit.test.ts @@ -0,0 +1,101 @@ +import type { PluginMetadataWithPaths } from '@baseplate-dev/project-builder-lib'; + +import { describe, expect, vi } from 'vitest'; + +import { invokeServiceActionForTest } from '../__tests__/action-test-utils.js'; +import { test } from './definition-test-fixtures.test-helper.js'; +import { listPluginsAction } from './list-plugins.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { + ...actual, + getOrCreateDraftSession: vi.fn(), + }; +}); + +function createTestPlugin( + overrides: Partial & { key: string; name: string }, +): PluginMetadataWithPaths { + return { + displayName: overrides.name, + description: `${overrides.name} plugin`, + version: '1.0.0', + packageName: `@baseplate-dev/plugin-${overrides.name}`, + fullyQualifiedName: `@baseplate-dev/plugin-${overrides.name}:${overrides.name}`, + pluginDirectory: `/plugins/${overrides.name}`, + webBuildDirectory: `/plugins/${overrides.name}/web`, + nodeModulePaths: [], + webModulePaths: [], + ...overrides, + }; +} + +describe('listPluginsAction', () => { + test('lists available plugins with enabled/disabled status', async ({ + context, + }) => { + const plugins = [ + createTestPlugin({ key: 'auth', name: 'auth' }), + createTestPlugin({ key: 'email', name: 'email' }), + ]; + context.plugins = plugins; + + const result = await invokeServiceActionForTest( + listPluginsAction, + { project: 'test-project' }, + context, + ); + + expect(result.plugins).toHaveLength(2); + expect(result.plugins[0]).toMatchObject({ + key: 'auth', + name: 'auth', + enabled: false, + }); + expect(result.plugins[1]).toMatchObject({ + key: 'email', + name: 'email', + enabled: false, + }); + }); + + test('filters out hidden plugins', async ({ context }) => { + const plugins = [ + createTestPlugin({ key: 'auth', name: 'auth' }), + createTestPlugin({ key: 'internal', name: 'internal', hidden: true }), + ]; + context.plugins = plugins; + + const result = await invokeServiceActionForTest( + listPluginsAction, + { project: 'test-project' }, + context, + ); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0]?.key).toBe('auth'); + }); + + test('includes managedBy metadata', async ({ context }) => { + const plugins = [ + createTestPlugin({ + key: 'better-auth', + name: 'better-auth', + managedBy: '@baseplate-dev/plugin-auth:auth', + }), + ]; + context.plugins = plugins; + + const result = await invokeServiceActionForTest( + listPluginsAction, + { project: 'test-project' }, + context, + ); + + expect(result.plugins[0]?.managedBy).toBe( + '@baseplate-dev/plugin-auth:auth', + ); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts b/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts index 6215ec186..def3bd217 100644 --- a/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts +++ b/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts @@ -3,12 +3,13 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; -import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { getOrCreateDraftSession } from './draft-session.js'; import { assertEntityTypeNotBlacklisted } from './entity-type-blacklist.js'; import { definitionIssueSchema, - fixAndValidateDraftDefinition, mapIssueToOutput, + validateAndSaveDraft, + writeIssuesCliOutput, } from './validate-draft.js'; const stageCreateEntityInputSchema = z.object({ @@ -57,28 +58,17 @@ export const stageCreateEntityAction = createServiceAction({ entityContext, ); - const { fixedSerializedDefinition, errors, warnings } = - fixAndValidateDraftDefinition(newDefinition, parserContext); - - if (errors.length > 0) { - const messages = errors.map((e) => e.message).join('; '); - throw new Error(`Staging blocked by definition errors: ${messages}`); - } - - session.draftDefinition = fixedSerializedDefinition; - await saveDraftSession(projectDirectory, session); + const { warnings } = await validateAndSaveDraft( + newDefinition, + parserContext, + session, + projectDirectory, + ); return { message: `Staged creation of ${input.entityTypeName} entity. Use commit-draft to persist.`, issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, }; }, - writeCliOutput: (output) => { - console.info(`✓ ${output.message}`); - if (output.issues) { - for (const issue of output.issues) { - console.warn(` ⚠ ${issue.message}`); - } - } - }, + writeCliOutput: writeIssuesCliOutput, }); diff --git a/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts b/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts index 24d66b8cc..2d1da7c7b 100644 --- a/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts +++ b/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts @@ -3,12 +3,13 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; -import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { getOrCreateDraftSession } from './draft-session.js'; import { assertEntityTypeNotBlacklisted } from './entity-type-blacklist.js'; import { definitionIssueSchema, - fixAndValidateDraftDefinition, mapIssueToOutput, + validateAndSaveDraft, + writeIssuesCliOutput, } from './validate-draft.js'; const stageDeleteEntityInputSchema = z.object({ @@ -50,28 +51,17 @@ export const stageDeleteEntityAction = createServiceAction({ entityContext, ); - const { fixedSerializedDefinition, errors, warnings } = - fixAndValidateDraftDefinition(newDefinition, parserContext); - - if (errors.length > 0) { - const messages = errors.map((e) => e.message).join('; '); - throw new Error(`Staging blocked by definition errors: ${messages}`); - } - - session.draftDefinition = fixedSerializedDefinition; - await saveDraftSession(projectDirectory, session); + const { warnings } = await validateAndSaveDraft( + newDefinition, + parserContext, + session, + projectDirectory, + ); return { message: `Staged deletion of ${input.entityTypeName} entity "${input.entityId}". Use commit-draft to persist.`, issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, }; }, - writeCliOutput: (output) => { - console.info(`✓ ${output.message}`); - if (output.issues) { - for (const issue of output.issues) { - console.warn(` ⚠ ${issue.message}`); - } - } - }, + writeCliOutput: writeIssuesCliOutput, }); diff --git a/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts b/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts index bd7059ae4..5a5d7b9c3 100644 --- a/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts +++ b/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts @@ -3,12 +3,13 @@ import { z } from 'zod'; import { createServiceAction } from '#src/actions/types.js'; -import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { getOrCreateDraftSession } from './draft-session.js'; import { assertEntityTypeNotBlacklisted } from './entity-type-blacklist.js'; import { definitionIssueSchema, - fixAndValidateDraftDefinition, mapIssueToOutput, + validateAndSaveDraft, + writeIssuesCliOutput, } from './validate-draft.js'; const stageUpdateEntityInputSchema = z.object({ @@ -54,28 +55,17 @@ export const stageUpdateEntityAction = createServiceAction({ entityContext, ); - const { fixedSerializedDefinition, errors, warnings } = - fixAndValidateDraftDefinition(newDefinition, parserContext); - - if (errors.length > 0) { - const messages = errors.map((e) => e.message).join('; '); - throw new Error(`Staging blocked by definition errors: ${messages}`); - } - - session.draftDefinition = fixedSerializedDefinition; - await saveDraftSession(projectDirectory, session); + const { warnings } = await validateAndSaveDraft( + newDefinition, + parserContext, + session, + projectDirectory, + ); return { message: `Staged update of ${input.entityTypeName} entity "${input.entityId}". Use commit-draft to persist.`, issues: warnings.length > 0 ? warnings.map(mapIssueToOutput) : undefined, }; }, - writeCliOutput: (output) => { - console.info(`✓ ${output.message}`); - if (output.issues) { - for (const issue of output.issues) { - console.warn(` ⚠ ${issue.message}`); - } - } - }, + writeCliOutput: writeIssuesCliOutput, }); diff --git a/packages/project-builder-server/src/actions/definition/validate-draft.ts b/packages/project-builder-server/src/actions/definition/validate-draft.ts index a4958072a..13e7e929f 100644 --- a/packages/project-builder-server/src/actions/definition/validate-draft.ts +++ b/packages/project-builder-server/src/actions/definition/validate-draft.ts @@ -13,6 +13,10 @@ import { } from '@baseplate-dev/project-builder-lib'; import { z } from 'zod'; +import type { DraftSession } from './draft-session.js'; + +import { saveDraftSession } from './draft-session.js'; + export const definitionIssueSchema = z.object({ message: z.string().describe('Human-readable description of the issue.'), entityId: z @@ -41,6 +45,10 @@ export const definitionIssueSchema = z.object({ /** * Generates a deterministic fix ID from an issue's identifying properties. + * + * STABILITY: This algorithm (djb2) must remain stable — fix IDs are returned + * to callers and later matched by `apply-fix`. Changing the hash function + * will silently invalidate all previously-issued fix IDs. */ export function generateFixId(issue: DefinitionIssue): string { const key = [issue.entityId ?? '', issue.path.join('.'), issue.message].join( @@ -163,3 +171,60 @@ export function fixAndValidateDraftDefinition( warnings, }; } + +// --------------------------------------------------------------------------- +// Shared CLI output for actions that return { message, issues? } +// --------------------------------------------------------------------------- + +/** + * Writes a success message and any warning issues to the console. + * Used as the `writeCliOutput` callback for staging actions. + */ +export function writeIssuesCliOutput(output: { + message: string; + issues?: { message: string }[]; +}): void { + console.info(`✓ ${output.message}`); + if (output.issues) { + for (const issue of output.issues) { + console.warn(` ⚠ ${issue.message}`); + } + } +} + +// --------------------------------------------------------------------------- +// Convenience: validate + save in one step (used by most staging actions) +// --------------------------------------------------------------------------- + +export interface ValidateAndSaveResult { + /** Warnings that did not block the operation. */ + warnings: DefinitionIssue[]; +} + +/** + * Validates a mutated definition, saves it to the draft session, and returns + * any non-blocking warnings. Throws if validation produces errors. + * + * This is the shared "tail" of every staging action: + * fixAndValidateDraftDefinition → assert no errors → persist → return warnings + */ +export async function validateAndSaveDraft( + definition: Record, + parserContext: SchemaParserContext, + session: DraftSession, + projectDirectory: string, + errorPrefix = 'Staging blocked by definition errors', +): Promise { + const { fixedSerializedDefinition, errors, warnings } = + fixAndValidateDraftDefinition(definition, parserContext); + + if (errors.length > 0) { + const messages = errors.map((e) => e.message).join('; '); + throw new Error(`${errorPrefix}: ${messages}`); + } + + session.draftDefinition = fixedSerializedDefinition; + await saveDraftSession(projectDirectory, session); + + return { warnings }; +} diff --git a/packages/project-builder-server/src/actions/definition/validate-draft.unit.test.ts b/packages/project-builder-server/src/actions/definition/validate-draft.unit.test.ts new file mode 100644 index 000000000..de79ff326 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/validate-draft.unit.test.ts @@ -0,0 +1,120 @@ +import type { DefinitionIssue } from '@baseplate-dev/project-builder-lib'; + +import { describe, expect, it } from 'vitest'; + +import { generateFixId, mapIssueToOutput } from './validate-draft.js'; + +describe('generateFixId', () => { + it('produces a deterministic ID for the same issue', () => { + const issue: DefinitionIssue = { + message: 'Field type mismatch', + entityId: 'model:abc', + path: ['model', 'fields', 0, 'type'], + severity: 'warning', + fix: { label: "Change type to 'uuid'" }, + }; + + const id1 = generateFixId(issue); + const id2 = generateFixId(issue); + + expect(id1).toBe(id2); + expect(id1).toMatch(/^fix-[0-9a-f]{8}$/); + }); + + it('produces different IDs for different issues', () => { + const issueA: DefinitionIssue = { + message: 'Field type mismatch', + entityId: 'model:abc', + path: ['model', 'fields', 0, 'type'], + severity: 'warning', + }; + + const issueB: DefinitionIssue = { + message: 'Duplicate name', + entityId: 'model:abc', + path: ['model', 'fields', 1, 'name'], + severity: 'error', + }; + + expect(generateFixId(issueA)).not.toBe(generateFixId(issueB)); + }); + + it('distinguishes issues with different entityIds but same message and path', () => { + const issueA: DefinitionIssue = { + message: 'Missing field', + entityId: 'model:aaa', + path: ['model', 'fields'], + severity: 'warning', + }; + + const issueB: DefinitionIssue = { + message: 'Missing field', + entityId: 'model:bbb', + path: ['model', 'fields'], + severity: 'warning', + }; + + expect(generateFixId(issueA)).not.toBe(generateFixId(issueB)); + }); + + it('handles root-scoped issues without entityId', () => { + const issue: DefinitionIssue = { + message: 'Duplicate feature name', + path: ['features', 0, 'name'], + severity: 'error', + }; + + const id = generateFixId(issue); + expect(id).toMatch(/^fix-[0-9a-f]{8}$/); + }); +}); + +describe('mapIssueToOutput', () => { + it('maps all fields including fix metadata', () => { + const issue: DefinitionIssue = { + message: 'Field type mismatch', + entityId: 'model:abc', + path: ['model', 'fields', 0, 'type'], + severity: 'warning', + fix: { label: "Change type to 'uuid'" }, + }; + + const output = mapIssueToOutput(issue); + + expect(output).toEqual({ + message: 'Field type mismatch', + entityId: 'model:abc', + path: ['model', 'fields', 0, 'type'], + severity: 'warning', + fixLabel: "Change type to 'uuid'", + fixId: generateFixId(issue), + }); + }); + + it('omits fix fields when no fix is available', () => { + const issue: DefinitionIssue = { + message: 'Some error', + entityId: 'model:abc', + path: ['model'], + severity: 'error', + }; + + const output = mapIssueToOutput(issue); + + expect(output.fixLabel).toBeUndefined(); + expect(output.fixId).toBeUndefined(); + }); + + it('handles root-scoped issues', () => { + const issue: DefinitionIssue = { + message: 'Global issue', + path: ['settings'], + severity: 'warning', + }; + + const output = mapIssueToOutput(issue); + + expect(output.entityId).toBeUndefined(); + expect(output.path).toEqual(['settings']); + }); +}); From 010b660def54124b09d8c704d72e5b4acdbaf966 Mon Sep 17 00:00:00 2001 From: Kingston Date: Fri, 13 Mar 2026 16:49:04 +0100 Subject: [PATCH 3/4] Add tests for entity IDs --- .../src/tools/assign-entity-ids.unit.test.ts | 164 ++++++++++++++++++ .../definition/draft-lifecycle.int.test.ts | 30 ++++ 2 files changed, 194 insertions(+) create mode 100644 packages/project-builder-lib/src/tools/assign-entity-ids.unit.test.ts diff --git a/packages/project-builder-lib/src/tools/assign-entity-ids.unit.test.ts b/packages/project-builder-lib/src/tools/assign-entity-ids.unit.test.ts new file mode 100644 index 000000000..9084dfe29 --- /dev/null +++ b/packages/project-builder-lib/src/tools/assign-entity-ids.unit.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; + +import { + createTestFeature, + createTestModel, + createTestScalarField, +} from '#src/testing/definition-helpers.test-helper.js'; +import { createTestProjectDefinitionContainer } from '#src/testing/project-definition-container.test-helper.js'; + +import { assignEntityIds } from './assign-entity-ids.js'; + +describe('assignEntityIds', () => { + const feature = createTestFeature({ name: 'core' }); + const model = createTestModel({ + name: 'User', + featureRef: feature.name, + model: { + fields: [ + createTestScalarField({ + name: 'id', + type: 'uuid', + options: { genUuid: true }, + }), + createTestScalarField({ name: 'email', type: 'string' }), + ], + primaryKeyFieldRefs: ['id'], + }, + }); + const container = createTestProjectDefinitionContainer({ + features: [feature], + models: [model], + }); + + const modelMetadata = container + .toEntityServiceContext() + .entityTypeMap.get('model'); + if (!modelMetadata) { + throw new Error('Expected model entity type metadata'); + } + + it('replaces user-provided IDs with generated IDs', () => { + const entityData = { + id: 'user-provided-id', + name: 'Post', + featureRef: 'core', + model: { + fields: [ + { + id: 'user-field-id', + name: 'title', + type: 'string', + isOptional: false, + options: { default: '' }, + }, + ], + primaryKeyFieldRefs: ['title'], + }, + service: { + create: { enabled: false }, + update: { enabled: false }, + delete: { enabled: false }, + transformers: [], + }, + }; + + const result = assignEntityIds(modelMetadata.elementSchema, entityData); + + // Top-level entity ID should be replaced + expect(result.id).not.toBe('user-provided-id'); + expect(result.id).toMatch(/^model:/); + + // Nested field ID should also be replaced + expect(result.model.fields[0].id).not.toBe('user-field-id'); + expect(result.model.fields[0].id).toMatch(/^model-scalar-field:/); + + // Non-ID data should be preserved + expect(result.name).toBe('Post'); + expect(result.model.fields[0].name).toBe('title'); + }); + + it('preserves IDs when isExistingId returns true', () => { + const existingModelId = model.id; + const existingFieldId = model.model.fields[0].id; + + const entityData = { + id: existingModelId, + name: 'User', + featureRef: 'core', + model: { + fields: [ + { + id: existingFieldId, + name: 'id', + type: 'uuid', + isOptional: false, + options: { genUuid: true }, + }, + { + id: 'brand-new-field', + name: 'name', + type: 'string', + isOptional: false, + options: { default: '' }, + }, + ], + primaryKeyFieldRefs: ['id'], + }, + service: { + create: { enabled: false }, + update: { enabled: false }, + delete: { enabled: false }, + transformers: [], + }, + }; + + const existingIds = new Set([existingModelId, existingFieldId]); + const result = assignEntityIds(modelMetadata.elementSchema, entityData, { + isExistingId: (id) => existingIds.has(id), + }); + + // Existing IDs should be preserved + expect(result.id).toBe(existingModelId); + expect(result.model.fields[0].id).toBe(existingFieldId); + + // New field ID should be replaced + expect(result.model.fields[1].id).not.toBe('brand-new-field'); + expect(result.model.fields[1].id).toMatch(/^model-scalar-field:/); + }); + + it('assigns IDs when entity has no ID', () => { + const entityData = { + name: 'Tag', + featureRef: 'core', + model: { + fields: [ + { + name: 'id', + type: 'uuid', + isOptional: false, + options: { genUuid: true }, + }, + ], + primaryKeyFieldRefs: ['id'], + }, + service: { + create: { enabled: false }, + update: { enabled: false }, + delete: { enabled: false }, + transformers: [], + }, + }; + + const result = assignEntityIds( + modelMetadata.elementSchema, + entityData, + ) as typeof entityData & { + id: string; + model: { fields: { id: string }[] }; + }; + + expect(result.id).toMatch(/^model:/); + expect(result.model.fields[0].id).toMatch(/^model-scalar-field:/); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/draft-lifecycle.int.test.ts b/packages/project-builder-server/src/actions/definition/draft-lifecycle.int.test.ts index 785ab7f5f..a0d188a63 100644 --- a/packages/project-builder-server/src/actions/definition/draft-lifecycle.int.test.ts +++ b/packages/project-builder-server/src/actions/definition/draft-lifecycle.int.test.ts @@ -249,6 +249,36 @@ describe('draft lifecycle', () => { ).rejects.toThrow(); }); + test('stage replaces user-provided IDs with generated IDs', async ({ + context, + projectDir, + }) => { + // Stage a feature with a user-provided ID (e.g. from MCP client) + const stageResult = await invokeServiceActionForTest( + stageCreateEntityAction, + { + project: 'test-project', + entityTypeName: 'feature', + entityData: { id: 'user-provided-id', name: 'payments' }, + }, + context, + ); + expect(stageResult.message).toContain('Staged creation'); + + // Read the draft definition and verify the ID was replaced + const defContents = await readFile( + `${projectDir}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + features: { id: string; name: string }[]; + }; + const payments = definition.features.find((f) => f.name === 'payments'); + expect(payments).toBeDefined(); + expect(payments?.id).not.toBe('user-provided-id'); + expect(payments?.id).toMatch(/^feature:/); + }); + test('discard with no draft', async ({ context }) => { const result = await invokeServiceActionForTest( discardDraftAction, From 2c0cd6c314a4e8e02a5505c2e2d872911464e758 Mon Sep 17 00:00:00 2001 From: Kingston Date: Fri, 13 Mar 2026 17:01:02 +0100 Subject: [PATCH 4/4] Remove unused function --- .../src/actions/definition/validate-draft.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/project-builder-server/src/actions/definition/validate-draft.ts b/packages/project-builder-server/src/actions/definition/validate-draft.ts index 13e7e929f..6c4995efe 100644 --- a/packages/project-builder-server/src/actions/definition/validate-draft.ts +++ b/packages/project-builder-server/src/actions/definition/validate-draft.ts @@ -1,6 +1,5 @@ import type { DefinitionIssue, - PartitionedIssues, SchemaParserContext, } from '@baseplate-dev/project-builder-lib'; @@ -79,24 +78,6 @@ export function mapIssueToOutput( }; } -/** - * Validates a draft definition by collecting all definition issues - * (both field-level and definition-level) and partitioning them by severity. - */ -export function validateDraftDefinition( - draftDefinition: Record, - parserContext: SchemaParserContext, -): PartitionedIssues { - const container = ProjectDefinitionContainer.fromSerializedConfig( - draftDefinition, - parserContext, - ); - - const issues = collectDefinitionIssues(container); - - return partitionIssuesBySeverity(issues); -} - export interface FixAndValidateResult { /** The fixed serialized (name-based) definition. */ fixedSerializedDefinition: Record;