Skip to content

Commit 9406c1d

Browse files
authored
Merge pull request #117 from better-stack-ai/fix/cogegen-cli-prisma
fix: codegen + cms relations
2 parents 431c01a + 7ce1721 commit 9406c1d

12 files changed

Lines changed: 333 additions & 121 deletions

File tree

docs/content/docs/plugins/cms.mdx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,10 +1315,10 @@ export async function generateStaticParams() {
13151315

13161316
### Server-side mutation — `createContentItem`
13171317

1318-
In addition to read-only getters, the CMS plugin exposes a **mutation function** for creating content items directly from server-side code.
1318+
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.
13191319

13201320
<Callout type="warn">
1321-
**`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.
1321+
**`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.
13221322
</Callout>
13231323

13241324
**Via `myStack.api.cms`:**
@@ -1348,6 +1348,52 @@ await createCMSContentItem(myStack.adapter, "client-profile", {
13481348
})
13491349
```
13501350

1351+
#### `syncRelations` option
1352+
1353+
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:
1354+
1355+
- The admin UI's "Related Items" / inverse-relations panel will not discover the item.
1356+
- `useContentByRelation`, `getContentByRelation`, and `*/populated` endpoints will not return it.
1357+
- Only the JSON `{ id: "..." }` reference on the item itself is persisted.
1358+
1359+
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.
1360+
1361+
```ts
1362+
await myStack.api.cms.createContentItem(
1363+
"study-reference",
1364+
{
1365+
slug: "bpc157-ref-gwyer-2019",
1366+
data: {
1367+
compoundId: { id: bpc157.id }, // belongsTo
1368+
categoryIds: [{ id: catPeptides.id }], // manyToMany
1369+
author: "Gwyer, D. et al.",
1370+
year: 2019,
1371+
title: "BPC-157 promotes angiogenesis…",
1372+
quote: "",
1373+
relevance: "Mechanism of Action",
1374+
},
1375+
},
1376+
{ syncRelations: true },
1377+
)
1378+
```
1379+
1380+
<Callout type="info">
1381+
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.
1382+
</Callout>
1383+
1384+
The same option is available on the direct import:
1385+
1386+
```ts
1387+
import { createCMSContentItem } from "@btst/stack/plugins/cms/api"
1388+
1389+
await createCMSContentItem(
1390+
myStack.adapter,
1391+
"study-reference",
1392+
{ slug: "", data: { compoundId: { id: bpc157.id }, /**/ } },
1393+
{ syncRelations: true },
1394+
)
1395+
```
1396+
13511397
Throws if:
13521398
- The content type slug is not found (run `ensureSynced` first if calling outside a plugin request)
13531399
- A content item with the same slug already exists in that content type

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@btst/codegen",
3-
"version": "0.1.2",
3+
"version": "0.1.3",
44
"description": "BTST project scaffolding and CLI passthrough commands.",
55
"repository": {
66
"type": "git",

packages/cli/src/commands/init.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { installInitDependencies } from "../utils/package-installer";
3131
import {
3232
adapterNeedsGenerate,
3333
getGenerateHintForAdapter,
34+
getOutputForAdapter,
3435
runCliPassthrough,
3536
} from "../utils/passthrough";
3637
import { buildScaffoldPlan } from "../utils/scaffold-plan";
@@ -321,8 +322,13 @@ export function createInitCommand() {
321322
const orm = ADAPTERS.find(
322323
(item) => item.key === adapter,
323324
)?.ormForGenerate;
325+
const outputPath = getOutputForAdapter(adapter);
324326
const args = orm
325-
? [`--orm=${orm}`, `--config=${stackPath}`]
327+
? [
328+
`--orm=${orm}`,
329+
`--config=${stackPath}`,
330+
...(outputPath ? [`--output=${outputPath}`] : []),
331+
]
326332
: [`--config=${stackPath}`];
327333
const exitCode = await runCliPassthrough({
328334
cwd,

packages/cli/src/utils/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export interface AdapterMeta {
55
label: string;
66
packageName: string;
77
ormForGenerate?: "prisma" | "drizzle" | "kysely";
8+
/** Additional npm packages that must be installed when this adapter is selected. */
9+
extraPackages?: string[];
810
}
911

1012
export interface PluginMeta {
@@ -33,6 +35,7 @@ export const ADAPTERS: readonly AdapterMeta[] = [
3335
label: "Prisma",
3436
packageName: "@btst/adapter-prisma",
3537
ormForGenerate: "prisma",
38+
extraPackages: ["@prisma/adapter-pg", "pg"],
3639
},
3740
{
3841
key: "drizzle",

packages/cli/src/utils/package-installer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export async function installInitDependencies(input: {
3939
"@btst/yar",
4040
"@tanstack/react-query",
4141
adapterMeta.packageName,
42+
...(adapterMeta.extraPackages ?? []),
4243
...pluginExtraPackages,
4344
];
4445
const { command, args } = getInstallCommand(input.packageManager, packages);

packages/cli/src/utils/passthrough.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,24 @@ export function adapterNeedsGenerate(adapter: Adapter): boolean {
77
return Boolean(ADAPTERS.find((item) => item.key === adapter)?.ormForGenerate);
88
}
99

10+
export function getOutputForAdapter(adapter: Adapter): string | null {
11+
const meta = ADAPTERS.find((item) => item.key === adapter);
12+
if (!meta?.ormForGenerate) return null;
13+
14+
if (meta.ormForGenerate === "prisma") return "prisma/schema.prisma";
15+
if (meta.ormForGenerate === "drizzle") return "src/db/schema.ts";
16+
return "migrations/schema.sql";
17+
}
18+
1019
export function getGenerateHintForAdapter(
1120
adapter: Adapter,
1221
configPath: string,
1322
): string | null {
1423
const meta = ADAPTERS.find((item) => item.key === adapter);
1524
if (!meta?.ormForGenerate) return null;
1625

17-
const output =
18-
meta.ormForGenerate === "prisma"
19-
? "schema.prisma"
20-
: meta.ormForGenerate === "drizzle"
21-
? "src/db/schema.ts"
22-
: "migrations/schema.sql";
26+
const output = getOutputForAdapter(adapter);
27+
if (!output) return null;
2328

2429
return `npx @btst/codegen generate --orm=${meta.ormForGenerate} --config=${configPath} --output=${output}`;
2530
}

packages/cli/src/utils/scaffold-plan.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ function buildPluginTemplateContext(
322322
};
323323
}
324324

325-
function buildAdapterTemplateContext(adapter: Adapter) {
325+
function buildAdapterTemplateContext(adapter: Adapter, stackPath: string) {
326326
const meta = ADAPTERS.find((item) => item.key === adapter);
327327
if (!meta) {
328328
throw new Error(`Unsupported adapter: ${adapter}`);
@@ -337,15 +337,19 @@ function buildAdapterTemplateContext(adapter: Adapter) {
337337
}
338338

339339
if (adapter === "prisma") {
340+
const depth = stackPath.split("/").length - 1;
341+
const prismaClientPath = `${"../".repeat(depth)}generated/prisma/client`;
340342
return {
341343
adapterImport: `import { createPrismaAdapter } from "${meta.packageName}"
342-
import { PrismaClient } from "@prisma/client"`,
343-
adapterSetup: `const prisma = new PrismaClient()
344+
import { PrismaClient } from "${prismaClientPath}"
345+
import { PrismaPg } from "@prisma/adapter-pg"`,
346+
adapterSetup: `const pgAdapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! })
347+
const prisma = new PrismaClient({ adapter: pgAdapter })
344348
345-
const provider = process.env.BTST_PRISMA_PROVIDER ?? "postgresql"
349+
const provider = (process.env.BTST_PRISMA_PROVIDER ?? "postgresql") as "postgresql" | "sqlite" | "cockroachdb" | "mysql" | "sqlserver" | "mongodb"
346350
`,
347351
adapterStackLine:
348-
"adapter: (db) => createPrismaAdapter(prisma, db, { provider }),",
352+
"adapter: (db) => createPrismaAdapter(prisma, db, { provider })({}),",
349353
};
350354
}
351355

@@ -385,7 +389,10 @@ export async function buildScaffoldPlan(
385389
input.plugins,
386390
input.framework,
387391
);
388-
const adapterContext = buildAdapterTemplateContext(input.adapter);
392+
const adapterContext = buildAdapterTemplateContext(
393+
input.adapter,
394+
frameworkPaths.stackPath,
395+
);
389396

390397
const sharedContext = {
391398
alias: input.alias,
@@ -402,6 +409,20 @@ export async function buildScaffoldPlan(
402409
content: await renderTemplate("shared/lib/stack.ts.hbs", sharedContext),
403410
description: "BTST backend stack configuration",
404411
},
412+
...(input.adapter === "prisma"
413+
? [
414+
{
415+
path: "prisma/schema.prisma",
416+
content: `generator client {\n provider = "prisma-client"\n output = "../generated/prisma"\n}\n\ndatasource db {\n provider = "postgresql"\n}\n`,
417+
description: "Prisma schema with explicit client output path",
418+
},
419+
{
420+
path: "prisma.config.ts",
421+
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`,
422+
description: "Prisma configuration file",
423+
},
424+
]
425+
: []),
405426
{
406427
path: frameworkPaths.stackClientPath,
407428
content: await renderTemplate(

packages/stack/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@btst/stack",
3-
"version": "2.11.3",
3+
"version": "2.11.4",
44
"description": "A composable, plugin-based library for building full-stack applications.",
55
"repository": {
66
"type": "git",

packages/stack/src/plugins/cms/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ export {
1515
export {
1616
createCMSContentItem,
1717
type CreateCMSContentItemInput,
18+
type CreateCMSContentItemOptions,
1819
} from "./mutations";
1920
export { CMS_QUERY_KEYS } from "./query-key-defs";

packages/stack/src/plugins/cms/api/mutations.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import type { DBAdapter as Adapter } from "@btst/db";
22
import type { ContentType, ContentItem } from "../types";
33
import { serializeContentItem } from "./getters";
44
import type { SerializedContentItem } from "../types";
5+
import {
6+
collectExistingRelationIds,
7+
extractRelationFields,
8+
syncRelations,
9+
} from "./relations";
510

611
/**
712
* Input for creating a new CMS content item.
@@ -13,12 +18,37 @@ export interface CreateCMSContentItemInput {
1318
data: Record<string, unknown>;
1419
}
1520

21+
/**
22+
* Options for {@link createCMSContentItem}.
23+
*/
24+
export interface CreateCMSContentItemOptions {
25+
/**
26+
* When `true`, persist relation fields (`belongsTo`, `hasMany`,
27+
* `manyToMany`) into the `contentRelation` junction table in addition to
28+
* the item's JSON payload.
29+
*
30+
* This is what the HTTP `POST /content/:typeSlug` route does, and is
31+
* required for the admin "Related Items" panel / inverse-relation queries
32+
* to find the item.
33+
*
34+
* Seeds and other programmatic callers that want the admin UI to work
35+
* should enable this flag. Callers are still expected to pass
36+
* pre-created target IDs (`{ id }` or `[{ id }, ...]`) — inline `_new`
37+
* creation is only supported via the HTTP route.
38+
*
39+
* Defaults to `false` for backwards compatibility (a no-op for content
40+
* types without relations).
41+
*/
42+
syncRelations?: boolean;
43+
}
44+
1645
/**
1746
* Create a new content item for a content type (looked up by slug).
1847
*
19-
* Bypasses Zod schema validation and relation processing — the caller is
20-
* responsible for providing valid, relation-free data. For relation fields or
21-
* schema validation, use the HTTP endpoint instead.
48+
* Bypasses Zod schema validation and inline `_new` relation creation — the
49+
* caller is responsible for providing valid data and pre-created relation
50+
* IDs. For schema validation or inline creation of related items, use the
51+
* HTTP endpoint instead.
2252
*
2353
* Throws if the content type is not found or the slug is already taken within
2454
* that content type.
@@ -30,11 +60,15 @@ export interface CreateCMSContentItemInput {
3060
* @param adapter - The database adapter
3161
* @param contentTypeSlug - Slug of the target content type
3262
* @param input - Item slug and data payload
63+
* @param options - See {@link CreateCMSContentItemOptions}. Pass
64+
* `{ syncRelations: true }` to also populate the `contentRelation`
65+
* junction table for relation fields in the payload.
3366
*/
3467
export async function createCMSContentItem(
3568
adapter: Adapter,
3669
contentTypeSlug: string,
3770
input: CreateCMSContentItemInput,
71+
options: CreateCMSContentItemOptions = {},
3872
): Promise<SerializedContentItem> {
3973
const contentType = await adapter.findOne<ContentType>({
4074
model: "contentType",
@@ -80,5 +114,16 @@ export async function createCMSContentItem(
80114
},
81115
});
82116

117+
if (options.syncRelations) {
118+
const relationFields = extractRelationFields(contentType);
119+
if (Object.keys(relationFields).length > 0) {
120+
const relationIds = collectExistingRelationIds(
121+
input.data,
122+
relationFields,
123+
);
124+
await syncRelations(adapter, item.id, relationIds);
125+
}
126+
}
127+
83128
return serializeContentItem(item);
84129
}

0 commit comments

Comments
 (0)