diff --git a/.changeset/support-expression-renames.md b/.changeset/support-expression-renames.md new file mode 100644 index 000000000..f1bdd436a --- /dev/null +++ b/.changeset/support-expression-renames.md @@ -0,0 +1,7 @@ +--- +'@baseplate-dev/project-builder-lib': patch +'@baseplate-dev/project-builder-server': patch +'@baseplate-dev/project-builder-web': patch +--- + +Support renames in reference expressions: when fields, relations, or roles are renamed, authorizer expressions are automatically updated to use the new names diff --git a/.claude/settings.json b/.claude/settings.json index 4ae3db4b4..fd826690f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -58,6 +58,7 @@ "Bash(git checkout -b *)", "Bash(echo $?)", "Bash(jq *)", + "Bash(git mv *)", "WebFetch(domain:ui.shadcn.com)" ], "deny": ["Edit(**/baseplate/generated/**)"] diff --git a/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-acorn-parser.ts b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-acorn-parser.ts similarity index 100% rename from packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-acorn-parser.ts rename to packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-acorn-parser.ts diff --git a/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-acorn-parser.unit.test.ts b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-acorn-parser.unit.test.ts similarity index 100% rename from packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-acorn-parser.unit.test.ts rename to packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-acorn-parser.unit.test.ts diff --git a/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-ast.ts b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-ast.ts similarity index 100% rename from packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-ast.ts rename to packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-ast.ts diff --git a/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-parser.ts b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-parser.ts new file mode 100644 index 000000000..f6c576984 --- /dev/null +++ b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-parser.ts @@ -0,0 +1,382 @@ +import { z } from 'zod'; + +import type { + ExpressionValidationContext, + RefExpressionDependency, + RefExpressionParseResult, + RefExpressionWarning, + ResolvedExpressionSlots, +} from '#src/references/expression-types.js'; +import type { DefinitionEntityType } from '#src/references/types.js'; +import type { ModelConfig } from '#src/schema/models/models.js'; +import type { modelEntityType } from '#src/schema/models/types.js'; +import type { ProjectDefinition } from '#src/schema/project-definition.js'; + +import { RefExpressionParser } from '#src/references/expression-types.js'; +import { modelAuthorizerRoleEntityType } from '#src/schema/models/authorizer/types.js'; +import { + modelForeignRelationEntityType, + modelLocalRelationEntityType, + modelScalarFieldEntityType, +} from '#src/schema/models/types.js'; + +import type { AuthorizerExpressionInfo } from './authorizer-expression-ast.js'; +import type { ModelValidationContext } from './authorizer-expression-validator.js'; +import type { AuthorizerExpressionVisitor } from './authorizer-expression-visitor.js'; + +import { parseAuthorizerExpression } from './authorizer-expression-acorn-parser.js'; +import { AuthorizerExpressionParseError } from './authorizer-expression-ast.js'; +import { + buildModelExpressionContext, + validateAuthorizerExpression, +} from './authorizer-expression-validator.js'; +import { visitAuthorizerExpression } from './authorizer-expression-visitor.js'; + +/** + * Expression parser for model authorizer role expressions. + * + * Parses expressions like: + * - `model.id === auth.userId` (ownership check) + * - `auth.hasRole('admin')` (global role check) + * - `model.id === auth.userId || auth.hasRole('admin')` (combined) + * + * Uses Acorn to parse JavaScript expressions and validates + * that only supported constructs are used. + * + * @example + * ```typescript + * const schema = z.object({ + * expression: ctx.withExpression(authorizerExpressionParser, { model: modelSlot }), + * }); + * ``` + */ +export class AuthorizerExpressionParser extends RefExpressionParser< + string, + AuthorizerExpressionInfo, + { model: typeof modelEntityType } +> { + readonly name = 'authorizer-expression'; + + /** + * Creates a Zod schema for validating expression strings. + * Requires a non-empty string value. + */ + createSchema(): z.ZodType { + return z.string().min(1, 'Expression is required'); + } + + /** + * Parse the expression string into an AST. + * + * @param value - The expression string + * @returns Success with parsed expression info, or failure with error message + */ + parse(value: string): RefExpressionParseResult { + try { + return { success: true, value: parseAuthorizerExpression(value) }; + } catch (error) { + if (error instanceof AuthorizerExpressionParseError) { + return { success: false, error: error.message }; + } + throw error; + } + } + + /** + * Get validation warnings for the expression. + * + * Validates: + * - Syntax errors from parsing + * - Model field references exist + * - Auth field references are valid + * - Role names exist in project config (warning only) + */ + getWarnings( + parseResult: AuthorizerExpressionInfo, + context: ExpressionValidationContext, + resolvedSlots: ResolvedExpressionSlots<{ model: typeof modelEntityType }>, + ): RefExpressionWarning[] { + // Get model context from resolved slots (throws if model not found) + const modelContext = this.getModelContext( + context.definition, + resolvedSlots, + ); + + // Validate the expression against model fields and roles + return validateAuthorizerExpression( + parseResult.ast, + modelContext, + context.pluginStore, + context.definition, + ); + } + + /** + * Get entity references from the expression with their positions. + * + * Walks the AST and resolves each name reference (field, relation, role) + * to its entity ID by navigating the model definition from the resolved slots. + * Returns positions marking exactly which text to replace when an entity is renamed. + */ + getReferencedEntities( + _value: string, + parseResult: RefExpressionParseResult, + definition: ProjectDefinition, + resolvedSlots: ResolvedExpressionSlots<{ model: typeof modelEntityType }>, + ): RefExpressionDependency[] { + if (!parseResult.success) { + return []; + } + + const model = this.getRawModel(definition, resolvedSlots); + + const allModels = definition.models.filter( + (m): m is ModelConfig => typeof m.name === 'string', + ); + + // Build lookup maps + const fieldByName = new Map(); + for (const field of model.model.fields) { + fieldByName.set(field.name, { id: field.id }); + } + + const relationByName = new Map< + string, + { id: string; modelRef: string; entityType: DefinitionEntityType } + >(); + // Local relations (defined on this model) + for (const relation of model.model.relations) { + relationByName.set(relation.name, { + id: relation.id, + modelRef: relation.modelRef, + entityType: modelLocalRelationEntityType, + }); + } + + const modelById = new Map(); + for (const m of allModels) { + modelById.set(m.id, m); + } + + // Foreign relations (defined on other models pointing to this model via foreignRelationName) + for (const m of allModels) { + for (const relation of m.model.relations) { + if (relation.foreignRelationName && relation.modelRef === model.id) { + relationByName.set(relation.foreignRelationName, { + id: relation.foreignId, + // Foreign relation points back to the model that defines it + modelRef: m.id, + entityType: modelForeignRelationEntityType, + }); + } + } + } + + const deps: RefExpressionDependency[] = []; + + const visitor: AuthorizerExpressionVisitor = { + fieldComparison(node) { + for (const side of [node.left, node.right]) { + if (side.type === 'fieldRef' && side.source === 'model') { + const field = fieldByName.get(side.field); + if (field) { + deps.push({ + entityType: modelScalarFieldEntityType, + entityId: field.id, + start: side.end - side.field.length, + end: side.end, + }); + } + } + } + }, + hasRole() { + // Global auth roles are defined by plugins, not navigable from + // the raw model definition. Skip — auth role renames are rare + // and would require traversing plugin-specific config. + }, + hasSomeRole() { + // Same as hasRole — skip global auth role references + }, + nestedHasRole(node) { + const relation = relationByName.get(node.relationName); + if (relation) { + deps.push({ + entityType: relation.entityType, + entityId: relation.id, + start: node.relationEnd - node.relationName.length, + end: node.relationEnd, + }); + // Foreign authorizer role + const foreignModel = modelById.get(relation.modelRef); + if (foreignModel) { + const foreignRole = foreignModel.authorizer.roles.find( + (r) => r.name === node.role, + ); + if (foreignRole) { + deps.push({ + entityType: modelAuthorizerRoleEntityType, + entityId: foreignRole.id, + start: node.roleStart + 1, + end: node.roleEnd - 1, + }); + } + } + } + }, + nestedHasSomeRole(node) { + const relation = relationByName.get(node.relationName); + if (relation) { + deps.push({ + entityType: relation.entityType, + entityId: relation.id, + start: node.relationEnd - node.relationName.length, + end: node.relationEnd, + }); + const foreignModel = modelById.get(relation.modelRef); + if (foreignModel) { + const foreignRoleByName = new Map( + foreignModel.authorizer.roles.map((r) => [r.name, r]), + ); + for (let i = 0; i < node.roles.length; i++) { + const foreignRole = foreignRoleByName.get(node.roles[i]); + if (foreignRole) { + deps.push({ + entityType: modelAuthorizerRoleEntityType, + entityId: foreignRole.id, + start: node.rolesStart[i] + 1, + end: node.rolesEnd[i] - 1, + }); + } + } + } + } + }, + relationFilter(node) { + const relation = relationByName.get(node.relationName); + if (relation) { + deps.push({ + entityType: relation.entityType, + entityId: relation.id, + start: node.relationEnd - node.relationName.length, + end: node.relationEnd, + }); + // Foreign model fields referenced in conditions + const foreignModel = modelById.get(relation.modelRef); + const foreignFieldByName = new Map(); + if (foreignModel) { + for (const f of foreignModel.model.fields) { + foreignFieldByName.set(f.name, { id: f.id }); + } + } + for (const condition of node.conditions) { + // Condition key references a field on the foreign model + const foreignField = foreignFieldByName.get(condition.field); + if (foreignField) { + deps.push({ + entityType: modelScalarFieldEntityType, + entityId: foreignField.id, + start: condition.fieldStart, + end: condition.fieldEnd, + }); + } + // Condition value may be a model field ref + if ( + condition.value.type === 'fieldRef' && + condition.value.source === 'model' + ) { + const field = fieldByName.get(condition.value.field); + if (field) { + deps.push({ + entityType: modelScalarFieldEntityType, + entityId: field.id, + start: condition.value.end - condition.value.field.length, + end: condition.value.end, + }); + } + } + } + } + }, + isAuthenticated() { + // No entity references + }, + binaryLogical(_node, _ctx, visit) { + visit(_node.left); + visit(_node.right); + }, + }; + + visitAuthorizerExpression(parseResult.value.ast, visitor); + + return deps; + } + + /** + * Navigate to the raw model object from the definition using resolved slots. + * + * Resolved slot paths point to the entity's ID field (e.g., `['models', 2, 'id']`), + * so we walk parent paths until we find an object with a string `name` property. + */ + private getRawModel( + definition: ProjectDefinition, + resolvedSlots: ResolvedExpressionSlots<{ model: typeof modelEntityType }>, + ): ModelConfig { + const modelPath = resolvedSlots.model; + + // Walk progressively shorter paths to find the model object. + // Slot paths include the idPath suffix (e.g., ['models', 2, 'id']), + // so we try the full path first, then strip segments until we find + // an object with a name property. + for (let len = modelPath.length; len > 0; len--) { + let current: unknown = definition; + for (let i = 0; i < len; i++) { + if (current === null || current === undefined) { + break; + } + current = (current as Record)[modelPath[i]]; + } + if ( + current !== null && + current !== undefined && + typeof current === 'object' && + 'name' in current && + typeof (current as Record).name === 'string' + ) { + return current as ModelConfig; + } + } + + throw new Error(`Could not resolve model at path ${modelPath.join('.')}`); + } + + /** + * Extract model context from the project definition using resolved slots. + */ + private getModelContext( + definition: ProjectDefinition, + resolvedSlots: ResolvedExpressionSlots<{ model: typeof modelEntityType }>, + ): ModelValidationContext { + const model = this.getRawModel(definition, resolvedSlots); + + const allModels = definition.models.filter( + (m): m is ModelConfig => typeof m.name === 'string', + ); + + return buildModelExpressionContext( + { + id: model.id, + name: model.name, + fields: model.model.fields, + model: { relations: model.model.relations }, + }, + allModels.map((m) => ({ + id: m.id, + name: m.name, + authorizer: m.authorizer, + fields: m.model.fields, + model: { relations: m.model.relations }, + })), + ); + } +} diff --git a/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-rename.unit.test.ts b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-rename.unit.test.ts new file mode 100644 index 000000000..24488a448 --- /dev/null +++ b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-rename.unit.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from 'vitest'; + +import type { RefExpressionDependency } from '#src/references/expression-types.js'; +import type { ModelConfigInput } from '#src/schema/models/models.js'; +import type { ProjectDefinition } from '#src/schema/project-definition.js'; + +import { modelAuthorizerRoleEntityType } from '#src/schema/models/authorizer/types.js'; +import { + modelLocalRelationEntityType, + modelScalarFieldEntityType, +} from '#src/schema/models/types.js'; +import { createTestModel } from '#src/testing/definition-helpers.test-helper.js'; +import { createTestProjectDefinition } from '#src/testing/project-definition-container.test-helper.js'; + +import { parseAuthorizerExpression } from './authorizer-expression-acorn-parser.js'; +import { AuthorizerExpressionParser } from './authorizer-expression-parser.js'; + +/** + * Build a definition with models for testing getReferencedEntities. + * Returns properly-typed objects from createTestModel/createTestProjectDefinition. + */ +function buildDefinition( + mainModelInput: TestModelOverrides, + otherModelInputs: TestModelOverrides[] = [], +): { + definition: ProjectDefinition; + resolvedSlots: { model: (string | number)[] }; +} { + const mainModel = createTestModel({ + id: 'model:main', + name: 'Main', + ...mainModelInput, + } as Partial); + + const otherModels = otherModelInputs.map((input) => + createTestModel(input as Partial), + ); + + const definition = createTestProjectDefinition({ + models: [mainModel, ...otherModels], + }); + + return { + definition, + resolvedSlots: { model: ['models', 0] }, + }; +} + +function getReferencedEntities( + expression: string, + mainModelInput: TestModelOverrides, + otherModelInputs?: TestModelOverrides[], +): RefExpressionDependency[] { + const parser = new AuthorizerExpressionParser(); + const info = parseAuthorizerExpression(expression); + const { definition, resolvedSlots } = buildDefinition( + mainModelInput, + otherModelInputs, + ); + return parser.getReferencedEntities( + expression, + { success: true, value: info }, + definition, + resolvedSlots, + ); +} + +/** + * Test-only model input that allows partial model sub-fields. + * createTestModel fills in all required defaults at runtime. + */ +// oxlint-disable-next-line typescript/no-explicit-any +type TestModelInput = Record; + +/** Loose override type for test model creation — cast to ModelConfigInput at call site. */ +interface TestModelOverrides { + id?: string; + name?: string; + model?: TestModelInput; + authorizer?: TestModelInput; + [key: string]: unknown; +} + +/** Helper: create model input with extra fields (default id field is kept by createTestModel). */ +function modelWithFields( + extraFields: { id: string; name: string }[], +): TestModelInput { + return { + fields: extraFields.map((f) => ({ + ...f, + type: 'string' as const, + isOptional: false, + options: { default: '' }, + })), + }; +} + +/** Helper: create model input with relations. */ +function modelWithRelations( + relations: { id: string; name: string; modelRef: string }[], +): TestModelInput { + return { + relations: relations.map((r) => ({ + ...r, + foreignRelationName: 'backRef', + references: [], + })), + }; +} + +/** + * Helper to apply renames using getReferencedEntities output. + * Mimics the generic orchestrator logic. + */ +function applyRenames( + expression: string, + deps: RefExpressionDependency[], + renames: Map, +): string { + const replacements = deps + .filter((ref) => renames.has(ref.entityId)) + .map((ref) => ({ + start: ref.start, + end: ref.end, + newValue: renames.get(ref.entityId) ?? '', + })) + .toSorted((a, b) => b.start - a.start); + + let result = expression; + for (const { start, end, newValue } of replacements) { + result = result.slice(0, start) + newValue + result.slice(end); + } + return result; +} + +describe('AuthorizerExpressionParser.getReferencedEntities', () => { + describe('model field references', () => { + it('should resolve model.field to field entity ID', () => { + const deps = getReferencedEntities('model.title === userId', { + model: modelWithFields([ + { id: 'model-scalar-field:title', name: 'title' }, + ]), + }); + expect(deps).toEqual([ + { + entityType: modelScalarFieldEntityType, + entityId: 'model-scalar-field:title', + start: 6, + end: 11, + }, + ]); + }); + + it('should resolve fields on both sides of a comparison', () => { + const deps = getReferencedEntities('model.authorId === model.creatorId', { + model: modelWithFields([ + { id: 'model-scalar-field:author', name: 'authorId' }, + { id: 'model-scalar-field:creator', name: 'creatorId' }, + ]), + }); + expect(deps).toHaveLength(2); + expect(deps[0].entityId).toBe('model-scalar-field:author'); + expect(deps[1].entityId).toBe('model-scalar-field:creator'); + }); + + it('should skip unknown fields', () => { + const deps = getReferencedEntities('model.unknown === userId', {}); + expect(deps).toEqual([]); + }); + }); + + describe('relation references', () => { + it('should resolve relation in nested hasRole', () => { + const deps = getReferencedEntities( + "hasRole(model.todoList, 'owner')", + { + model: modelWithRelations([ + { + id: 'model-local-relation:todoList', + name: 'todoList', + modelRef: 'model:todo', + }, + ]), + }, + [ + { + id: 'model:todo', + name: 'Todo', + authorizer: { + roles: [ + { + id: 'model-authorizer-role:owner', + name: 'owner', + expression: 'model.id === userId', + }, + ], + }, + }, + ], + ); + expect(deps).toHaveLength(2); + expect(deps[0].entityId).toBe('model-local-relation:todoList'); + expect(deps[0].entityType).toBe(modelLocalRelationEntityType); + expect(deps[1].entityId).toBe('model-authorizer-role:owner'); + expect(deps[1].entityType).toBe(modelAuthorizerRoleEntityType); + }); + + it('should resolve relation in exists filter', () => { + const deps = getReferencedEntities( + 'exists(model.members, { memberId: userId })', + { + model: modelWithRelations([ + { + id: 'model-local-relation:members', + name: 'members', + modelRef: 'model:member', + }, + ]), + }, + [ + { + id: 'model:member', + name: 'Member', + model: modelWithFields([ + { id: 'model-scalar-field:memberId', name: 'memberId' }, + ]), + }, + ], + ); + expect(deps).toHaveLength(2); + expect(deps[0].entityId).toBe('model-local-relation:members'); + expect(deps[1].entityId).toBe('model-scalar-field:memberId'); + }); + }); + + describe('end-to-end rename via generic orchestrator', () => { + it('should rename a model field', () => { + const expression = 'model.title === userId'; + const deps = getReferencedEntities(expression, { + model: modelWithFields([ + { id: 'model-scalar-field:title', name: 'title' }, + ]), + }); + const result = applyRenames( + expression, + deps, + new Map([['model-scalar-field:title', 'heading']]), + ); + expect(result).toBe('model.heading === userId'); + }); + + it('should rename a relation in nested hasRole', () => { + const expression = "hasRole(model.todoList, 'owner')"; + const deps = getReferencedEntities( + expression, + { + model: modelWithRelations([ + { + id: 'model-local-relation:todoList', + name: 'todoList', + modelRef: 'model:todo', + }, + ]), + }, + [ + { + id: 'model:todo', + name: 'Todo', + authorizer: { + roles: [ + { + id: 'model-authorizer-role:owner', + name: 'owner', + expression: 'model.id === userId', + }, + ], + }, + }, + ], + ); + const result = applyRenames( + expression, + deps, + new Map([['model-local-relation:todoList', 'list']]), + ); + expect(result).toBe("hasRole(model.list, 'owner')"); + }); + + it('should rename a foreign authorizer role', () => { + const expression = "hasRole(model.todoList, 'owner')"; + const deps = getReferencedEntities( + expression, + { + model: modelWithRelations([ + { + id: 'model-local-relation:todoList', + name: 'todoList', + modelRef: 'model:todo', + }, + ]), + }, + [ + { + id: 'model:todo', + name: 'Todo', + authorizer: { + roles: [ + { + id: 'model-authorizer-role:owner', + name: 'owner', + expression: 'model.id === userId', + }, + ], + }, + }, + ], + ); + const result = applyRenames( + expression, + deps, + new Map([['model-authorizer-role:owner', 'admin']]), + ); + expect(result).toBe("hasRole(model.todoList, 'admin')"); + }); + + it('should rename relation and foreign role together', () => { + const expression = "hasRole(model.todoList, 'owner')"; + const deps = getReferencedEntities( + expression, + { + model: modelWithRelations([ + { + id: 'model-local-relation:todoList', + name: 'todoList', + modelRef: 'model:todo', + }, + ]), + }, + [ + { + id: 'model:todo', + name: 'Todo', + authorizer: { + roles: [ + { + id: 'model-authorizer-role:owner', + name: 'owner', + expression: 'model.id === userId', + }, + ], + }, + }, + ], + ); + const result = applyRenames( + expression, + deps, + new Map([ + ['model-local-relation:todoList', 'list'], + ['model-authorizer-role:owner', 'admin'], + ]), + ); + expect(result).toBe("hasRole(model.list, 'admin')"); + }); + + it('should rename foreign field in exists condition', () => { + const expression = 'exists(model.members, { userName: userId })'; + const deps = getReferencedEntities( + expression, + { + model: modelWithRelations([ + { + id: 'model-local-relation:members', + name: 'members', + modelRef: 'model:member', + }, + ]), + }, + [ + { + id: 'model:member', + name: 'Member', + model: modelWithFields([ + { id: 'model-scalar-field:userName', name: 'userName' }, + ]), + }, + ], + ); + const result = applyRenames( + expression, + deps, + new Map([['model-scalar-field:userName', 'memberName']]), + ); + expect(result).toBe('exists(model.members, { memberName: userId })'); + }); + + it('should not rename when no entities match', () => { + const expression = 'model.id === userId'; + const deps = getReferencedEntities(expression, {}); + // The default id field from createTestModel should be resolved + expect(deps).toHaveLength(1); + + const result = applyRenames( + expression, + deps, + new Map([['model-scalar-field:other', 'something']]), + ); + expect(result).toBe(expression); + }); + }); +}); diff --git a/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-validator.ts b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-validator.ts similarity index 100% rename from packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-validator.ts rename to packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-validator.ts diff --git a/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-validator.unit.test.ts b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-validator.unit.test.ts similarity index 100% rename from packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-validator.unit.test.ts rename to packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-validator.unit.test.ts diff --git a/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-visitor.ts b/packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-visitor.ts similarity index 100% rename from packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-visitor.ts rename to packages/project-builder-lib/src/expression-parsers/authorizer/authorizer-expression-visitor.ts diff --git a/packages/project-builder-lib/src/expression-parsers/authorizer/index.ts b/packages/project-builder-lib/src/expression-parsers/authorizer/index.ts new file mode 100644 index 000000000..ab085e929 --- /dev/null +++ b/packages/project-builder-lib/src/expression-parsers/authorizer/index.ts @@ -0,0 +1,5 @@ +export * from './authorizer-expression-acorn-parser.js'; +export * from './authorizer-expression-ast.js'; +export * from './authorizer-expression-parser.js'; +export * from './authorizer-expression-validator.js'; +export * from './authorizer-expression-visitor.js'; diff --git a/packages/project-builder-lib/src/expression-parsers/register-core-module.ts b/packages/project-builder-lib/src/expression-parsers/register-core-module.ts new file mode 100644 index 000000000..0a18604bc --- /dev/null +++ b/packages/project-builder-lib/src/expression-parsers/register-core-module.ts @@ -0,0 +1,33 @@ +import type { PluginModuleWithKey } from '#src/plugins/imports/types.js'; + +import { createPluginModule } from '#src/plugins/imports/types.js'; + +import { expressionParserSpec } from '../references/expression-parser-spec.js'; +import { AuthorizerExpressionParser } from './authorizer/authorizer-expression-parser.js'; + +/** + * Core module that registers built-in expression parsers. + * + * This module is included in every consumer's coreModules array + * (server, web, CLI, tests) to ensure the authorizer expression parser + * is available for schemas that use `withExpression(authorizerExpressionRef, ...)`. + */ +const registerExpressionParsersModule = createPluginModule({ + name: 'register-expression-parsers', + dependencies: { + expressionParsers: expressionParserSpec, + }, + initialize: ({ expressionParsers }) => { + const parser = new AuthorizerExpressionParser(); + expressionParsers.parsers.set(parser.name, parser); + }, +}); + +/** + * Core module with key for inclusion in PluginStore.coreModules. + */ +export const expressionParserCoreModule: PluginModuleWithKey = { + key: 'core/lib/expression-parsers', + pluginKey: 'core', + module: registerExpressionParsersModule, +}; diff --git a/packages/project-builder-lib/src/index.ts b/packages/project-builder-lib/src/index.ts index 23668cadb..cf5752a68 100644 --- a/packages/project-builder-lib/src/index.ts +++ b/packages/project-builder-lib/src/index.ts @@ -1,6 +1,7 @@ export * from './compiler/index.js'; export * from './constants/index.js'; export * from './definition/index.js'; +export { expressionParserCoreModule } from './expression-parsers/register-core-module.js'; export * from './feature-flags/index.js'; export * from './migrations/index.js'; export * from './parser/index.js'; diff --git a/packages/project-builder-lib/src/parser/collect-definition-issues.ts b/packages/project-builder-lib/src/parser/collect-definition-issues.ts index 273ce70ea..eb1f1ee3e 100644 --- a/packages/project-builder-lib/src/parser/collect-definition-issues.ts +++ b/packages/project-builder-lib/src/parser/collect-definition-issues.ts @@ -122,12 +122,12 @@ export function collectDefinitionIssues( issues.push(...result); } - // Collect expression validation issues - const expressionIssues = collectExpressionIssues( - schema, + // Collect expression validation issues (uses container's pre-resolved expressions) + const expressionIssues = collectExpressionIssues({ definition, pluginStore, - ); + expressions: container.refPayload.expressions, + }); issues.push(...expressionIssues); return issues; diff --git a/packages/project-builder-lib/src/parser/collect-definition-issues.unit.test.ts b/packages/project-builder-lib/src/parser/collect-definition-issues.unit.test.ts index 2661afd5a..bf459f7c9 100644 --- a/packages/project-builder-lib/src/parser/collect-definition-issues.unit.test.ts +++ b/packages/project-builder-lib/src/parser/collect-definition-issues.unit.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import { z } from 'zod'; +import type { ProjectDefinition } from '#src/schema/project-definition.js'; + import { PluginSpecStore } from '#src/plugins/index.js'; +import { extractDefinitionRefs } from '#src/references/extract-definition-refs.js'; import { definitionFieldIssueRegistry, withIssueChecker, @@ -272,7 +275,13 @@ describe('collectExpressionIssues', () => { ); const data = { name: 'test', condition: 'model.badField === auth.userId' }; - const issues = collectExpressionIssues(schema, data, pluginStore); + const parsed = schema.parse(data); + const refPayload = extractDefinitionRefs(schema, parsed); + const issues = collectExpressionIssues({ + definition: parsed as unknown as ProjectDefinition, + pluginStore, + expressions: refPayload.expressions, + }); const expressionIssues = issues.filter( (i) => i.message === 'Invalid field reference', diff --git a/packages/project-builder-lib/src/parser/collect-expression-issues.ts b/packages/project-builder-lib/src/parser/collect-expression-issues.ts index fd3c5c2ee..749dd78cf 100644 --- a/packages/project-builder-lib/src/parser/collect-expression-issues.ts +++ b/packages/project-builder-lib/src/parser/collect-expression-issues.ts @@ -1,48 +1,62 @@ -import type { z } from 'zod'; - import type { PluginSpecStore } from '#src/plugins/index.js'; -import type { ExpressionValidationContext } from '#src/references/expression-types.js'; +import type { + DefinitionExpression, + ExpressionValidationContext, +} from '#src/references/expression-types.js'; import type { DefinitionIssue } from '#src/schema/creator/definition-issue-types.js'; +import type { ProjectDefinition } from '#src/schema/project-definition.js'; -import { extractDefinitionRefs } from '#src/references/extract-definition-refs.js'; +/** + * Input for expression issue collection. + * Satisfied by ProjectDefinitionContainer and by lightweight test fixtures. + */ +export interface CollectExpressionIssuesInput { + definition: ProjectDefinition; + pluginStore: PluginSpecStore; + expressions: readonly DefinitionExpression[]; +} /** - * Collects validation issues from expression parsers registered on the schema. + * Collects validation issues from expression parsers in the definition. * - * Walks the schema+data to find expression annotations, resolves their slots, - * then calls each parser's `validate()` method. Warnings are mapped to - * `DefinitionIssue` objects with warning severity. + * Uses pre-resolved expressions to avoid redundant schema walks. + * Each parser's `validate()` method is called with the expression value and + * resolved slots. Warnings are mapped to `DefinitionIssue` objects. * - * @param schema - The Zod schema to walk - * @param data - The parsed definition data - * @param pluginStore - The plugin spec store for validation context + * @param input - The definition, plugin store, and pre-resolved expressions * @returns Array of definition issues from expression validation */ export function collectExpressionIssues( - schema: z.ZodType, - data: unknown, - pluginStore: PluginSpecStore, + input: CollectExpressionIssuesInput, ): DefinitionIssue[] { - const refPayload = extractDefinitionRefs(schema, data); + const { definition, pluginStore, expressions } = input; const context: ExpressionValidationContext = { - definition: data, + definition, pluginStore, }; const issues: DefinitionIssue[] = []; - for (const expression of refPayload.expressions) { - const warnings = expression.parser.validate( - expression.value, - data, - context, - expression.resolvedSlots, - ); + for (const expression of expressions) { + try { + const warnings = expression.parser.validate( + expression.value, + definition, + context, + expression.resolvedSlots, + ); - for (const warning of warnings) { + for (const warning of warnings) { + issues.push({ + message: warning.message, + path: expression.path, + severity: 'warning', + }); + } + } catch (error) { issues.push({ - message: warning.message, + message: `Expression parser "${expression.parser.name}" threw an error: ${error instanceof Error ? error.message : String(error)}`, path: expression.path, severity: 'warning', }); diff --git a/packages/project-builder-lib/src/parser/collect-expression-issues.unit.test.ts b/packages/project-builder-lib/src/parser/collect-expression-issues.unit.test.ts index eb5c4ccc8..d89dd599c 100644 --- a/packages/project-builder-lib/src/parser/collect-expression-issues.unit.test.ts +++ b/packages/project-builder-lib/src/parser/collect-expression-issues.unit.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'; import { z } from 'zod'; import type { RefExpressionParser } from '#src/references/expression-types.js'; +import type { ProjectDefinition } from '#src/schema/project-definition.js'; import { PluginSpecStore } from '#src/plugins/index.js'; +import { extractDefinitionRefs } from '#src/references/extract-definition-refs.js'; import { createDefinitionSchemaParserContext, definitionSchema, @@ -14,6 +16,8 @@ import { WarningParser, } from '#src/testing/expression-warning-parser.test-helper.js'; +import type { CollectExpressionIssuesInput } from './collect-expression-issues.js'; + import { collectExpressionIssues } from './collect-expression-issues.js'; describe('collectExpressionIssues', () => { @@ -33,21 +37,36 @@ describe('collectExpressionIssues', () => { ); } + function buildInput( + schema: z.ZodType, + data: unknown, + ): CollectExpressionIssuesInput { + const parsed = schema.parse(data); + const refPayload = extractDefinitionRefs(schema, parsed); + return { + // Cast: test schemas produce mock data, not real ProjectDefinition + definition: parsed as ProjectDefinition, + pluginStore, + expressions: refPayload.expressions, + }; + } + it('returns empty array when schema has no expressions', () => { const schema = z.object({ name: z.string() }); const issues = collectExpressionIssues( - schema, - { name: 'test' }, - pluginStore, + buildInput(schema, { name: 'test' }), ); expect(issues).toEqual([]); }); it('returns empty array when expression parser produces no warnings', () => { const schema = createSchemaWithExpression(stubParser); - const data = { name: 'test', condition: 'model.id === auth.userId' }; - - const issues = collectExpressionIssues(schema, data, pluginStore); + const issues = collectExpressionIssues( + buildInput(schema, { + name: 'test', + condition: 'model.id === auth.userId', + }), + ); expect(issues).toEqual([]); }); @@ -57,9 +76,10 @@ describe('collectExpressionIssues', () => { { message: 'Role not defined', start: 10, end: 20 }, ]); const schema = createSchemaWithExpression(warningParser); - const data = { name: 'test', condition: 'some expression' }; - const issues = collectExpressionIssues(schema, data, pluginStore); + const issues = collectExpressionIssues( + buildInput(schema, { name: 'test', condition: 'some expression' }), + ); expect(issues).toHaveLength(2); expect(issues[0]).toEqual({ @@ -89,14 +109,15 @@ describe('collectExpressionIssues', () => { const schema = schemaCreator( createDefinitionSchemaParserContext({ plugins: pluginStore }), ); - const data = { - rules: [ - { name: 'rule1', condition: 'expr1' }, - { name: 'rule2', condition: 'expr2' }, - ], - }; - const issues = collectExpressionIssues(schema, data, pluginStore); + const issues = collectExpressionIssues( + buildInput(schema, { + rules: [ + { name: 'rule1', condition: 'expr1' }, + { name: 'rule2', condition: 'expr2' }, + ], + }), + ); expect(issues).toHaveLength(2); expect(issues[0]?.path).toEqual(['rules', 0, 'condition']); @@ -106,9 +127,10 @@ describe('collectExpressionIssues', () => { it('returns parse error as warning when parse fails', () => { const failingParser = new FailingParser(); const schema = createSchemaWithExpression(failingParser); - const data = { name: 'test', condition: 'bad expression' }; - const issues = collectExpressionIssues(schema, data, pluginStore); + const issues = collectExpressionIssues( + buildInput(schema, { name: 'test', condition: 'bad expression' }), + ); expect(issues).toHaveLength(1); expect(issues[0]).toEqual({ @@ -119,12 +141,11 @@ describe('collectExpressionIssues', () => { }); it('returns empty array when schema has no expression annotations', () => { - const schema = z.object({ name: z.string() }); - const issues = collectExpressionIssues( - schema, - 'not an object', + const issues = collectExpressionIssues({ + definition: 'not an object' as unknown as ProjectDefinition, pluginStore, - ); + expressions: [], + }); expect(issues).toEqual([]); }); }); diff --git a/packages/project-builder-lib/src/references/expression-parser-ref.ts b/packages/project-builder-lib/src/references/expression-parser-ref.ts new file mode 100644 index 000000000..930229b0d --- /dev/null +++ b/packages/project-builder-lib/src/references/expression-parser-ref.ts @@ -0,0 +1,70 @@ +import type { z } from 'zod'; + +import type { DefinitionEntityType } from './types.js'; + +/** + * A lightweight reference to an expression parser registered in the + * `expressionParserSpec`. + * + * Used in schema definitions instead of importing the full parser class + * directly. The actual parser is resolved at runtime during schema+data + * walking via the plugin spec store. + * + * Phantom type parameters enforce slot requirements at compile time + * without requiring the parser implementation to be imported. + * + * @typeParam TValue - The type of the raw expression value (e.g., string) + * @typeParam TRequiredSlots - Record of required slot names to entity types + * + * @example + * ```typescript + * const authorizerExpressionRef = createExpressionParserRef< + * string, + * { model: typeof modelEntityType } + * >( + * 'authorizer-expression', + * () => z.string().min(1, 'Expression is required'), + * ); + * ``` + */ +export interface ExpressionParserRef< + TValue = unknown, + TRequiredSlots extends Record = Record< + string, + never + >, +> { + /** Unique name matching the parser registered in expressionParserSpec */ + readonly name: string; + /** + * Creates a fresh Zod schema instance for basic validation. + * Must return a new instance per call to avoid shared metadata conflicts + * when the same ref is used at multiple schema sites. + */ + readonly createSchema: () => z.ZodType; + /** @internal Phantom type for slot enforcement */ + readonly _slots?: TRequiredSlots; +} + +/** + * Creates a typed reference to an expression parser. + * + * The ref carries a parser name and a schema factory for basic value validation. + * The actual parser implementation is looked up from `expressionParserSpec` + * at runtime. + * + * @param name - Unique name matching the registered parser + * @param createSchema - Factory that returns a fresh Zod schema per call site + */ +export function createExpressionParserRef< + TValue, + TRequiredSlots extends Record = Record< + string, + never + >, +>( + name: string, + createSchema: () => z.ZodType, +): ExpressionParserRef { + return { name, createSchema }; +} diff --git a/packages/project-builder-lib/src/references/expression-parser-spec.ts b/packages/project-builder-lib/src/references/expression-parser-spec.ts new file mode 100644 index 000000000..0916f76fe --- /dev/null +++ b/packages/project-builder-lib/src/references/expression-parser-spec.ts @@ -0,0 +1,39 @@ +import { createFieldMapSpec } from '#src/plugins/utils/create-field-map-spec.js'; + +import type { RefExpressionParser } from './expression-types.js'; + +// oxlint-disable-next-line typescript/no-explicit-any +type AnyExpressionParser = RefExpressionParser; + +/** + * Plugin spec for registering expression parsers. + * + * Expression parsers handle parsing, validation, and rename detection + * for expression fields in the project definition (e.g., authorizer + * role expressions). + * + * Built-in parsers (authorizer expressions) are registered by core modules. + * Plugins can register additional parsers during initialization. + * + * @example + * ```typescript + * createPluginModule({ + * dependencies: { expressionParsers: expressionParserSpec }, + * initialize: ({ expressionParsers }) => { + * expressionParsers.parsers.set('my-expression', myParser); + * }, + * }); + * ``` + */ +export const expressionParserSpec = createFieldMapSpec( + 'core/expression-parsers', + (t) => ({ + parsers: t.map(), + }), + { + use: (values) => ({ + getParser: (name: string): AnyExpressionParser | undefined => + values.parsers.get(name), + }), + }, +); diff --git a/packages/project-builder-lib/src/references/expression-types.ts b/packages/project-builder-lib/src/references/expression-types.ts index 8b5863c39..7f301cbbc 100644 --- a/packages/project-builder-lib/src/references/expression-types.ts +++ b/packages/project-builder-lib/src/references/expression-types.ts @@ -1,6 +1,7 @@ import type { z } from 'zod'; import type { PluginSpecStore } from '#src/plugins/index.js'; +import type { ProjectDefinition } from '#src/schema/project-definition.js'; import type { RefContextSlot } from './ref-context-slot.js'; import type { DefinitionEntityType, ReferencePath } from './types.js'; @@ -11,8 +12,8 @@ import type { DefinitionEntityType, ReferencePath } from './types.js'; * validating expressions against model fields, roles, etc. */ export interface ExpressionValidationContext { - /** The raw project definition data */ - readonly definition: unknown; + /** The project definition data */ + readonly definition: ProjectDefinition; /** The plugin spec store for accessing plugin-registered configuration */ readonly pluginStore: PluginSpecStore; } @@ -50,9 +51,10 @@ export interface RefExpressionDependency { entityType: DefinitionEntityType; /** The ID of the entity being referenced */ entityId: string; - /** Position in the expression for rename updates */ - start?: number; - end?: number; + /** Start position (inclusive) in the expression text to replace on rename */ + start: number; + /** End position (exclusive) in the expression text to replace on rename */ + end: number; } /** @@ -97,8 +99,7 @@ export type ResolvedExpressionSlots< * readonly name = 'stub'; * parse(): undefined { return undefined; } * getWarnings(): [] { return []; } - * getDependencies(): [] { return []; } - * updateForRename(value: string): string { return value; } + * getReferencedEntities(): [] { return []; } * } * * // A parser that requires a model slot @@ -135,12 +136,12 @@ export abstract class RefExpressionParser< * The result is cached on the marker for subsequent operations. * * @param value - The raw expression value - * @param projectDef - The project definition for context (typed as unknown to avoid circular reference) + * @param projectDef - The project definition for context * @returns Success with parsed value, or failure with error message */ abstract parse( value: TValue, - projectDef: unknown, + projectDef: ProjectDefinition, ): RefExpressionParseResult; /** @@ -159,31 +160,25 @@ export abstract class RefExpressionParser< ): RefExpressionWarning[]; /** - * Get entity/field dependencies from the expression. - * Used for tracking what the expression references for rename handling. + * Get entity references from the expression with their positions. * - * @param value - The raw expression value - * @param parseResult - The cached parse result - * @returns Array of dependencies - */ - abstract getDependencies( - value: TValue, - parseResult: RefExpressionParseResult, - ): RefExpressionDependency[]; - - /** - * Update the expression when dependencies are renamed. + * Used by the generic rename system to detect and apply renames. + * Each returned dependency identifies an entity by ID and marks the + * position in the expression text that should be replaced with the + * entity's new name if it is renamed. * * @param value - The raw expression value * @param parseResult - The cached parse result - * @param renames - Map of old entity ID to new name - * @returns The updated expression value + * @param definition - The project definition + * @param resolvedSlots - The resolved slot paths for this expression + * @returns Array of entity references with positions for rename */ - abstract updateForRename( + abstract getReferencedEntities( value: TValue, parseResult: RefExpressionParseResult, - renames: Map, - ): TValue; + definition: ProjectDefinition, + resolvedSlots: ResolvedExpressionSlots, + ): RefExpressionDependency[]; /** * Convenience method that combines parse() and getWarnings() into a single call. @@ -197,7 +192,7 @@ export abstract class RefExpressionParser< */ validate( value: TValue, - projectDef: unknown, + projectDef: ProjectDefinition, context: ExpressionValidationContext, resolvedSlots: ResolvedExpressionSlots, ): RefExpressionWarning[] { diff --git a/packages/project-builder-lib/src/references/extend-parser-context-with-refs.ts b/packages/project-builder-lib/src/references/extend-parser-context-with-refs.ts index 8214872f2..9aa865ddc 100644 --- a/packages/project-builder-lib/src/references/extend-parser-context-with-refs.ts +++ b/packages/project-builder-lib/src/references/extend-parser-context-with-refs.ts @@ -3,15 +3,14 @@ import type { TuplePaths } from '@baseplate-dev/utils'; import { z } from 'zod'; import type { DefinitionEntityType } from '#src/index.js'; +import type { PluginSpecStore } from '#src/plugins/index.js'; import type { DefinitionEntityInput, DefinitionReferenceInput, } from './definition-ref-builder.js'; -import type { - ExpressionSlotMap, - RefExpressionParser, -} from './expression-types.js'; +import type { ExpressionParserRef } from './expression-parser-ref.js'; +import type { ExpressionSlotMap } from './expression-types.js'; import type { RefContextSlot, RefContextSlotDefinition, @@ -19,6 +18,8 @@ import type { } from './ref-context-slot.js'; import { definitionRefRegistry } from './definition-ref-registry.js'; +import { expressionParserSpec } from './expression-parser-spec.js'; +import { RefExpressionParser } from './expression-types.js'; import { createRefContextSlotMap } from './ref-context-slot.js'; type ZodTypeWithOptional = T extends z.ZodOptional @@ -62,12 +63,12 @@ export type RefContextType = < * provided as the second argument. TypeScript enforces this at compile time. */ export interface WithExpressionType { - // Overload for parsers with no required slots + // Overload for direct parser with no required slots ( parser: RefExpressionParser, ): z.ZodType; - // Overload for parsers with required slots + // Overload for direct parser with required slots < TValue, TParseResult, @@ -76,6 +77,15 @@ export interface WithExpressionType { parser: RefExpressionParser, slots: ExpressionSlotMap, ): z.ZodType; + + // Overload for parser ref with no required slots + (parserRef: ExpressionParserRef): z.ZodType; + + // Overload for parser ref with required slots + >( + parserRef: ExpressionParserRef, + slots: ExpressionSlotMap, + ): z.ZodType; } /** @@ -202,28 +212,55 @@ function refContext< * ); * ``` */ -function withExpression< - TValue, - TParseResult, - TRequiredSlots extends Record, ->( - parser: RefExpressionParser, - slots?: ExpressionSlotMap, -): z.ZodType { - // createSchema() returns a fresh instance per call, allowing per-call - // metadata to be registered without conflicting across call sites. - const schema = parser.createSchema() as z.ZodType; - - definitionRefRegistry.add(schema, { - kind: 'expression', +/** + * Creates a `withExpression` function that resolves parser refs eagerly + * from the plugin spec store at schema construction time. + */ +function createWithExpression(plugins?: PluginSpecStore): WithExpressionType { + // Implementation + function withExpression( + parserOrRef: // oxlint-disable-next-line typescript/no-explicit-any + | RefExpressionParser + // oxlint-disable-next-line typescript/no-explicit-any + | ExpressionParserRef, + // oxlint-disable-next-line typescript/no-explicit-any + slots?: ExpressionSlotMap, + ): z.ZodType { + // oxlint-disable-next-line typescript/no-explicit-any + let parser: RefExpressionParser; + if (parserOrRef instanceof RefExpressionParser) { + parser = parserOrRef; + } else { + // Resolve parser ref from plugin spec store + if (!plugins) { + throw new Error( + `PluginSpecStore is required to resolve expression parser ref "${parserOrRef.name}". ` + + `Ensure plugins are provided to createDefinitionSchemaParserContext.`, + ); + } + const specUse = plugins.use(expressionParserSpec); + const resolved = specUse.getParser(parserOrRef.name); + if (!resolved) { + throw new Error( + `Expression parser "${parserOrRef.name}" not found in expressionParserSpec. ` + + `Ensure it is registered via a core module or plugin.`, + ); + } + parser = resolved; + } - parser, - slots, - }); - return schema; + const schema = parser.createSchema(); + definitionRefRegistry.add(schema, { + kind: 'expression', + parser, + slots, + }); + return schema; + } + return withExpression as WithExpressionType; } -export function extendParserContextWithRefs(): { +export function extendParserContextWithRefs(plugins?: PluginSpecStore): { withRef: WithRefType; withEnt: WithEntType; refContext: RefContextType; @@ -247,6 +284,6 @@ export function extendParserContextWithRefs(): { ); }, refContext, - withExpression, + withExpression: createWithExpression(plugins), }; } diff --git a/packages/project-builder-lib/src/references/extract-definition-refs.ts b/packages/project-builder-lib/src/references/extract-definition-refs.ts index 37ca3f8a0..6e78a6650 100644 --- a/packages/project-builder-lib/src/references/extract-definition-refs.ts +++ b/packages/project-builder-lib/src/references/extract-definition-refs.ts @@ -128,7 +128,7 @@ export function extractDefinitionRefs( }, }); - // Expression collector: records expression annotations + // Expression collector: records expression annotations (direct parsers) const expressionCollector = createRefSchemaCollector({ kind: 'expression', visit(meta, data, context): void { diff --git a/packages/project-builder-lib/src/references/fix-definition-refs.ts b/packages/project-builder-lib/src/references/fix-definition-refs.ts new file mode 100644 index 000000000..1e672b2a8 --- /dev/null +++ b/packages/project-builder-lib/src/references/fix-definition-refs.ts @@ -0,0 +1,50 @@ +import type { z } from 'zod'; + +import type { FixRefDeletionResult } from './fix-ref-deletions.js'; +import type { ResolvedZodRefPayload } from './types.js'; + +import { applyExpressionRenames } from './fix-expression-renames.js'; +import { fixRefDeletions } from './fix-ref-deletions.js'; +import { parseSchemaWithTransformedReferences } from './parse-schema-with-references.js'; + +export interface FixDefinitionRefsOptions { + /** Ref payload from the previous definition version, for detecting expression renames. */ + oldRefPayload?: ResolvedZodRefPayload; +} + +/** + * Fixes expression renames and dangling references in a single pass. + * + * Expression renames use the OLD definition (via `oldRefPayload`) to resolve + * entity references — expressions still contain old names like `model.title`, + * which can only be resolved against the definition where those names exist. + * The new entity names are then used to detect what was renamed. + * + * @param schema - The project definition Zod schema + * @param value - The definition after auto-fixes + * @param options - Optional old ref payload for rename detection + * @returns The fixed definition with ref payload + */ +export function fixDefinitionRefs( + schema: T, + value: unknown, + options?: FixDefinitionRefsOptions, +): FixRefDeletionResult> { + if (!options?.oldRefPayload) { + return fixRefDeletions(schema, value); + } + + // Parse the new definition to get new entities (for rename comparison) + const newRefPayload = parseSchemaWithTransformedReferences(schema, value, { + allowInvalidReferences: true, + }); + + const { value: renamedValue, modified } = applyExpressionRenames( + newRefPayload.data, + newRefPayload.entities, + options.oldRefPayload, + ); + + // Run fixRefDeletions on the (possibly renamed) definition + return fixRefDeletions(schema, modified ? renamedValue : newRefPayload.data); +} diff --git a/packages/project-builder-lib/src/references/fix-expression-renames.ts b/packages/project-builder-lib/src/references/fix-expression-renames.ts new file mode 100644 index 000000000..93bbe2e61 --- /dev/null +++ b/packages/project-builder-lib/src/references/fix-expression-renames.ts @@ -0,0 +1,127 @@ +import { get, set } from 'es-toolkit/compat'; + +import type { ProjectDefinition } from '#src/schema/project-definition.js'; + +import type { + DefinitionEntity, + ReferencePath, + ResolvedZodRefPayload, +} from './types.js'; + +export interface ApplyExpressionRenamesResult { + value: T; + modified: boolean; +} + +/** + * Detects renamed entities and updates expression strings accordingly. + * + * Uses the OLD definition/refPayload to resolve entity references (since + * expressions still contain old names like `model.title`), then compares + * old vs new entity names to detect renames and apply position-based + * string replacements on the NEW definition. + * + * @param newDefinition - The new definition value (where expressions will be updated) + * @param newEntities - Entities from the new definition (with new names) + * @param oldRefPayload - The ref payload from the old definition (has old expressions, entities, and definition) + * @returns The (possibly modified) definition and whether any renames were applied + */ +export function applyExpressionRenames( + newDefinition: T, + newEntities: readonly DefinitionEntity[], + oldRefPayload: ResolvedZodRefPayload, +): ApplyExpressionRenamesResult { + const { expressions: oldExpressions, entities: oldEntities } = oldRefPayload; + + if (oldExpressions.length === 0) { + return { value: newDefinition, modified: false }; + } + + // Detect renames: compare old vs new entity names by ID + const renames = new Map(); // entityId → newName + const newNameById = new Map(); + for (const entity of newEntities) { + newNameById.set(entity.id, entity.name); + } + for (const entity of oldEntities) { + const newName = newNameById.get(entity.id); + if (newName !== undefined && newName !== entity.name) { + renames.set(entity.id, newName); + } + } + + if (renames.size === 0) { + return { value: newDefinition, modified: false }; + } + + let modified = false; + const updates: { path: ReferencePath; value: string }[] = []; + + const oldDefinition = oldRefPayload.data as ProjectDefinition; + + for (const expression of oldExpressions) { + // Verify the expression still exists at the same path in the new definition. + // If the expression was removed or its shape changed, skip it to avoid + // resurrecting deleted nodes via set(). + const currentValue: unknown = get(newDefinition as object, expression.path); + if (typeof currentValue !== 'string') { + continue; + } + + // Parse and resolve entities against the OLD definition where old names still exist + const parseResult = expression.parser.parse( + expression.value, + oldDefinition, + ); + if (!parseResult.success) { + // Don't touch broken expressions + continue; + } + + const refs = expression.parser.getReferencedEntities( + expression.value, + parseResult, + oldDefinition, + expression.resolvedSlots, + ); + + // Build replacements for renamed entities + const replacements: { start: number; end: number; newValue: string }[] = []; + for (const ref of refs) { + const newName = renames.get(ref.entityId); + if (newName !== undefined) { + replacements.push({ + start: ref.start, + end: ref.end, + newValue: newName, + }); + } + } + + if (replacements.length === 0) { + continue; + } + + // Sort by position descending so earlier replacements don't shift later positions + replacements.sort((a, b) => b.start - a.start); + + let updated = expression.value as string; + for (const { start, end, newValue } of replacements) { + updated = updated.slice(0, start) + newValue + updated.slice(end); + } + + modified = true; + // Update at the same path in the NEW definition + updates.push({ path: expression.path, value: updated }); + } + + if (!modified) { + return { value: newDefinition, modified: false }; + } + + const result = structuredClone(newDefinition) as object; + for (const { path, value } of updates) { + set(result, path, value); + } + return { value: result as T, modified: true }; +} diff --git a/packages/project-builder-lib/src/references/fix-expression-renames.unit.test.ts b/packages/project-builder-lib/src/references/fix-expression-renames.unit.test.ts new file mode 100644 index 000000000..ef9f887cd --- /dev/null +++ b/packages/project-builder-lib/src/references/fix-expression-renames.unit.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import type { + DefinitionExpression, + RefExpressionDependency, + RefExpressionParseResult, +} from './expression-types.js'; +import type { DefinitionEntity, ResolvedZodRefPayload } from './types.js'; + +import { RefExpressionParser } from './expression-types.js'; +import { applyExpressionRenames } from './fix-expression-renames.js'; +import { createEntityType } from './types.js'; + +const testEntityType = createEntityType('test-entity'); + +/** + * A fake parser that returns configurable dependencies from getReferencedEntities. + */ +class FakeParser extends RefExpressionParser { + readonly name = 'fake'; + private readonly deps: RefExpressionDependency[]; + private readonly shouldFailParse: boolean; + + constructor( + deps: RefExpressionDependency[] = [], + options?: { shouldFailParse?: boolean }, + ) { + super(); + this.deps = deps; + this.shouldFailParse = options?.shouldFailParse ?? false; + } + + createSchema(): z.ZodType { + return z.string(); + } + + parse(value: string): RefExpressionParseResult { + if (this.shouldFailParse) { + return { success: false, error: 'Parse failed' }; + } + return { success: true, value }; + } + + getWarnings(): [] { + return []; + } + + getReferencedEntities(): RefExpressionDependency[] { + return this.deps; + } +} + +function makeEntity(id: string, name: string): DefinitionEntity { + return { + id, + name, + type: testEntityType, + path: [], + idPath: ['id'], + }; +} + +function makeExpression( + value: string, + parser: FakeParser, + path: (string | number)[] = ['expressions', 0], +): DefinitionExpression { + return { + value, + parser, + path, + resolvedSlots: {}, + }; +} + +function makeOldPayload( + data: unknown, + entities: DefinitionEntity[], + expressions: DefinitionExpression[], +): ResolvedZodRefPayload { + return { data, entities, references: [], expressions }; +} + +describe('applyExpressionRenames', () => { + it('should apply a single rename', () => { + const parser = new FakeParser([ + { entityType: testEntityType, entityId: 'e:1', start: 0, end: 5 }, + ]); + + const result = applyExpressionRenames( + { expressions: ['hello world'] }, // new definition (expressions not yet updated) + [makeEntity('e:1', 'world')], // new entities (renamed) + makeOldPayload( + { expressions: ['hello world'] }, + [makeEntity('e:1', 'hello')], // old entity name was 'hello' + [makeExpression('hello world', parser, ['expressions', 0])], + ), + ); + + expect(result.modified).toBe(true); + expect(result.value).toEqual({ expressions: ['world world'] }); + }); + + it('should apply multiple renames in descending position order', () => { + const parser = new FakeParser([ + { entityType: testEntityType, entityId: 'e:1', start: 0, end: 3 }, + { entityType: testEntityType, entityId: 'e:2', start: 4, end: 7 }, + ]); + + const result = applyExpressionRenames( + { expr: 'aaa bbb' }, + [makeEntity('e:1', 'xxx'), makeEntity('e:2', 'yyy')], + makeOldPayload( + { expr: 'aaa bbb' }, + [makeEntity('e:1', 'aaa'), makeEntity('e:2', 'bbb')], + [makeExpression('aaa bbb', parser, ['expr'])], + ), + ); + + expect(result.modified).toBe(true); + expect(result.value).toEqual({ expr: 'xxx yyy' }); + }); + + it('should return modified: false when no entities were renamed', () => { + const parser = new FakeParser([ + { entityType: testEntityType, entityId: 'e:1', start: 0, end: 5 }, + ]); + + const result = applyExpressionRenames( + { expr: 'hello' }, + [makeEntity('e:1', 'hello')], + makeOldPayload( + { expr: 'hello' }, + [makeEntity('e:1', 'hello')], // same name — no rename + [makeExpression('hello', parser, ['expr'])], + ), + ); + + expect(result.modified).toBe(false); + expect(result.value).toEqual({ expr: 'hello' }); + }); + + it('should return modified: false when no expressions exist', () => { + const result = applyExpressionRenames( + { expr: 'hello' }, + [makeEntity('e:1', 'world')], + makeOldPayload( + { expr: 'hello' }, + [makeEntity('e:1', 'hello')], + [], // no expressions + ), + ); + + expect(result.modified).toBe(false); + }); + + it('should skip expressions where parse fails', () => { + const failingParser = new FakeParser( + [{ entityType: testEntityType, entityId: 'e:1', start: 0, end: 5 }], + { shouldFailParse: true }, + ); + + const result = applyExpressionRenames( + { expr: 'hello' }, + [makeEntity('e:1', 'world')], + makeOldPayload( + { expr: 'hello' }, + [makeEntity('e:1', 'hello')], + [makeExpression('hello', failingParser, ['expr'])], + ), + ); + + expect(result.modified).toBe(false); + expect(result.value).toEqual({ expr: 'hello' }); + }); + + it('should skip dependencies whose entity ID was not renamed', () => { + const parser = new FakeParser([ + { entityType: testEntityType, entityId: 'e:2', start: 0, end: 5 }, + ]); + + const result = applyExpressionRenames( + { expr: 'hello' }, + [makeEntity('e:1', 'world'), makeEntity('e:2', 'hello')], + makeOldPayload( + { expr: 'hello' }, + [makeEntity('e:1', 'old'), makeEntity('e:2', 'hello')], // e:1 renamed, but dep is on e:2 + [makeExpression('hello', parser, ['expr'])], + ), + ); + + expect(result.modified).toBe(false); + }); + + it('should handle multiple expressions in the same definition', () => { + const parser1 = new FakeParser([ + { entityType: testEntityType, entityId: 'e:1', start: 0, end: 3 }, + ]); + const parser2 = new FakeParser([ + { entityType: testEntityType, entityId: 'e:1', start: 0, end: 3 }, + ]); + + const result = applyExpressionRenames( + { items: [{ expr: 'foo' }, { expr: 'foo bar' }] }, + [makeEntity('e:1', 'baz')], + makeOldPayload( + { items: [{ expr: 'foo' }, { expr: 'foo bar' }] }, + [makeEntity('e:1', 'foo')], + [ + makeExpression('foo', parser1, ['items', 0, 'expr']), + makeExpression('foo bar', parser2, ['items', 1, 'expr']), + ], + ), + ); + + expect(result.modified).toBe(true); + expect(result.value).toEqual({ + items: [{ expr: 'baz' }, { expr: 'baz bar' }], + }); + }); + + it('should handle renames that change string length', () => { + const parser = new FakeParser([ + { entityType: testEntityType, entityId: 'e:1', start: 0, end: 2 }, + { entityType: testEntityType, entityId: 'e:2', start: 3, end: 4 }, + ]); + + const result = applyExpressionRenames( + { expr: 'ab c' }, + [makeEntity('e:1', 'xxxx'), makeEntity('e:2', 'yyyyy')], + makeOldPayload( + { expr: 'ab c' }, + [makeEntity('e:1', 'ab'), makeEntity('e:2', 'c')], + [makeExpression('ab c', parser, ['expr'])], + ), + ); + + expect(result.modified).toBe(true); + expect(result.value).toEqual({ expr: 'xxxx yyyyy' }); + }); +}); diff --git a/packages/project-builder-lib/src/references/index.ts b/packages/project-builder-lib/src/references/index.ts index 2478ac26a..7d69555df 100644 --- a/packages/project-builder-lib/src/references/index.ts +++ b/packages/project-builder-lib/src/references/index.ts @@ -1,9 +1,13 @@ export * from './definition-ref-builder.js'; export * from './definition-ref-registry.js'; export * from './deserialize-schema.js'; +export * from './expression-parser-ref.js'; +export * from './expression-parser-spec.js'; export * from './expression-types.js'; export { withEnt, withRef } from './extend-parser-context-with-refs.js'; export * from './extract-definition-refs.js'; +export * from './fix-definition-refs.js'; +export * from './fix-expression-renames.js'; export * from './fix-ref-deletions.js'; export * from './ref-context-slot.js'; export * from './ref-schema-visitor.js'; diff --git a/packages/project-builder-lib/src/references/parse-schema-with-references.ts b/packages/project-builder-lib/src/references/parse-schema-with-references.ts index 36e0e68e4..526b68dc7 100644 --- a/packages/project-builder-lib/src/references/parse-schema-with-references.ts +++ b/packages/project-builder-lib/src/references/parse-schema-with-references.ts @@ -6,6 +6,9 @@ import type { ResolvedZodRefPayload } from './types.js'; import { extractDefinitionRefs } from './extract-definition-refs.js'; import { resolveZodRefPayloadNames } from './resolve-zod-ref-payload-names.js'; +export type ParseSchemaWithTransformedReferencesOptions = + ResolveZodRefPayloadNamesOptions; + /** * Parses a schema with references. * @@ -20,7 +23,7 @@ import { resolveZodRefPayloadNames } from './resolve-zod-ref-payload-names.js'; export function parseSchemaWithTransformedReferences( schema: T, input: unknown, - options?: ResolveZodRefPayloadNamesOptions, + options?: ParseSchemaWithTransformedReferencesOptions, ): ResolvedZodRefPayload> { // Step 1: Validate with Zod const value = schema.parse(input); diff --git a/packages/project-builder-lib/src/schema/creator/schema-creator.ts b/packages/project-builder-lib/src/schema/creator/schema-creator.ts index 5301b04d2..5bdaed719 100644 --- a/packages/project-builder-lib/src/schema/creator/schema-creator.ts +++ b/packages/project-builder-lib/src/schema/creator/schema-creator.ts @@ -31,7 +31,7 @@ export function createDefinitionSchemaParserContext( const context: DefinitionSchemaParserContext = { ...options, - ...extendParserContextWithRefs(), + ...extendParserContextWithRefs(options.plugins), ...extendParserContextWithDefaults(), }; contextCache.set(options.plugins, context); diff --git a/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-parser.ts b/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-parser.ts deleted file mode 100644 index c641b659c..000000000 --- a/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-parser.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { z } from 'zod'; - -import type { - ExpressionValidationContext, - RefExpressionDependency, - RefExpressionParseResult, - RefExpressionWarning, - ResolvedExpressionSlots, -} from '#src/references/expression-types.js'; - -import { RefExpressionParser } from '#src/references/expression-types.js'; - -import type { modelEntityType } from '../types.js'; -import type { AuthorizerExpressionInfo } from './authorizer-expression-ast.js'; -import type { ModelValidationContext } from './authorizer-expression-validator.js'; - -import { parseAuthorizerExpression } from './authorizer-expression-acorn-parser.js'; -import { AuthorizerExpressionParseError } from './authorizer-expression-ast.js'; -import { - buildModelExpressionContext, - validateAuthorizerExpression, -} from './authorizer-expression-validator.js'; - -/** - * Shape of a raw model in the project definition JSON. - * Used for navigating the untyped definition to extract relation and authorizer info. - */ -interface RawModelDefinition { - id?: string; - name?: string; - model?: { - fields?: { name: string; type?: string }[]; - relations?: { - name: string; - modelRef: string; - foreignRelationName?: string; - references?: { localRef: string; foreignRef: string }[]; - }[]; - }; - authorizer?: { - roles?: { name: string }[]; - }; -} - -/** - * Expression parser for model authorizer role expressions. - * - * Parses expressions like: - * - `model.id === auth.userId` (ownership check) - * - `auth.hasRole('admin')` (global role check) - * - `model.id === auth.userId || auth.hasRole('admin')` (combined) - * - * Uses Acorn to parse JavaScript expressions and validates - * that only supported constructs are used. - * - * @example - * ```typescript - * const schema = z.object({ - * expression: ctx.withExpression(authorizerExpressionParser, { model: modelSlot }), - * }); - * ``` - */ -export class AuthorizerExpressionParser extends RefExpressionParser< - string, - AuthorizerExpressionInfo, - { model: typeof modelEntityType } -> { - readonly name = 'authorizer-expression'; - - /** - * Creates a Zod schema for validating expression strings. - * Requires a non-empty string value. - */ - createSchema(): z.ZodType { - return z.string().min(1, 'Expression is required'); - } - - /** - * Parse the expression string into an AST. - * - * @param value - The expression string - * @returns Success with parsed expression info, or failure with error message - */ - parse(value: string): RefExpressionParseResult { - try { - return { success: true, value: parseAuthorizerExpression(value) }; - } catch (error) { - if (error instanceof AuthorizerExpressionParseError) { - return { success: false, error: error.message }; - } - throw error; - } - } - - /** - * Get validation warnings for the expression. - * - * Validates: - * - Syntax errors from parsing - * - Model field references exist - * - Auth field references are valid - * - Role names exist in project config (warning only) - */ - getWarnings( - parseResult: AuthorizerExpressionInfo, - context: ExpressionValidationContext, - resolvedSlots: ResolvedExpressionSlots<{ model: typeof modelEntityType }>, - ): RefExpressionWarning[] { - // Get model context from resolved slots - const modelContext = this.getModelContext( - context.definition, - resolvedSlots, - ); - if (!modelContext) { - // Can't validate without model context - return []; - } - - // Validate the expression against model fields and roles - return validateAuthorizerExpression( - parseResult.ast, - modelContext, - context.pluginStore, - context.definition, - ); - } - - /** - * Get entity/field dependencies from the expression. - * - * Currently returns empty array as we don't yet track - * entity-level dependencies (just field names). - * Future: could track model field entity references for renames. - */ - getDependencies(): RefExpressionDependency[] { - // TODO: Track model field entities for rename support - return []; - } - - /** - * Update the expression when dependencies are renamed. - * - * Currently returns value unchanged as we don't yet - * support field renames in expressions. - */ - updateForRename(value: string): string { - // TODO: Implement rename support using AST position info - return value; - } - - /** - * Extract model context from the project definition using resolved slots. - */ - private getModelContext( - definition: unknown, - resolvedSlots: ResolvedExpressionSlots<{ model: typeof modelEntityType }>, - ): ModelValidationContext | undefined { - const modelPath = resolvedSlots.model; - if (modelPath.length === 0) { - return undefined; - } - - // Navigate to the model in the project definition - // The path is like ['models', 0] for models[0] - let current: unknown = definition; - for (const segment of modelPath) { - if (current === null || current === undefined) { - return undefined; - } - current = (current as Record)[segment]; - } - - const model = current as RawModelDefinition | null; - - if (!model || typeof model.name !== 'string') { - return undefined; - } - - const allModels = ( - (definition as { models?: RawModelDefinition[] }).models ?? [] - ).filter( - (m): m is RawModelDefinition & { name: string } => - typeof m.name === 'string', - ); - - return buildModelExpressionContext( - { - id: model.id, - name: model.name, - fields: model.model?.fields, - model: { relations: model.model?.relations }, - }, - allModels.map((m) => ({ - id: m.id, - name: m.name, - authorizer: m.authorizer, - fields: m.model?.fields, - model: { relations: m.model?.relations }, - })), - ); - } -} - -/** - * Singleton instance of AuthorizerExpressionParser. - */ -export const authorizerExpressionParser = new AuthorizerExpressionParser(); diff --git a/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-ref.ts b/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-ref.ts new file mode 100644 index 000000000..78fd7bbe4 --- /dev/null +++ b/packages/project-builder-lib/src/schema/models/authorizer/authorizer-expression-ref.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { createExpressionParserRef } from '#src/references/expression-parser-ref.js'; + +import type { modelEntityType } from '../types.js'; + +/** + * Typed reference to the authorizer expression parser. + * + * Used in schema definitions instead of importing the full parser class. + * The actual parser is resolved at runtime from `expressionParserSpec`. + */ +export const authorizerExpressionRef = createExpressionParserRef< + string, + { model: typeof modelEntityType } +>('authorizer-expression', () => z.string().min(1, 'Expression is required')); diff --git a/packages/project-builder-lib/src/schema/models/authorizer/authorizer.ts b/packages/project-builder-lib/src/schema/models/authorizer/authorizer.ts index 43a39dd4a..cbb569c48 100644 --- a/packages/project-builder-lib/src/schema/models/authorizer/authorizer.ts +++ b/packages/project-builder-lib/src/schema/models/authorizer/authorizer.ts @@ -6,7 +6,7 @@ import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js import { VALIDATORS } from '#src/schema/utils/validation.js'; import { modelEntityType } from '../types.js'; -import { authorizerExpressionParser } from './authorizer-expression-parser.js'; +import { authorizerExpressionRef } from './authorizer-expression-ref.js'; import { modelAuthorizerRoleEntityType } from './types.js'; /** @@ -40,7 +40,7 @@ export const createAuthorizerRoleSchema = definitionSchemaWithSlots( * @example 'hasRole("admin")' * @example 'model.authorId === userId || hasRole("admin")' */ - expression: ctx.withExpression(authorizerExpressionParser, { + expression: ctx.withExpression(authorizerExpressionRef, { model: modelSlot, }), }), diff --git a/packages/project-builder-lib/src/schema/models/authorizer/index.ts b/packages/project-builder-lib/src/schema/models/authorizer/index.ts index 894879d1e..a4fc3929b 100644 --- a/packages/project-builder-lib/src/schema/models/authorizer/index.ts +++ b/packages/project-builder-lib/src/schema/models/authorizer/index.ts @@ -1,7 +1,3 @@ -export * from './authorizer-expression-acorn-parser.js'; -export * from './authorizer-expression-ast.js'; -export * from './authorizer-expression-parser.js'; -export * from './authorizer-expression-validator.js'; -export * from './authorizer-expression-visitor.js'; export * from './authorizer.js'; export * from './types.js'; +export * from '#src/expression-parsers/authorizer/index.js'; diff --git a/packages/project-builder-lib/src/testing/expression-stub-parser.test-helper.ts b/packages/project-builder-lib/src/testing/expression-stub-parser.test-helper.ts index 38b150a8f..2e6bbd2b2 100644 --- a/packages/project-builder-lib/src/testing/expression-stub-parser.test-helper.ts +++ b/packages/project-builder-lib/src/testing/expression-stub-parser.test-helper.ts @@ -36,15 +36,9 @@ class StubParser extends RefExpressionParser { return []; } - getDependencies(): [] { - // No dependencies tracked by stub parser + getReferencedEntities(): [] { return []; } - - updateForRename(value: string): string { - // No rename handling - return value unchanged - return value; - } } /** @@ -86,11 +80,7 @@ export class StubParserWithSlots< return []; } - getDependencies(): [] { + getReferencedEntities(): [] { return []; } - - updateForRename(value: string): string { - return value; - } } diff --git a/packages/project-builder-lib/src/testing/expression-warning-parser.test-helper.ts b/packages/project-builder-lib/src/testing/expression-warning-parser.test-helper.ts index 35622f2d9..dac625415 100644 --- a/packages/project-builder-lib/src/testing/expression-warning-parser.test-helper.ts +++ b/packages/project-builder-lib/src/testing/expression-warning-parser.test-helper.ts @@ -44,13 +44,9 @@ export class WarningParser extends RefExpressionParser { return this.warningsToReturn; } - getDependencies(): [] { + getReferencedEntities(): [] { return []; } - - updateForRename(value: string): string { - return value; - } } /** @@ -74,11 +70,7 @@ export class FailingParser extends RefExpressionParser { return []; } - getDependencies(): [] { + getReferencedEntities(): [] { return []; } - - updateForRename(value: string): string { - return value; - } } diff --git a/packages/project-builder-lib/src/testing/parser-context.test-helper.ts b/packages/project-builder-lib/src/testing/parser-context.test-helper.ts index 4dcc6a814..3c2e778ca 100644 --- a/packages/project-builder-lib/src/testing/parser-context.test-helper.ts +++ b/packages/project-builder-lib/src/testing/parser-context.test-helper.ts @@ -1,11 +1,12 @@ import type { DefinitionSchemaParserContext } from '../schema/creator/types.js'; +import { expressionParserCoreModule } from '../expression-parsers/register-core-module.js'; import { createPluginSpecStore } from '../parser/parser.js'; import { createDefinitionSchemaParserContext } from '../schema/creator/schema-creator.js'; const emptyPluginStore = { availablePlugins: [], - coreModules: [], + coreModules: [expressionParserCoreModule], }; /** diff --git a/packages/project-builder-lib/src/testing/project-definition-container.test-helper.ts b/packages/project-builder-lib/src/testing/project-definition-container.test-helper.ts index f70ef9e08..4400ca4f4 100644 --- a/packages/project-builder-lib/src/testing/project-definition-container.test-helper.ts +++ b/packages/project-builder-lib/src/testing/project-definition-container.test-helper.ts @@ -6,6 +6,7 @@ import type { import type { EntityServiceContext } from '#src/tools/entity-service/types.js'; import { ProjectDefinitionContainer } from '#src/definition/project-definition-container.js'; +import { expressionParserCoreModule } from '#src/expression-parsers/register-core-module.js'; import { getLatestMigrationVersion } from '#src/migrations/index.js'; import { createPluginSpecStore } from '#src/parser/parser.js'; import { deserializeSchemaWithTransformedReferences } from '#src/references/deserialize-schema.js'; @@ -48,7 +49,7 @@ export function createTestProjectDefinitionContainer( ): ProjectDefinitionContainer { const pluginStore: PluginStore = { availablePlugins: [], - coreModules: [], + coreModules: [expressionParserCoreModule], }; const pluginSpecStore = createPluginSpecStore(pluginStore, { plugins: [], 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 index 2fe02dc2b..67938597f 100644 --- 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 @@ -102,6 +102,7 @@ export const test = baseTest.extend<{ testEntityServiceContext.entityContext.serializedDefinition, }, entityContext: testEntityServiceContext.entityContext, + oldRefPayload: testEntityServiceContext.container.refPayload, parserContext: testEntityServiceContext.parserContext, projectDirectory: '/test-project', }; 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 9bd66f522..0e33cb282 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 @@ -164,6 +164,7 @@ describe('draft lifecycle', () => { draftDefinition: mismatchContext.entityContext.serializedDefinition, }, entityContext: mismatchContext.entityContext, + oldRefPayload: mismatchContext.container.refPayload, parserContext: mismatchContext.parserContext, projectDirectory: '/test-project', }; diff --git a/packages/project-builder-server/src/actions/definition/draft-session.ts b/packages/project-builder-server/src/actions/definition/draft-session.ts index 72ffccf97..43f402c70 100644 --- a/packages/project-builder-server/src/actions/definition/draft-session.ts +++ b/packages/project-builder-server/src/actions/definition/draft-session.ts @@ -2,6 +2,7 @@ import type { EntityServiceContext, SchemaParserContext, } from '@baseplate-dev/project-builder-lib'; +import type { ResolvedZodRefPayload } from '@baseplate-dev/project-builder-lib'; import { ProjectDefinitionContainer } from '@baseplate-dev/project-builder-lib'; import { hashWithSHA256, stringifyPrettyStable } from '@baseplate-dev/utils'; @@ -112,6 +113,8 @@ export async function deleteDraftSession( export interface DraftSessionContext { session: DraftSession; entityContext: EntityServiceContext; + /** Ref payload from the current draft (before the pending edit), used for rename detection. */ + oldRefPayload: ResolvedZodRefPayload; parserContext: SchemaParserContext; projectDirectory: string; } @@ -167,6 +170,7 @@ export async function getOrCreateDraftSession( return { session: existingDraft, entityContext, + oldRefPayload: draftContainer.refPayload, parserContext, projectDirectory: project.directory, }; @@ -187,6 +191,7 @@ export async function getOrCreateDraftSession( return { session, entityContext, + oldRefPayload: container.refPayload, parserContext, projectDirectory: project.directory, }; 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 5a5d7b9c3..7c39cf3be 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 @@ -43,8 +43,13 @@ export const stageUpdateEntityAction = createServiceAction({ handler: async (input, context) => { assertEntityTypeNotBlacklisted(input.entityTypeName); - const { session, entityContext, parserContext, projectDirectory } = - await getOrCreateDraftSession(input.project, context); + const { + session, + entityContext, + oldRefPayload, + parserContext, + projectDirectory, + } = await getOrCreateDraftSession(input.project, context); const newDefinition = updateEntity( { @@ -60,6 +65,8 @@ export const stageUpdateEntityAction = createServiceAction({ parserContext, session, projectDirectory, + undefined, + oldRefPayload, ); return { 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 7cab963bb..6ac3ece0a 100644 --- a/packages/project-builder-server/src/actions/definition/validate-draft.ts +++ b/packages/project-builder-server/src/actions/definition/validate-draft.ts @@ -1,12 +1,13 @@ import type { DefinitionIssue, + ResolvedZodRefPayload, SchemaParserContext, } from '@baseplate-dev/project-builder-lib'; import { applyDefinitionFixes, collectDefinitionIssues, - fixRefDeletions, + fixDefinitionRefs, partitionIssuesBySeverity, ProjectDefinitionContainer, } from '@baseplate-dev/project-builder-lib'; @@ -91,16 +92,18 @@ export interface FixAndValidateResult { } /** - * Applies auto-fixes, fixes dangling references, then validates the definition. + * Applies auto-fixes, fixes dangling references and expression renames, + * then validates the definition. * * Mirrors the web UI save pipeline: * 1. applyDefinitionFixes — clears disabled services, etc. - * 2. fixRefDeletions — cascades reference deletions + * 2. fixDefinitionRefs — cascades reference deletions + updates expressions for renames * 3. collectDefinitionIssues — partitions into errors/warnings */ export function fixAndValidateDraftDefinition( draftDefinition: Record, parserContext: SchemaParserContext, + oldRefPayload?: ResolvedZodRefPayload, ): FixAndValidateResult { const container = ProjectDefinitionContainer.fromSerializedConfig( draftDefinition, @@ -113,8 +116,10 @@ export function fixAndValidateDraftDefinition( container.definition, ); - // Step 2: Fix dangling references - const refResult = fixRefDeletions(container.schema, fixedDefinition); + // Step 2: Fix dangling references and expression renames + const refResult = fixDefinitionRefs(container.schema, fixedDefinition, { + oldRefPayload, + }); if (refResult.type === 'failure') { // RESTRICT issues — report as errors @@ -196,9 +201,10 @@ export async function validateAndSaveDraft( session: DraftSession, projectDirectory: string, errorPrefix = 'Staging blocked by definition errors', + oldRefPayload?: ResolvedZodRefPayload, ): Promise { const { fixedSerializedDefinition, errors, warnings } = - fixAndValidateDraftDefinition(definition, parserContext); + fixAndValidateDraftDefinition(definition, parserContext, oldRefPayload); if (errors.length > 0) { const messages = errors.map((e) => e.message).join('; '); diff --git a/packages/project-builder-server/src/core-modules/index.ts b/packages/project-builder-server/src/core-modules/index.ts index d6a295d0f..f296f514f 100644 --- a/packages/project-builder-server/src/core-modules/index.ts +++ b/packages/project-builder-server/src/core-modules/index.ts @@ -1,5 +1,7 @@ import type { PluginModuleWithKey } from '@baseplate-dev/project-builder-lib'; +import { expressionParserCoreModule } from '@baseplate-dev/project-builder-lib'; + import { adminCrudActionCoreModule } from './admin-crud-action-compiler.js'; import { adminCrudColumnCoreModule } from './admin-crud-column-compiler.js'; import { adminCrudInputCoreModule } from './admin-crud-input-compiler.js'; @@ -7,13 +9,16 @@ import { libraryTypeCoreModule } from './library-type-spec.js'; import { modelTransformerCoreModule } from './model-transformer-compiler.js'; export const SERVER_CORE_MODULES: PluginModuleWithKey[] = [ - adminCrudActionCoreModule, - modelTransformerCoreModule, - adminCrudColumnCoreModule, - adminCrudInputCoreModule, - libraryTypeCoreModule, -].map((module) => ({ - key: `core/server/${module.name}`, - pluginKey: 'core', - module, -})); + expressionParserCoreModule, + ...[ + adminCrudActionCoreModule, + modelTransformerCoreModule, + adminCrudColumnCoreModule, + adminCrudInputCoreModule, + libraryTypeCoreModule, + ].map((module) => ({ + key: `core/server/${module.name}`, + pluginKey: 'core', + module, + })), +]; diff --git a/packages/project-builder-web/src/app/project-definition-provider/project-definition-provider.tsx b/packages/project-builder-web/src/app/project-definition-provider/project-definition-provider.tsx index a353f8538..1d700d8b5 100644 --- a/packages/project-builder-web/src/app/project-definition-provider/project-definition-provider.tsx +++ b/packages/project-builder-web/src/app/project-definition-provider/project-definition-provider.tsx @@ -15,7 +15,7 @@ import { createDefinitionSchemaParserContext, createPluginSpecStore, createProjectDefinitionSchema, - fixRefDeletions, + fixDefinitionRefs, partitionIssuesBySeverity, ProjectDefinitionContainer, } from '@baseplate-dev/project-builder-lib'; @@ -94,7 +94,7 @@ export function ProjectDefinitionProvider({ const result: UseProjectDefinitionResult | undefined = useMemo(() => { if (!projectDefinitionContainer || !schemaParserContext) return; - const { definition } = projectDefinitionContainer; + const { definition, refPayload } = projectDefinitionContainer; const parserContext = schemaParserContext; async function saveDefinition( @@ -126,12 +126,15 @@ export function ProjectDefinitionProvider({ createDefinitionSchemaParserContext(schemaCreatorOptions); const defSchema = createProjectDefinitionSchema(defContext); const parsedProjectDefinition = defSchema.parse(rawProjectDefinition); - const newProjectDefinition = applyDefinitionFixes( + const autoFixedDefinition = applyDefinitionFixes( defSchema, parsedProjectDefinition, ); - const result = fixRefDeletions(defSchema, newProjectDefinition); + // Fix dangling references and update expressions for renames + const result = fixDefinitionRefs(defSchema, autoFixedDefinition, { + oldRefPayload: refPayload, + }); if (result.type === 'failure') { throw new RefDeleteError(result.issues); } diff --git a/packages/project-builder-web/src/core-modules/index.ts b/packages/project-builder-web/src/core-modules/index.ts index 8b3f6b7cc..502669299 100644 --- a/packages/project-builder-web/src/core-modules/index.ts +++ b/packages/project-builder-web/src/core-modules/index.ts @@ -1,5 +1,7 @@ import type { PluginModuleWithKey } from '@baseplate-dev/project-builder-lib'; +import { expressionParserCoreModule } from '@baseplate-dev/project-builder-lib'; + import { actionWebConfigsCoreModule } from './action-web-configs.js'; import { columnWebConfigsCoreModule } from './column-web-configs.js'; import { entityTypeUrlsCoreModule } from './entity-type-urls-core-module.js'; @@ -8,14 +10,17 @@ import { libraryTypeWebConfigsCoreModule } from './library-type-web-configs.js'; import { transformerWebConfigsCoreModule } from './transformer-web-configs.js'; export const WEB_CORE_MODULES: PluginModuleWithKey[] = [ - columnWebConfigsCoreModule, - actionWebConfigsCoreModule, - transformerWebConfigsCoreModule, - inputWebConfigsCoreModule, - libraryTypeWebConfigsCoreModule, - entityTypeUrlsCoreModule, -].map((mod) => ({ - key: `core/web/${mod.name}`, - pluginKey: 'core', - module: mod, -})); + expressionParserCoreModule, + ...[ + columnWebConfigsCoreModule, + actionWebConfigsCoreModule, + transformerWebConfigsCoreModule, + inputWebConfigsCoreModule, + libraryTypeWebConfigsCoreModule, + entityTypeUrlsCoreModule, + ].map((mod) => ({ + key: `core/web/${mod.name}`, + pluginKey: 'core', + module: mod, + })), +];