diff --git a/.changeset/swift-foxes-dance.md b/.changeset/swift-foxes-dance.md new file mode 100644 index 000000000..4623f40c9 --- /dev/null +++ b/.changeset/swift-foxes-dance.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/project-builder-lib': patch +--- + +Refactor reference extraction to use functional approach with `refContext` and `provides` instead of `withRefBuilder` diff --git a/examples/blog-with-auth/baseplate/project-definition.json b/examples/blog-with-auth/baseplate/project-definition.json index 06a880737..f947c4114 100644 --- a/examples/blog-with-auth/baseplate/project-definition.json +++ b/examples/blog-with-auth/baseplate/project-definition.json @@ -95,7 +95,7 @@ "type": "backend" } ], - "cliVersion": "0.3.1", + "cliVersion": "0.4.1", "features": [ { "id": "feature:I_EnFXnHjbGQ", "name": "accounts" }, { "id": "feature:c28pJNS_89Oz", "name": "blogs" }, @@ -694,16 +694,7 @@ ], "plugins": [ { - "config": { - "initialUserRoles": ["admin"], - "modelRefs": { - "user": "User", - "userAccount": "UserAccount", - "userRole": "UserRole", - "userSession": "UserSession" - }, - "userAdminRoles": ["admin"] - }, + "config": { "initialUserRoles": ["admin"], "userAdminRoles": ["admin"] }, "id": "plugin:baseplate-dev_plugin-auth_local-auth", "name": "local-auth", "packageName": "@baseplate-dev/plugin-auth", diff --git a/packages/project-builder-lib/src/references/collect-refs.ts b/packages/project-builder-lib/src/references/collect-refs.ts new file mode 100644 index 000000000..72cdea2f0 --- /dev/null +++ b/packages/project-builder-lib/src/references/collect-refs.ts @@ -0,0 +1,114 @@ +import type { + DefinitionEntityAnnotation, + DefinitionRefAnnotations, + DefinitionReferenceAnnotation, + DefinitionSlotAnnotation, +} from './markers.js'; +import type { ReferencePath } from './types.js'; + +import { + DefinitionReferenceMarker, + REF_ANNOTATIONS_MARKER_SYMBOL, +} from './markers.js'; + +/** + * Result of collecting refs before slot resolution. + */ +export interface CollectedRefs { + /** + * All input entities from the definition. + */ + entities: DefinitionEntityAnnotation[]; + /** + * All input references from the definition. + */ + references: DefinitionReferenceAnnotation[]; + /** + * All slots from the definition. + */ + slots: DefinitionSlotAnnotation[]; +} + +function collectRefAnnotationsRecursive( + pathPrefix: ReferencePath, + value: unknown, +): CollectedRefs | undefined { + if (value === undefined || value === null) return undefined; + if (value instanceof DefinitionReferenceMarker) { + return { + entities: [], + references: [ + { ...value.reference, path: [...pathPrefix, ...value.reference.path] }, + ], + slots: [], + }; + } + const collected = { + entities: [], + references: [], + slots: [], + } as CollectedRefs; + if (Array.isArray(value)) { + for (const [i, element] of value.entries()) { + const childCollected = collectRefAnnotationsRecursive( + [...pathPrefix, i], + element, + ); + if (childCollected) { + collected.entities.push(...childCollected.entities); + collected.references.push(...childCollected.references); + collected.slots.push(...childCollected.slots); + } + } + return collected; + } + if (typeof value === 'object') { + if (REF_ANNOTATIONS_MARKER_SYMBOL in value) { + const annotations = value[ + REF_ANNOTATIONS_MARKER_SYMBOL + ] as DefinitionRefAnnotations; + collected.entities.push( + ...annotations.entities.map((entity) => ({ + ...entity, + path: [...pathPrefix, ...entity.path], + })), + ); + collected.references.push( + ...annotations.references.map((reference) => ({ + ...reference, + path: [...pathPrefix, ...reference.path], + })), + ); + collected.slots.push( + ...annotations.slots.map((slot) => ({ + ...slot, + path: [...pathPrefix, ...slot.path], + })), + ); + } + for (const [key, childValue] of Object.entries(value)) { + if (typeof key !== 'string') continue; + const childCollected = collectRefAnnotationsRecursive( + [...pathPrefix, key], + childValue, + ); + if (childCollected) { + collected.entities.push(...childCollected.entities); + collected.references.push(...childCollected.references); + collected.slots.push(...childCollected.slots); + } + } + return collected; + } + return undefined; +} + +export function collectRefs(value: unknown): CollectedRefs { + return ( + collectRefAnnotationsRecursive([], value) ?? { + entities: [], + references: [], + slots: [], + } + ); +} diff --git a/packages/project-builder-lib/src/references/definition-ref-builder.ts b/packages/project-builder-lib/src/references/definition-ref-builder.ts index 836a540ff..024be6b43 100644 --- a/packages/project-builder-lib/src/references/definition-ref-builder.ts +++ b/packages/project-builder-lib/src/references/definition-ref-builder.ts @@ -1,24 +1,12 @@ -import type { Paths } from 'type-fest'; - -import { get } from 'es-toolkit/compat'; +import type { TuplePaths } from '@baseplate-dev/utils'; +import type { RefContextSlot } from './ref-context-slot.js'; import type { DefinitionEntity, DefinitionEntityType, DefinitionReference, - ReferencePath, } from './types.js'; -import { stripRefMarkers } from './strip-ref-markers.js'; - -export type PathInput = Exclude, number>; - -interface ContextValue { - context: string; -} - -type PathInputOrContext = PathInput | ContextValue; - /** * Allows the caller to resolve the name of an entity, optionally providing a * map of entity ids that need to be resolved prior to resolving the entity's @@ -67,22 +55,19 @@ export function createDefinitionEntityNameResolver< interface DefinitionEntityInputBase< TInput, TEntityType extends DefinitionEntityType, - TPath extends PathInput | undefined = undefined, - TIdKey = 'id', + TIdPath extends TuplePaths | undefined = undefined, > { /** The entity type definition. */ type: TEntityType; - /** Optional dot-separated string representing the location of the entity within the input. */ - path?: TPath; /** Optional path key used to store the entity's id; if not provided, the id is assumed to be under the entity's path with key "id". */ - idPath?: TIdKey; + idPath?: TIdPath; /** Optional function used to get the name resolver from the input data. Otherwise, the entity's name is assumed to be under the entity's path with key "name". */ getNameResolver?: ( value: TInput, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed to allow more specific generic typed to be put in here ) => DefinitionEntityNameResolver | string; - /** Optional context identifier used to register the entity's path in a shared context. */ - addContext?: string; + /** Optional ref context slot that this entity provides. Registers this entity's path in a shared context. */ + provides?: RefContextSlot; } /** @@ -91,10 +76,10 @@ interface DefinitionEntityInputBase< interface DefinitionEntityInputWithParent< TInput, TEntityType extends DefinitionEntityType, - TPath extends PathInput | undefined = undefined, - TIDKey extends string = 'id', -> extends DefinitionEntityInputBase { - parentPath: PathInputOrContext; + TIdPath extends TuplePaths | undefined = undefined, +> extends DefinitionEntityInputBase { + /** The slot from which to resolve the parent entity path. */ + parentSlot: RefContextSlot>; } /** @@ -103,10 +88,9 @@ interface DefinitionEntityInputWithParent< interface DefinitionEntityInputWithoutParent< TInput, TEntityType extends DefinitionEntityType, - TPath extends PathInput | undefined = undefined, - TIDKey extends string = 'id', -> extends DefinitionEntityInputBase { - parentPath?: never; + TIdPath extends TuplePaths | undefined = undefined, +> extends DefinitionEntityInputBase { + parentSlot?: never; } /** @@ -116,381 +100,48 @@ interface DefinitionEntityInputWithoutParent< export type DefinitionEntityInput< TInput, TEntityType extends DefinitionEntityType, - TPath extends PathInput | undefined = undefined, - TIDKey extends string = 'id', + TIdPath extends TuplePaths | undefined = undefined, > = TEntityType['parentType'] extends undefined - ? DefinitionEntityInputWithoutParent - : DefinitionEntityInputWithParent; + ? DefinitionEntityInputWithoutParent + : DefinitionEntityInputWithParent; /** * Base interface for defining a reference input. * @template TInput - The overall input type. * @template TEntityType - The entity type. */ -interface DefinitionReferenceInputBase< - TInput, - TEntityType extends DefinitionEntityType, -> extends Pick { +interface DefinitionReferenceInputBase + extends Pick { type: TEntityType; - path?: PathInput; - addContext?: string; + /** Optional ref context slot that this reference provides. Registers this reference's path in a shared context. */ + provides?: RefContextSlot; } interface DefinitionReferenceInputWithParent< - TInput, TEntityType extends DefinitionEntityType, -> extends DefinitionReferenceInputBase { - parentPath: PathInputOrContext; +> extends DefinitionReferenceInputBase { + /** The slot from which to resolve the parent entity path. */ + parentSlot: RefContextSlot>; } interface DefinitionReferenceInputWithoutParent< - TInput, TEntityType extends DefinitionEntityType, -> extends DefinitionReferenceInputBase { - parentPath?: never; +> extends DefinitionReferenceInputBase { + parentSlot?: never; } /** * Depending on the entity type’s requirements, defines the input required to create a definition reference. */ -export type DefinitionReferenceInput< - TInput, - TEntityType extends DefinitionEntityType, -> = TEntityType['parentType'] extends undefined - ? DefinitionReferenceInputWithoutParent - : DefinitionReferenceInputWithParent; +export type DefinitionReferenceInput = + TEntityType['parentType'] extends undefined + ? DefinitionReferenceInputWithoutParent + : DefinitionReferenceInputWithParent; /** * Entity with a name resolver. */ -interface DefinitionEntityWithNameResolver +export interface DefinitionEntityWithNameResolver extends Omit { - nameResolver: DefinitionEntityNameResolver; -} - -export interface RefBuilderContext { - pathMap: Map; -} - -export interface ZodRefBuilderInterface { - addReference( - reference: DefinitionReferenceInput, - ): void; - addEntity< - TEntityType extends DefinitionEntityType, - TPath extends PathInput | undefined = undefined, - TIDKey extends string | PathInput = 'id', - >( - entity: DefinitionEntityInput, - ): void; - addPathToContext( - path: PathInput, - type: DefinitionEntityType, - context: string, - ): void; -} - -/** - * DefinitionRefBuilder is responsible for constructing reference paths, and registering - * references and entities as defined in a Zod schema. - * - * The builder uses a prefix (usually the current parsing path) and context (a - * shared map for resolving relative references) to build complete reference paths. - * - * @template TInput - The type of the input data being parsed. - */ -export class DefinitionRefBuilder - implements ZodRefBuilderInterface -{ - readonly references: DefinitionReference[]; - readonly entitiesWithNameResolver: DefinitionEntityWithNameResolver[]; - readonly pathPrefix: ReferencePath; - readonly context: RefBuilderContext; - readonly pathMap: Map< - string, - { path: ReferencePath; type: DefinitionEntityType } - >; - readonly data: TInput; - - /** - * Creates a new builder instance. - * @param pathPrefix - The starting path for all references. - * @param context - Shared context including a path map and deserialize flag. - * @param data - The data being parsed. - */ - constructor( - pathPrefix: ReferencePath, - context: RefBuilderContext, - data: TInput, - ) { - this.references = []; - this.entitiesWithNameResolver = []; - this.pathPrefix = pathPrefix; - this.context = context; - this.pathMap = new Map(); - this.data = data; - } - - /** - * Converts a dot-separated string path into an array of keys. - * @param path - A string (e.g. "a.b.0.c") representing the path. - * @returns An array of keys (numbers for numeric strings, otherwise strings). - */ - protected _constructPathWithoutPrefix( - path: PathInput | undefined, - ): ReferencePath { - if (!path) return []; - - const pathComponents = path - .split('.') - .map((key) => (/^[0-9]+$/.test(key) ? Number.parseInt(key, 10) : key)); - - return pathComponents; - } - - /** - * Prepends the builder's path prefix to the provided path. - * @param path - The dot-separated path string. - * @returns The complete reference path as an array. - */ - protected _constructPath(path: PathInput | undefined): ReferencePath { - if (!path) return this.pathPrefix; - - return [...this.pathPrefix, ...this._constructPathWithoutPrefix(path)]; - } - - /** - * Constructs a reference path that may be defined directly as a string or indirectly - * via a context object. If a context object is provided, the function looks up the - * actual path from the builder's context. - * - * @param path - Either a dot-separated string path or an object with a context key. - * @param expectedEntityType - The entity type expected for this context. - * @returns The resolved reference path. - * @throws If the context cannot be found or its type does not match. - */ - protected _constructPathWithContext( - path: PathInputOrContext, - expectedEntityType: DefinitionEntityType, - ): ReferencePath { - if (typeof path === 'string') { - return this._constructPath(path); - } - // Lookup the context for the given key. - const pathContext = this.context.pathMap.get(path.context); - if (!pathContext) { - throw new Error( - `Could not find context for ${path.context} from ${this.pathPrefix.join('.')}`, - ); - } - if (pathContext.type !== expectedEntityType) { - throw new Error( - `Attempted to retrieve context for ${path.context} from ${this.pathPrefix.join( - '.', - )} expecting ${expectedEntityType.name}, but found ${pathContext.type.name}`, - ); - } - return pathContext.path; - } - - /** - * Registers a reference based on the provided input definition. - * - * Flow: - * 1. Validate that the parent path is provided if required (and vice versa). - * 2. Compute the reference path; if the path is empty, use the entire data. - * 3. If the referenced value is null or undefined, skip adding the reference. - * 4. Otherwise, add the reference and, if requested, register its context. - * - * @param reference - The reference definition. - * @throws If parent path usage is incorrect. - */ - addReference( - reference: DefinitionReferenceInput, - ): void { - if (!reference.type.parentType && reference.parentPath) { - throw new Error( - `Parent path does nothing since reference does not have parent`, - ); - } - if (reference.type.parentType && !reference.parentPath) { - throw new Error(`Parent path required if reference type has parent type`); - } - - // Compute the path without prefix once. - const refPathWithoutPrefix = this._constructPathWithoutPrefix( - reference.path, - ); - // If the path is empty, use the entire data; otherwise, retrieve the value. - const refValue = - refPathWithoutPrefix.length === 0 - ? this.data - : (get(this.data, refPathWithoutPrefix) as string); - if (refValue === undefined || refValue === null) return; - - const fullPath = this._constructPath(reference.path); - - this.references.push({ - type: reference.type, - path: fullPath, - parentPath: - reference.parentPath && - reference.type.parentType && - this._constructPathWithContext( - reference.parentPath, - reference.type.parentType, - ), - onDelete: reference.onDelete, - }); - - // Optionally, add this path to the shared context. - if (reference.addContext) { - this._addPathToContext(fullPath, reference.type, reference.addContext); - } - } - - /** - * Registers an entity based on the provided definition. - * - * Flow: - * 1. Validate that not both a name and a name reference path are provided. - * 2. Compute the full entity path. - * 3. Resolve the entity ID: - * - Use the provided idPath if available; otherwise, default to appending 'id' - * to the entity path. - * - If no id is found, generate a new one. - * 4. Resolve the entity name: - * - Use the provided resolveName if available; otherwise, default to using the - * name path. - * 5. Register the entity in either the direct entities list or the name-ref list. - * 6. Optionally, add the entity’s id path to the shared context. - * - * @param entity - The entity definition. - * @throws If both name and nameRefPath are provided or if no name is resolved. - */ - addEntity< - TEntityType extends DefinitionEntityType, - TPath extends PathInput | undefined = undefined, - TIDKey extends string | PathInput = 'id', - >(entity: DefinitionEntityInput): void { - // Build the full path for the entity. - const path = this._constructPath(entity.path); - - // Resolve the id path: if provided use it; otherwise, assume the id is at "entity.path.id" - const idPath = entity.idPath - ? this._constructPathWithoutPrefix(entity.idPath as PathInput) - : [...this._constructPathWithoutPrefix(entity.path), 'id']; - - const id = get(this.data, idPath) as string; - - if (!id) { - throw new Error(`No id found for entity ${entity.type.name}`); - } - - if (!entity.type.isId(id)) { - throw new Error(`Invalid id: ${id} for entity ${entity.type.name}`); - } - - // Resolve the name: if getNameResolver is provided, use it to build the name resolver; otherwise, - // use the default name resolver. - const getNameResolver = - entity.getNameResolver ?? - ((value) => get(value, 'name') as string | undefined); - const nameResolver = getNameResolver(stripRefMarkers(this.data)); - - if (!nameResolver) { - throw new Error( - `No name resolver found for entity ${entity.type.name} at ${path.join( - '.', - )}`, - ); - } - - // Base entity definition shared between regular entities and those with a name reference. - const entityBase = { - id, - type: entity.type, - path, - idPath: [...this.pathPrefix, ...idPath], - parentPath: - entity.parentPath && - entity.type.parentType && - this._constructPathWithContext( - entity.parentPath, - entity.type.parentType, - ), - }; - - this.entitiesWithNameResolver.push({ - ...entityBase, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- needed to allow more specific generic typed to be put in here - nameResolver: - typeof nameResolver === 'string' - ? { resolveName: () => nameResolver } - : nameResolver, - }); - - // Optionally add the id path to the context. - if (entity.addContext) { - this._addPathToContext( - [...this.pathPrefix, ...idPath], - entity.type, - entity.addContext, - ); - } - } - - /** - * Registers a given path into the builder's context map. - * @param path - The full reference path. - * @param type - The entity type associated with the path. - * @param context - A unique key to identify this context. - * @throws If the context key is already registered. - */ - _addPathToContext( - path: ReferencePath, - type: DefinitionEntityType, - context: string, - ): void { - // For now, allow overriding contexts to maintain compatibility - this.pathMap.set(context, { path, type }); - // Also register in the shared context for other builders to access - this.context.pathMap.set(context, { path, type }); - } - - /** - * Convenience method that builds a full path from a dot-separated string and - * adds it to the context. - * @param path - The dot-separated string path. - * @param type - The entity type. - * @param context - The context key. - */ - addPathToContext( - path: PathInput, - type: DefinitionEntityType, - context: string, - ): void { - this._addPathToContext(this._constructPath(path), type, context); - } -} - -/** - * Function type for builder functions that register references and entities. - * - * @template Input - The input data type. - */ -export type ZodBuilderFunction = ( - builder: ZodRefBuilderInterface, - data: Input, -) => void; - -/** - * Payload returned after parsing, containing the data, references, and entities. - * - * @template TData - The type of the parsed data. - */ -export interface ZodRefPayload { - data: TData; - references: DefinitionReference[]; - entitiesWithNameResolver: DefinitionEntityWithNameResolver[]; + nameResolver: DefinitionEntityNameResolver | string; } diff --git a/packages/project-builder-lib/src/references/deserialize-schema.unit.test.ts b/packages/project-builder-lib/src/references/deserialize-schema.unit.test.ts index e5a562d43..4d656f7b7 100644 --- a/packages/project-builder-lib/src/references/deserialize-schema.unit.test.ts +++ b/packages/project-builder-lib/src/references/deserialize-schema.unit.test.ts @@ -167,56 +167,4 @@ describe('deserializeSchemaWithTransformedReferences', () => { expect(parsedData.data.ref).toEqual(parsedData.data.entity[2].id); expect(parsedData.data.nestedRef.ref).toEqual(parsedData.data.entity[1].id); }); - - it('should work with withRefBuilder for complex scenarios', () => { - const entityType = createEntityType('entity'); - - const schemaCreator = definitionSchema((ctx) => - z.object({ - entities: z.array( - ctx.withEnt(z.object({ id: z.string(), name: z.string() }), { - type: entityType, - }), - ), - complexRef: ctx.withRefBuilder( - z.object({ - targetName: z.string(), - metadata: z.object({ - description: z.string(), - }), - }), - (builder, _data) => { - builder.addReference({ - type: entityType, - onDelete: 'DELETE', - path: 'targetName', - }); - }, - ), - }), - ); - - const dataInput = { - entities: [{ id: entityType.generateNewId(), name: 'target-entity' }], - complexRef: { - targetName: 'target-entity', - metadata: { - description: 'This is a complex reference', - }, - }, - }; - - const parsedData = deserializeSchemaWithTransformedReferences( - schemaCreator, - dataInput, - { plugins: pluginStore }, - ); - - expect(parsedData.data.complexRef.targetName).toEqual( - parsedData.data.entities[0].id, - ); - expect(parsedData.data.complexRef.metadata.description).toEqual( - 'This is a complex reference', - ); - }); }); 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 60c5ab1ce..d48dec669 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 @@ -1,5 +1,6 @@ -import type { Paths } from 'type-fest'; +import type { TuplePaths } from '@baseplate-dev/utils'; +import { get } from 'es-toolkit/compat'; import { z } from 'zod'; import type { DefinitionEntityType } from '#src/index.js'; @@ -8,18 +9,23 @@ import type { DefinitionSchemaCreatorOptions } from '#src/schema/index.js'; import type { DefinitionEntityInput, DefinitionReferenceInput, - ZodBuilderFunction, - ZodRefBuilderInterface, } from './definition-ref-builder.js'; import type { - AnyDefinitionEntityInput, + DefinitionEntityAnnotation, DefinitionRefAnnotations, } from './markers.js'; +import type { + RefContextSlot, + RefContextSlotDefinition, + RefContextSlotMap, +} from './ref-context-slot.js'; import { DefinitionReferenceMarker, REF_ANNOTATIONS_MARKER_SYMBOL, } from './markers.js'; +import { createRefContextSlotMap } from './ref-context-slot.js'; +import { stripRefMarkers } from './strip-ref-markers.js'; type ZodTypeWithOptional = T extends z.ZodOptional ? z.ZodOptional, z.input>> @@ -30,41 +36,79 @@ type ZodTypeWithOptional = T extends z.ZodOptional : z.ZodType, z.input>; export type WithRefType = ( - reference: DefinitionReferenceInput, + reference: DefinitionReferenceInput, ) => z.ZodType; -type PathInput = Exclude, number>; - export type WithEntType = < - TObject extends z.ZodObject, + TType extends z.ZodType, TEntityType extends DefinitionEntityType, - TPath extends PathInput>, + TIdPath extends TuplePaths> | undefined = undefined, >( - schema: TObject, - entity: DefinitionEntityInput, TEntityType, TPath>, -) => ZodTypeWithOptional; + schema: TType, + entity: DefinitionEntityInput, TEntityType, TIdPath>, +) => ZodTypeWithOptional; -export type WithRefBuilder = ( - schema: T, - builder?: ZodBuilderFunction>, -) => ZodTypeWithOptional; +/** + * Creates ref context slots for use within a schema definition. + * Slots provide type-safe context for parent-child entity relationships. + */ +export type RefContextType = < + TSlotDef extends RefContextSlotDefinition, + TSchema extends z.ZodType, +>( + slotDefinition: TSlotDef, + schemaBuilder: (slots: RefContextSlotMap) => TSchema, +) => ZodTypeWithOptional; export function extendParserContextWithRefs({ transformReferences, }: DefinitionSchemaCreatorOptions): { withRef: WithRefType; withEnt: WithEntType; - withRefBuilder: WithRefBuilder; + refContext: RefContextType; } { + function modifyAnnotations( + value: unknown, + ctx: z.RefinementCtx, + modifier: ( + annotations: DefinitionRefAnnotations, + ) => DefinitionRefAnnotations, + ): unknown { + if (value === null || value === undefined) return value; + if (typeof value !== 'object') { + ctx.addIssue({ + code: 'invalid_type', + expected: 'object', + message: `Entity must be an object`, + input: value, + }); + return value; + } + if (transformReferences) { + const existingAnnotations = + REF_ANNOTATIONS_MARKER_SYMBOL in value + ? (value[REF_ANNOTATIONS_MARKER_SYMBOL] as DefinitionRefAnnotations) + : { entities: [], references: [], slots: [] }; + return { + ...value, + [REF_ANNOTATIONS_MARKER_SYMBOL]: modifier(existingAnnotations), + }; + } + return value; + } + function withRef( - reference: DefinitionReferenceInput, + reference: DefinitionReferenceInput, ): z.ZodType { return z.string().transform((value) => { if (transformReferences && value) { - return new DefinitionReferenceMarker( - value, - reference, - ) as unknown as string; + return new DefinitionReferenceMarker(value, { + path: [], + type: reference.type, + onDelete: reference.onDelete, + parentSlot: reference.parentSlot, + provides: reference.provides, + }) as unknown as string; } return value; @@ -72,95 +116,111 @@ export function extendParserContextWithRefs({ } function withEnt< - TObject extends z.ZodObject, + TType extends z.ZodType, TEntityType extends DefinitionEntityType, - TPath extends PathInput>, + TIdPath extends TuplePaths> | undefined = undefined, >( - schema: TObject, - entity: DefinitionEntityInput, TEntityType, TPath>, - ): ZodTypeWithOptional { - if (!('id' in schema.shape)) { - throw new Error( - `Entity must have an id field. Entity type: ${entity.type.name}. Schema keys: ${Object.keys( - schema.shape, - ).join(', ')}`, - ); - } - return schema.transform((value) => { - // Check if the id is valid - if (!('id' in value) || !entity.type.isId(value.id as string)) { - throw new Error( - `Invalid id for entity ${entity.type.name}. Id: ${value.id as string}`, - ); - } + schema: TType, + entity: DefinitionEntityInput, TEntityType, TIdPath>, + ): ZodTypeWithOptional { + return schema.transform((value, ctx) => { + if (value === null || value === undefined) return value; if (transformReferences) { - const existingAnnotations = - REF_ANNOTATIONS_MARKER_SYMBOL in value - ? (value[REF_ANNOTATIONS_MARKER_SYMBOL] as DefinitionRefAnnotations) - : undefined; - return { - ...value, - [REF_ANNOTATIONS_MARKER_SYMBOL]: { - entities: [...(existingAnnotations?.entities ?? []), entity], - references: existingAnnotations?.references ?? [], - contextPaths: existingAnnotations?.contextPaths ?? [], - }, + if (typeof value !== 'object') { + ctx.addIssue({ + code: 'invalid_type', + expected: 'object', + message: `Entity must be an object`, + input: value, + }); + return value; + } + // Check if the id is valid + const idPath = entity.idPath ?? ['id']; + const id = get(value, idPath) as unknown; + if (typeof id !== 'string' || !id || !entity.type.isId(id)) { + ctx.addIssue({ + code: 'custom', + message: `Unable to find string id field '${entity.idPath?.join('.') ?? 'id'}' in entity ${entity.type.name}`, + input: value, + }); + return value; + } + + const nameResolver = (() => { + if (entity.getNameResolver) { + return entity.getNameResolver(stripRefMarkers(value)); + } + if (!('name' in value) || typeof value.name !== 'string') { + ctx.addIssue({ + code: 'custom', + message: `Unable to find string name field in entity ${entity.type.name}`, + input: value, + }); + return 'invalid'; + } + return value.name; + })(); + + const newEntity: DefinitionEntityAnnotation = { + id, + idPath, + path: [], + type: entity.type, + nameResolver, + parentSlot: entity.parentSlot, + provides: entity.provides, }; + return modifyAnnotations(value, ctx, (annotations) => ({ + ...annotations, + entities: [...annotations.entities, newEntity], + })); } return value; - }) as unknown as ZodTypeWithOptional; + }) as unknown as ZodTypeWithOptional; } - function withRefBuilder( - schema: T, - builder?: ZodBuilderFunction>, - ): ZodTypeWithOptional { - return schema.transform((value) => { - if (!value) { - return value; - } - if (typeof value !== 'object') { - throw new TypeError( - `refBuilder requires an object, but got ${typeof value}`, - ); - } - const existingAnnotations = - REF_ANNOTATIONS_MARKER_SYMBOL in value - ? (value[REF_ANNOTATIONS_MARKER_SYMBOL] as DefinitionRefAnnotations) - : undefined; - const entities = existingAnnotations?.entities ?? []; - const references = existingAnnotations?.references ?? []; - const contextPaths = existingAnnotations?.contextPaths ?? []; - const refBuilder: ZodRefBuilderInterface> = { - addReference: (reference) => { - references.push(reference); - }, - addEntity: (entity) => { - entities.push(entity as AnyDefinitionEntityInput); - }, - addPathToContext: (path, type, context) => { - contextPaths.push({ path, type, context }); - }, - }; - builder?.(refBuilder, value as z.output); - if (transformReferences) { - return { - ...value, - [REF_ANNOTATIONS_MARKER_SYMBOL]: { - entities, - references, - contextPaths, - }, - }; - } - - return value; - }) as unknown as ZodTypeWithOptional; + /** + * Creates ref context slots for use within a schema definition. + * Slots provide type-safe context for parent-child entity relationships. + * + * @example + * ```typescript + * ctx.refContext( + * { modelSlot: modelEntityType }, + * ({ modelSlot }) => + * ctx.withEnt(schema, { + * type: modelEntityType, + * provides: modelSlot, + * }), + * ); + * ``` + */ + function refContext< + TSlotDef extends RefContextSlotDefinition, + TSchema extends z.ZodType, + >( + slotDefinition: TSlotDef, + schemaBuilder: (slots: RefContextSlotMap) => TSchema, + ): ZodTypeWithOptional { + const slots = createRefContextSlotMap(slotDefinition); + return schemaBuilder(slots).transform((value, ctx) => + modifyAnnotations(value, ctx, (annotations) => ({ + ...annotations, + slots: [ + ...annotations.slots, + ...Object.values(slots).map((slot: RefContextSlot) => ({ + path: [], + slot, + })), + ], + })), + ) as unknown as ZodTypeWithOptional; } return { withRef, withEnt, - withRefBuilder, + refContext, }; } 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 490cc1eb6..7055ad50e 100644 --- a/packages/project-builder-lib/src/references/extract-definition-refs.ts +++ b/packages/project-builder-lib/src/references/extract-definition-refs.ts @@ -1,132 +1,89 @@ -import type { - DefinitionEntityNameResolver, - PathInput, - RefBuilderContext, - ZodRefPayload, -} from './definition-ref-builder.js'; -import type { DefinitionRefAnnotations } from './markers.js'; -import type { - DefinitionEntity, - DefinitionReference, - ReferencePath, -} from './types.js'; +import type { DefinitionEntityWithNameResolver } from './definition-ref-builder.js'; +import type { RefContextSlot } from './ref-context-slot.js'; +import type { DefinitionReference, ReferencePath } from './types.js'; -import { DefinitionRefBuilder } from './definition-ref-builder.js'; -import { - DefinitionReferenceMarker, - REF_ANNOTATIONS_MARKER_SYMBOL, -} from './markers.js'; +import { collectRefs } from './collect-refs.js'; +import { findNearestAncestorSlot, resolveSlots } from './resolve-slots.js'; +import { stripRefMarkers } from './strip-ref-markers.js'; /** - * Entity with a name resolver. + * Payload returned after parsing, containing the data, references, and entities. + * + * @template TData - The type of the parsed data. */ -export interface DefinitionEntityWithNameResolver - extends Omit { - nameResolver: DefinitionEntityNameResolver; -} - -/** - * Context for storing references, entities, and builder context. - */ -export interface ZodRefContext { - context: RefBuilderContext; +export interface ExtractDefinitionRefsPayload { + data: TData; references: DefinitionReference[]; entitiesWithNameResolver: DefinitionEntityWithNameResolver[]; } -export function extractDefinitionRefsRecursive( - value: unknown, - context: ZodRefContext, - path: ReferencePath, -): unknown { - const builder = new DefinitionRefBuilder( - path, - context.context, - value, - ); - - if (value instanceof DefinitionReferenceMarker) { - builder.addReference(value.reference); - context.references.push(...builder.references); - context.entitiesWithNameResolver.push(...builder.entitiesWithNameResolver); - return value.value; - } - - if ( - typeof value === 'object' && - value !== null && - REF_ANNOTATIONS_MARKER_SYMBOL in value - ) { - const annotations = value[ - REF_ANNOTATIONS_MARKER_SYMBOL - ] as DefinitionRefAnnotations; - - for (const entity of annotations.entities) { - builder.addEntity(entity); - } - - for (const reference of annotations.references) { - builder.addReference(reference); - } - - for (const pathInfo of annotations.contextPaths) { - builder.addPathToContext( - pathInfo.path as PathInput, - pathInfo.type, - pathInfo.context, +/** + * Extracts definition refs from a parsed value using functional approach. + * + * Flow: + * 1. Collect all refs (entities, references, slots) recursively + * 2. Resolve all slot references to actual paths + * 3. Strip ref markers from the data + * 4. Validate no duplicate IDs + * + * @param value - The parsed value from Zod schema + * @returns The extracted refs with clean data + */ +export function extractDefinitionRefs( + value: T, +): ExtractDefinitionRefsPayload { + // Step 1: Collect all refs without resolving slots + const collected = collectRefs(value); + + // Step 2: Resolve all slots to paths + const resolvedSlots = resolveSlots(collected); + + // Step 3: Strip markers from data + const cleanData = stripRefMarkers(value); + + // Step 4: Resolve entity and reference parent paths + function resolveParentPath( + parentSlot: RefContextSlot, + path: ReferencePath, + ): ReferencePath | undefined { + const resolvedSlot = findNearestAncestorSlot( + resolvedSlots.get(parentSlot.id), + path, + ); + if (!resolvedSlot) { + throw new Error( + `Could not resolve parent path from ${path.join('.')} for slot ${parentSlot.id.description}`, ); } - - context.references.push(...builder.references); - context.entitiesWithNameResolver.push(...builder.entitiesWithNameResolver); - - // Remove the marker symbol and process the clean object - const { [REF_ANNOTATIONS_MARKER_SYMBOL]: _, ...cleanValue } = value; - - // Process the clean object recursively - return Object.fromEntries( - Object.entries(cleanValue).map(([key, childValue]) => [ - key, - extractDefinitionRefsRecursive(childValue, context, [...path, key]), - ]), - ); + return resolvedSlot.resolvedPath; } - // Run recursively for arrays first (arrays are also objects) - if (Array.isArray(value)) { - return value.map((element, i) => - extractDefinitionRefsRecursive(element, context, [...path, i]), - ); - } - - // Run recursively for regular objects - if (typeof value === 'object' && value !== null) { - return Object.fromEntries( - Object.entries(value).map(([key, childValue]) => [ - key, - extractDefinitionRefsRecursive(childValue, context, [...path, key]), - ]), - ); - } - - // Return primitive values as-is - return value; -} - -export function extractDefinitionRefs(value: T): ZodRefPayload { - const refContext: ZodRefContext = { - context: { - pathMap: new Map(), - }, - references: [], - entitiesWithNameResolver: [], - }; - - const cleanData = extractDefinitionRefsRecursive(value, refContext, []); + const entitiesWithNameResolver: DefinitionEntityWithNameResolver[] = + collected.entities.map((entity) => ({ + id: entity.id, + idPath: entity.idPath, + nameResolver: entity.nameResolver, + type: entity.type, + path: entity.path, + parentPath: entity.parentSlot + ? resolveParentPath(entity.parentSlot, entity.path) + : undefined, + })); + + const references: DefinitionReference[] = collected.references.map( + (reference) => ({ + type: reference.type, + path: reference.path, + onDelete: reference.onDelete, + parentPath: reference.parentSlot + ? resolveParentPath(reference.parentSlot, reference.path) + : undefined, + }), + ); - // Simple sanity check to make sure we don't have duplicate IDs + // Step 4: Validate no duplicate IDs const idSet = new Set(); - for (const entity of refContext.entitiesWithNameResolver) { + for (const entity of collected.entities) { if (idSet.has(entity.id)) { throw new Error(`Duplicate ID found: ${entity.id}`); } @@ -134,8 +91,8 @@ export function extractDefinitionRefs(value: T): ZodRefPayload { } return { - data: cleanData as T, - references: refContext.references, - entitiesWithNameResolver: refContext.entitiesWithNameResolver, + data: cleanData, + references, + entitiesWithNameResolver, }; } diff --git a/packages/project-builder-lib/src/references/extract-definition-refs.unit.test.ts b/packages/project-builder-lib/src/references/extract-definition-refs.unit.test.ts index 64cf1b8b6..2f8db0c85 100644 --- a/packages/project-builder-lib/src/references/extract-definition-refs.unit.test.ts +++ b/packages/project-builder-lib/src/references/extract-definition-refs.unit.test.ts @@ -4,19 +4,16 @@ import { z } from 'zod'; import { PluginImplementationStore } from '#src/plugins/index.js'; import { definitionSchema } from '#src/schema/creator/schema-creator.js'; -import type { ZodRefContext } from './extract-definition-refs.js'; - +import { collectRefs } from './collect-refs.js'; import { createDefinitionEntityNameResolver } from './definition-ref-builder.js'; import { deserializeSchemaWithTransformedReferences } from './deserialize-schema.js'; -import { - extractDefinitionRefs, - extractDefinitionRefsRecursive, -} from './extract-definition-refs.js'; +import { extractDefinitionRefs } from './extract-definition-refs.js'; import { DefinitionReferenceMarker, REF_ANNOTATIONS_MARKER_SYMBOL, } from './markers.js'; import { parseSchemaWithTransformedReferences } from './parse-schema-with-references.js'; +import { createRefContextSlot } from './ref-context-slot.js'; import { createEntityType, DefinitionEntityType } from './types.js'; describe('extract-definition-refs', () => { @@ -261,40 +258,40 @@ describe('extract-definition-refs', () => { const fieldType = createEntityType('field', { parentType: modelType }); const schemaCreator = definitionSchema((ctx) => - z.object({ - model: ctx.withEnt( - z.object({ - id: z.string(), - name: z.string(), - field: ctx.withEnt( - z.object({ id: z.string(), name: z.string() }), - { - type: fieldType, - parentPath: { context: 'model' }, - }, - ), - }), - { type: modelType, addContext: 'model' }, - ), - foreignRelation: ctx.withRefBuilder( - z.object({ - modelRef: z.string(), - fieldRef: ctx.withRef({ - type: fieldType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, + ctx.refContext({ modelSlot: modelType }, ({ modelSlot }) => + z.object({ + model: ctx.withEnt( + z.object({ + id: z.string(), + name: z.string(), + field: ctx.withEnt( + z.object({ id: z.string(), name: z.string() }), + { + type: fieldType, + parentSlot: modelSlot, + }, + ), }), - }), - (builder) => { - builder.addReference({ - path: 'modelRef', - type: modelType, - onDelete: 'RESTRICT', - addContext: 'model', - }); - }, - ), - }), + { type: modelType, provides: modelSlot }, + ), + foreignRelation: ctx.refContext( + { foreignModelSlot: modelType }, + ({ foreignModelSlot }) => + z.object({ + modelRef: ctx.withRef({ + type: modelType, + onDelete: 'RESTRICT', + provides: foreignModelSlot, + }), + fieldRef: ctx.withRef({ + type: fieldType, + onDelete: 'RESTRICT', + parentSlot: foreignModelSlot, + }), + }), + ), + }), + ), ); const input = { @@ -341,37 +338,43 @@ describe('extract-definition-refs', () => { const schemaCreator = definitionSchema((ctx) => z.object({ models: z.array( - ctx.withEnt( - z.object({ - id: z.string(), - name: z.string(), - fields: z.array( - ctx.withEnt( - z.object({ id: z.string(), name: z.string() }), - { - type: fieldType, - parentPath: { context: 'model' }, - }, - ), - ), - relations: z.array( - z.object({ - modelName: ctx.withRef({ - type: modelType, - onDelete: 'RESTRICT', - addContext: 'foreignModel', - }), - fields: z.array( - ctx.withRef({ + ctx.refContext({ modelSlot: modelType }, ({ modelSlot }) => + ctx.withEnt( + z.object({ + id: z.string(), + name: z.string(), + fields: z.array( + ctx.withEnt( + z.object({ id: z.string(), name: z.string() }), + { type: fieldType, - onDelete: 'RESTRICT', - parentPath: { context: 'foreignModel' }, - }), + parentSlot: modelSlot, + }, ), - }), - ), - }), - { type: modelType, addContext: 'model' }, + ), + relations: z.array( + ctx.refContext( + { foreignModelSlot: modelType }, + ({ foreignModelSlot }) => + z.object({ + modelName: ctx.withRef({ + type: modelType, + onDelete: 'RESTRICT', + provides: foreignModelSlot, + }), + fields: z.array( + ctx.withRef({ + type: fieldType, + onDelete: 'RESTRICT', + parentSlot: foreignModelSlot, + }), + ), + }), + ), + ), + }), + { type: modelType, provides: modelSlot }, + ), ), ), }), @@ -585,7 +588,7 @@ describe('extract-definition-refs', () => { }); describe('RefBuilder Scenarios', () => { - it('should handle withRefBuilder for complex scenarios', () => { + it('should handle complex reference scenarios', () => { const entityType = createEntityType('entity'); const schemaCreator = definitionSchema((ctx) => @@ -595,21 +598,15 @@ describe('extract-definition-refs', () => { type: entityType, }), ), - complexRef: ctx.withRefBuilder( - z.object({ - targetName: z.string(), - metadata: z.object({ - description: z.string(), - }), + complexRef: z.object({ + targetName: ctx.withRef({ + type: entityType, + onDelete: 'DELETE', }), - (builder, _data) => { - builder.addReference({ - path: 'targetName', - type: entityType, - onDelete: 'DELETE', - }); - }, - ), + metadata: z.object({ + description: z.string(), + }), + }), }), ); @@ -722,68 +719,68 @@ describe('extract-definition-refs', () => { }); }); - describe('Core Logic Tests (extractDefinitionRefsRecursive)', () => { + describe('Core Logic Tests (collectRefs + resolveSlots)', () => { describe('Edge Cases', () => { - it('should handle missing context paths gracefully', () => { + it('should handle missing slots during extraction', () => { const modelType = new DefinitionEntityType('model', 'model'); const fieldType = new DefinitionEntityType('field', 'field', modelType); - - const context: ZodRefContext = { - context: { - pathMap: new Map([ - ['model', { path: ['models', 0], type: modelType }], - ]), - }, - references: [], - entitiesWithNameResolver: [], - }; + const missingSlot = createRefContextSlot('missingSlot', modelType); const referenceWithMissingContext = new DefinitionReferenceMarker( 'field:missing-context-field', { + path: [], type: fieldType, onDelete: 'RESTRICT', - parentPath: { context: 'nonExistentContext' }, + parentSlot: missingSlot, }, ); + // Extraction should fail because missingSlot is not registered expect(() => - extractDefinitionRefsRecursive(referenceWithMissingContext, context, [ - 'field', - 'ref', - ]), - ).toThrow( - 'Could not find context for nonExistentContext from field.ref', - ); + extractDefinitionRefs({ ref: referenceWithMissingContext }), + ).toThrow('Could not resolve parent path'); }); - it('should return primitive values unchanged', () => { - const context: ZodRefContext = { - context: { pathMap: new Map() }, + it('should collect nothing from primitive values', () => { + expect(collectRefs('string')).toEqual({ + entities: [], references: [], - entitiesWithNameResolver: [], - }; - - expect(extractDefinitionRefsRecursive('string', context, [])).toBe( - 'string', - ); - expect(extractDefinitionRefsRecursive(42, context, [])).toBe(42); - expect(extractDefinitionRefsRecursive(true, context, [])).toBe(true); - expect(extractDefinitionRefsRecursive(null, context, [])).toBe(null); - expect(extractDefinitionRefsRecursive(undefined, context, [])).toBe( - undefined, - ); + slots: [], + }); + expect(collectRefs(42)).toEqual({ + entities: [], + references: [], + slots: [], + }); + expect(collectRefs(true)).toEqual({ + entities: [], + references: [], + slots: [], + }); + expect(collectRefs(null)).toEqual({ + entities: [], + references: [], + slots: [], + }); + expect(collectRefs(undefined)).toEqual({ + entities: [], + references: [], + slots: [], + }); }); it('should handle empty objects and arrays', () => { - const context: ZodRefContext = { - context: { pathMap: new Map() }, + expect(collectRefs({})).toEqual({ + entities: [], references: [], - entitiesWithNameResolver: [], - }; - - expect(extractDefinitionRefsRecursive({}, context, [])).toEqual({}); - expect(extractDefinitionRefsRecursive([], context, [])).toEqual([]); + slots: [], + }); + expect(collectRefs([])).toEqual({ + entities: [], + references: [], + slots: [], + }); }); }); @@ -791,75 +788,51 @@ describe('extract-definition-refs', () => { const testEntityType = new DefinitionEntityType('test', 'test'); const testRefEntityType = new DefinitionEntityType('testref', 'testref'); - it('should extract reference markers and return clean values', () => { - const context: ZodRefContext = { - context: { pathMap: new Map() }, - references: [], - entitiesWithNameResolver: [], - }; - + it('should collect reference markers', () => { const referenceMarker = new DefinitionReferenceMarker( 'testref:test-id', { + path: [], type: testRefEntityType, onDelete: 'RESTRICT', }, ); - const result = extractDefinitionRefsRecursive( - referenceMarker, - context, - ['field'], - ); + const collected = collectRefs({ field: referenceMarker }); - expect(result).toBe('testref:test-id'); - expect(context.references).toHaveLength(1); - expect(context.references[0]).toEqual({ - type: testRefEntityType, + expect(collected.references).toHaveLength(1); + expect(collected.references[0]).toEqual({ path: ['field'], + type: testRefEntityType, onDelete: 'RESTRICT', - parentPath: undefined, }); }); - it('should extract entity annotations and return clean objects', () => { - const context: ZodRefContext = { - context: { pathMap: new Map() }, - references: [], - entitiesWithNameResolver: [], - }; - + it('should collect entity annotations', () => { const inputWithAnnotations = { id: 'test:test-id', name: 'Test Entity', [REF_ANNOTATIONS_MARKER_SYMBOL]: { entities: [ { + path: [], + id: 'test:test-id', type: testEntityType, - getNameResolver: () => ({ resolveName: () => 'Test Entity' }), + nameResolver: { resolveName: () => 'Test Entity' }, }, ], references: [], - contextPaths: [], + slots: [], }, }; - const result = extractDefinitionRefsRecursive( - inputWithAnnotations, - context, - ['entity'], - ); + const collected = collectRefs({ entity: inputWithAnnotations }); - expect(result).toEqual({ - id: 'test:test-id', - name: 'Test Entity', - }); - expect(context.entitiesWithNameResolver).toHaveLength(1); - expect(context.entitiesWithNameResolver[0]).toMatchObject({ + expect(collected.entities).toHaveLength(1); + expect(collected.entities[0]).toMatchObject({ id: 'test:test-id', type: testEntityType, path: ['entity'], - idPath: ['entity', 'id'], }); }); }); @@ -875,12 +848,14 @@ describe('extract-definition-refs', () => { [REF_ANNOTATIONS_MARKER_SYMBOL]: { entities: [ { + path: [], + id: 'test:duplicate-id', type: testEntityType, - getNameResolver: () => ({ resolveName: () => 'Entity One' }), + nameResolver: { resolveName: () => 'Entity One' }, }, ], references: [], - contextPaths: [], + slots: [], }, }, entity2: { @@ -889,12 +864,14 @@ describe('extract-definition-refs', () => { [REF_ANNOTATIONS_MARKER_SYMBOL]: { entities: [ { + path: [], + id: 'test:duplicate-id', type: testEntityType, - getNameResolver: () => ({ resolveName: () => 'Entity Two' }), + nameResolver: { resolveName: () => 'Entity Two' }, }, ], references: [], - contextPaths: [], + slots: [], }, }, }; @@ -916,12 +893,14 @@ describe('extract-definition-refs', () => { [REF_ANNOTATIONS_MARKER_SYMBOL]: { entities: [ { + path: [], + id: 'test:test-id', type: testEntityType, - getNameResolver: () => ({ resolveName: () => 'Test Entity' }), + nameResolver: { resolveName: () => 'Test Entity' }, }, ], references: [], - contextPaths: [], + slots: [], }, }; @@ -936,7 +915,6 @@ describe('extract-definition-refs', () => { id: 'test:test-id', type: testEntityType, path: [], - idPath: ['id'], }); expect(result.references).toHaveLength(0); }); @@ -952,15 +930,18 @@ describe('extract-definition-refs', () => { [REF_ANNOTATIONS_MARKER_SYMBOL]: { entities: [ { + path: [], + id: 'test:entity-id', type: testEntityType, - getNameResolver: () => ({ resolveName: () => 'Test Entity' }), + nameResolver: { resolveName: () => 'Test Entity' }, }, ], references: [], - contextPaths: [], + slots: [], }, }, ref: new DefinitionReferenceMarker('ref:ref-id', { + path: [], type: refEntityType, onDelete: 'RESTRICT', }), diff --git a/packages/project-builder-lib/src/references/index.ts b/packages/project-builder-lib/src/references/index.ts index 88a21e7a5..c3cacba27 100644 --- a/packages/project-builder-lib/src/references/index.ts +++ b/packages/project-builder-lib/src/references/index.ts @@ -2,5 +2,6 @@ export * from './definition-ref-builder.js'; export * from './deserialize-schema.js'; export * from './extract-definition-refs.js'; export * from './fix-ref-deletions.js'; +export * from './ref-context-slot.js'; export * from './serialize-schema.js'; export * from './types.js'; diff --git a/packages/project-builder-lib/src/references/markers.ts b/packages/project-builder-lib/src/references/markers.ts index a30fea214..a97c95c19 100644 --- a/packages/project-builder-lib/src/references/markers.ts +++ b/packages/project-builder-lib/src/references/markers.ts @@ -1,22 +1,41 @@ +import type { DefinitionEntityNameResolver } from './definition-ref-builder.js'; +import type { RefContextSlot } from './ref-context-slot.js'; import type { - DefinitionEntityInput, - DefinitionReferenceInput, -} from './definition-ref-builder.js'; -import type { DefinitionEntityType } from './types.js'; + DefinitionEntityType, + ReferenceOnDeleteAction, + ReferencePath, +} from './types.js'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- to allow it to accept any generic -type AnyDefinitionReferenceInput = DefinitionReferenceInput; +export interface DefinitionEntityAnnotation { + path: ReferencePath; + id: string; + idPath: ReferencePath; + type: DefinitionEntityType; + nameResolver: DefinitionEntityNameResolver | string; + parentSlot?: RefContextSlot; + provides?: RefContextSlot; +} + +export interface DefinitionReferenceAnnotation { + path: ReferencePath; + type: DefinitionEntityType; + onDelete: ReferenceOnDeleteAction; + parentSlot?: RefContextSlot; + provides?: RefContextSlot; +} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- to allow it to accept any generic -export type AnyDefinitionEntityInput = DefinitionEntityInput; +export interface DefinitionSlotAnnotation { + path: ReferencePath; + slot: RefContextSlot; +} export class DefinitionReferenceMarker { public value: string | undefined; - public reference: AnyDefinitionReferenceInput; + public reference: DefinitionReferenceAnnotation; constructor( value: string | undefined, - reference: AnyDefinitionReferenceInput, + reference: DefinitionReferenceAnnotation, ) { this.value = value; this.reference = reference; @@ -30,7 +49,7 @@ export class DefinitionReferenceMarker { export const REF_ANNOTATIONS_MARKER_SYMBOL = Symbol('refAnnotationsMarker'); export interface DefinitionRefAnnotations { - entities: AnyDefinitionEntityInput[]; - references: AnyDefinitionReferenceInput[]; - contextPaths: { path: string; type: DefinitionEntityType; context: string }[]; + entities: DefinitionEntityAnnotation[]; + references: DefinitionReferenceAnnotation[]; + slots: DefinitionSlotAnnotation[]; } diff --git a/packages/project-builder-lib/src/references/ref-context-slot.ts b/packages/project-builder-lib/src/references/ref-context-slot.ts new file mode 100644 index 000000000..b9634ff57 --- /dev/null +++ b/packages/project-builder-lib/src/references/ref-context-slot.ts @@ -0,0 +1,109 @@ +import type { DefinitionEntityType } from './types.js'; + +/** + * A typed slot representing a context that can be provided and consumed. + * The type parameter T represents the DefinitionEntityType this slot is for. + * + * Slots are created via `createRefContextSlot()` or `ctx.refContext()`. + * Each slot instance is unique - you cannot accidentally use a slot from + * a different schema context. + * + * @example + * ```typescript + * const modelSlot = createRefContextSlot(modelEntityType); + * + * // Provider: marks this entity as providing the slot + * ctx.withEnt(schema, { type: modelEntityType, provides: modelSlot }); + * + * // Consumer: uses the slot for parent path resolution + * ctx.withRef({ type: fieldEntityType, parentSlot: modelSlot }); + * ``` + */ +export interface RefContextSlot< + T extends DefinitionEntityType = DefinitionEntityType, +> { + /** The entity type this slot is associated with */ + readonly entityType: T; + /** Unique identifier for this slot instance */ + readonly id: symbol; +} + +/** + * Creates a new ref context slot for the given entity type. + * Each call creates a unique slot, even for the same entity type. + * + * @param name - A descriptive name for debugging (shows in Symbol description) + * @param entityType - The entity type this slot will be associated with + * @returns A new unique RefContextSlot instance + * + * @example + * ```typescript + * const modelSlot = createRefContextSlot('modelSlot', modelEntityType); + * const anotherModelSlot = createRefContextSlot('anotherModelSlot', modelEntityType); + * + * // These are different slots despite same entity type + * modelSlot.id !== anotherModelSlot.id + * // modelSlot.id.toString() === 'Symbol(modelSlot)' + * ``` + */ +export function createRefContextSlot( + name: string, + entityType: T, +): RefContextSlot { + return { + entityType, + id: Symbol(name), + }; +} + +/** + * A map of slot names to their entity types, used in refContext signatures. + * + * @example + * ```typescript + * const slotDef: RefContextSlotDefinition = { + * modelSlot: modelEntityType, + * foreignModelSlot: modelEntityType, + * }; + * ``` + */ +export type RefContextSlotDefinition = Record; + +/** + * Converts a slot definition to actual RefContextSlot instances. + * This is the type returned by refContext's callback parameter. + * + * @example + * ```typescript + * type SlotDef = { modelSlot: typeof modelEntityType }; + * type Slots = RefContextSlotMap; + * // Slots = { modelSlot: RefContextSlot } + * ``` + */ +export type RefContextSlotMap = { + [K in keyof T]: RefContextSlot; +}; + +/** + * Helper type to extract entity type from a slot. + */ +export type SlotEntityType = + T extends RefContextSlot ? E : never; + +/** + * Creates a RefContextSlotMap from a slot definition. + * Used internally by ctx.refContext(). + * + * @param slotDefinition - Map of slot names to entity types + * @returns Map of slot names to RefContextSlot instances + */ +export function createRefContextSlotMap( + slotDefinition: T, +): RefContextSlotMap { + return Object.fromEntries( + Object.entries(slotDefinition).map(([key, entityType]) => [ + key, + createRefContextSlot(key, entityType), + ]), + ) as RefContextSlotMap; +} diff --git a/packages/project-builder-lib/src/references/resolve-slots.ts b/packages/project-builder-lib/src/references/resolve-slots.ts new file mode 100644 index 000000000..10f51f355 --- /dev/null +++ b/packages/project-builder-lib/src/references/resolve-slots.ts @@ -0,0 +1,122 @@ +import { mapGroupBy } from '@baseplate-dev/utils'; + +import type { CollectedRefs } from './collect-refs.js'; +import type { RefContextSlot } from './ref-context-slot.js'; +import type { ReferencePath } from './types.js'; + +interface ResolvedSlot { + /** + * Path to the resolved slot + */ + resolvedPath: ReferencePath; + /** + * Path to the parent context of the slot + */ + path: ReferencePath; +} + +/** + * Calculates the length of the common prefix of two paths. + * + * @param a - The first path + * @param b - The second path + * @returns The length of the common prefix of the two paths + */ +function commonPrefixLength(a: ReferencePath, b: ReferencePath): number { + let length = 0; + const maxLength = Math.min(a.length, b.length); + for (let i = 0; i < maxLength; i++) { + if (a[i] !== b[i]) break; + length++; + } + return length; +} + +/** + * Finds the nearest ancestor slot for a given target path. + * + * @param candidateSlots - The candidate slots to search through. + * @param targetPath - The target path to find the nearest ancestor slot for. + * @returns The nearest ancestor slot, or undefined if no slot is found. + */ +export function findNearestAncestorSlot( + candidateSlots: T[] | undefined = [], + targetPath: ReferencePath, +): T | undefined { + let bestMatch: { prefixLength: number; slot: T } | undefined; + for (const candidateSlot of candidateSlots) { + const prefixLength = commonPrefixLength(candidateSlot.path, targetPath); + // A slot at path [] (root) is a valid ancestor of any path + // For non-root slots, require at least 1 common prefix element + const isValidAncestor = candidateSlot.path.length === 0 || prefixLength > 0; + if ( + isValidAncestor && + (!bestMatch || prefixLength > bestMatch.prefixLength) + ) { + bestMatch = { prefixLength, slot: candidateSlot }; + } + } + return bestMatch?.slot; +} + +/** + * Resolves all slot references to actual paths. + * + * This function takes the collected refs (which have `parentSlot` references) + * and resolves them to `parentPath` values using the slotPaths map. + * + * For scoped slots (where the same slot can be registered multiple times), + * we find the registration whose path is the nearest ancestor of the entity/reference. + * + * @param collected - The collected refs with unresolved slots. + * @returns The resolved refs with parentPath instead of parentSlot. + * @throws If a required slot is not found in the slotPaths map. + */ +export function resolveSlots( + collected: CollectedRefs, +): Map { + const { entities, references, slots } = collected; + + // Collect all slots by path + const slotsByType = mapGroupBy(slots, (slot) => slot.slot.id); + const resolvedSlotsByType = new Map(); + + function registerSlot( + slot: RefContextSlot, + resolvedPath: ReferencePath, + ): void { + const slotId = slot.id; + const candidateSlots = slotsByType.get(slotId) ?? []; + const nearestAncestorSlot = findNearestAncestorSlot( + candidateSlots, + resolvedPath, + ); + if (!nearestAncestorSlot) { + throw new Error( + `Could not find slot "${slotId.description ?? 'unknown'}" ` + + `within path ${resolvedPath.join('.')}. Make sure the slot is registered for this path.`, + ); + } + const existingSlots = resolvedSlotsByType.get(slotId) ?? []; + resolvedSlotsByType.set(slotId, [ + ...existingSlots, + { resolvedPath, path: nearestAncestorSlot.path }, + ]); + } + + // Collect entity provides + for (const entity of entities) { + if (entity.provides) { + registerSlot(entity.provides, [...entity.path, ...entity.idPath]); + } + } + + // Collect reference provides + for (const reference of references) { + if (reference.provides) { + registerSlot(reference.provides, reference.path); + } + } + + return resolvedSlotsByType; +} diff --git a/packages/project-builder-lib/src/references/resolve-zod-ref-payload-names.ts b/packages/project-builder-lib/src/references/resolve-zod-ref-payload-names.ts index a308bc958..50d3c9b29 100644 --- a/packages/project-builder-lib/src/references/resolve-zod-ref-payload-names.ts +++ b/packages/project-builder-lib/src/references/resolve-zod-ref-payload-names.ts @@ -1,7 +1,7 @@ import { toposort } from '@baseplate-dev/utils'; import { keyBy, mapValues } from 'es-toolkit'; -import type { ZodRefPayload } from './definition-ref-builder.js'; +import type { ExtractDefinitionRefsPayload } from './extract-definition-refs.js'; import type { DefinitionEntity, ResolvedZodRefPayload } from './types.js'; /** @@ -28,7 +28,7 @@ export interface ResolveZodRefPayloadNamesOptions { * @template T - The type of the payload. */ export function resolveZodRefPayloadNames( - payload: ZodRefPayload, + payload: ExtractDefinitionRefsPayload, { skipReferenceNameResolution = false, allowInvalidReferences = false, @@ -41,6 +41,9 @@ export function resolveZodRefPayloadNames( const orderedEntities = toposort( entitiesWithNameResolver.map((entity) => entity.id), entitiesWithNameResolver.flatMap((entity) => { + if (typeof entity.nameResolver !== 'object') { + return []; + } const entityIds = entity.nameResolver.idsToResolve ?? {}; return Object.values(entityIds) .flat() @@ -65,15 +68,24 @@ export function resolveZodRefPayloadNames( for (const id of orderedEntities) { const { nameResolver, ...rest } = entitiesById[id]; - const resolvedIds = mapValues(nameResolver.idsToResolve ?? {}, (idOrIds) => - Array.isArray(idOrIds) - ? idOrIds.map((id) => resolveIdToName(id)) - : resolveIdToName(idOrIds), - ); - resolvedEntitiesById.set(rest.id, { - ...rest, - name: nameResolver.resolveName(resolvedIds), - }); + if (typeof nameResolver === 'string') { + resolvedEntitiesById.set(rest.id, { + ...rest, + name: nameResolver, + }); + } else { + const resolvedIds = mapValues( + nameResolver.idsToResolve ?? {}, + (idOrIds) => + Array.isArray(idOrIds) + ? idOrIds.map((id) => resolveIdToName(id)) + : resolveIdToName(idOrIds), + ); + resolvedEntitiesById.set(rest.id, { + ...rest, + name: nameResolver.resolveName(resolvedIds), + }); + } } return { diff --git a/packages/project-builder-lib/src/references/resolve-zod-ref-payload-names.unit.test.ts b/packages/project-builder-lib/src/references/resolve-zod-ref-payload-names.unit.test.ts index 108fd8b22..1ac70836f 100644 --- a/packages/project-builder-lib/src/references/resolve-zod-ref-payload-names.unit.test.ts +++ b/packages/project-builder-lib/src/references/resolve-zod-ref-payload-names.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { ZodRefPayload } from './definition-ref-builder.js'; +import type { ExtractDefinitionRefsPayload } from './extract-definition-refs.js'; import { resolveZodRefPayloadNames } from './resolve-zod-ref-payload-names.js'; import { createEntityType } from './types.js'; @@ -8,7 +8,7 @@ import { createEntityType } from './types.js'; describe('resolveZodRefPayloadNames', () => { it('should resolve simple entity names without dependencies', () => { const entityType = createEntityType('test'); - const payload: ZodRefPayload = { + const payload: ExtractDefinitionRefsPayload = { data: {}, references: [], entitiesWithNameResolver: [ @@ -16,7 +16,7 @@ describe('resolveZodRefPayloadNames', () => { id: 'test-1', type: entityType, path: ['test'], - idPath: ['test', 'id'], + idPath: ['id'], nameResolver: { resolveName: () => 'Test Entity', }, @@ -30,14 +30,14 @@ describe('resolveZodRefPayloadNames', () => { id: 'test-1', type: entityType, path: ['test'], - idPath: ['test', 'id'], + idPath: ['id'], name: 'Test Entity', }); }); it('should resolve entity names with dependencies in correct order', () => { const entityType = createEntityType('test'); - const payload: ZodRefPayload = { + const payload: ExtractDefinitionRefsPayload = { data: {}, references: [], entitiesWithNameResolver: [ @@ -45,7 +45,7 @@ describe('resolveZodRefPayloadNames', () => { id: 'child-1', type: entityType, path: ['child'], - idPath: ['child', 'id'], + idPath: ['id'], nameResolver: { idsToResolve: { parentId: 'parent-1' }, resolveName: ({ parentId }) => `Child of ${parentId as string}`, @@ -55,7 +55,7 @@ describe('resolveZodRefPayloadNames', () => { id: 'parent-1', type: entityType, path: ['parent'], - idPath: ['parent', 'id'], + idPath: ['id'], nameResolver: { resolveName: () => 'Parent Entity', }, @@ -75,7 +75,7 @@ describe('resolveZodRefPayloadNames', () => { it('should handle array dependencies', () => { const entityType = createEntityType('test'); - const payload: ZodRefPayload = { + const payload: ExtractDefinitionRefsPayload = { data: {}, references: [], entitiesWithNameResolver: [ @@ -83,7 +83,7 @@ describe('resolveZodRefPayloadNames', () => { id: 'collection-1', type: entityType, path: ['collection'], - idPath: ['collection', 'id'], + idPath: ['id'], nameResolver: { idsToResolve: { itemIds: ['item-1', 'item-2'] }, resolveName: ({ itemIds }) => @@ -94,7 +94,7 @@ describe('resolveZodRefPayloadNames', () => { id: 'item-1', type: entityType, path: ['items', 0], - idPath: ['items', 0, 'id'], + idPath: ['id'], nameResolver: { resolveName: () => 'Item One', }, @@ -103,7 +103,7 @@ describe('resolveZodRefPayloadNames', () => { id: 'item-2', type: entityType, path: ['items', 1], - idPath: ['items', 1, 'id'], + idPath: ['id'], nameResolver: { resolveName: () => 'Item Two', }, @@ -120,7 +120,7 @@ describe('resolveZodRefPayloadNames', () => { it('should throw error for unresolvable dependencies', () => { const entityType = createEntityType('test'); - const payload: ZodRefPayload = { + const payload: ExtractDefinitionRefsPayload = { data: {}, references: [], entitiesWithNameResolver: [ @@ -128,7 +128,7 @@ describe('resolveZodRefPayloadNames', () => { id: 'child-1', type: entityType, path: ['child'], - idPath: ['child', 'id'], + idPath: ['id'], nameResolver: { idsToResolve: { parentId: 'non-existent' }, resolveName: ({ parentId }) => `Child of ${parentId as string}`, @@ -144,7 +144,7 @@ describe('resolveZodRefPayloadNames', () => { it('should allow invalid references when allowInvalidReferences is true', () => { const entityType = createEntityType('test'); - const payload: ZodRefPayload = { + const payload: ExtractDefinitionRefsPayload = { data: {}, references: [], entitiesWithNameResolver: [ @@ -152,7 +152,7 @@ describe('resolveZodRefPayloadNames', () => { id: 'child-1', type: entityType, path: ['child'], - idPath: ['child', 'id'], + idPath: ['id'], nameResolver: { idsToResolve: { parentId: 'non-existent' }, resolveName: ({ parentId }) => `Child of ${parentId as string}`, @@ -170,7 +170,7 @@ describe('resolveZodRefPayloadNames', () => { it('should skip reference name resolution when skipReferenceNameResolution is true', () => { const entityType = createEntityType('test'); - const payload: ZodRefPayload = { + const payload: ExtractDefinitionRefsPayload = { data: {}, references: [], entitiesWithNameResolver: [ @@ -178,7 +178,7 @@ describe('resolveZodRefPayloadNames', () => { id: 'child-1', type: entityType, path: ['child'], - idPath: ['child', 'id'], + idPath: ['id'], nameResolver: { idsToResolve: { parentId: 'parent-1' }, resolveName: ({ parentId }) => `Child of ${parentId as string}`, @@ -203,7 +203,7 @@ describe('resolveZodRefPayloadNames', () => { onDelete: 'RESTRICT' as const, }, ]; - const payload: ZodRefPayload = { + const payload: ExtractDefinitionRefsPayload = { data: {}, references, entitiesWithNameResolver: [], @@ -215,7 +215,7 @@ describe('resolveZodRefPayloadNames', () => { it('should preserve input data in the output', () => { const data = { test: 'value' }; - const payload: ZodRefPayload = { + const payload: ExtractDefinitionRefsPayload = { data, references: [], entitiesWithNameResolver: [], diff --git a/packages/project-builder-lib/src/references/serialize-schema.unit.test.ts b/packages/project-builder-lib/src/references/serialize-schema.unit.test.ts index 3e5538140..fbffe1d55 100644 --- a/packages/project-builder-lib/src/references/serialize-schema.unit.test.ts +++ b/packages/project-builder-lib/src/references/serialize-schema.unit.test.ts @@ -121,42 +121,46 @@ describe('serializeSchema', () => { parentType: modelType, }); const schemaCreator = definitionSchema((ctx) => - z.object({ - models: z.array( - ctx.withEnt( - z.object({ - id: z.string(), - name: z.string(), - fields: z.array( - ctx.withEnt( - z.object({ - id: z.string(), - name: z.string(), - }), - { type: fieldType, parentPath: { context: 'model' } }, - ), - ), - relations: z.array( + ctx.refContext( + { modelSlot: modelType, foreignModelSlot: modelType }, + ({ modelSlot, foreignModelSlot }) => + z.object({ + models: z.array( + ctx.withEnt( z.object({ - modelName: ctx.withRef({ - type: modelType, - onDelete: 'RESTRICT', - addContext: 'foreignModel', - }), + id: z.string(), + name: z.string(), fields: z.array( - ctx.withRef({ - type: fieldType, - onDelete: 'RESTRICT', - parentPath: { context: 'foreignModel' }, + ctx.withEnt( + z.object({ + id: z.string(), + name: z.string(), + }), + { type: fieldType, parentSlot: modelSlot }, + ), + ), + relations: z.array( + z.object({ + modelName: ctx.withRef({ + type: modelType, + onDelete: 'RESTRICT', + provides: foreignModelSlot, + }), + fields: z.array( + ctx.withRef({ + type: fieldType, + onDelete: 'RESTRICT', + parentSlot: foreignModelSlot, + }), + ), }), ), }), + { type: modelType, provides: modelSlot }, ), - }), - { type: modelType, addContext: 'model' }, - ), - ), - }), + ), + }), + ), ); const data = { diff --git a/packages/project-builder-lib/src/references/types.ts b/packages/project-builder-lib/src/references/types.ts index f4aff5e52..ec05c1771 100644 --- a/packages/project-builder-lib/src/references/types.ts +++ b/packages/project-builder-lib/src/references/types.ts @@ -70,6 +70,10 @@ export interface DefinitionEntity { * The ID of the entity. */ id: string; + /** + * The ID path of the entity. + */ + idPath: ReferencePath; /** * The name of the entity. */ @@ -82,17 +86,13 @@ export interface DefinitionEntity { * The path to the entity in the definition. */ path: ReferencePath; - /** - * The path to the entity's ID. - */ - idPath: ReferencePath; /** * The path to the entity's parent in the definition. */ parentPath?: ReferencePath; } -type ReferenceOnDeleteAction = +export type ReferenceOnDeleteAction = /** * Set the reference to undefined. Cannot be used for references inside arrays. */ diff --git a/packages/project-builder-lib/src/schema/apps/backend/index.ts b/packages/project-builder-lib/src/schema/apps/backend/index.ts index bbb7d83b6..f6e4e8982 100644 --- a/packages/project-builder-lib/src/schema/apps/backend/index.ts +++ b/packages/project-builder-lib/src/schema/apps/backend/index.ts @@ -2,21 +2,23 @@ import { z } from 'zod'; import type { def } from '#src/schema/creator/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js'; import { baseAppValidators } from '../base.js'; -import { createAppEntryType } from '../types.js'; +import { appEntityType, createAppEntryType } from '../types.js'; -export const createBackendAppSchema = definitionSchema(() => - z.object({ - ...baseAppValidators, - type: z.literal('backend'), - enableStripe: z.boolean().optional(), - enableBullQueue: z.boolean().optional(), - enablePostmark: z.boolean().optional(), - enableSubscriptions: z.boolean().optional(), - enableAxios: z.boolean().optional(), - }), +export const createBackendAppSchema = definitionSchemaWithSlots( + { appSlot: appEntityType }, + () => + z.object({ + ...baseAppValidators, + type: z.literal('backend'), + enableStripe: z.boolean().optional(), + enableBullQueue: z.boolean().optional(), + enablePostmark: z.boolean().optional(), + enableSubscriptions: z.boolean().optional(), + enableAxios: z.boolean().optional(), + }), ); export type BackendAppConfig = def.InferOutput; diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/admin.ts b/packages/project-builder-lib/src/schema/apps/web/admin/admin.ts index add22f029..387fcd179 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/admin.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/admin.ts @@ -2,20 +2,25 @@ import { z } from 'zod'; import type { def } from '#src/schema/index.js'; +import { appEntityType } from '#src/schema/apps/types.js'; import { authRoleEntityType } from '#src/schema/auth/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js'; import { createAdminCrudSectionSchema } from './sections/crud.js'; import { adminSectionEntityType } from './sections/types.js'; -export const createWebAdminSectionSchema = definitionSchema((ctx) => - ctx.withRefBuilder(createAdminCrudSectionSchema(ctx), (builder) => { - builder.addEntity({ - type: adminSectionEntityType, - parentPath: { context: 'app' }, - addContext: 'admin-section', - }); - }), +export const createWebAdminSectionSchema = definitionSchemaWithSlots( + { appSlot: appEntityType }, + (ctx, { appSlot }) => + ctx.refContext( + { adminSectionSlot: adminSectionEntityType }, + ({ adminSectionSlot }) => + ctx.withEnt(createAdminCrudSectionSchema(ctx, { adminSectionSlot }), { + type: adminSectionEntityType, + parentSlot: appSlot, + provides: adminSectionSlot, + }), + ), ); export type WebAdminSectionConfig = def.InferOutput< @@ -26,22 +31,27 @@ export type WebAdminSectionConfigInput = def.InferInput< typeof createWebAdminSectionSchema >; -export const createAdminAppSchema = definitionSchema((ctx) => - ctx.withDefault( - z.object({ - enabled: z.boolean(), - pathPrefix: z.string().default('/admin'), - allowedRoles: ctx.withDefault( - z.array( - ctx.withRef({ - type: authRoleEntityType, - onDelete: 'DELETE', - }), +export const createAdminAppSchema = definitionSchemaWithSlots( + { appSlot: appEntityType }, + (ctx, { appSlot }) => + ctx.withDefault( + z.object({ + enabled: z.boolean(), + pathPrefix: z.string().default('/admin'), + allowedRoles: ctx.withDefault( + z.array( + ctx.withRef({ + type: authRoleEntityType, + onDelete: 'DELETE', + }), + ), + [], ), - [], - ), - sections: ctx.withDefault(z.array(createWebAdminSectionSchema(ctx)), []), - }), - { enabled: false, pathPrefix: '/admin' }, - ), + sections: ctx.withDefault( + z.array(createWebAdminSectionSchema(ctx, { appSlot })), + [], + ), + }), + { enabled: false, pathPrefix: '/admin' }, + ), ); diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/admin-column-spec.ts b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/admin-column-spec.ts index 7dd09b441..a723f5e59 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/admin-column-spec.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/admin-column-spec.ts @@ -1,9 +1,11 @@ import type { PluginSpecImplementation } from '#src/plugins/spec/types.js'; -import type { DefinitionSchemaCreator } from '#src/schema/creator/types.js'; import { createPluginSpec } from '#src/plugins/spec/types.js'; -import type { AdminCrudColumnSchema, AdminCrudColumnType } from './types.js'; +import type { + AdminCrudColumnSchemaCreator, + AdminCrudColumnType, +} from './types.js'; import { BUILT_IN_ADMIN_CRUD_COLUMNS } from './built-in-columns.js'; @@ -11,9 +13,7 @@ import { BUILT_IN_ADMIN_CRUD_COLUMNS } from './built-in-columns.js'; * Spec for registering additional admin CRUD table columns */ export interface AdminCrudColumnSpec extends PluginSpecImplementation { - registerAdminCrudColumn: < - T extends DefinitionSchemaCreator, - >( + registerAdminCrudColumn: ( column: AdminCrudColumnType, ) => void; getAdminCrudColumns: () => Map; diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/admin-crud-column.ts b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/admin-crud-column.ts index d74f6bbe8..46ed88f53 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/admin-crud-column.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/admin-crud-column.ts @@ -1,21 +1,25 @@ import type { def } from '#src/schema/creator/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js'; +import { modelEntityType } from '#src/schema/models/index.js'; import { adminCrudColumnSpec } from './admin-column-spec.js'; import { baseAdminCrudColumnSchema } from './types.js'; -export const createAdminCrudColumnSchema = definitionSchema((ctx) => { - const adminCrudColumns = ctx.plugins - .getPluginSpec(adminCrudColumnSpec) - .getAdminCrudColumns(); +export const createAdminCrudColumnSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, slots) => { + const adminCrudColumns = ctx.plugins + .getPluginSpec(adminCrudColumnSpec) + .getAdminCrudColumns(); - return baseAdminCrudColumnSchema.transform((data) => { - const columnDef = adminCrudColumns.get(data.type); - if (!columnDef) return data; - return columnDef.createSchema(ctx).parse(data); - }); -}); + return baseAdminCrudColumnSchema.transform((data) => { + const columnDef = adminCrudColumns.get(data.type); + if (!columnDef) return data; + return columnDef.createSchema(ctx, slots).parse(data); + }); + }, +); export type AdminCrudColumnConfig = def.InferOutput< typeof createAdminCrudColumnSchema diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/built-in-columns.ts b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/built-in-columns.ts index 20859cd76..5203e7132 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/built-in-columns.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/built-in-columns.ts @@ -2,8 +2,9 @@ import { z } from 'zod'; import type { def } from '#src/schema/creator/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js'; import { + modelEntityType, modelLocalRelationEntityType, modelScalarFieldEntityType, } from '#src/schema/models/index.js'; @@ -16,15 +17,17 @@ import { } from './types.js'; // Text Column -export const createAdminCrudTextColumnSchema = definitionSchema((ctx) => - baseAdminCrudColumnSchema.extend({ - type: z.literal('text'), - modelFieldRef: ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, +export const createAdminCrudTextColumnSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, { modelSlot }) => + baseAdminCrudColumnSchema.extend({ + type: z.literal('text'), + modelFieldRef: ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), }), - }), ); export type AdminCrudTextColumnInput = def.InferInput< @@ -41,17 +44,19 @@ const adminCrudTextColumnType = createAdminCrudColumnType({ }); // Foreign Column -export const createAdminCrudForeignColumnSchema = definitionSchema((ctx) => - baseAdminCrudColumnSchema.extend({ - type: z.literal('foreign'), - localRelationRef: ctx.withRef({ - type: modelLocalRelationEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, +export const createAdminCrudForeignColumnSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, { modelSlot }) => + baseAdminCrudColumnSchema.extend({ + type: z.literal('foreign'), + localRelationRef: ctx.withRef({ + type: modelLocalRelationEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), + labelExpression: z.string().min(1), + valueExpression: z.string().min(1), }), - labelExpression: z.string().min(1), - valueExpression: z.string().min(1), - }), ); export type AdminCrudForeignColumnInput = def.InferInput< diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/types.ts b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/types.ts index bb570b55f..c5e363762 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/types.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-columns/types.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; -import type { DefinitionSchemaCreator } from '#src/schema/creator/types.js'; +import type { RefContextSlot } from '#src/references/ref-context-slot.js'; +import type { DefinitionSchemaParserContext } from '#src/schema/creator/types.js'; +import type { modelEntityType } from '#src/schema/models/types.js'; import { createEntityType } from '#src/references/types.js'; @@ -26,16 +28,27 @@ export type AdminCrudColumnSchema = z.ZodType< AdminCrudColumnInput >; +/** Slots required by admin crud column schemas */ +export interface AdminCrudColumnSlots { + modelSlot: RefContextSlot; +} + +/** + * Schema creator for admin crud columns that requires modelSlot. + */ +export type AdminCrudColumnSchemaCreator< + T extends AdminCrudColumnSchema = AdminCrudColumnSchema, +> = (ctx: DefinitionSchemaParserContext, slots: AdminCrudColumnSlots) => T; + export interface AdminCrudColumnType< - T extends - DefinitionSchemaCreator = DefinitionSchemaCreator, + T extends AdminCrudColumnSchemaCreator = AdminCrudColumnSchemaCreator, > { name: string; createSchema: T; } export function createAdminCrudColumnType< - T extends DefinitionSchemaCreator, + T extends AdminCrudColumnSchemaCreator, >(payload: AdminCrudColumnType): AdminCrudColumnType { return payload; } diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/admin-crud-input.ts b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/admin-crud-input.ts index f0f7552d9..49e6585ba 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/admin-crud-input.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/admin-crud-input.ts @@ -1,16 +1,21 @@ -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js'; +import { modelEntityType } from '#src/schema/models/index.js'; +import { adminSectionEntityType } from '../types.js'; import { adminCrudInputSpec } from './admin-input-spec.js'; import { baseAdminCrudInputSchema } from './types.js'; -export const createAdminCrudInputSchema = definitionSchema((ctx) => { - const adminCrudInputs = ctx.plugins - .getPluginSpec(adminCrudInputSpec) - .getAdminCrudInputs(); +export const createAdminCrudInputSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType, adminSectionSlot: adminSectionEntityType }, + (ctx, slots) => { + const adminCrudInputs = ctx.plugins + .getPluginSpec(adminCrudInputSpec) + .getAdminCrudInputs(); - return baseAdminCrudInputSchema.transform((data) => { - const inputDef = adminCrudInputs.get(data.type); - if (!inputDef) return data; - return inputDef.createSchema(ctx).parse(data); - }); -}); + return baseAdminCrudInputSchema.transform((data) => { + const inputDef = adminCrudInputs.get(data.type); + if (!inputDef) return data; + return inputDef.createSchema(ctx, slots).parse(data); + }); + }, +); diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/admin-input-spec.ts b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/admin-input-spec.ts index 562f9c626..a4ced5ec1 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/admin-input-spec.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/admin-input-spec.ts @@ -1,9 +1,11 @@ import type { PluginSpecImplementation } from '#src/plugins/spec/types.js'; -import type { DefinitionSchemaCreator } from '#src/schema/creator/types.js'; import { createPluginSpec } from '#src/plugins/spec/types.js'; -import type { AdminCrudInputSchema, AdminCrudInputType } from './types.js'; +import type { + AdminCrudInputSchemaCreator, + AdminCrudInputType, +} from './types.js'; import { BUILT_IN_ADMIN_CRUD_INPUTS } from './built-in-input.js'; @@ -11,9 +13,7 @@ import { BUILT_IN_ADMIN_CRUD_INPUTS } from './built-in-input.js'; * Spec for registering additional model input types */ export interface AdminCrudInputSpec extends PluginSpecImplementation { - registerAdminCrudInput: < - T extends DefinitionSchemaCreator, - >( + registerAdminCrudInput: ( input: AdminCrudInputType, ) => void; getAdminCrudInputs: () => Map; diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/built-in-input.ts b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/built-in-input.ts index 14c6b185b..d2b605d8f 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/built-in-input.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/built-in-input.ts @@ -2,8 +2,12 @@ import { z } from 'zod'; import type { def } from '#src/schema/creator/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; import { + definitionSchema, + definitionSchemaWithSlots, +} from '#src/schema/creator/schema-creator.js'; +import { + modelEntityType, modelForeignRelationEntityType, modelLocalRelationEntityType, modelScalarFieldEntityType, @@ -11,23 +15,26 @@ import { import type { AdminCrudInputType } from './types.js'; +import { adminSectionEntityType } from '../types.js'; import { adminCrudEmbeddedFormEntityType, baseAdminCrudInputSchema, createAdminCrudInputType, } from './types.js'; -export const createAdminCrudTextInputSchema = definitionSchema((ctx) => - baseAdminCrudInputSchema.extend({ - type: z.literal('text'), - label: z.string().min(1), - modelFieldRef: ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, +export const createAdminCrudTextInputSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType, adminSectionSlot: adminSectionEntityType }, + (ctx, { modelSlot }) => + baseAdminCrudInputSchema.extend({ + type: z.literal('text'), + label: z.string().min(1), + modelFieldRef: ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), + validation: z.string().optional(), }), - validation: z.string().optional(), - }), ); export type AdminCrudTextInputConfig = def.InferInput< @@ -39,20 +46,22 @@ const adminCrudTextInputType = createAdminCrudInputType({ createSchema: createAdminCrudTextInputSchema, }); -export const createAdminCrudForeignInputSchema = definitionSchema((ctx) => - baseAdminCrudInputSchema.extend({ - type: z.literal('foreign'), - label: z.string().min(1), - localRelationRef: ctx.withRef({ - type: modelLocalRelationEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, +export const createAdminCrudForeignInputSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType, adminSectionSlot: adminSectionEntityType }, + (ctx, { modelSlot }) => + baseAdminCrudInputSchema.extend({ + type: z.literal('foreign'), + label: z.string().min(1), + localRelationRef: ctx.withRef({ + type: modelLocalRelationEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), + labelExpression: z.string().min(1), + valueExpression: z.string().min(1), + defaultLabel: z.string().optional(), + nullLabel: z.string().optional(), }), - labelExpression: z.string().min(1), - valueExpression: z.string().min(1), - defaultLabel: z.string().optional(), - nullLabel: z.string().optional(), - }), ); export type AdminCrudForeignInputConfig = def.InferInput< @@ -64,16 +73,18 @@ const adminCrudForeignInputType = createAdminCrudInputType({ createSchema: createAdminCrudForeignInputSchema, }); -export const createAdminCrudEnumInputSchema = definitionSchema((ctx) => - baseAdminCrudInputSchema.extend({ - type: z.literal('enum'), - label: z.string().min(1), - modelFieldRef: ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, +export const createAdminCrudEnumInputSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType, adminSectionSlot: adminSectionEntityType }, + (ctx, { modelSlot }) => + baseAdminCrudInputSchema.extend({ + type: z.literal('enum'), + label: z.string().min(1), + modelFieldRef: ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), }), - }), ); export type AdminCrudEnumInputConfig = def.InferInput< @@ -85,21 +96,23 @@ const adminCrudEnumInputType = createAdminCrudInputType({ createSchema: createAdminCrudEnumInputSchema, }); -export const createAdminCrudEmbeddedInputSchema = definitionSchema((ctx) => - baseAdminCrudInputSchema.extend({ - type: z.literal('embedded'), - label: z.string().min(1), - modelRelationRef: ctx.withRef({ - type: modelForeignRelationEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, - }), - embeddedFormRef: ctx.withRef({ - type: adminCrudEmbeddedFormEntityType, - parentPath: { context: 'admin-section' }, - onDelete: 'RESTRICT', +export const createAdminCrudEmbeddedInputSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType, adminSectionSlot: adminSectionEntityType }, + (ctx, { modelSlot, adminSectionSlot }) => + baseAdminCrudInputSchema.extend({ + type: z.literal('embedded'), + label: z.string().min(1), + modelRelationRef: ctx.withRef({ + type: modelForeignRelationEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), + embeddedFormRef: ctx.withRef({ + type: adminCrudEmbeddedFormEntityType, + parentSlot: adminSectionSlot, + onDelete: 'RESTRICT', + }), }), - }), ); export type AdminCrudEmbeddedInputConfig = def.InferInput< @@ -111,22 +124,25 @@ export const adminCrudEmbeddedInputType = createAdminCrudInputType({ createSchema: createAdminCrudEmbeddedInputSchema, }); -export const createAdminCrudEmbeddedLocalInputSchema = definitionSchema((ctx) => - baseAdminCrudInputSchema.extend({ - type: z.literal('embeddedLocal'), - label: z.string().min(1), - localRelationRef: ctx.withRef({ - type: modelLocalRelationEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, - }), - embeddedFormRef: ctx.withRef({ - type: adminCrudEmbeddedFormEntityType, - parentPath: { context: 'admin-section' }, - onDelete: 'RESTRICT', - }), - }), -); +export const createAdminCrudEmbeddedLocalInputSchema = + definitionSchemaWithSlots( + { modelSlot: modelEntityType, adminSectionSlot: adminSectionEntityType }, + (ctx, { modelSlot, adminSectionSlot }) => + baseAdminCrudInputSchema.extend({ + type: z.literal('embeddedLocal'), + label: z.string().min(1), + localRelationRef: ctx.withRef({ + type: modelLocalRelationEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), + embeddedFormRef: ctx.withRef({ + type: adminCrudEmbeddedFormEntityType, + parentSlot: adminSectionSlot, + onDelete: 'RESTRICT', + }), + }), + ); export type AdminCrudEmbeddedLocalInputConfig = def.InferInput< typeof createAdminCrudEmbeddedLocalInputSchema diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/types.ts b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/types.ts index e0e595d34..35e39d7c5 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/types.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud-form/types.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; -import type { DefinitionSchemaCreator } from '#src/schema/creator/types.js'; +import type { RefContextSlot } from '#src/references/ref-context-slot.js'; +import type { DefinitionSchemaParserContext } from '#src/schema/creator/types.js'; +import type { modelEntityType } from '#src/schema/models/types.js'; import { createEntityType } from '#src/references/types.js'; @@ -24,17 +26,29 @@ export type AdminCrudInputSchema = z.ZodType< AdminCrudInputInput >; +/** Slots required by admin crud input schemas */ +export interface AdminCrudInputSlots { + modelSlot: RefContextSlot; + adminSectionSlot: RefContextSlot; +} + +/** + * Schema creator for admin crud inputs that requires modelSlot and adminSectionSlot. + */ +export type AdminCrudInputSchemaCreator< + T extends AdminCrudInputSchema = AdminCrudInputSchema, +> = (ctx: DefinitionSchemaParserContext, slots: AdminCrudInputSlots) => T; + export interface AdminCrudInputType< - T extends - DefinitionSchemaCreator = DefinitionSchemaCreator, + T extends AdminCrudInputSchemaCreator = AdminCrudInputSchemaCreator, > { name: string; createSchema: T; } -export function createAdminCrudInputType< - T extends DefinitionSchemaCreator, ->(payload: AdminCrudInputType): AdminCrudInputType { +export function createAdminCrudInputType( + payload: AdminCrudInputType, +): AdminCrudInputType { return payload; } diff --git a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud.ts b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud.ts index 4e93bb4e3..e7c6fcb0f 100644 --- a/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud.ts +++ b/packages/project-builder-lib/src/schema/apps/web/admin/sections/crud.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import type { def } from '#src/schema/creator/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js'; import { modelEntityType, modelScalarFieldEntityType, @@ -13,57 +13,75 @@ import { createAdminCrudActionSchema } from './crud-actions/admin-crud-action.js import { createAdminCrudColumnSchema } from './crud-columns/admin-crud-column.js'; import { createAdminCrudInputSchema } from './crud-form/admin-crud-input.js'; import { adminCrudEmbeddedFormEntityType } from './crud-form/types.js'; +import { adminSectionEntityType } from './types.js'; // Embedded Crud -export const createAdminCrudEmbeddedObjectSchema = definitionSchema((ctx) => - z.object({ - id: z.string().min(1), - name: z.string().min(1), - modelRef: ctx.withRef({ - type: modelEntityType, - onDelete: 'RESTRICT', - }), - includeIdField: z.boolean().optional(), - type: z.literal('object'), - form: z.object({ - fields: z.array(createAdminCrudInputSchema(ctx)), +const createAdminCrudEmbeddedObjectSchemaInternal = definitionSchemaWithSlots( + { modelSlot: modelEntityType, adminSectionSlot: adminSectionEntityType }, + (ctx, { modelSlot, adminSectionSlot }) => + z.object({ + id: z.string().min(1), + name: z.string().min(1), + modelRef: ctx.withRef({ + type: modelEntityType, + onDelete: 'RESTRICT', + provides: modelSlot, + }), + includeIdField: z.boolean().optional(), + type: z.literal('object'), + form: z.object({ + fields: z.array( + createAdminCrudInputSchema(ctx, { modelSlot, adminSectionSlot }), + ), + }), }), - }), ); -export const createAdminCrudEmbeddedListSchema = definitionSchema((ctx) => - z.object({ - id: z.string().min(1), - name: z.string().min(1), - modelRef: ctx.withRef({ - type: modelEntityType, - onDelete: 'RESTRICT', - }), - includeIdField: z.boolean().optional(), - type: z.literal('list'), - table: z.object({ - columns: z.array(createAdminCrudColumnSchema(ctx)), - }), - form: z.object({ - fields: z.array(createAdminCrudInputSchema(ctx)), +const createAdminCrudEmbeddedListSchemaInternal = definitionSchemaWithSlots( + { modelSlot: modelEntityType, adminSectionSlot: adminSectionEntityType }, + (ctx, { modelSlot, adminSectionSlot }) => + z.object({ + id: z.string().min(1), + name: z.string().min(1), + modelRef: ctx.withRef({ + type: modelEntityType, + onDelete: 'RESTRICT', + provides: modelSlot, + }), + includeIdField: z.boolean().optional(), + type: z.literal('list'), + table: z.object({ + columns: z.array(createAdminCrudColumnSchema(ctx, { modelSlot })), + }), + form: z.object({ + fields: z.array( + createAdminCrudInputSchema(ctx, { modelSlot, adminSectionSlot }), + ), + }), }), - }), ); -export const createAdminCrudEmbeddedFormSchema = definitionSchema((ctx) => - ctx.withRefBuilder( - z.discriminatedUnion('type', [ - createAdminCrudEmbeddedObjectSchema(ctx), - createAdminCrudEmbeddedListSchema(ctx), - ]), - (builder) => { - builder.addEntity({ - type: adminCrudEmbeddedFormEntityType, - parentPath: { context: 'admin-section' }, - }); - builder.addPathToContext('modelRef', modelEntityType, 'model'); - }, - ), +export const createAdminCrudEmbeddedFormSchema = definitionSchemaWithSlots( + { adminSectionSlot: adminSectionEntityType }, + (ctx, { adminSectionSlot }) => + ctx.refContext({ modelSlot: modelEntityType }, ({ modelSlot }) => + ctx.withEnt( + z.discriminatedUnion('type', [ + createAdminCrudEmbeddedObjectSchemaInternal(ctx, { + modelSlot, + adminSectionSlot, + }), + createAdminCrudEmbeddedListSchemaInternal(ctx, { + modelSlot, + adminSectionSlot, + }), + ]), + { + type: adminCrudEmbeddedFormEntityType, + parentSlot: adminSectionSlot, + }, + ), + ), ); export type AdminCrudEmbeddedFormConfig = def.InferOutput< @@ -76,43 +94,49 @@ export type AdminCrudEmbeddedFormConfigInput = def.InferInput< // Admin Section -export const createAdminCrudSectionSchema = definitionSchema((ctx) => - ctx.withRefBuilder( - createBaseAdminSectionValidators(ctx).and( - z.object({ - type: z.literal('crud'), - modelRef: ctx.withRef({ - type: modelEntityType, - onDelete: 'RESTRICT', - }), - /* The field that will be used to display the name of the entity in the form */ - nameFieldRef: ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'RESTRICT', - parentPath: { - context: 'model', - }, +export const createAdminCrudSectionSchema = definitionSchemaWithSlots( + { adminSectionSlot: adminSectionEntityType }, + (ctx, { adminSectionSlot }) => + ctx.refContext({ modelSlot: modelEntityType }, ({ modelSlot }) => + createBaseAdminSectionValidators(ctx).and( + z.object({ + type: z.literal('crud'), + modelRef: ctx.withRef({ + type: modelEntityType, + onDelete: 'RESTRICT', + provides: modelSlot, + }), + /* The field that will be used to display the name of the entity in the form */ + nameFieldRef: ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), + disableCreate: ctx.withDefault(z.boolean(), false), + table: z.object({ + columns: z.array(createAdminCrudColumnSchema(ctx, { modelSlot })), + actions: ctx.withDefault( + z.array(createAdminCrudActionSchema(ctx)), + [ + { type: 'edit', position: 'inline' }, + { type: 'delete', position: 'dropdown' }, + ], + ), + }), + form: z.object({ + fields: z.array( + createAdminCrudInputSchema(ctx, { + modelSlot, + adminSectionSlot, + }), + ), + }), + embeddedForms: z + .array(createAdminCrudEmbeddedFormSchema(ctx, { adminSectionSlot })) + .optional(), }), - disableCreate: ctx.withDefault(z.boolean(), false), - table: z.object({ - columns: z.array(createAdminCrudColumnSchema(ctx)), - actions: ctx.withDefault(z.array(createAdminCrudActionSchema(ctx)), [ - { type: 'edit', position: 'inline' }, - { type: 'delete', position: 'dropdown' }, - ]), - }), - form: z.object({ - fields: z.array(createAdminCrudInputSchema(ctx)), - }), - embeddedForms: z - .array(createAdminCrudEmbeddedFormSchema(ctx)) - .optional(), - }), + ), ), - (builder) => { - builder.addPathToContext('modelRef', modelEntityType, 'model'); - }, - ), ); export type AdminCrudSectionConfig = def.InferOutput< diff --git a/packages/project-builder-lib/src/schema/apps/web/web-app.ts b/packages/project-builder-lib/src/schema/apps/web/web-app.ts index da9847c9c..614ce91ef 100644 --- a/packages/project-builder-lib/src/schema/apps/web/web-app.ts +++ b/packages/project-builder-lib/src/schema/apps/web/web-app.ts @@ -2,23 +2,25 @@ import { z } from 'zod'; import type { def } from '#src/schema/creator/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js'; import { baseAppValidators } from '../base.js'; -import { createAppEntryType } from '../types.js'; +import { appEntityType, createAppEntryType } from '../types.js'; import { createAdminAppSchema } from './admin/admin.js'; -export const createWebAppSchema = definitionSchema((ctx) => - z.object({ - ...baseAppValidators, - type: z.literal('web'), - includeAuth: ctx.withDefault(z.boolean(), false), - title: z.string().default(''), - description: z.string().default(''), - includeUploadComponents: ctx.withDefault(z.boolean(), false), - enableSubscriptions: ctx.withDefault(z.boolean(), false), - adminApp: createAdminAppSchema(ctx), - }), +export const createWebAppSchema = definitionSchemaWithSlots( + { appSlot: appEntityType }, + (ctx, { appSlot }) => + z.object({ + ...baseAppValidators, + type: z.literal('web'), + includeAuth: ctx.withDefault(z.boolean(), false), + title: z.string().default(''), + description: z.string().default(''), + includeUploadComponents: ctx.withDefault(z.boolean(), false), + enableSubscriptions: ctx.withDefault(z.boolean(), false), + adminApp: createAdminAppSchema(ctx, { appSlot }), + }), ); export type WebAppConfig = def.InferOutput; diff --git a/packages/project-builder-lib/src/schema/creator/infer-types.ts b/packages/project-builder-lib/src/schema/creator/infer-types.ts index 0235fe411..69745b078 100644 --- a/packages/project-builder-lib/src/schema/creator/infer-types.ts +++ b/packages/project-builder-lib/src/schema/creator/infer-types.ts @@ -1,13 +1,34 @@ import type { z } from 'zod'; -import type { DefinitionSchemaCreator } from './types.js'; +import type { DefinitionSchemaParserContext } from './types.js'; -export type InferSchema = ReturnType; +/** + * Type constraint for any schema creator function (with or without slots). + * Works with both: + * - `definitionSchema` which returns `(ctx) => ZodType` + * - `definitionSchemaWithSlots` which returns `(ctx, slots) => ZodType` + */ +type AnyDefinitionSchemaCreator = ( + ctx: DefinitionSchemaParserContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] +) => z.ZodType; -export type InferInput = z.input< +/** + * Infers the Zod schema type from a schema creator function. + */ +export type InferSchema = ReturnType; + +/** + * Infers the input type (what you pass to parse) from a schema creator function. + */ +export type InferInput = z.input< ReturnType >; -export type InferOutput = z.output< +/** + * Infers the output type (what parse returns) from a schema creator function. + */ +export type InferOutput = z.output< ReturnType >; 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 f0c5cce2b..511990994 100644 --- a/packages/project-builder-lib/src/schema/creator/schema-creator.ts +++ b/packages/project-builder-lib/src/schema/creator/schema-creator.ts @@ -1,10 +1,17 @@ import type { z } from 'zod'; +import type { + RefContextSlotDefinition, + RefContextSlotMap, +} from '#src/references/ref-context-slot.js'; + import { extendParserContextWithRefs } from '#src/references/extend-parser-context-with-refs.js'; +import { createRefContextSlotMap } from '#src/references/ref-context-slot.js'; import type { DefinitionSchemaCreator, DefinitionSchemaCreatorOptions, + DefinitionSchemaCreatorWithSlots, DefinitionSchemaParserContext, } from './types.js'; @@ -25,3 +32,76 @@ export function definitionSchema( ): (context: DefinitionSchemaParserContext) => T { return (context) => creator(context); } + +/** + * Creates a schema that requires slots to be passed from parent schemas. + * Used when a schema needs to reference entities from a parent context. + * + * @example + * ```typescript + * // Child schema requiring modelSlot from parent + * export const createModelScalarFieldSchema = definitionSchemaWithSlots( + * { modelSlot: modelEntityType }, + * (ctx, { modelSlot }) => + * ctx.withEnt(schema, { + * type: modelScalarFieldEntityType, + * parentSlot: modelSlot, + * }), + * ); + * + * // Called from parent: + * createModelScalarFieldSchema(ctx, { modelSlot: parentModelSlot }) + * ``` + */ +export function definitionSchemaWithSlots< + TSlotDef extends RefContextSlotDefinition, + T extends z.ZodType, +>( + slotDefinition: TSlotDef, + creator: ( + ctx: DefinitionSchemaParserContext, + slots: RefContextSlotMap, + ) => T, +): DefinitionSchemaCreatorWithSlots { + const creatorWithSlots: DefinitionSchemaCreatorWithSlots = ( + context, + slots, + ) => creator(context, slots); + creatorWithSlots.slotDefinition = slotDefinition; + return creatorWithSlots; +} + +/** + * Wraps a schema creator that requires slots with placeholder slots, + * producing a simple schema creator that can be used for validation-only + * contexts (e.g., React Hook Form). + * + * The placeholder slots allow the schema to parse without actual parent context, + * which is useful when you only need schema validation without ref extraction. + * + * @example + * ```typescript + * // Schema that normally requires modelSlot from parent + * const createModelScalarFieldSchema = definitionSchemaWithSlots( + * { modelSlot: modelEntityType }, + * (ctx, { modelSlot }) => ctx.withEnt(schema, { parentSlot: modelSlot }), + * ); + * + * // For React Hook Form validation, wrap with placeholder slots + * const fieldSchema = withPlaceholderSlots(createModelScalarFieldSchema); + * const zodSchema = fieldSchema(ctx); // No slots needed + * ``` + */ +export function withPlaceholderSlots< + T extends z.ZodType, + TSlotDef extends RefContextSlotDefinition, +>( + schemaCreator: DefinitionSchemaCreatorWithSlots, +): DefinitionSchemaCreator { + return (ctx) => { + const placeholderSlots = createRefContextSlotMap( + schemaCreator.slotDefinition, + ); + return schemaCreator(ctx, placeholderSlots); + }; +} diff --git a/packages/project-builder-lib/src/schema/creator/types.ts b/packages/project-builder-lib/src/schema/creator/types.ts index 1f0486191..addb0f93d 100644 --- a/packages/project-builder-lib/src/schema/creator/types.ts +++ b/packages/project-builder-lib/src/schema/creator/types.ts @@ -2,10 +2,14 @@ import type { z } from 'zod'; import type { PluginImplementationStore } from '#src/plugins/index.js'; import type { + RefContextType, WithEntType, - WithRefBuilder, WithRefType, } from '#src/references/extend-parser-context-with-refs.js'; +import type { + RefContextSlotDefinition, + RefContextSlotMap, +} from '#src/references/ref-context-slot.js'; import type { WithDefaultType } from './extend-parser-context-with-defaults.js'; @@ -58,10 +62,6 @@ export interface DefinitionSchemaParserContext { * Adds an entity to the schema. */ withEnt: WithEntType; - /** - * Provides access to the reference builder functions for the schema. - */ - withRefBuilder: WithRefBuilder; /** * Wraps a schema with default value handling based on the defaultMode. * - 'populate': Uses preprocess to ensure defaults are present @@ -69,8 +69,23 @@ export interface DefinitionSchemaParserContext { * - 'preserve': Returns schema unchanged */ withDefault: WithDefaultType; + /** + * Creates ref context slots for use within a schema definition. + * Slots provide type-safe context for parent-child entity relationships. + */ + refContext: RefContextType; } export type DefinitionSchemaCreator = ( ctx: DefinitionSchemaParserContext, ) => T; + +export type DefinitionSchemaCreatorWithSlots< + TDefinitionSchema extends z.ZodType = z.ZodType, + TSlotDefinition extends RefContextSlotDefinition = RefContextSlotDefinition, +> = (( + ctx: DefinitionSchemaParserContext, + slots: RefContextSlotMap, +) => TDefinitionSchema) & { + slotDefinition: TSlotDefinition; +}; diff --git a/packages/project-builder-lib/src/schema/models/enums.ts b/packages/project-builder-lib/src/schema/models/enums.ts index fa7d75594..cf92386f5 100644 --- a/packages/project-builder-lib/src/schema/models/enums.ts +++ b/packages/project-builder-lib/src/schema/models/enums.ts @@ -2,45 +2,54 @@ import { z } from 'zod'; import type { def } from '#src/schema/creator/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { + definitionSchema, + definitionSchemaWithSlots, +} from '#src/schema/creator/schema-creator.js'; import { featureEntityType } from '../features/index.js'; import { modelEnumEntityType, modelEnumValueEntityType } from './types.js'; -export const createEnumValueSchema = definitionSchema((ctx) => - ctx.withEnt( - z.object({ - id: z.string(), - name: z.string().min(1), - friendlyName: z.string().min(1), - }), - { - type: modelEnumValueEntityType, - parentPath: { context: 'enum' }, - }, - ), +export const createEnumValueSchema = definitionSchemaWithSlots( + { enumSlot: modelEnumEntityType }, + (ctx, { enumSlot }) => + ctx.withEnt( + z.object({ + id: z.string(), + name: z.string().min(1), + friendlyName: z.string().min(1), + }), + { + type: modelEnumValueEntityType, + parentSlot: enumSlot, + }, + ), ); export type EnumValueConfig = def.InferOutput; -export const createEnumBaseSchema = definitionSchema((ctx) => - z.object({ - id: z.string(), - name: z.string().min(1), - featureRef: ctx.withRef({ - type: featureEntityType, - onDelete: 'RESTRICT', +export const createEnumBaseSchema = definitionSchemaWithSlots( + { enumSlot: modelEnumEntityType }, + (ctx, { enumSlot }) => + z.object({ + id: z.string(), + name: z.string().min(1), + featureRef: ctx.withRef({ + type: featureEntityType, + onDelete: 'RESTRICT', + }), + values: z.array(createEnumValueSchema(ctx, { enumSlot })), + isExposed: z.boolean(), }), - values: z.array(createEnumValueSchema(ctx)), - isExposed: z.boolean(), - }), ); export const createEnumSchema = definitionSchema((ctx) => - ctx.withEnt(createEnumBaseSchema(ctx), { - type: modelEnumEntityType, - addContext: 'enum', - }), + ctx.refContext({ enumSlot: modelEnumEntityType }, ({ enumSlot }) => + ctx.withEnt(createEnumBaseSchema(ctx, { enumSlot }), { + type: modelEnumEntityType, + provides: enumSlot, + }), + ), ); export type EnumConfig = def.InferOutput; diff --git a/packages/project-builder-lib/src/schema/models/graphql.ts b/packages/project-builder-lib/src/schema/models/graphql.ts index 33a776478..f5801f7af 100644 --- a/packages/project-builder-lib/src/schema/models/graphql.ts +++ b/packages/project-builder-lib/src/schema/models/graphql.ts @@ -2,10 +2,14 @@ import { z } from 'zod'; import type { def } from '#src/schema/creator/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { + definitionSchema, + definitionSchemaWithSlots, +} from '#src/schema/creator/schema-creator.js'; import { authRoleEntityType } from '../auth/index.js'; import { + modelEntityType, modelForeignRelationEntityType, modelLocalRelationEntityType, modelScalarFieldEntityType, @@ -23,90 +27,94 @@ const createRoleArray = definitionSchema((ctx) => ), ); -export const createModelGraphqlSchema = definitionSchema((ctx) => - z.object({ - objectType: ctx.withDefault( - z.object({ - enabled: ctx.withDefault(z.boolean(), false), - fields: ctx.withDefault( - z.array( - ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'DELETE', - parentPath: { context: 'model' }, +export const createModelGraphqlSchema = definitionSchemaWithSlots( + { + modelSlot: modelEntityType, + }, + (ctx, { modelSlot }) => + z.object({ + objectType: ctx.withDefault( + z.object({ + enabled: ctx.withDefault(z.boolean(), false), + fields: ctx.withDefault( + z.array( + ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'DELETE', + parentSlot: modelSlot, + }), + ), + [], + ), + localRelations: ctx.withDefault( + z.array( + ctx.withRef({ + type: modelLocalRelationEntityType, + onDelete: 'DELETE', + parentSlot: modelSlot, + }), + ), + [], + ), + foreignRelations: ctx.withDefault( + z.array( + ctx.withRef({ + type: modelForeignRelationEntityType, + onDelete: 'DELETE', + parentSlot: modelSlot, + }), + ), + [], + ), + }), + {}, + ), + queries: ctx.withDefault( + z.object({ + get: ctx.withDefault( + z.object({ + enabled: ctx.withDefault(z.boolean(), false), + roles: createRoleArray(ctx), }), + {}, ), - [], - ), - localRelations: ctx.withDefault( - z.array( - ctx.withRef({ - type: modelLocalRelationEntityType, - onDelete: 'DELETE', - parentPath: { context: 'model' }, + list: ctx.withDefault( + z.object({ + enabled: ctx.withDefault(z.boolean(), false), + roles: createRoleArray(ctx), }), + {}, ), - [], - ), - foreignRelations: ctx.withDefault( - z.array( - ctx.withRef({ - type: modelForeignRelationEntityType, - onDelete: 'DELETE', - parentPath: { context: 'model' }, + }), + {}, + ), + mutations: ctx.withDefault( + z.object({ + create: ctx.withDefault( + z.object({ + enabled: ctx.withDefault(z.boolean(), false), + roles: createRoleArray(ctx), }), + {}, ), - [], - ), - }), - {}, - ), - queries: ctx.withDefault( - z.object({ - get: ctx.withDefault( - z.object({ - enabled: ctx.withDefault(z.boolean(), false), - roles: createRoleArray(ctx), - }), - {}, - ), - list: ctx.withDefault( - z.object({ - enabled: ctx.withDefault(z.boolean(), false), - roles: createRoleArray(ctx), - }), - {}, - ), - }), - {}, - ), - mutations: ctx.withDefault( - z.object({ - create: ctx.withDefault( - z.object({ - enabled: ctx.withDefault(z.boolean(), false), - roles: createRoleArray(ctx), - }), - {}, - ), - update: ctx.withDefault( - z.object({ - enabled: ctx.withDefault(z.boolean(), false), - roles: createRoleArray(ctx), - }), - {}, - ), - delete: ctx.withDefault( - z.object({ - enabled: ctx.withDefault(z.boolean(), false), - roles: createRoleArray(ctx), - }), - {}, - ), - }), - {}, - ), - }), + update: ctx.withDefault( + z.object({ + enabled: ctx.withDefault(z.boolean(), false), + roles: createRoleArray(ctx), + }), + {}, + ), + delete: ctx.withDefault( + z.object({ + enabled: ctx.withDefault(z.boolean(), false), + roles: createRoleArray(ctx), + }), + {}, + ), + }), + {}, + ), + }), ); export type ModelGraphqlInput = def.InferInput; diff --git a/packages/project-builder-lib/src/schema/models/index.ts b/packages/project-builder-lib/src/schema/models/index.ts index b7d7dd86c..5731ab18f 100644 --- a/packages/project-builder-lib/src/schema/models/index.ts +++ b/packages/project-builder-lib/src/schema/models/index.ts @@ -3,7 +3,10 @@ import { z } from 'zod'; import type { def } from '#src/schema/creator/index.js'; import { createDefinitionEntityNameResolver } from '#src/references/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { + definitionSchema, + definitionSchemaWithSlots, +} from '#src/schema/creator/schema-creator.js'; import { SCALAR_FIELD_TYPES } from '#src/types/field-types.js'; import { featureEntityType } from '../features/index.js'; @@ -26,80 +29,83 @@ export * from './graphql.js'; export * from './transformers/index.js'; export * from './types.js'; -export const createModelScalarFieldSchema = definitionSchema((ctx) => - ctx - .withEnt( - z.object({ - id: z.string(), - name: VALIDATORS.CAMEL_CASE_STRING, - type: z.enum(SCALAR_FIELD_TYPES), - isOptional: z.boolean().default(false), - options: ctx.withRefBuilder( - z - .object({ - // string options - default: z.string().default(''), - // uuid options - genUuid: z.boolean().optional(), - // date options - updatedAt: z.boolean().optional(), - defaultToNow: z.boolean().optional(), - // enum options - enumRef: ctx - .withRef({ - type: modelEnumEntityType, - onDelete: 'RESTRICT', +export const createModelScalarFieldSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, { modelSlot }) => + ctx + .withEnt( + z.object({ + id: z.string(), + name: VALIDATORS.CAMEL_CASE_STRING, + type: z.enum(SCALAR_FIELD_TYPES), + isOptional: z.boolean().default(false), + options: ctx.refContext( + { enumSlot: modelEnumEntityType }, + ({ enumSlot }) => + z + .object({ + // string options + default: z.string().default(''), + // uuid options + genUuid: z.boolean().optional(), + // date options + updatedAt: z.boolean().optional(), + defaultToNow: z.boolean().optional(), + // enum options + enumRef: ctx + .withRef({ + type: modelEnumEntityType, + onDelete: 'RESTRICT', + provides: enumSlot, + }) + .optional(), + defaultEnumValueRef: ctx + .withRef({ + type: modelEnumValueEntityType, + onDelete: 'RESTRICT', + parentSlot: enumSlot, + }) + .optional(), }) - .optional(), - defaultEnumValueRef: z.string().optional(), - }) - .transform((val) => ({ - ...val, - ...(val.enumRef ? {} : { defaultEnumValueRef: undefined }), - })) - .prefault({}), - (builder) => { - builder.addReference({ - type: modelEnumValueEntityType, - onDelete: 'RESTRICT', - path: 'defaultEnumValueRef', - parentPath: 'enumRef', - }); - }, - ), + .transform((val) => ({ + ...val, + ...(val.enumRef ? {} : { defaultEnumValueRef: undefined }), + })) + .prefault({}), + ), + }), + { + type: modelScalarFieldEntityType, + parentSlot: modelSlot, + }, + ) + .superRefine((arg, ctx) => { + // check default values + const defaultValue = arg.options.default; + const { type } = arg; + if (!defaultValue) { + return; + } + if (type === 'boolean' && !['true', 'false'].includes(defaultValue)) { + ctx.addIssue({ + path: ['options', 'default'], + code: 'custom', + message: 'Default value must be true or false', + }); + } + }) + .transform((value) => { + if (value.type !== 'enum' && value.options.enumRef) { + return { + ...value, + options: { + ...value.options, + enumRef: undefined, + }, + }; + } + return value; }), - { - type: modelScalarFieldEntityType, - parentPath: { context: 'model' }, - }, - ) - .superRefine((arg, ctx) => { - // check default values - const defaultValue = arg.options.default; - const { type } = arg; - if (!defaultValue) { - return; - } - if (type === 'boolean' && !['true', 'false'].includes(defaultValue)) { - ctx.addIssue({ - path: ['options', 'default'], - code: 'custom', - message: 'Default value must be true or false', - }); - } - }) - .transform((value) => { - if (value.type !== 'enum' && value.options.enumRef) { - return { - ...value, - options: { - ...value.options, - enumRef: undefined, - }, - }; - } - return value; - }), ); export type ModelScalarFieldConfig = def.InferOutput< @@ -118,52 +124,56 @@ export const REFERENTIAL_ACTIONS = [ 'SetDefault', ] as const; -export const createModelRelationFieldSchema = definitionSchema((ctx) => - ctx.withRefBuilder( - z.object({ - id: z.string(), - foreignId: z - .string() - .default(() => modelForeignRelationEntityType.generateNewId()), - name: VALIDATORS.CAMEL_CASE_STRING, - references: z.array( - z.object({ - localRef: ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, - }), - foreignRef: ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'foreignModel' }, - }), - }), - ), - modelRef: z.string().min(1), - foreignRelationName: VALIDATORS.CAMEL_CASE_STRING, - onDelete: z.enum(REFERENTIAL_ACTIONS).default('Cascade'), - onUpdate: z.enum(REFERENTIAL_ACTIONS).default('Restrict'), - }), - (builder) => { - builder.addReference({ - type: modelEntityType, - onDelete: 'RESTRICT', - addContext: 'foreignModel', - path: 'modelRef', - }); - builder.addEntity({ - type: modelLocalRelationEntityType, - parentPath: { context: 'model' }, - }); - builder.addEntity({ - type: modelForeignRelationEntityType, - idPath: 'foreignId', - getNameResolver: (entity) => entity.foreignRelationName, - parentPath: 'modelRef', - }); - }, - ), +export const createModelRelationFieldSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, { modelSlot }) => + ctx.refContext( + { foreignModelSlot: modelEntityType }, + ({ foreignModelSlot }) => + ctx.withEnt( + ctx.withEnt( + z.object({ + id: z.string(), + foreignId: z + .string() + .default(() => modelForeignRelationEntityType.generateNewId()), + name: VALIDATORS.CAMEL_CASE_STRING, + references: z.array( + z.object({ + localRef: ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), + foreignRef: ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'RESTRICT', + parentSlot: foreignModelSlot, + }), + }), + ), + modelRef: ctx.withRef({ + type: modelEntityType, + onDelete: 'RESTRICT', + provides: foreignModelSlot, + }), + foreignRelationName: VALIDATORS.CAMEL_CASE_STRING, + onDelete: z.enum(REFERENTIAL_ACTIONS).default('Cascade'), + onUpdate: z.enum(REFERENTIAL_ACTIONS).default('Restrict'), + }), + { + type: modelLocalRelationEntityType, + parentSlot: modelSlot, + }, + ), + { + type: modelForeignRelationEntityType, + idPath: ['foreignId'], + getNameResolver: (entity) => entity.foreignRelationName, + parentSlot: foreignModelSlot, + }, + ), + ), ); export type ModelRelationFieldConfig = def.InferOutput< @@ -174,31 +184,33 @@ export type ModelRelationFieldConfigInput = def.InferInput< typeof createModelRelationFieldSchema >; -export const createModelUniqueConstraintSchema = definitionSchema((ctx) => - ctx.withEnt( - z.object({ - id: z.string(), - fields: z.array( - z.object({ - fieldRef: ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, +export const createModelUniqueConstraintSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, { modelSlot }) => + ctx.withEnt( + z.object({ + id: z.string(), + fields: z.array( + z.object({ + fieldRef: ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), }), - }), - ), - }), - { - type: modelUniqueConstraintEntityType, - parentPath: { context: 'model' }, - getNameResolver(value) { - return createDefinitionEntityNameResolver({ - idsToResolve: { fields: value.fields.map((f) => f.fieldRef) }, - resolveName: (entityNames) => entityNames.fields.join('_'), - }); + ), + }), + { + type: modelUniqueConstraintEntityType, + parentSlot: modelSlot, + getNameResolver(value) { + return createDefinitionEntityNameResolver({ + idsToResolve: { fields: value.fields.map((f) => f.fieldRef) }, + resolveName: (entityNames) => entityNames.fields.join('_'), + }); + }, }, - }, - ), + ), ); export type ModelUniqueConstraintConfig = def.InferOutput< @@ -209,108 +221,121 @@ export type ModelUniqueConstraintConfigInput = def.InferInput< typeof createModelUniqueConstraintSchema >; -export const createModelServiceSchema = definitionSchema((ctx) => - z.object({ - create: z - .object({ - enabled: z.boolean().default(false), - fields: z - .array( - ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'DELETE', - parentPath: { context: 'model' }, - }), - ) - .optional(), - transformerNames: z - .array( - ctx.withRef({ - type: modelTransformerEntityType, - onDelete: 'DELETE', - parentPath: { context: 'model' }, - }), - ) +export const createModelServiceSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, { modelSlot }) => + z.object({ + create: z + .object({ + enabled: z.boolean().default(false), + fields: z + .array( + ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'DELETE', + parentSlot: modelSlot, + }), + ) + .optional(), + transformerNames: z + .array( + ctx.withRef({ + type: modelTransformerEntityType, + onDelete: 'DELETE', + parentSlot: modelSlot, + }), + ) + .optional(), + }) + .default({ enabled: false }), + update: z + .object({ + enabled: z.boolean().default(false), + fields: z + .array( + ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'DELETE', + parentSlot: modelSlot, + }), + ) + .optional(), + transformerNames: z + .array( + ctx.withRef({ + type: modelTransformerEntityType, + onDelete: 'DELETE', + parentSlot: modelSlot, + }), + ) + .optional(), + }) + .default({ enabled: false }), + delete: z + .object({ + enabled: z.boolean().default(false), + }) + .default({ + enabled: false, + }), + transformers: z + .array(createTransformerSchema(ctx, { modelSlot })) + .default([]), + }), +); + +export type ModelServiceConfig = def.InferOutput< + typeof createModelServiceSchema +>; + +export const createModelBaseSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, slots) => + z.object({ + id: z.string(), + name: VALIDATORS.PASCAL_CASE_STRING, + featureRef: ctx.withRef({ + type: featureEntityType, + onDelete: 'RESTRICT', + }), + model: z.object({ + fields: z.array(createModelScalarFieldSchema(ctx, slots)), + relations: z + .array(createModelRelationFieldSchema(ctx, slots)) .optional(), - }) - .default({ enabled: false }), - update: z - .object({ - enabled: z.boolean().default(false), - fields: z + primaryKeyFieldRefs: z .array( ctx.withRef({ type: modelScalarFieldEntityType, - onDelete: 'DELETE', - parentPath: { context: 'model' }, - }), - ) - .optional(), - transformerNames: z - .array( - ctx.withRef({ - type: modelTransformerEntityType, - onDelete: 'DELETE', - parentPath: { context: 'model' }, + onDelete: 'RESTRICT', + parentSlot: slots.modelSlot, }), ) + .min(1), + uniqueConstraints: z + .array(createModelUniqueConstraintSchema(ctx, slots)) .optional(), - }) - .default({ enabled: false }), - delete: z - .object({ - enabled: z.boolean().default(false), - }) - .default({ - enabled: false, }), - transformers: z.array(createTransformerSchema(ctx)).default([]), - }), -); - -export type ModelServiceConfig = def.InferOutput< - typeof createModelServiceSchema ->; - -export const createModelBaseSchema = definitionSchema((ctx) => - z.object({ - id: z.string(), - name: VALIDATORS.PASCAL_CASE_STRING, - featureRef: ctx.withRef({ - type: featureEntityType, - onDelete: 'RESTRICT', - }), - model: z.object({ - fields: z.array(createModelScalarFieldSchema(ctx)), - relations: z.array(createModelRelationFieldSchema(ctx)).optional(), - primaryKeyFieldRefs: z - .array( - ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, - }), - ) - .min(1), - uniqueConstraints: z - .array(createModelUniqueConstraintSchema(ctx)) - .optional(), - }), - service: createModelServiceSchema(ctx).default({ - create: { enabled: false }, - update: { enabled: false }, - delete: { enabled: false }, - transformers: [], + service: createModelServiceSchema(ctx, slots).default({ + create: { enabled: false }, + update: { enabled: false }, + delete: { enabled: false }, + transformers: [], + }), + graphql: ctx.withDefault( + createModelGraphqlSchema(ctx, slots).optional(), + {}, + ), }), - graphql: ctx.withDefault(createModelGraphqlSchema(ctx).optional(), {}), - }), ); export const createModelSchema = definitionSchema((ctx) => - ctx.withEnt(createModelBaseSchema(ctx), { - type: modelEntityType, - addContext: 'model', - }), + ctx.refContext({ modelSlot: modelEntityType }, ({ modelSlot }) => + ctx.withEnt(createModelBaseSchema(ctx, { modelSlot }), { + type: modelEntityType, + provides: modelSlot, + }), + ), ); export type ModelConfig = def.InferOutput; diff --git a/packages/project-builder-lib/src/schema/models/transformers/built-in-transformers.ts b/packages/project-builder-lib/src/schema/models/transformers/built-in-transformers.ts index 19c87bda6..81e88ca63 100644 --- a/packages/project-builder-lib/src/schema/models/transformers/built-in-transformers.ts +++ b/packages/project-builder-lib/src/schema/models/transformers/built-in-transformers.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import type { def } from '#src/schema/creator/index.js'; import { createDefinitionEntityNameResolver } from '#src/references/index.js'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js'; import { modelEntityType, @@ -13,53 +13,56 @@ import { } from '../types.js'; import { baseTransformerFields, createModelTransformerType } from './types.js'; -export const createEmbeddedRelationTransformerSchema = definitionSchema((ctx) => - ctx.withRefBuilder( - ctx.withEnt( - z.object({ - ...baseTransformerFields, - foreignRelationRef: ctx.withRef({ - type: modelForeignRelationEntityType, - onDelete: 'DELETE_PARENT', - parentPath: { context: 'model' }, - }), - type: z.literal('embeddedRelation'), - embeddedFieldNames: z.array( - ctx.withRef({ - type: modelScalarFieldEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'embeddedModel' }, - }), - ), - embeddedTransformerNames: z - .array( - ctx.withRef({ - type: modelTransformerEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'embeddedModel' }, +export const createEmbeddedRelationTransformerSchema = + definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, { modelSlot }) => + ctx.refContext( + { embeddedModelSlot: modelEntityType }, + ({ embeddedModelSlot }) => + ctx.withEnt( + z.object({ + ...baseTransformerFields, + foreignRelationRef: ctx.withRef({ + type: modelForeignRelationEntityType, + onDelete: 'DELETE_PARENT', + parentSlot: modelSlot, + }), + type: z.literal('embeddedRelation'), + embeddedFieldNames: z.array( + ctx.withRef({ + type: modelScalarFieldEntityType, + onDelete: 'RESTRICT', + parentSlot: embeddedModelSlot, + }), + ), + embeddedTransformerNames: z + .array( + ctx.withRef({ + type: modelTransformerEntityType, + onDelete: 'RESTRICT', + parentSlot: embeddedModelSlot, + }), + ) + .optional(), + modelRef: ctx.withRef({ + type: modelEntityType, + onDelete: 'RESTRICT', + provides: embeddedModelSlot, + }), }), - ) - .optional(), - modelRef: ctx.withRef({ - type: modelEntityType, - onDelete: 'RESTRICT', - }), - }), - { - type: modelTransformerEntityType, - parentPath: { context: 'model' }, - getNameResolver: (entity) => - createDefinitionEntityNameResolver({ - idsToResolve: { foreignRelation: entity.foreignRelationRef }, - resolveName: (entityNames) => entityNames.foreignRelation, - }), - }, - ), - (builder) => { - builder.addPathToContext('modelRef', modelEntityType, 'embeddedModel'); - }, - ), -); + { + type: modelTransformerEntityType, + parentSlot: modelSlot, + getNameResolver: (entity) => + createDefinitionEntityNameResolver({ + idsToResolve: { foreignRelation: entity.foreignRelationRef }, + resolveName: (entityNames) => entityNames.foreignRelation, + }), + }, + ), + ), + ); export type EmbeddedRelationTransformerConfig = def.InferOutput< typeof createEmbeddedRelationTransformerSchema diff --git a/packages/project-builder-lib/src/schema/models/transformers/model-transformer-spec.ts b/packages/project-builder-lib/src/schema/models/transformers/model-transformer-spec.ts index 13038f43c..e65cd560f 100644 --- a/packages/project-builder-lib/src/schema/models/transformers/model-transformer-spec.ts +++ b/packages/project-builder-lib/src/schema/models/transformers/model-transformer-spec.ts @@ -1,9 +1,11 @@ import type { PluginSpecImplementation } from '#src/plugins/spec/types.js'; -import type { DefinitionSchemaCreator } from '#src/schema/creator/types.js'; import { createPluginSpec } from '#src/plugins/spec/types.js'; -import type { ModelTransformerType } from './types.js'; +import type { + ModelTransformerSchemaCreator, + ModelTransformerType, +} from './types.js'; import { BUILT_IN_TRANSFORMERS } from './built-in-transformers.js'; @@ -11,7 +13,7 @@ import { BUILT_IN_TRANSFORMERS } from './built-in-transformers.js'; * Spec for registering additional model transformer types */ export interface ModelTransformerSpec extends PluginSpecImplementation { - registerModelTransformer: ( + registerModelTransformer: ( transformer: ModelTransformerType, ) => void; getModelTransformers: () => Record; diff --git a/packages/project-builder-lib/src/schema/models/transformers/transformers.ts b/packages/project-builder-lib/src/schema/models/transformers/transformers.ts index 350446ec1..712e7078a 100644 --- a/packages/project-builder-lib/src/schema/models/transformers/transformers.ts +++ b/packages/project-builder-lib/src/schema/models/transformers/transformers.ts @@ -1,19 +1,23 @@ import z from 'zod'; -import { definitionSchema } from '#src/schema/creator/schema-creator.js'; +import { definitionSchemaWithSlots } from '#src/schema/creator/schema-creator.js'; import type { baseTransformerSchema } from './types.js'; +import { modelEntityType } from '../types.js'; import { modelTransformerSpec } from './model-transformer-spec.js'; -export const createTransformerSchema = definitionSchema((ctx) => { - const transformers = ctx.plugins - .getPluginSpec(modelTransformerSpec) - .getModelTransformers(); - return z.discriminatedUnion( - 'type', - Object.values(transformers).map((transformer) => - transformer.createSchema(ctx), - ) as [typeof baseTransformerSchema], - ); -}); +export const createTransformerSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, slots) => { + const transformers = ctx.plugins + .getPluginSpec(modelTransformerSpec) + .getModelTransformers(); + return z.discriminatedUnion( + 'type', + Object.values(transformers).map((transformer) => + transformer.createSchema(ctx, slots), + ) as [typeof baseTransformerSchema], + ); + }, +); diff --git a/packages/project-builder-lib/src/schema/models/transformers/types.ts b/packages/project-builder-lib/src/schema/models/transformers/types.ts index ccefe7d94..f80acf249 100644 --- a/packages/project-builder-lib/src/schema/models/transformers/types.ts +++ b/packages/project-builder-lib/src/schema/models/transformers/types.ts @@ -1,8 +1,11 @@ import { z } from 'zod'; import type { ProjectDefinitionContainer } from '#src/definition/project-definition-container.js'; +import type { RefContextSlot } from '#src/references/ref-context-slot.js'; import type { def } from '#src/schema/creator/index.js'; -import type { DefinitionSchemaCreator } from '#src/schema/creator/types.js'; +import type { DefinitionSchemaParserContext } from '#src/schema/creator/types.js'; + +import type { modelEntityType } from '../types.js'; export const baseTransformerFields = { id: z.string(), @@ -11,8 +14,21 @@ export const baseTransformerFields = { export const baseTransformerSchema = z.looseObject(baseTransformerFields); +/** Slots required by model transformer schemas */ +export interface ModelTransformerSlots { + modelSlot: RefContextSlot; +} + +/** + * Schema creator for model transformers that requires modelSlot. + */ +export type ModelTransformerSchemaCreator = ( + ctx: DefinitionSchemaParserContext, + slots: ModelTransformerSlots, +) => T; + export interface ModelTransformerType< - T extends DefinitionSchemaCreator = DefinitionSchemaCreator, + T extends ModelTransformerSchemaCreator = ModelTransformerSchemaCreator, > { name: string; createSchema: T; @@ -22,9 +38,9 @@ export interface ModelTransformerType< ) => string; } -export function createModelTransformerType( - payload: ModelTransformerType, -): ModelTransformerType { +export function createModelTransformerType< + T extends ModelTransformerSchemaCreator, +>(payload: ModelTransformerType): ModelTransformerType { return payload; } diff --git a/packages/project-builder-lib/src/schema/project-definition.ts b/packages/project-builder-lib/src/schema/project-definition.ts index 772c0577f..6e6a9aa90 100644 --- a/packages/project-builder-lib/src/schema/project-definition.ts +++ b/packages/project-builder-lib/src/schema/project-definition.ts @@ -13,17 +13,17 @@ import { createPluginsSchema } from './plugins/index.js'; import { createSettingsSchema } from './settings/index.js'; export const createAppSchema = definitionSchema((ctx) => - ctx.withRefBuilder( - z.discriminatedUnion('type', [ - createBackendAppSchema(ctx), - createWebAppSchema(ctx), - ]), - (builder) => { - builder.addEntity({ + ctx.refContext({ appSlot: appEntityType }, ({ appSlot }) => + ctx.withEnt( + z.discriminatedUnion('type', [ + createBackendAppSchema(ctx, { appSlot }), + createWebAppSchema(ctx, { appSlot }), + ]), + { type: appEntityType, - addContext: 'app', - }); - }, + provides: appSlot, + }, + ), ), ); diff --git a/packages/project-builder-lib/src/tools/model-merger/model-merger.unit.test.ts b/packages/project-builder-lib/src/tools/model-merger/model-merger.unit.test.ts index c0803d12a..ef1440ad9 100644 --- a/packages/project-builder-lib/src/tools/model-merger/model-merger.unit.test.ts +++ b/packages/project-builder-lib/src/tools/model-merger/model-merger.unit.test.ts @@ -777,7 +777,7 @@ describe('GraphQL support', () => { relations: [ { name: 'author', - modelRef: authorModel.id, + modelRef: 'Author', references: [ { localRef: 'id', diff --git a/packages/project-builder-lib/src/web/hooks/use-definition-schema.ts b/packages/project-builder-lib/src/web/hooks/use-definition-schema.ts index 22725a583..3f7bbf77d 100644 --- a/packages/project-builder-lib/src/web/hooks/use-definition-schema.ts +++ b/packages/project-builder-lib/src/web/hooks/use-definition-schema.ts @@ -1,15 +1,36 @@ +import type { z } from 'zod'; + import { useMemo } from 'react'; -import type { def, DefinitionSchemaCreator } from '#src/schema/index.js'; +import type { RefContextSlotDefinition } from '#src/references/ref-context-slot.js'; +import type { + DefinitionSchemaCreator, + DefinitionSchemaCreatorWithSlots, +} from '#src/schema/index.js'; + +import { withPlaceholderSlots } from '#src/schema/index.js'; import { useProjectDefinition } from './use-project-definition.js'; -export function useDefinitionSchema( - schemaCreator: T, -): def.InferSchema { +/** + * Hook to get a Zod schema from a definition schema creator. + * Automatically handles schemas with slots by providing placeholder slots. + */ +export function useDefinitionSchema( + schemaCreator: DefinitionSchemaCreator, +): T; +export function useDefinitionSchema< + T extends z.ZodType, + S extends RefContextSlotDefinition, +>(schemaCreator: DefinitionSchemaCreatorWithSlots): T; +export function useDefinitionSchema( + schemaCreator: DefinitionSchemaCreator | DefinitionSchemaCreatorWithSlots, +): z.ZodType { const { definitionSchemaParserContext } = useProjectDefinition(); - return useMemo( - () => schemaCreator(definitionSchemaParserContext), - [definitionSchemaParserContext, schemaCreator], - ) as def.InferSchema; + return useMemo(() => { + if ('slotDefinition' in schemaCreator) { + return withPlaceholderSlots(schemaCreator)(definitionSchemaParserContext); + } + return schemaCreator(definitionSchemaParserContext); + }, [definitionSchemaParserContext, schemaCreator]); } diff --git a/packages/project-builder-server/src/dev-server/server.ts b/packages/project-builder-server/src/dev-server/server.ts index ea338a043..788f49bc3 100644 --- a/packages/project-builder-server/src/dev-server/server.ts +++ b/packages/project-builder-server/src/dev-server/server.ts @@ -35,7 +35,9 @@ async function createServer( const server = fastify({ loggerInstance: context.logger as FastifyBaseLogger, forceCloseConnections: 'idle', - maxParamLength: 10_000, + routerOptions: { + maxParamLength: 10_000, + }, }); server.setValidatorCompiler(validatorCompiler); diff --git a/packages/project-builder-server/src/server/server.ts b/packages/project-builder-server/src/server/server.ts index 59dc2cf01..a0562a5e4 100644 --- a/packages/project-builder-server/src/server/server.ts +++ b/packages/project-builder-server/src/server/server.ts @@ -40,7 +40,9 @@ export async function buildServer({ const server = fastify({ forceCloseConnections: 'idle', loggerInstance: logger as FastifyBaseLogger, - maxParamLength: 10_000, + routerOptions: { + maxParamLength: 10_000, + }, }); server.setValidatorCompiler(validatorCompiler); diff --git a/packages/project-builder-web/src/hooks/use-definition-schema.ts b/packages/project-builder-web/src/hooks/use-definition-schema.ts deleted file mode 100644 index 24c084a80..000000000 --- a/packages/project-builder-web/src/hooks/use-definition-schema.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { - def, - DefinitionSchemaCreator, -} from '@baseplate-dev/project-builder-lib'; - -import { useProjectDefinition } from '@baseplate-dev/project-builder-lib/web'; -import { useMemo } from 'react'; - -export function useDefinitionSchema( - schemaCreator: T, -): def.InferSchema { - const { definitionSchemaParserContext } = useProjectDefinition(); - return useMemo( - () => schemaCreator(definitionSchemaParserContext), - [definitionSchemaParserContext, schemaCreator], - ) as def.InferSchema; -} diff --git a/packages/project-builder-web/src/routes/admin-sections.$appKey/-components/new-admin-section-dialog.tsx b/packages/project-builder-web/src/routes/admin-sections.$appKey/-components/new-admin-section-dialog.tsx index e293bdcc6..4247e304b 100644 --- a/packages/project-builder-web/src/routes/admin-sections.$appKey/-components/new-admin-section-dialog.tsx +++ b/packages/project-builder-web/src/routes/admin-sections.$appKey/-components/new-admin-section-dialog.tsx @@ -5,6 +5,7 @@ import { createWebAdminSectionSchema, } from '@baseplate-dev/project-builder-lib'; import { + useDefinitionSchema, useProjectDefinition, useResettableForm, } from '@baseplate-dev/project-builder-lib/web'; @@ -28,8 +29,6 @@ import { useNavigate } from '@tanstack/react-router'; import { useMemo } from 'react'; import { useWatch } from 'react-hook-form'; -import { useDefinitionSchema } from '#src/hooks/use-definition-schema.js'; - interface NewAdminSectionDialogProps { children: React.ReactNode; appId: string; diff --git a/packages/project-builder-web/src/routes/admin-sections.$appKey/edit.$sectionKey.tsx b/packages/project-builder-web/src/routes/admin-sections.$appKey/edit.$sectionKey.tsx index 820536c40..36f62313a 100644 --- a/packages/project-builder-web/src/routes/admin-sections.$appKey/edit.$sectionKey.tsx +++ b/packages/project-builder-web/src/routes/admin-sections.$appKey/edit.$sectionKey.tsx @@ -6,6 +6,7 @@ import { } from '@baseplate-dev/project-builder-lib'; import { useBlockUnsavedChangesNavigate, + useDefinitionSchema, useProjectDefinition, useResettableForm, } from '@baseplate-dev/project-builder-lib/web'; @@ -32,7 +33,6 @@ import { import { zodResolver } from '@hookform/resolvers/zod'; import { createFileRoute, notFound, useNavigate } from '@tanstack/react-router'; -import { useDefinitionSchema } from '#src/hooks/use-definition-schema.js'; import { logAndFormatError } from '#src/services/error-formatter.js'; import AdminCrudSectionForm from './-components/admin-crud-section-form.js'; diff --git a/packages/project-builder-web/src/routes/apps/edit.$key/backend.tsx b/packages/project-builder-web/src/routes/apps/edit.$key/backend.tsx index 94389bb60..8927642d8 100644 --- a/packages/project-builder-web/src/routes/apps/edit.$key/backend.tsx +++ b/packages/project-builder-web/src/routes/apps/edit.$key/backend.tsx @@ -3,6 +3,7 @@ import type React from 'react'; import { createBackendAppSchema } from '@baseplate-dev/project-builder-lib'; import { useBlockUnsavedChangesNavigate, + useDefinitionSchema, useProjectDefinition, useResettableForm, } from '@baseplate-dev/project-builder-lib/web'; @@ -20,8 +21,6 @@ import { import { zodResolver } from '@hookform/resolvers/zod'; import { createFileRoute, notFound, redirect } from '@tanstack/react-router'; -import { useDefinitionSchema } from '#src/hooks/use-definition-schema.js'; - export const Route = createFileRoute('/apps/edit/$key/backend')({ component: BackendAppEditPage, loader: ({ context: { app }, params: { key } }) => { diff --git a/packages/project-builder-web/src/routes/apps/edit.$key/web/admin.tsx b/packages/project-builder-web/src/routes/apps/edit.$key/web/admin.tsx index 9c78c1a18..8115c32e9 100644 --- a/packages/project-builder-web/src/routes/apps/edit.$key/web/admin.tsx +++ b/packages/project-builder-web/src/routes/apps/edit.$key/web/admin.tsx @@ -7,6 +7,7 @@ import { } from '@baseplate-dev/project-builder-lib'; import { useBlockUnsavedChangesNavigate, + useDefinitionSchema, useProjectDefinition, useResettableForm, } from '@baseplate-dev/project-builder-lib/web'; @@ -27,8 +28,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { createFileRoute, Link } from '@tanstack/react-router'; import { MdSettings } from 'react-icons/md'; -import { useDefinitionSchema } from '#src/hooks/use-definition-schema.js'; - export const Route = createFileRoute('/apps/edit/$key/web/admin')({ component: WebAdminPage, }); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 93615786a..ea886adde 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -9,4 +9,5 @@ export * from './sets/index.js'; export * from './string/index.js'; export * from './toposort/index.js'; export * from './type-guards/index.js'; +export type * from './types/index.js'; export * from './validators/index.js'; diff --git a/packages/utils/src/types/index.ts b/packages/utils/src/types/index.ts new file mode 100644 index 000000000..741324fb1 --- /dev/null +++ b/packages/utils/src/types/index.ts @@ -0,0 +1 @@ +export type { TuplePaths } from './tuple-paths.js'; diff --git a/packages/utils/src/types/tuple-paths.ts b/packages/utils/src/types/tuple-paths.ts new file mode 100644 index 000000000..002afa097 --- /dev/null +++ b/packages/utils/src/types/tuple-paths.ts @@ -0,0 +1,83 @@ +type Primitive = string | number | boolean | symbol | null | undefined; + +// Helper to convert string keys "0", "1" to number 0, 1 +type ToNumber = T extends `${infer N extends number}` ? N : never; + +/** + * Checks if a type is `any`. + * Uses the fact that `any` extends everything, so `0 extends (1 & T)` is true when T is `any`. + */ +type IsAny = 0 extends 1 & T ? true : false; + +/** + * Recursively generates a Union of all possible valid paths within Object `T` + * formatted as Tuples. + * + * This handles: + * - **Objects**: Uses string keys (e.g., `['user', 'name']`) + * - **Tuples**: Uses specific numeric literal indices (e.g., `['coords', 0]`) + * - **Arrays**: Uses generic `number` indices (e.g., `['tags', number]`) + * + * Includes safeguards: + * - **IsAny Guard**: Immediately bails out when encountering `any` type + * - **Depth Limiter**: Stops recursion after 10 levels to prevent infinite loops on recursive types + * + * @template T - The object or array to inspect. + * @template D - Internal depth counter (defaults to 10, decrements on each recursive call) + * + * @example + * // 1. Standard Object + * type Obj = { user: { name: string } }; + * type P1 = TuplePaths; + * // Result: ['user'] | ['user', 'name'] + * + * @example + * // 2. Fixed-Length Tuple + * type Tuple = { point: [number, number] }; + * type P2 = TuplePaths; + * // Result: ['point'] | ['point', 0] | ['point', 1] + * + * @example + * // 3. Generic Array + * type List = { items: string[] }; + * type P3 = TuplePaths; + * // Result: ['items'] | ['items', number] + */ +// --- Main Type --- +export type TuplePaths< + T, + Depth extends number = 10, + Stack extends unknown[] = [], +> = + // 1. Stop Recursion if Depth limit reached + Stack['length'] extends Depth + ? never + : // 2. Stop if 'any' is detected (Return never or any[] depending on preference) + IsAny extends true + ? never + : T extends Primitive + ? never + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends readonly any[] + ? // Check if Generic Array or Tuple + number extends T['length'] + ? // --- CASE A: Generic Array (e.g. User[]) --- + | [number] + | [number, ...TuplePaths] + : // --- CASE B: Tuple (e.g. [string, number]) --- + { + [K in keyof T]: K extends `${number}` + ? + | [ToNumber] + | [ + ToNumber, + ...TuplePaths, + ] + : never; + }[number] + : // --- CASE C: Object --- + { + [K in keyof T]: + | [K] + | [K, ...TuplePaths]; + }[keyof T]; diff --git a/packages/utils/src/types/tuple-paths.unit.test.ts b/packages/utils/src/types/tuple-paths.unit.test.ts new file mode 100644 index 000000000..439c77ac4 --- /dev/null +++ b/packages/utils/src/types/tuple-paths.unit.test.ts @@ -0,0 +1,160 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import type { TuplePaths } from './tuple-paths.js'; + +describe('TuplePaths Type Definitions', () => { + test('Case 1: Simple Object', () => { + interface User { + name: string; + age: number; + } + + type Result = TuplePaths; + + // Exact Match: Must be exactly these two paths + expectTypeOf().toEqualTypeOf<['name'] | ['age']>(); + }); + + test('Case 2: Fixed-Length Tuple', () => { + // A tuple implies specific indices: 0 and 1 + interface Point { + coords: [number, number]; + } + + type Result = TuplePaths; + + type Expected = ['coords'] | ['coords', 0] | ['coords', 1]; + + expectTypeOf().toEqualTypeOf(); + }); + + test('Case 3: Generic Array', () => { + // A generic array implies unknown indices (represented as `number`) + interface Tags { + list: string[]; + } + + type Result = TuplePaths; + + // We expect the index to be 'number', not specific literals like 0 or 1 + type Expected = ['list'] | ['list', number]; + + expectTypeOf().toEqualTypeOf(); + }); + + test('Case 4: Deeply Nested Mixed Types', () => { + interface Complex { + users: [ + { + id: string; + posts: [{ title: string }]; // Tuple inside object inside tuple + }, + ]; + } + + type Result = TuplePaths; + + type Expected = + | ['users'] + | ['users', 0] + | ['users', 0, 'id'] + | ['users', 0, 'posts'] + | ['users', 0, 'posts', 0] + | ['users', 0, 'posts', 0, 'title']; + + expectTypeOf().toEqualTypeOf(); + }); + + test('Negative Test: Should not allow invalid paths', () => { + interface Data { + config: [string]; + } + type Result = TuplePaths; + + // Verify that incorrect paths do NOT extend the result + // equivalent to "not.toExtend" + expectTypeOf<['config', 'invalid_prop']>().not.toExtend(); + + // Verify that string indices on tuples are rejected + expectTypeOf<['config', '0']>().not.toExtend(); + }); + + test('IsAny Guard: Should handle any type without excessive depth', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type Result = TuplePaths; + + // Should return never instead of causing type instantiation errors + expectTypeOf().toEqualTypeOf(); + }); + + test('IsAny Guard: Should handle objects with any properties', () => { + interface DataWithAny { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; + name: string; + } + + type Result = TuplePaths; + + // Should include valid paths but not recurse into any + expectTypeOf().toEqualTypeOf<['value'] | ['name']>(); + }); + + test('Depth Limiter: Should handle recursive types without infinite recursion', () => { + interface Node { + value: string; + child: Node; + } + + type Result = TuplePaths; + + // Should generate paths up to depth limit, then stop + // This test verifies it doesn't cause "Type instantiation is excessively deep" error + expectTypeOf<['value']>().toExtend(); + expectTypeOf<['child']>().toExtend(); + expectTypeOf<['child', 'value']>().toExtend(); + expectTypeOf<['child', 'child']>().toExtend(); + }); + + test('Depth Limiter: Should handle deeply nested structures', () => { + interface Level1 { + level2: { + level3: { + level4: { + level5: { + level6: { + level7: { + level8: { + level9: { + level10: { + level11: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + } + + type Result = TuplePaths; + + // Should handle deep nesting without errors + expectTypeOf<['level2']>().toExtend(); + expectTypeOf< + [ + 'level2', + 'level3', + 'level4', + 'level5', + 'level6', + 'level7', + 'level8', + 'level9', + 'level10', + ] + >().toExtend(); + }); +}); diff --git a/plugins/plugin-storage/src/storage/admin-crud/types.ts b/plugins/plugin-storage/src/storage/admin-crud/types.ts index 2a958829f..a6c75a475 100644 --- a/plugins/plugin-storage/src/storage/admin-crud/types.ts +++ b/plugins/plugin-storage/src/storage/admin-crud/types.ts @@ -2,20 +2,23 @@ import type { def } from '@baseplate-dev/project-builder-lib'; import { baseAdminCrudInputSchema, - definitionSchema, + definitionSchemaWithSlots, + modelEntityType, modelTransformerEntityType, } from '@baseplate-dev/project-builder-lib'; import { z } from 'zod'; -export const createAdminCrudFileInputSchema = definitionSchema((ctx) => - baseAdminCrudInputSchema.extend({ - type: z.literal('file'), - modelRelationRef: ctx.withRef({ - type: modelTransformerEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'model' }, +export const createAdminCrudFileInputSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, { modelSlot }) => + baseAdminCrudInputSchema.extend({ + type: z.literal('file'), + modelRelationRef: ctx.withRef({ + type: modelTransformerEntityType, + onDelete: 'RESTRICT', + parentSlot: modelSlot, + }), }), - }), ); export type AdminCrudFileInputInput = def.InferInput< diff --git a/plugins/plugin-storage/src/storage/core/schema/plugin-definition.ts b/plugins/plugin-storage/src/storage/core/schema/plugin-definition.ts index 996476e06..7a5498b63 100644 --- a/plugins/plugin-storage/src/storage/core/schema/plugin-definition.ts +++ b/plugins/plugin-storage/src/storage/core/schema/plugin-definition.ts @@ -13,25 +13,23 @@ export const storageAdapterEntityType = createEntityType( ); export const createStoragePluginDefinitionSchema = definitionSchema((ctx) => - ctx.withRefBuilder( - z.object({ - storageFeatureRef: ctx.withRef({ - type: featureEntityType, - onDelete: 'RESTRICT', - }), - s3Adapters: z.array( - ctx.withEnt( - z.object({ - id: z.string(), - name: VALIDATORS.CAMEL_CASE_STRING, - bucketConfigVar: VALIDATORS.CONSTANT_CASE_STRING, - hostedUrlConfigVar: VALIDATORS.OPTIONAL_CONSTANT_CASE_STRING, - }), - { type: storageAdapterEntityType }, - ), - ), + z.object({ + storageFeatureRef: ctx.withRef({ + type: featureEntityType, + onDelete: 'RESTRICT', }), - ), + s3Adapters: z.array( + ctx.withEnt( + z.object({ + id: z.string(), + name: VALIDATORS.CAMEL_CASE_STRING, + bucketConfigVar: VALIDATORS.CONSTANT_CASE_STRING, + hostedUrlConfigVar: VALIDATORS.OPTIONAL_CONSTANT_CASE_STRING, + }), + { type: storageAdapterEntityType }, + ), + ), + }), ); export type StoragePluginDefinition = def.InferOutput< diff --git a/plugins/plugin-storage/src/storage/transformers/schema/file-transformer.schema.ts b/plugins/plugin-storage/src/storage/transformers/schema/file-transformer.schema.ts index 5023119df..d97df0c9a 100644 --- a/plugins/plugin-storage/src/storage/transformers/schema/file-transformer.schema.ts +++ b/plugins/plugin-storage/src/storage/transformers/schema/file-transformer.schema.ts @@ -4,7 +4,8 @@ import { authRoleEntityType, baseTransformerFields, createDefinitionEntityNameResolver, - definitionSchema, + definitionSchemaWithSlots, + modelEntityType, modelLocalRelationEntityType, modelTransformerEntityType, } from '@baseplate-dev/project-builder-lib'; @@ -13,43 +14,45 @@ import { z } from 'zod'; import { storageAdapterEntityType } from '#src/storage/core/schema/plugin-definition.js'; -export const createFileTransformerSchema = definitionSchema((ctx) => - ctx.withEnt( - z.object({ - ...baseTransformerFields, - fileRelationRef: ctx.withRef({ - type: modelLocalRelationEntityType, - onDelete: 'DELETE_PARENT', - parentPath: { context: 'model' }, - }), - category: z.object({ - name: CASE_VALIDATORS.CONSTANT_CASE, - maxFileSizeMb: z.int().positive(), - authorize: z.object({ - uploadRoles: z.array( - ctx.withRef({ - type: authRoleEntityType, - onDelete: 'RESTRICT', - }), - ), +export const createFileTransformerSchema = definitionSchemaWithSlots( + { modelSlot: modelEntityType }, + (ctx, { modelSlot }) => + ctx.withEnt( + z.object({ + ...baseTransformerFields, + fileRelationRef: ctx.withRef({ + type: modelLocalRelationEntityType, + onDelete: 'DELETE_PARENT', + parentSlot: modelSlot, }), - adapterRef: ctx.withRef({ - type: storageAdapterEntityType, - onDelete: 'RESTRICT', + category: z.object({ + name: CASE_VALIDATORS.CONSTANT_CASE, + maxFileSizeMb: z.int().positive(), + authorize: z.object({ + uploadRoles: z.array( + ctx.withRef({ + type: authRoleEntityType, + onDelete: 'RESTRICT', + }), + ), + }), + adapterRef: ctx.withRef({ + type: storageAdapterEntityType, + onDelete: 'RESTRICT', + }), }), + type: z.literal('file'), }), - type: z.literal('file'), - }), - { - type: modelTransformerEntityType, - parentPath: { context: 'model' }, - getNameResolver: (entity) => - createDefinitionEntityNameResolver({ - idsToResolve: { fileRelation: entity.fileRelationRef }, - resolveName: (entityNames) => entityNames.fileRelation, - }), - }, - ), + { + type: modelTransformerEntityType, + parentSlot: modelSlot, + getNameResolver: (entity) => + createDefinitionEntityNameResolver({ + idsToResolve: { fileRelation: entity.fileRelationRef }, + resolveName: (entityNames) => entityNames.fileRelation, + }), + }, + ), ); export type FileTransformerDefinition = def.InferInput<