diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index c200a2d7..25a99682 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -1315,10 +1315,10 @@ export async function generateStaticParams() { ### Server-side mutation — `createContentItem` -In addition to read-only getters, the CMS plugin exposes a **mutation function** for creating content items directly from server-side code. +In addition to read-only getters, the CMS plugin exposes a **mutation function** for creating content items directly from server-side code. This is the recommended path for seeds, imports, and scheduled jobs. -**`createContentItem` bypasses authorization hooks and Zod schema validation.** Hooks such as `onBeforeCreate` and `onAfterCreate` are **not** called, and the data payload is stored as-is without running the content type's schema validation. The caller is responsible for providing valid, relation-free data and for any access-control checks. For relation fields or schema validation, use the HTTP endpoint instead. +**`createContentItem` bypasses authorization hooks and Zod schema validation.** Hooks such as `onBeforeCreate` and `onAfterCreate` are **not** called, and the data payload is stored as-is without running the content type's schema validation. The caller is responsible for providing valid data and for any access-control checks. Inline `_new` relation creation is not supported — pre-create related items and pass their IDs. For schema validation or inline `_new` creation, use the HTTP endpoint instead. **Via `myStack.api.cms`:** @@ -1348,6 +1348,52 @@ await createCMSContentItem(myStack.adapter, "client-profile", { }) ``` +#### `syncRelations` option + +By default, `createContentItem` only writes the item's JSON payload — it does **not** populate the `contentRelation` junction table. This is a no-op for content types without relations, but for content types with `belongsTo` / `hasMany` / `manyToMany` fields it means: + +- The admin UI's "Related Items" / inverse-relations panel will not discover the item. +- `useContentByRelation`, `getContentByRelation`, and `*/populated` endpoints will not return it. +- Only the JSON `{ id: "..." }` reference on the item itself is persisted. + +Pass `{ syncRelations: true }` to also persist relation fields into the junction table — the same behavior the HTTP `POST /content/:typeSlug` route provides, minus inline `_new` creation. + +```ts +await myStack.api.cms.createContentItem( + "study-reference", + { + slug: "bpc157-ref-gwyer-2019", + data: { + compoundId: { id: bpc157.id }, // belongsTo + categoryIds: [{ id: catPeptides.id }], // manyToMany + author: "Gwyer, D. et al.", + year: 2019, + title: "BPC-157 promotes angiogenesis…", + quote: "…", + relevance: "Mechanism of Action", + }, + }, + { syncRelations: true }, +) +``` + + +Enable `syncRelations: true` whenever the content type has relation fields — especially in seed scripts. Without it, seeded items appear correctly on their own detail page but are invisible to inverse-relation queries, so the admin "Related Items" panel and any `by-relation` filter will silently show zero results. + + +The same option is available on the direct import: + +```ts +import { createCMSContentItem } from "@btst/stack/plugins/cms/api" + +await createCMSContentItem( + myStack.adapter, + "study-reference", + { slug: "…", data: { compoundId: { id: bpc157.id }, /* … */ } }, + { syncRelations: true }, +) +``` + Throws if: - The content type slug is not found (run `ensureSynced` first if calling outside a plugin request) - A content item with the same slug already exists in that content type diff --git a/packages/cli/package.json b/packages/cli/package.json index 6e60fbab..3adb2bba 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@btst/codegen", - "version": "0.1.2", + "version": "0.1.3", "description": "BTST project scaffolding and CLI passthrough commands.", "repository": { "type": "git", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 3ca036a3..398ce7fc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -31,6 +31,7 @@ import { installInitDependencies } from "../utils/package-installer"; import { adapterNeedsGenerate, getGenerateHintForAdapter, + getOutputForAdapter, runCliPassthrough, } from "../utils/passthrough"; import { buildScaffoldPlan } from "../utils/scaffold-plan"; @@ -321,8 +322,13 @@ export function createInitCommand() { const orm = ADAPTERS.find( (item) => item.key === adapter, )?.ormForGenerate; + const outputPath = getOutputForAdapter(adapter); const args = orm - ? [`--orm=${orm}`, `--config=${stackPath}`] + ? [ + `--orm=${orm}`, + `--config=${stackPath}`, + ...(outputPath ? [`--output=${outputPath}`] : []), + ] : [`--config=${stackPath}`]; const exitCode = await runCliPassthrough({ cwd, diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index c15724b6..dfcb9000 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -5,6 +5,8 @@ export interface AdapterMeta { label: string; packageName: string; ormForGenerate?: "prisma" | "drizzle" | "kysely"; + /** Additional npm packages that must be installed when this adapter is selected. */ + extraPackages?: string[]; } export interface PluginMeta { @@ -33,6 +35,7 @@ export const ADAPTERS: readonly AdapterMeta[] = [ label: "Prisma", packageName: "@btst/adapter-prisma", ormForGenerate: "prisma", + extraPackages: ["@prisma/adapter-pg", "pg"], }, { key: "drizzle", diff --git a/packages/cli/src/utils/package-installer.ts b/packages/cli/src/utils/package-installer.ts index ef93d33e..cf9b7b3d 100644 --- a/packages/cli/src/utils/package-installer.ts +++ b/packages/cli/src/utils/package-installer.ts @@ -39,6 +39,7 @@ export async function installInitDependencies(input: { "@btst/yar", "@tanstack/react-query", adapterMeta.packageName, + ...(adapterMeta.extraPackages ?? []), ...pluginExtraPackages, ]; const { command, args } = getInstallCommand(input.packageManager, packages); diff --git a/packages/cli/src/utils/passthrough.ts b/packages/cli/src/utils/passthrough.ts index 8cbcc0c1..41fec26c 100644 --- a/packages/cli/src/utils/passthrough.ts +++ b/packages/cli/src/utils/passthrough.ts @@ -7,6 +7,15 @@ export function adapterNeedsGenerate(adapter: Adapter): boolean { return Boolean(ADAPTERS.find((item) => item.key === adapter)?.ormForGenerate); } +export function getOutputForAdapter(adapter: Adapter): string | null { + const meta = ADAPTERS.find((item) => item.key === adapter); + if (!meta?.ormForGenerate) return null; + + if (meta.ormForGenerate === "prisma") return "prisma/schema.prisma"; + if (meta.ormForGenerate === "drizzle") return "src/db/schema.ts"; + return "migrations/schema.sql"; +} + export function getGenerateHintForAdapter( adapter: Adapter, configPath: string, @@ -14,12 +23,8 @@ export function getGenerateHintForAdapter( const meta = ADAPTERS.find((item) => item.key === adapter); if (!meta?.ormForGenerate) return null; - const output = - meta.ormForGenerate === "prisma" - ? "schema.prisma" - : meta.ormForGenerate === "drizzle" - ? "src/db/schema.ts" - : "migrations/schema.sql"; + const output = getOutputForAdapter(adapter); + if (!output) return null; return `npx @btst/codegen generate --orm=${meta.ormForGenerate} --config=${configPath} --output=${output}`; } diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 4183eb98..789f6825 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -322,7 +322,7 @@ function buildPluginTemplateContext( }; } -function buildAdapterTemplateContext(adapter: Adapter) { +function buildAdapterTemplateContext(adapter: Adapter, stackPath: string) { const meta = ADAPTERS.find((item) => item.key === adapter); if (!meta) { throw new Error(`Unsupported adapter: ${adapter}`); @@ -337,15 +337,19 @@ function buildAdapterTemplateContext(adapter: Adapter) { } if (adapter === "prisma") { + const depth = stackPath.split("/").length - 1; + const prismaClientPath = `${"../".repeat(depth)}generated/prisma/client`; return { adapterImport: `import { createPrismaAdapter } from "${meta.packageName}" -import { PrismaClient } from "@prisma/client"`, - adapterSetup: `const prisma = new PrismaClient() +import { PrismaClient } from "${prismaClientPath}" +import { PrismaPg } from "@prisma/adapter-pg"`, + adapterSetup: `const pgAdapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }) +const prisma = new PrismaClient({ adapter: pgAdapter }) -const provider = process.env.BTST_PRISMA_PROVIDER ?? "postgresql" +const provider = (process.env.BTST_PRISMA_PROVIDER ?? "postgresql") as "postgresql" | "sqlite" | "cockroachdb" | "mysql" | "sqlserver" | "mongodb" `, adapterStackLine: - "adapter: (db) => createPrismaAdapter(prisma, db, { provider }),", + "adapter: (db) => createPrismaAdapter(prisma, db, { provider })({}),", }; } @@ -385,7 +389,10 @@ export async function buildScaffoldPlan( input.plugins, input.framework, ); - const adapterContext = buildAdapterTemplateContext(input.adapter); + const adapterContext = buildAdapterTemplateContext( + input.adapter, + frameworkPaths.stackPath, + ); const sharedContext = { alias: input.alias, @@ -402,6 +409,20 @@ export async function buildScaffoldPlan( content: await renderTemplate("shared/lib/stack.ts.hbs", sharedContext), description: "BTST backend stack configuration", }, + ...(input.adapter === "prisma" + ? [ + { + path: "prisma/schema.prisma", + content: `generator client {\n provider = "prisma-client"\n output = "../generated/prisma"\n}\n\ndatasource db {\n provider = "postgresql"\n}\n`, + description: "Prisma schema with explicit client output path", + }, + { + path: "prisma.config.ts", + content: `import { defineConfig } from 'prisma/config'\n\nexport default defineConfig({\n schema: 'prisma/schema.prisma',\n datasource: {\n url: process.env.DATABASE_URL ?? '',\n },\n})\n`, + description: "Prisma configuration file", + }, + ] + : []), { path: frameworkPaths.stackClientPath, content: await renderTemplate( diff --git a/packages/stack/package.json b/packages/stack/package.json index 1498d9e4..82f7b182 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.11.3", + "version": "2.11.4", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", diff --git a/packages/stack/src/plugins/cms/api/index.ts b/packages/stack/src/plugins/cms/api/index.ts index 30426edb..2c24b752 100644 --- a/packages/stack/src/plugins/cms/api/index.ts +++ b/packages/stack/src/plugins/cms/api/index.ts @@ -15,5 +15,6 @@ export { export { createCMSContentItem, type CreateCMSContentItemInput, + type CreateCMSContentItemOptions, } from "./mutations"; export { CMS_QUERY_KEYS } from "./query-key-defs"; diff --git a/packages/stack/src/plugins/cms/api/mutations.ts b/packages/stack/src/plugins/cms/api/mutations.ts index a690ccdf..1c5cf770 100644 --- a/packages/stack/src/plugins/cms/api/mutations.ts +++ b/packages/stack/src/plugins/cms/api/mutations.ts @@ -2,6 +2,11 @@ import type { DBAdapter as Adapter } from "@btst/db"; import type { ContentType, ContentItem } from "../types"; import { serializeContentItem } from "./getters"; import type { SerializedContentItem } from "../types"; +import { + collectExistingRelationIds, + extractRelationFields, + syncRelations, +} from "./relations"; /** * Input for creating a new CMS content item. @@ -13,12 +18,37 @@ export interface CreateCMSContentItemInput { data: Record; } +/** + * Options for {@link createCMSContentItem}. + */ +export interface CreateCMSContentItemOptions { + /** + * When `true`, persist relation fields (`belongsTo`, `hasMany`, + * `manyToMany`) into the `contentRelation` junction table in addition to + * the item's JSON payload. + * + * This is what the HTTP `POST /content/:typeSlug` route does, and is + * required for the admin "Related Items" panel / inverse-relation queries + * to find the item. + * + * Seeds and other programmatic callers that want the admin UI to work + * should enable this flag. Callers are still expected to pass + * pre-created target IDs (`{ id }` or `[{ id }, ...]`) — inline `_new` + * creation is only supported via the HTTP route. + * + * Defaults to `false` for backwards compatibility (a no-op for content + * types without relations). + */ + syncRelations?: boolean; +} + /** * Create a new content item for a content type (looked up by slug). * - * Bypasses Zod schema validation and relation processing — the caller is - * responsible for providing valid, relation-free data. For relation fields or - * schema validation, use the HTTP endpoint instead. + * Bypasses Zod schema validation and inline `_new` relation creation — the + * caller is responsible for providing valid data and pre-created relation + * IDs. For schema validation or inline creation of related items, use the + * HTTP endpoint instead. * * Throws if the content type is not found or the slug is already taken within * that content type. @@ -30,11 +60,15 @@ export interface CreateCMSContentItemInput { * @param adapter - The database adapter * @param contentTypeSlug - Slug of the target content type * @param input - Item slug and data payload + * @param options - See {@link CreateCMSContentItemOptions}. Pass + * `{ syncRelations: true }` to also populate the `contentRelation` + * junction table for relation fields in the payload. */ export async function createCMSContentItem( adapter: Adapter, contentTypeSlug: string, input: CreateCMSContentItemInput, + options: CreateCMSContentItemOptions = {}, ): Promise { const contentType = await adapter.findOne({ model: "contentType", @@ -80,5 +114,16 @@ export async function createCMSContentItem( }, }); + if (options.syncRelations) { + const relationFields = extractRelationFields(contentType); + if (Object.keys(relationFields).length > 0) { + const relationIds = collectExistingRelationIds( + input.data, + relationFields, + ); + await syncRelations(adapter, item.id, relationIds); + } + } + return serializeContentItem(item); } diff --git a/packages/stack/src/plugins/cms/api/plugin.ts b/packages/stack/src/plugins/cms/api/plugin.ts index 93f32a9d..30bc5e72 100644 --- a/packages/stack/src/plugins/cms/api/plugin.ts +++ b/packages/stack/src/plugins/cms/api/plugin.ts @@ -15,7 +15,6 @@ import type { CMSBackendConfig, CMSHookContext, SerializedContentItemWithType, - RelationConfig, RelationValue, InverseRelation, } from "../types"; @@ -31,6 +30,12 @@ import { serializeContentItemWithType, } from "./getters"; import { createCMSContentItem } from "./mutations"; +import { + extractRelationFields, + isExistingRelationValue, + isNewRelationValue, + syncRelations, +} from "./relations"; import type { QueryClient } from "@tanstack/react-query"; import { CMS_QUERY_KEYS } from "./query-key-defs"; import { runHookWithShim } from "../../utils"; @@ -145,67 +150,9 @@ function getContentTypeZodSchema(contentType: ContentType): z.ZodTypeAny { } // ========== Relation Helpers ========== - -interface JsonSchemaProperty { - fieldType?: string; - relation?: RelationConfig; - type?: string; - items?: JsonSchemaProperty; - [key: string]: unknown; -} - -interface JsonSchemaWithProperties { - properties?: Record; - [key: string]: unknown; -} - -/** - * Extract relation field configurations from a content type's JSON Schema - */ -function extractRelationFields( - contentType: ContentType, -): Record { - const jsonSchema = JSON.parse( - contentType.jsonSchema, - ) as JsonSchemaWithProperties; - const properties = jsonSchema.properties || {}; - const relationFields: Record = {}; - - for (const [fieldName, fieldSchema] of Object.entries(properties)) { - if (fieldSchema.fieldType === "relation" && fieldSchema.relation) { - relationFields[fieldName] = fieldSchema.relation; - } - } - - return relationFields; -} - -/** - * Check if a value is a "new" relation item (to be created) - */ -function isNewRelationValue( - value: unknown, -): value is { _new: true; data: Record } { - return ( - typeof value === "object" && - value !== null && - "_new" in value && - (value as { _new: unknown })._new === true && - "data" in value - ); -} - -/** - * Check if a value is an existing relation reference - */ -function isExistingRelationValue(value: unknown): value is { id: string } { - return ( - typeof value === "object" && - value !== null && - "id" in value && - typeof (value as { id: unknown }).id === "string" - ); -} +// `extractRelationFields`, `isNewRelationValue`, `isExistingRelationValue`, +// and `syncRelations` live in ./relations so both this HTTP route module +// and the programmatic mutations helper can share them. /** * Process relation fields in content data: @@ -363,44 +310,6 @@ async function createRelatedItem( return item; } -/** - * Sync relations in the junction table for a content item. - * - * Only updates relations for fields explicitly present in relationIds. - * Fields not in relationIds are left unchanged - this preserves existing - * relations during partial updates. - */ -async function syncRelations( - adapter: Adapter, - sourceId: string, - relationIds: Record, -): Promise { - // Only sync fields that are explicitly included in relationIds - for (const [fieldName, targetIds] of Object.entries(relationIds)) { - // Delete existing relations for this specific field only - await adapter.delete({ - model: "contentRelation", - where: [ - { field: "sourceId", value: sourceId, operator: "eq" as const }, - { field: "fieldName", value: fieldName, operator: "eq" as const }, - ], - }); - - // Create new relations for this field - for (const targetId of targetIds) { - await adapter.create({ - model: "contentRelation", - data: { - sourceId, - targetId, - fieldName, - createdAt: new Date(), - }, - }); - } - } -} - /** * Populate relations for a content item by fetching related items */ @@ -575,9 +484,10 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => { createContentItem: async ( typeSlug: string, input: Parameters[2], + options?: Parameters[3], ) => { await ensureSynced(adapter); - return createCMSContentItem(adapter, typeSlug, input); + return createCMSContentItem(adapter, typeSlug, input, options); }, }), diff --git a/packages/stack/src/plugins/cms/api/relations.ts b/packages/stack/src/plugins/cms/api/relations.ts new file mode 100644 index 00000000..dd0a7720 --- /dev/null +++ b/packages/stack/src/plugins/cms/api/relations.ts @@ -0,0 +1,174 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { + ContentType, + ContentRelation, + RelationConfig, + RelationValue, +} from "../types"; + +/** + * Shape of a property inside a content type's stored JSON Schema. + * Relation fields carry `fieldType: "relation"` and a `relation` descriptor. + */ +interface JsonSchemaProperty { + fieldType?: string; + relation?: RelationConfig; + type?: string; + items?: JsonSchemaProperty; + [key: string]: unknown; +} + +interface JsonSchemaWithProperties { + properties?: Record; + [key: string]: unknown; +} + +/** + * Extract relation field configurations from a content type's JSON Schema. + * + * Returns a map of field name -> RelationConfig for every field declared + * with `fieldType: "relation"` and a populated `relation` descriptor. + */ +export function extractRelationFields( + contentType: ContentType, +): Record { + const jsonSchema = JSON.parse( + contentType.jsonSchema, + ) as JsonSchemaWithProperties; + const properties = jsonSchema.properties || {}; + const relationFields: Record = {}; + + for (const [fieldName, fieldSchema] of Object.entries(properties)) { + if (fieldSchema.fieldType === "relation" && fieldSchema.relation) { + relationFields[fieldName] = fieldSchema.relation; + } + } + + return relationFields; +} + +/** + * Type guard: value is a `{ _new: true, data: ... }` descriptor used by the + * HTTP endpoints to request inline creation of a related item on save. + */ +export function isNewRelationValue( + value: unknown, +): value is { _new: true; data: Record } { + return ( + typeof value === "object" && + value !== null && + "_new" in value && + (value as { _new: unknown })._new === true && + "data" in value + ); +} + +/** + * Type guard: value is an existing relation reference (`{ id: string }`). + */ +export function isExistingRelationValue( + value: unknown, +): value is { id: string } { + return ( + typeof value === "object" && + value !== null && + "id" in value && + typeof (value as { id: unknown }).id === "string" + ); +} + +/** + * Collect relation target IDs out of a content item's data payload, + * grouped by relation field name. + * + * Only processes fields that are: + * - present in `data` + * - declared as relations in `relationFields` + * - hold existing `{ id }` references (or arrays of them) + * + * Unlike `processRelationsInData` in the HTTP route, this does NOT create + * inline `_new` items — callers passing `_new` values will have them + * silently ignored. Programmatic callers (seeds, imports) are expected to + * supply pre-created IDs. + * + * Returns a map of `{ fieldName: targetIds[] }` ready to hand to + * {@link syncRelations}. + */ +export function collectExistingRelationIds( + data: Record, + relationFields: Record, +): Record { + const relationIds: Record = {}; + + for (const [fieldName, relationConfig] of Object.entries(relationFields)) { + if (!(fieldName in data)) continue; + + const fieldValue = data[fieldName]; + if (!fieldValue) { + relationIds[fieldName] = []; + continue; + } + + const ids: string[] = []; + + if (relationConfig.type === "belongsTo") { + const value = fieldValue as RelationValue; + if (isExistingRelationValue(value)) { + ids.push(value.id); + } + } else { + // hasMany / manyToMany — expect an array of { id } + const values = ( + Array.isArray(fieldValue) ? fieldValue : [] + ) as RelationValue[]; + + for (const value of values) { + if (isExistingRelationValue(value)) { + ids.push(value.id); + } + } + } + + relationIds[fieldName] = ids; + } + + return relationIds; +} + +/** + * Sync relations in the junction table for a content item. + * + * Only updates relations for fields explicitly present in `relationIds`. + * Fields not included are left untouched — this preserves existing + * relations during partial updates. + * + * For each included field, existing junction rows (matching sourceId + + * fieldName) are deleted and replaced with the provided target IDs. + */ +export async function syncRelations( + adapter: Adapter, + sourceId: string, + relationIds: Record, +): Promise { + for (const [fieldName, targetIds] of Object.entries(relationIds)) { + await adapter.delete({ + model: "contentRelation", + where: [ + { field: "sourceId", value: sourceId, operator: "eq" as const }, + { field: "fieldName", value: fieldName, operator: "eq" as const }, + ], + }); + + for (const targetId of targetIds) { + await adapter.create({ + model: "contentRelation", + data: { + sourceId, + targetId, + fieldName, + createdAt: new Date(), + }, + }); + } + } +}