Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions docs/content/docs/plugins/cms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="warn">
**`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.
</Callout>

**Via `myStack.api.cms`:**
Expand Down Expand Up @@ -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 },
)
```

<Callout type="info">
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.
</Callout>

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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { installInitDependencies } from "../utils/package-installer";
import {
adapterNeedsGenerate,
getGenerateHintForAdapter,
getOutputForAdapter,
runCliPassthrough,
} from "../utils/passthrough";
import { buildScaffoldPlan } from "../utils/scaffold-plan";
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -33,6 +35,7 @@ export const ADAPTERS: readonly AdapterMeta[] = [
label: "Prisma",
packageName: "@btst/adapter-prisma",
ormForGenerate: "prisma",
extraPackages: ["@prisma/adapter-pg", "pg"],
},
{
key: "drizzle",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/package-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/src/utils/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,24 @@ 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,
): string | null {
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}`;
}
Expand Down
33 changes: 27 additions & 6 deletions packages/cli/src/utils/scaffold-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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"`,
Comment thread
cursor[bot] marked this conversation as resolved.
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 })({}),",
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/stack/src/plugins/cms/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ export {
export {
createCMSContentItem,
type CreateCMSContentItemInput,
type CreateCMSContentItemOptions,
} from "./mutations";
export { CMS_QUERY_KEYS } from "./query-key-defs";
51 changes: 48 additions & 3 deletions packages/stack/src/plugins/cms/api/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -13,12 +18,37 @@ export interface CreateCMSContentItemInput {
data: Record<string, unknown>;
}

/**
* 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.
Expand All @@ -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<SerializedContentItem> {
const contentType = await adapter.findOne<ContentType>({
model: "contentType",
Expand Down Expand Up @@ -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);
}
Loading
Loading