create-basalt 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,7 @@ Subscriptions / billing? (y/N) n
48
48
  Web UI (React + shadcn)? (y/N) n
49
49
  MCP server (routes as AI-agent tools)? (y/N) n
50
50
  'basalt' CLI (code generators)? (y/N) n
51
+ Database (PostgreSQL via Prisma)? (y/N) n
51
52
  Install dependencies now? (y/N) y
52
53
  Initialize a git repository? (y/N) y
53
54
  ```
@@ -91,6 +92,7 @@ Usage: npm create basalt <name> [options]
91
92
  | `--ui` | off | Generates the `web/` frontend (React + shadcn via `@basaltkit/admin-shadcn` + `@basaltkit/sdk`). **Forces pnpm** — see note below |
92
93
  | `--cli` | off | Generates the `basalt` CLI (`bin/basalt.ts`, `pnpm basalt` script, `make:*` generators from `@basaltkit/generator`) |
93
94
  | `--mcp` | off | Exposes read-only routes as MCP tools (`@basaltkit/mcp`) over HTTP at `POST /mcp` — the overview and health endpoints are opted in via `meta.mcp` |
95
+ | `--prisma` (alias `--db`) | off | Backs the app with PostgreSQL through Prisma: `prisma/schema.prisma`, `prisma.config.ts`, `src/db.ts`, the `@basaltkit/*-prisma` stores instead of the in-memory ones, a required `<APP>_DATABASE_URL`, the `db:*` scripts and `prismaPlugin({ assertMigrated: true })` — see *With `--prisma`* below |
94
96
  | `--install` | off | Installs dependencies at the end (with the detected/chosen manager) |
95
97
  | `--git` | off | Runs `git init` + first commit ("Initial commit from create-basalt") |
96
98
  | `--offline` | off | Don't query the npm registry for the latest versions; use the ranges bundled with this create-basalt release |
@@ -115,7 +117,7 @@ Behavior notes (faithful to the code):
115
117
  pnpm create basalt
116
118
 
117
119
  # Full project, no questions, with everything:
118
- pnpm create basalt my-app --billing --ui --cli --install --git
120
+ pnpm create basalt my-app --billing --ui --cli --prisma --install --git
119
121
 
120
122
  # Minimal API (no tenancy or auth), in another folder:
121
123
  npm create basalt service-api --no-tenancy --no-auth --dir=./apps/service-api
@@ -147,7 +149,7 @@ my-app/
147
149
  └── tests/app.test.ts # smoke test adapted to the options
148
150
  ```
149
151
 
150
- With `--cli`, adds `bin/basalt.ts` and the `"basalt": "tsx bin/basalt.ts"` script. With `--ui`, adds the `web/` folder (Vite 8 + React 19 + Tailwind CSS 4 via `@tailwindcss/vite` + shadcn — Tailwind is configured in `web/src/index.css`, no `tailwind.config.js`/PostCSS — with `web/src/api.ts` built on top of `@basaltkit/sdk`; with auth on it includes a login/register screen).
152
+ With `--prisma`, adds `prisma/schema.prisma`, `prisma.config.ts`, `src/db.ts`, `prisma/seed.ts` (with tenancy) and the `db:generate` / `db:migrate` / `db:deploy` / `db:seed` scripts. With `--cli`, adds `bin/basalt.ts` and the `"basalt": "tsx bin/basalt.ts"` script. With `--ui`, adds the `web/` folder (Vite 8 + React 19 + Tailwind CSS 4 via `@tailwindcss/vite` + shadcn — Tailwind is configured in `web/src/index.css`, no `tailwind.config.js`/PostCSS — with `web/src/api.ts` built on top of `@basaltkit/sdk`; with auth on it includes a login/register screen).
151
153
 
152
154
  ### With `--ui`: running the API and frontend
153
155
 
@@ -159,6 +161,44 @@ pnpm --filter my-app-web dev # terminal 2 — UI at http://localhost:5180
159
161
 
160
162
  Vite's dev server proxies `/api` to the API — there's no CORS to configure.
161
163
 
164
+ ### With `--prisma`: a PostgreSQL-backed app
165
+
166
+ Without the flag the generated app has **no database**: it boots on
167
+ `MemoryUserSource` / `MemoryTenantSource` and the default in-memory team and
168
+ subscription stores, which is ideal for a first run and for CI and is gone on
169
+ the next restart.
170
+
171
+ `--prisma` (or `--db`) generates the database-backed shape instead:
172
+
173
+ | File | What it holds |
174
+ | --- | --- |
175
+ | `prisma/schema.prisma` | The reference models of every `@basaltkit/*-prisma` package the project uses — the same blocks `basalt prisma:sync` merges — plus a `Project` model of your own |
176
+ | `prisma.config.ts` | Prisma 7 keeps the connection URL here. It reads `MY_APP_DATABASE_URL` first and the bare `DATABASE_URL` only as a fallback — the same precedence as `src/env.ts`, so the CLI and the app can never mean two different databases |
177
+ | `src/db.ts` | `prisma` (unscoped — the framework stores use it, they run before a tenant is known) and, with tenancy, `db = prisma.$extends(tenancyExtension())` |
178
+ | `src/app.ts` | `prismaPlugin({ client: db, assertMigrated: true })`, `prismaTenantSource`, `prismaAuthStores`, `prismaTeamsStores`, `prismaSubscriptionsStores` |
179
+ | `prisma/seed.ts` | The `demo` tenant the header and subdomain resolvers expect |
180
+
181
+ ```bash
182
+ pnpm create basalt my-app --prisma
183
+ cd my-app && pnpm install # postinstall runs `prisma generate`
184
+ cp .env.example .env # point MY_APP_DATABASE_URL at your database
185
+ pnpm db:migrate # prisma migrate dev — creates the tables and seeds `demo`
186
+ pnpm dev
187
+ ```
188
+
189
+ **Migrations, never `prisma db push`.** The generated app boots with
190
+ `assertMigrated: true`, which refuses to start unless the database it reached has
191
+ the `_prisma_migrations` table — written by `prisma migrate dev` /
192
+ `prisma migrate deploy`, and *not* by `db push`. That is what turns a wrong
193
+ `DATABASE_URL` (a shell that exported another project's) into a boot error naming
194
+ the database and host instead of a `500` on the first request. In production:
195
+ `pnpm db:deploy`.
196
+
197
+ The generated `tests/app.test.ts` skips itself when no database is configured, so
198
+ `pnpm test` stays green on a machine without PostgreSQL. To add another Basalt
199
+ domain later, install its `@basaltkit/<domain>-prisma` package, run
200
+ `pnpm basalt prisma:sync` (with `--cli`) and then `pnpm db:migrate`.
201
+
162
202
  ### With `--cli`: the `basalt` command line
163
203
 
164
204
  ```bash
@@ -240,6 +280,7 @@ Exported from `create-basalt` (in addition to the `create-basalt` executable):
240
280
  | `ui` | `boolean` | No | `false` | Generate the `web/` frontend |
241
281
  | `cli` | `boolean` | No | `false` | Generate the `basalt` CLI |
242
282
  | `mcp` | `boolean` | No | `false` | Expose read-only routes as MCP tools at `/mcp` |
283
+ | `prisma` | `boolean` | No | `false` | Back the app with PostgreSQL through Prisma (schema, migrations, Prisma-backed stores, `assertMigrated`) |
243
284
  | `resolveLatest` | `boolean` | No | `false` | Resolve every dependency to `^<latest>` from the npm registry before writing (falls back to the bundled ranges on any registry failure) |
244
285
  | `registry` | `ResolveLatestOptions` | No | — | `{ fetch?, registry?, timeoutMs?, overallTimeoutMs?, concurrency?, minimumReleaseAge?, now? }` — injectable fetch (tests), registry URL (default `npm_config_registry` or `https://registry.npmjs.org`), timeouts (5 s per request, 15 s overall), release-age window in minutes (default `pnpm_config_minimum_release_age` / `npm_config_minimum_release_age`, else 1440; `0` disables the probe), clock (tests) |
245
286
 
@@ -267,7 +308,7 @@ Error (extends `Error`) with the message `Target directory "<dir>" already exist
267
308
  | Type | Description |
268
309
  | --- | --- |
269
310
  | `PackageManager` | `'pnpm' \| 'npm' \| 'yarn' \| 'bun'` |
270
- | `ProjectOptions` | `{ name, tenancy, auth, billing, ui, cli }` — all resolved (no optionals) |
311
+ | `ProjectOptions` | `{ name, tenancy, auth, billing, ui, cli, mcp, prisma }` — all resolved (no optionals) |
271
312
  | `CreateProjectInput`, `CreateProjectResult` | Described above |
272
313
 
273
314
  ## Common errors and solutions (FAQ)
@@ -290,6 +331,20 @@ Automatic installation failed (network, Node version, etc.). Go into the folder
290
331
  **I started the app and `GET /auth/login` gives a secret error.**
291
332
  With auth on, `src/env.ts` requires `APP_SECRET` — read as `MY_SAAS_APP_SECRET`, with at least 32 characters (`secret()` supplies a development default only when `NODE_ENV` is explicitly `development`/`test`). Copy `.env.example` to `.env` and set your own secret before going to production.
292
333
 
334
+ **With `--prisma`, the app refuses to boot: `PRISMA_NOT_MIGRATED`.**
335
+ `assertMigrated` reached a database without the `_prisma_migrations` table. The
336
+ message names the database and host it actually reached (never the
337
+ credentials): either it is not the database you meant — check
338
+ `env | grep DATABASE_URL`, a shell may have exported another project's — or it
339
+ was never migrated: run `pnpm db:migrate` (development) or `pnpm db:deploy`
340
+ (production). A database set up with `prisma db push` has no
341
+ `_prisma_migrations` at all; this scaffold is migration-based on purpose.
342
+
343
+ **With `--prisma`, `pnpm typecheck` can't find `./generated/prisma/client.js`.**
344
+ The Prisma client is generated code, not a checked-in file. `pnpm install` runs
345
+ `prisma generate` for you (`postinstall`); after a `git clone` without install,
346
+ or after editing the schema, run `pnpm db:generate`.
347
+
293
348
  **My app connects to the wrong database / port.**
294
349
  A variable exported in your shell beats `--env-file`. The generated `src/env.ts` already reads app-prefixed names (`MY_SAAS_PORT`), so set those rather than the generic ones — the bare names are only a fallback. Check `env | grep DATABASE_URL`, and see *Environment variables and `--env-file`*.
295
350
 
@@ -307,6 +362,7 @@ There's no "re-scaffold" command. Either generate a new project with the right f
307
362
  - **`@basaltkit/tenancy`** — included by default (remove with `--no-tenancy`): header and subdomain resolvers, with a demo `MemoryTenantSource`.
308
363
  - **`@basaltkit/auth`** — included by default (remove with `--no-auth`): `/auth/*` routes and `APP_SECRET` validated in `env.ts`.
309
364
  - **`@basaltkit/subscriptions`** — with `--billing`: example `free`/`pro` plans with trial and feature limits.
365
+ - **`@basaltkit/prisma` + `@basaltkit/{tenancy,auth,teams,subscriptions}-prisma`** — with `--prisma`: the PostgreSQL layer — `prismaPlugin` (tenant-scoped client, boot-time `assertMigrated`), the composed `prisma/schema.prisma` and the Prisma-backed stores for whichever domains are on.
310
366
  - **`@basaltkit/cli` + `@basaltkit/generator`** — with `--cli`: `bin/basalt.ts` calls `runCli`, and `commandsPlugin(generatorCommands())` registers the `make:*` generators.
311
367
  - **`@basaltkit/sdk` + `@basaltkit/admin-shadcn` + `@basaltkit/admin`** — with `--ui`: the `web/` frontend calls the API through a typed client and uses the shadcn components.
312
368
  - **`@basaltkit/testing`** — always present in `devDependencies`, with a generated smoke test in `tests/app.test.ts`.
package/dist/args.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PackageManager } from './index.js';
2
- export declare const USAGE = "Usage: npm create basalt <name> [options]\n\nOptions:\n --dir=<path> Target directory (default: ./<name>)\n --no-tenancy Skip multi-tenancy\n --no-auth Skip authentication\n --billing Include subscriptions/billing\n --ui Scaffold a web/ frontend (React + shadcn + SDK)\n --cli Scaffold the 'basalt' CLI (code generators + commands)\n --mcp Expose read-only routes as MCP tools at /mcp\n --install Install dependencies (default: yes in an interactive\n terminal, skipped in CI/non-TTY; --no-install to opt out)\n --no-install Never install dependencies\n --git Initialize a git repository with a first commit\n (same default rule as --install; --no-git to opt out)\n --no-git Never initialize a git repository\n --offline Don't query the npm registry for the latest versions;\n use the ranges bundled with this create-basalt release\n --pm=<manager> Package manager: pnpm | npm | yarn | bun (default: auto-detect)\n -y, --yes Skip prompts and accept defaults\n -h, --help Show this help\n\nRun with no name in a terminal to be prompted interactively.\n";
2
+ export declare const USAGE = "Usage: npm create basalt <name> [options]\n\nOptions:\n --dir=<path> Target directory (default: ./<name>)\n --no-tenancy Skip multi-tenancy\n --no-auth Skip authentication\n --billing Include subscriptions/billing\n --ui Scaffold a web/ frontend (React + shadcn + SDK)\n --cli Scaffold the 'basalt' CLI (code generators + commands)\n --mcp Expose read-only routes as MCP tools at /mcp\n --prisma, --db Back the app with PostgreSQL through Prisma: schema,\n migrations, prisma-backed stores and a boot-time check\n that the database is the migrated one\n --install Install dependencies (default: yes in an interactive\n terminal, skipped in CI/non-TTY; --no-install to opt out)\n --no-install Never install dependencies\n --git Initialize a git repository with a first commit\n (same default rule as --install; --no-git to opt out)\n --no-git Never initialize a git repository\n --offline Don't query the npm registry for the latest versions;\n use the ranges bundled with this create-basalt release\n --pm=<manager> Package manager: pnpm | npm | yarn | bun (default: auto-detect)\n -y, --yes Skip prompts and accept defaults\n -h, --help Show this help\n\nRun with no name in a terminal to be prompted interactively.\n";
3
3
  export interface Flags {
4
4
  name?: string;
5
5
  dir?: string;
@@ -9,6 +9,7 @@ export interface Flags {
9
9
  ui: boolean;
10
10
  cli: boolean;
11
11
  mcp: boolean;
12
+ prisma: boolean;
12
13
  /** Tri-state: undefined = decide from the environment (TTY yes, CI no). */
13
14
  install?: boolean;
14
15
  git?: boolean;
package/dist/args.js CHANGED
@@ -9,6 +9,9 @@ Options:
9
9
  --ui Scaffold a web/ frontend (React + shadcn + SDK)
10
10
  --cli Scaffold the 'basalt' CLI (code generators + commands)
11
11
  --mcp Expose read-only routes as MCP tools at /mcp
12
+ --prisma, --db Back the app with PostgreSQL through Prisma: schema,
13
+ migrations, prisma-backed stores and a boot-time check
14
+ that the database is the migrated one
12
15
  --install Install dependencies (default: yes in an interactive
13
16
  terminal, skipped in CI/non-TTY; --no-install to opt out)
14
17
  --no-install Never install dependencies
@@ -31,6 +34,7 @@ export function parseArgs(argv) {
31
34
  ui: false,
32
35
  cli: false,
33
36
  mcp: false,
37
+ prisma: false,
34
38
  yes: false,
35
39
  offline: false,
36
40
  };
@@ -47,6 +51,10 @@ export function parseArgs(argv) {
47
51
  flags.cli = true;
48
52
  else if (token === '--mcp')
49
53
  flags.mcp = true;
54
+ // `--db` reads naturally in a sentence ("scaffold it with a db") and is the
55
+ // same switch; the canonical spelling stays the package name, like --mcp.
56
+ else if (token === '--prisma' || token === '--db')
57
+ flags.prisma = true;
50
58
  else if (token === '--install')
51
59
  flags.install = true;
52
60
  else if (token === '--no-install')
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process';
3
3
  import { stdin, stdout } from 'node:process';
4
4
  import { createProject, describeResolution, detectPackageManager, resolveRunDefaults, runWizard, ttyPrompter, TargetNotEmptyError, WizardCancelledError, } from './index.js';
5
5
  import { parseArgs, resolvesLatest, USAGE } from './args.js';
6
+ import { envPrefix } from './templates.js';
6
7
  /** Runs a command inheriting stdio; resolves false on non-zero exit (never throws). */
7
8
  function run(command, args, cwd) {
8
9
  return new Promise((resolve) => {
@@ -35,6 +36,7 @@ if (!flags.yes && flags.name === undefined && stdin.isTTY) {
35
36
  flags.ui = result.ui;
36
37
  flags.cli = result.cli;
37
38
  flags.mcp = result.mcp;
39
+ flags.prisma = result.prisma;
38
40
  flags.install = result.install;
39
41
  flags.git = result.git;
40
42
  flags.pm = result.pm;
@@ -68,6 +70,7 @@ try {
68
70
  ui: flags.ui,
69
71
  cli: flags.cli,
70
72
  mcp: flags.mcp,
73
+ prisma: flags.prisma,
71
74
  // New apps get the latest published version of every dependency (with the
72
75
  // bundled ranges as an offline fallback); --offline skips the registry.
73
76
  resolveLatest: resolvesLatest(flags),
@@ -108,6 +111,12 @@ try {
108
111
  const steps = [`cd ${result.dir}`];
109
112
  if (!run_.install)
110
113
  steps.push(`${pm} install`);
114
+ if (flags.prisma) {
115
+ // Nothing boots before the database exists: env.DATABASE_URL is required and
116
+ // the app asserts at boot that it reached the MIGRATED database.
117
+ steps.push(`cp .env.example .env # then point ${envPrefix(result.options.name)}_DATABASE_URL at your database`);
118
+ steps.push(`${pm} run db:migrate${flags.tenancy ? ' # creates the tables and seeds the demo tenant' : ' # creates the tables'}`);
119
+ }
111
120
  steps.push(`${pm} run dev${flags.ui ? ' # API on :3000' : ''}`);
112
121
  // `web` is wired as a pnpm workspace member (pnpm-workspace.yaml), so its dev
113
122
  // server is launched with a pnpm filter regardless of the root package manager.
package/dist/index.d.ts CHANGED
@@ -14,6 +14,12 @@ export interface CreateProjectInput {
14
14
  cli?: boolean;
15
15
  /** Expose opted-in routes as MCP tools over HTTP at `/mcp`. Default: false. */
16
16
  mcp?: boolean;
17
+ /**
18
+ * Back the app with PostgreSQL through Prisma: `prisma/schema.prisma`,
19
+ * `src/db.ts`, `prismaPlugin({ assertMigrated: true })` and the Prisma-backed
20
+ * stores instead of the in-memory ones. Default: false (no database at all).
21
+ */
22
+ prisma?: boolean;
17
23
  /**
18
24
  * Resolve every dependency to its latest published version (npm registry)
19
25
  * before writing files. Default: false — the embedded fallback ranges are
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
- import { appTest, appTs, devTs, dockerignore, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, readme, routesTs, serverTs, tsconfigJson, } from './templates.js';
3
+ import { appTest, appTs, dbTs, devTs, dockerignore, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, prismaConfigTs, prismaSchema, prismaSeedTs, readme, routesTs, serverTs, tsconfigJson, } from './templates.js';
4
4
  import { uiFiles } from './templates-ui.js';
5
5
  import { applyVersions, collectDependencies, resolveLatestVersions, } from './latest-versions.js';
6
6
  export class TargetNotEmptyError extends Error {
@@ -45,6 +45,7 @@ export async function createProject(input) {
45
45
  ui: input.ui ?? false,
46
46
  cli: input.cli ?? false,
47
47
  mcp: input.mcp ?? false,
48
+ prisma: input.prisma ?? false,
48
49
  };
49
50
  const dir = resolve(input.dir ?? input.name);
50
51
  const existing = await readdir(dir).catch(() => null);
@@ -52,9 +53,9 @@ export async function createProject(input) {
52
53
  throw new TargetNotEmptyError(dir);
53
54
  const files = {
54
55
  'package.json': packageJson(options),
55
- 'tsconfig.json': tsconfigJson(),
56
+ 'tsconfig.json': tsconfigJson(options),
56
57
  '.env.example': envExample(options),
57
- '.gitignore': gitignore(),
58
+ '.gitignore': gitignore(options),
58
59
  '.dockerignore': dockerignore(),
59
60
  'README.md': readme(options),
60
61
  'pnpm-workspace.yaml': pnpmWorkspaceYaml(options),
@@ -64,6 +65,15 @@ export async function createProject(input) {
64
65
  'src/server.ts': serverTs(),
65
66
  'src/dev.ts': devTs(),
66
67
  'tests/app.test.ts': appTest(options),
68
+ ...(options.prisma
69
+ ? {
70
+ 'prisma/schema.prisma': prismaSchema(options),
71
+ 'prisma.config.ts': prismaConfigTs(options),
72
+ 'src/db.ts': dbTs(options),
73
+ // The demo tenant only exists where tenancy does.
74
+ ...(options.tenancy ? { 'prisma/seed.ts': prismaSeedTs(options) } : {}),
75
+ }
76
+ : {}),
67
77
  ...(options.cli ? { 'bin/basalt.ts': basaltBin() } : {}),
68
78
  ...(options.mcp ? { '.mcp.json': mcpJson(options) } : {}),
69
79
  ...(options.ui ? uiFiles(options) : {}),
@@ -31,12 +31,17 @@
31
31
  * major. Bump a range to a new major only together with the templates.
32
32
  */
33
33
  export const THIRD_PARTY_VERSIONS = {
34
+ '@prisma/adapter-pg': '^7.10.0',
35
+ '@prisma/client': '^7.10.0',
34
36
  '@tailwindcss/vite': '^4.3.3',
35
37
  '@types/node': '^26.6.2',
38
+ '@types/pg': '^8.15.6',
36
39
  '@types/react': '^19.3.0',
37
40
  '@types/react-dom': '^19.3.0',
38
41
  '@vitejs/plugin-react': '^6.1.1',
42
+ pg: '^8.16.3',
39
43
  'pino-pretty': '^13.1.3',
44
+ prisma: '^7.10.0',
40
45
  react: '^19.3.0',
41
46
  'react-dom': '^19.3.0',
42
47
  tailwindcss: '^4.3.3',
@@ -9,6 +9,12 @@ export interface ProjectOptions {
9
9
  cli: boolean;
10
10
  /** Expose opted-in routes as MCP tools (@basaltkit/mcp) over HTTP at `/mcp`. */
11
11
  mcp: boolean;
12
+ /**
13
+ * Back the app with a real PostgreSQL database: a `prisma/schema.prisma`,
14
+ * `src/db.ts`, `prismaPlugin({ assertMigrated: true })` and the Prisma-backed
15
+ * stores instead of the in-memory ones.
16
+ */
17
+ prisma: boolean;
12
18
  }
13
19
  export { SCAFFOLD_VERSIONS } from './versions.js';
14
20
  export { THIRD_PARTY_VERSIONS } from './latest-versions.js';
@@ -29,7 +35,7 @@ export declare const thirdPartyVersionOf: (pkg: string) => string;
29
35
  /** True when the scaffold binds users to the tenant they act on (tenancy + auth). */
30
36
  export declare const enforcesMembership: (options: ProjectOptions) => boolean;
31
37
  export declare function packageJson(options: ProjectOptions): string;
32
- export declare function tsconfigJson(): string;
38
+ export declare function tsconfigJson(options: ProjectOptions): string;
33
39
  export declare function envTs(options: ProjectOptions): string;
34
40
  /**
35
41
  * An app-specific env-var prefix derived from the project name
@@ -38,6 +44,8 @@ export declare function envTs(options: ProjectOptions): string;
38
44
  */
39
45
  export declare function envPrefix(name: string): string;
40
46
  export declare function envExample(options: ProjectOptions): string;
47
+ /** A PostgreSQL-safe database name derived from the project name. */
48
+ export declare function databaseName(name: string): string;
41
49
  export declare function appTs(options: ProjectOptions): string;
42
50
  export declare function routesTs(options: ProjectOptions): string;
43
51
  export declare function serverTs(): string;
@@ -49,6 +57,34 @@ export declare function serverTs(): string;
49
57
  */
50
58
  export declare function devTs(): string;
51
59
  export declare function basaltBin(): string;
60
+ /**
61
+ * `prisma/schema.prisma` — the datasource, the client generator, the models of
62
+ * every Basalt domain this app enables, and the app's own first model.
63
+ *
64
+ * Prisma 7 no longer takes the connection URL here (prisma.config.ts does), and
65
+ * the client needs an explicit `output`; src/db.ts imports it from there.
66
+ */
67
+ export declare function prismaSchema(options: ProjectOptions): string;
68
+ /**
69
+ * `prisma.config.ts` — Prisma 7 reads the connection URL (and the seed command)
70
+ * from here instead of from schema.prisma. It applies the SAME precedence as
71
+ * src/env.ts: the app-prefixed name first, the bare `DATABASE_URL` only as a
72
+ * fallback, so the CLI and the running app can never mean different databases.
73
+ */
74
+ export declare function prismaConfigTs(options: ProjectOptions): string;
75
+ /**
76
+ * `src/db.ts` — the Prisma client(s). Two bindings on purpose: the unscoped
77
+ * `prisma` for the framework stores (they key their own rows and run outside a
78
+ * tenant context) and, with tenancy, the `db` client the whole application uses,
79
+ * where every query is filtered by the request's tenant.
80
+ */
81
+ export declare function dbTs(options: ProjectOptions): string;
82
+ /**
83
+ * `prisma/seed.ts` — creates the `demo` tenant the scaffolded resolvers expect
84
+ * (`x-tenant-id: demo`, `demo.localhost`). Runs from `pnpm db:seed` and after
85
+ * `prisma migrate dev` (prisma.config.ts declares it).
86
+ */
87
+ export declare function prismaSeedTs(options: ProjectOptions): string;
52
88
  export declare function appTest(options: ProjectOptions): string;
53
89
  export declare function readme(options: ProjectOptions): string;
54
90
  /**
@@ -65,7 +101,7 @@ export declare function mcpJson(_options: ProjectOptions): string;
65
101
  * `certs/server.key` through.
66
102
  */
67
103
  export declare function dockerignore(): string;
68
- export declare function gitignore(): string;
104
+ export declare function gitignore(options: ProjectOptions): string;
69
105
  /**
70
106
  * pnpm settings: esbuild's build script is required by tsx;
71
107
  * msgpackr-extract (optional native accelerator via BullMQ) is declined —
package/dist/templates.js CHANGED
@@ -46,13 +46,34 @@ export function packageJson(options) {
46
46
  basalt.push('@basaltkit/mcp');
47
47
  if (options.cli) {
48
48
  // Runtime deps: app.ts uses commandsPlugin (@basaltkit/cli); prisma powers
49
- // `basalt prisma:sync` (and prismaPlugin once the app adds a PrismaClient —
50
- // the scaffold wires no database itself). @basaltkit/generator is dev-only (below).
49
+ // `basalt prisma:sync` (and prismaPlugin, wired by --prisma).
50
+ // @basaltkit/generator is dev-only (below).
51
51
  basalt.push('@basaltkit/cli', '@basaltkit/prisma');
52
52
  }
53
+ if (options.prisma) {
54
+ // The database layer: prismaPlugin + tenancyExtension, plus the store
55
+ // packages for whichever domains are on (the memory ones are gone).
56
+ if (!options.cli)
57
+ basalt.push('@basaltkit/prisma');
58
+ if (options.tenancy)
59
+ basalt.push('@basaltkit/tenancy-prisma');
60
+ if (options.auth)
61
+ basalt.push('@basaltkit/auth-prisma');
62
+ if (enforcesMembership(options))
63
+ basalt.push('@basaltkit/teams-prisma');
64
+ if (options.billing)
65
+ basalt.push('@basaltkit/subscriptions-prisma');
66
+ }
53
67
  const dependencies = { zod: thirdPartyVersionOf('zod') };
54
68
  for (const pkg of basalt)
55
69
  dependencies[pkg] = versionOf(pkg);
70
+ if (options.prisma) {
71
+ // Prisma 7 talks to PostgreSQL through a driver adapter, so `pg` is a real
72
+ // runtime dependency (see src/db.ts); the `prisma` CLI is dev-only.
73
+ for (const pkg of ['@prisma/adapter-pg', '@prisma/client', 'pg']) {
74
+ dependencies[pkg] = thirdPartyVersionOf(pkg);
75
+ }
76
+ }
56
77
  const devDependencies = {
57
78
  '@basaltkit/testing': versionOf('@basaltkit/testing'),
58
79
  };
@@ -70,6 +91,10 @@ export function packageJson(options) {
70
91
  // app ships and runs without it (see `.mcp.json` for the client config).
71
92
  devDependencies['@basaltkit/ai-mcp'] = versionOf('@basaltkit/ai-mcp');
72
93
  }
94
+ if (options.prisma) {
95
+ for (const pkg of ['@types/pg', 'prisma'])
96
+ devDependencies[pkg] = thirdPartyVersionOf(pkg);
97
+ }
73
98
  return `${JSON.stringify({
74
99
  name: options.name,
75
100
  version: '0.1.0',
@@ -83,12 +108,27 @@ export function packageJson(options) {
83
108
  test: 'vitest run',
84
109
  typecheck: 'tsc --noEmit',
85
110
  ...(options.cli ? { basalt: 'tsx bin/basalt.ts' } : {}),
111
+ ...(options.prisma
112
+ ? {
113
+ // Generate right after install so `pnpm typecheck` has the client
114
+ // types (generation needs no database).
115
+ postinstall: 'prisma generate',
116
+ 'db:generate': 'prisma generate',
117
+ // Development and deployment BOTH go through migrations: they are
118
+ // what creates `_prisma_migrations`, which the app's
119
+ // prismaPlugin({ assertMigrated: true }) requires at boot. There
120
+ // is deliberately no `db:push` script.
121
+ 'db:migrate': 'prisma migrate dev',
122
+ 'db:deploy': 'prisma migrate deploy',
123
+ ...(options.tenancy ? { 'db:seed': 'tsx prisma/seed.ts' } : {}),
124
+ }
125
+ : {}),
86
126
  },
87
127
  dependencies: Object.fromEntries(Object.entries(dependencies).sort()),
88
128
  devDependencies: Object.fromEntries(Object.entries(devDependencies).sort()),
89
129
  }, null, 2)}\n`;
90
130
  }
91
- export function tsconfigJson() {
131
+ export function tsconfigJson(options) {
92
132
  return `${JSON.stringify({
93
133
  compilerOptions: {
94
134
  target: 'ES2022',
@@ -101,7 +141,10 @@ export function tsconfigJson() {
101
141
  noEmit: true,
102
142
  types: ['node'],
103
143
  },
104
- include: ['src', 'tests'],
144
+ // The generated Prisma client lives under src/generated (git-ignored, and
145
+ // re-created by `prisma generate`); the seed script under prisma/ is
146
+ // type-checked too.
147
+ include: ['src', 'tests', ...(options.prisma && options.tenancy ? ['prisma/seed.ts'] : [])],
105
148
  }, null, 2)}\n`;
106
149
  }
107
150
  export function envTs(options) {
@@ -119,7 +162,14 @@ export const env = defineEnv(
119
162
  LOG_LEVEL: z.enum(LOG_LEVELS).default('info'),
120
163
  // Unset counts as production (fail-closed); \`pnpm dev\` sets development.
121
164
  // NODE_ENV is a Node-wide convention and is never prefixed.
122
- NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),${options.auth
165
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),${options.prisma
166
+ ? `
167
+ // Required: the PostgreSQL connection string, read as ${prefix}_DATABASE_URL
168
+ // first (a stray DATABASE_URL exported in your shell must not win — that is
169
+ // how an app boots against another project's database). src/db.ts builds the
170
+ // Prisma client from it; prisma.config.ts gives the CLI the same URL.
171
+ DATABASE_URL: z.string().min(1),`
172
+ : ''}${options.auth
123
173
  ? `
124
174
  // Signs JWTs and sessions. secret() is fail-closed: required unless NODE_ENV
125
175
  // is explicitly development/test (no fallback when NODE_ENV is unset),
@@ -179,10 +229,20 @@ ${options.auth
179
229
  # without it. Generate a strong one: openssl rand -base64 48
180
230
  # ${prefix}_APP_SECRET=
181
231
  `
182
- : ''}# When you add a database, declare DATABASE_URL in src/env.ts and set it here
183
- # under the prefixed name:
232
+ : ''}${options.prisma
233
+ ? `# Required: the app does not boot without it. The Prisma CLI reads the same
234
+ # name from prisma.config.ts, so \`pnpm db:migrate\` and the running app always
235
+ # agree on which database they mean.
236
+ ${prefix}_DATABASE_URL=postgres://postgres:postgres@localhost:5432/${databaseName(options.name)}
237
+ `
238
+ : `# When you add a database, declare DATABASE_URL in src/env.ts and set it here
239
+ # under the prefixed name (or scaffold with --prisma, which does it for you):
184
240
  # ${prefix}_DATABASE_URL=postgres://user:pass@localhost:5432/${options.name.replace(/^@[^/]+\//, '')}
185
- `;
241
+ `}`;
242
+ }
243
+ /** A PostgreSQL-safe database name derived from the project name. */
244
+ export function databaseName(name) {
245
+ return name.replace(/^@[^/]+\//, '').replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase();
186
246
  }
187
247
  export function appTs(options) {
188
248
  const imports = [
@@ -207,24 +267,73 @@ export function appTs(options) {
207
267
  })`,
208
268
  ];
209
269
  let routesExpression = 'appRoutes';
270
+ /** Module-level bindings emitted between the imports and `buildApp`. */
271
+ const preamble = [];
272
+ if (options.prisma) {
273
+ imports.push(`import { prismaPlugin } from '@basaltkit/prisma'`);
274
+ imports.push(`import { db, prisma } from './db.js'`);
275
+ plugins.push(`prismaPlugin({
276
+ // \`db\` is the tenant-scoped client (src/db.ts): db<PrismaClient>() in a
277
+ // handler returns it, already filtered by the request's tenant.
278
+ client: db,
279
+ // Fail the BOOT when this database is not the migrated one — a shell that
280
+ // exported another project's DATABASE_URL, a typo in the database name.
281
+ // Without it the app starts and dies on the first query instead. Needs
282
+ // \`prisma migrate dev\`/\`migrate deploy\` (they write _prisma_migrations);
283
+ // \`prisma db push\` does not, so never use it against this app.
284
+ assertMigrated: true,
285
+ })`);
286
+ }
210
287
  if (options.tenancy) {
211
- imports.push(`import { headerResolver, MemoryTenantSource, subdomainResolver, tenancyPlugin } from '@basaltkit/tenancy'`);
212
- plugins.push(`tenancyPlugin({
288
+ if (options.prisma) {
289
+ imports.push(`import { headerResolver, subdomainResolver, tenancyPlugin } from '@basaltkit/tenancy'`);
290
+ imports.push(`import { prismaTenantSource } from '@basaltkit/tenancy-prisma'`);
291
+ // The stores read their own tables, outside any tenant context (a login
292
+ // happens before a tenant is known) — so they take the UNSCOPED client.
293
+ preamble.push(`/** Tenants live in the \`tenants\` table — seed one with \`pnpm db:seed\`. */
294
+ const tenants = prismaTenantSource(prisma)`);
295
+ plugins.push(`tenancyPlugin({
296
+ source: tenants,
297
+ resolvers: [headerResolver(), subdomainResolver({ base: 'localhost' })],
298
+ })`);
299
+ }
300
+ else {
301
+ imports.push(`import { headerResolver, MemoryTenantSource, subdomainResolver, tenancyPlugin } from '@basaltkit/tenancy'`);
302
+ plugins.push(`tenancyPlugin({
213
303
  // Replace MemoryTenantSource with your database-backed source.
214
304
  source: new MemoryTenantSource().add({ id: 'demo', name: 'Demo Tenant' }),
215
305
  resolvers: [headerResolver(), subdomainResolver({ base: 'localhost' })],
216
306
  })`);
307
+ }
217
308
  }
218
309
  if (options.auth) {
219
- imports.push(`import { authPlugin, authRoutes, mfaRoutes, MemoryUserSource } from '@basaltkit/auth'`);
220
- imports.push(`import { env } from './env.js'`);
221
- plugins.push(`authPlugin({
310
+ if (options.prisma) {
311
+ imports.push(`import { authPlugin, authRoutes, mfaRoutes } from '@basaltkit/auth'`);
312
+ imports.push(`import { prismaAuthStores } from '@basaltkit/auth-prisma'`);
313
+ imports.push(`import { env } from './env.js'`);
314
+ preamble.push(`/** Users, sessions, refresh tokens, email/reset tokens, MFA and API keys. */
315
+ const authStores = prismaAuthStores(prisma)`);
316
+ plugins.push(`authPlugin({
317
+ users: authStores.users,
318
+ sessions: authStores.sessions,
319
+ refreshTokens: authStores.refreshTokens,
320
+ // Email verification and password reset.
321
+ tokens: authStores.tokens,
322
+ mfa: authStores.mfa,
323
+ secret: env.APP_SECRET,
324
+ })`);
325
+ }
326
+ else {
327
+ imports.push(`import { authPlugin, authRoutes, mfaRoutes, MemoryUserSource } from '@basaltkit/auth'`);
328
+ imports.push(`import { env } from './env.js'`);
329
+ plugins.push(`authPlugin({
222
330
  // Replace MemoryUserSource with your database-backed source (e.g.
223
331
  // @basaltkit/auth-sqlite or @basaltkit/auth-prisma). The default in-memory
224
332
  // MFA store is enough for the ready-made TOTP flow below.
225
333
  users: new MemoryUserSource(),
226
334
  secret: env.APP_SECRET,
227
335
  })`);
336
+ }
228
337
  // authRoutes(): register, login, logout, refresh, me, email verification and
229
338
  // password recovery. mfaRoutes(): TOTP enroll/activate/status/disable.
230
339
  routesExpression = '[...appRoutes, ...authRoutes(), ...mfaRoutes()]';
@@ -237,10 +346,21 @@ export function appTs(options) {
237
346
  // meta: { central: true }.
238
347
  imports[0] = `import { createApp, definePlugin } from '@basaltkit/core'`;
239
348
  imports.push(`import { TEAMS, teamsPlugin, tenantMembershipPlugin } from '@basaltkit/teams'`);
240
- plugins.push(`teamsPlugin({
349
+ if (options.prisma) {
350
+ imports.push(`import { prismaTeamsStores } from '@basaltkit/teams-prisma'`);
351
+ preamble.push(`/** Memberships and invitations (\`team_memberships\`, \`team_invitations\`). */
352
+ const teamStores = prismaTeamsStores(prisma)`);
353
+ plugins.push(`teamsPlugin({
354
+ memberships: teamStores.memberships,
355
+ invitations: teamStores.invitations,
356
+ })`);
357
+ }
358
+ else {
359
+ plugins.push(`teamsPlugin({
241
360
  // TODO: the default membership/invitation stores are in-memory — pass
242
361
  // persistent ones (memberships, invitations) before production.
243
362
  })`);
363
+ }
244
364
  plugins.push(`// Rejects authenticated requests for a tenant the user is not a member of.
245
365
  tenantMembershipPlugin()`);
246
366
  plugins.push(`// Dev-only seed: each new registrant joins the 'demo' tenant so the scaffold
@@ -250,12 +370,22 @@ export function appTs(options) {
250
370
  }
251
371
  if (options.billing) {
252
372
  imports.push(`import { definePlans, subscriptionsPlugin } from '@basaltkit/subscriptions'`);
373
+ if (options.prisma) {
374
+ imports.push(`import { prismaSubscriptionsStores } from '@basaltkit/subscriptions-prisma'`);
375
+ preamble.push(`/** Subscriptions, usage counters and webhook idempotency. */
376
+ const subscriptionStores = prismaSubscriptionsStores(prisma)`);
377
+ }
253
378
  plugins.push(`subscriptionsPlugin({
254
379
  plans: definePlans({
255
380
  free: { price: 0, features: { projects: 3 } },
256
381
  pro: { price: { monthly: 29, yearly: 290 }, trial: '14d', features: { projects: 50 } },
257
382
  }),
258
- fallbackPlan: 'free',
383
+ fallbackPlan: 'free',${options.prisma
384
+ ? `
385
+ store: subscriptionStores.store,
386
+ usage: subscriptionStores.usage,
387
+ webhooks: subscriptionStores.webhooks,`
388
+ : ''}
259
389
  })`);
260
390
  }
261
391
  if (options.cli) {
@@ -284,7 +414,7 @@ export function appTs(options) {
284
414
  : '';
285
415
  return `${imports.join('\n')}
286
416
  import { appRoutes } from './routes.js'
287
-
417
+ ${preamble.length > 0 ? `\n${preamble.join('\n\n')}\n` : ''}
288
418
  export interface BuildAppOptions {
289
419
  logLevel?: LogLevel
290
420
  pretty?: boolean${commandsField}
@@ -423,14 +553,368 @@ const app = buildApp({
423
553
  process.exit(await runCli({ app }))
424
554
  `;
425
555
  }
556
+ /**
557
+ * The models each `@basaltkit/<domain>-prisma` package needs, copied VERBATIM
558
+ * from that package's reference `prisma/schema.prisma` — the same blocks
559
+ * `basalt prisma:sync` merges into an existing schema. The scaffold ships the
560
+ * merged result so a new app can run `prisma migrate dev` straight away;
561
+ * `tests/prisma.test.ts` fails if a package's reference schema drifts from the
562
+ * copy here.
563
+ */
564
+ const PRISMA_MODELS = {
565
+ tenancy: `model Tenant {
566
+ id String @id
567
+ // The open tenant record ({ id, ...anything }) as JSON — attach whatever
568
+ // per-tenant fields you like; they round-trip unchanged.
569
+ data Json
570
+ domains TenantDomain[]
571
+
572
+ @@map("tenants")
573
+ }
574
+
575
+ model TenantDomain {
576
+ domain String @id
577
+ tenantId String
578
+ tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
579
+
580
+ @@index([tenantId])
581
+ @@map("tenant_domains")
582
+ }`,
583
+ auth: `model AuthUser {
584
+ id String @id
585
+ email String @unique
586
+ passwordHash String
587
+ emailVerified Boolean @default(false)
588
+
589
+ @@map("auth_users")
590
+ }
591
+
592
+ model AuthSession {
593
+ id String @id
594
+ userId String
595
+ expiresAt DateTime
596
+
597
+ @@index([userId])
598
+ @@map("auth_sessions")
599
+ }
600
+
601
+ model AuthRefreshToken {
602
+ token String @id
603
+ familyId String
604
+ userId String
605
+ expiresAt DateTime
606
+ usedAt DateTime?
607
+
608
+ @@index([familyId])
609
+ @@index([userId])
610
+ @@map("auth_refresh_tokens")
611
+ }
612
+
613
+ model AuthToken {
614
+ token String @id
615
+ userId String
616
+ purpose String
617
+ expiresAt DateTime
618
+ usedAt DateTime?
619
+
620
+ @@index([userId, purpose])
621
+ @@map("auth_tokens")
622
+ }
623
+
624
+ model AuthApiKey {
625
+ id String @id
626
+ name String
627
+ prefix String
628
+ hash String @unique
629
+ tenantId String?
630
+ userId String?
631
+ scopes String[]
632
+ createdAt DateTime
633
+ expiresAt DateTime?
634
+ lastUsedAt DateTime?
635
+ revokedAt DateTime?
636
+
637
+ @@index([tenantId])
638
+ @@index([userId])
639
+ @@map("auth_api_keys")
640
+ }
641
+
642
+ model AuthMfa {
643
+ userId String @id
644
+ secret String
645
+ enabled Boolean @default(false)
646
+ recoveryCodes String[]
647
+ lastUsedStep Int?
648
+
649
+ @@map("auth_mfa")
650
+ }
651
+
652
+ model AuthTokenVersion {
653
+ userId String @id
654
+ version Int @default(0)
655
+
656
+ @@map("auth_token_versions")
657
+ }
658
+
659
+ model AuthAccountLink {
660
+ // SHA-256 (hex) of provider + NUL + subject — the natural key hashed so the
661
+ // primary key stays short on MySQL (an OIDC \`sub\` may be 255 characters).
662
+ id String @id
663
+ provider String
664
+ subject String
665
+ userId String
666
+ email String
667
+ createdAt DateTime
668
+
669
+ @@index([userId])
670
+ @@map("auth_account_links")
671
+ }
672
+
673
+ model AuthPasskey {
674
+ // SHA-256 (hex) of the credential id (credential ids reach 1 023 bytes).
675
+ id String @id
676
+ credentialId String
677
+ userId String
678
+ publicKey String
679
+ counter BigInt
680
+ transports String?
681
+ deviceName String?
682
+ createdAt DateTime
683
+ lastUsedAt DateTime?
684
+
685
+ @@index([userId])
686
+ @@map("auth_passkeys")
687
+ }`,
688
+ teams: `model TeamMembership {
689
+ tenantId String
690
+ userId String
691
+ role String
692
+ createdAt DateTime
693
+
694
+ @@id([tenantId, userId])
695
+ @@map("team_memberships")
696
+ }
697
+
698
+ model TeamInvitation {
699
+ id String @id
700
+ tenantId String
701
+ email String
702
+ role String
703
+ token String @unique
704
+ invitedBy String?
705
+ expiresAt DateTime
706
+ acceptedAt DateTime?
707
+ revokedAt DateTime?
708
+
709
+ @@index([tenantId, email])
710
+ @@map("team_invitations")
711
+ }`,
712
+ subscriptions: `model Subscription {
713
+ billableId String @id
714
+ plan String
715
+ period String
716
+ status String
717
+ trialEndsAt DateTime?
718
+ cancelAtPeriodEnd Boolean?
719
+ canceledAt DateTime?
720
+ gatewayRef String?
721
+ pendingPlan String?
722
+ pendingPeriod String?
723
+
724
+ @@map("subscriptions")
725
+ }
726
+
727
+ model UsageCounter {
728
+ billableId String
729
+ feature String
730
+ periodKey String
731
+ value Int @default(0)
732
+
733
+ @@id([billableId, feature, periodKey])
734
+ @@map("usage_counters")
735
+ }
736
+
737
+ model WebhookEvent {
738
+ id String @id
739
+ seenAt DateTime @default(now())
740
+
741
+ @@map("webhook_events")
742
+ }`,
743
+ };
744
+ /**
745
+ * `prisma/schema.prisma` — the datasource, the client generator, the models of
746
+ * every Basalt domain this app enables, and the app's own first model.
747
+ *
748
+ * Prisma 7 no longer takes the connection URL here (prisma.config.ts does), and
749
+ * the client needs an explicit `output`; src/db.ts imports it from there.
750
+ */
751
+ export function prismaSchema(options) {
752
+ const domains = [];
753
+ if (options.tenancy)
754
+ domains.push(PRISMA_MODELS.tenancy);
755
+ if (options.auth)
756
+ domains.push(PRISMA_MODELS.auth);
757
+ if (enforcesMembership(options))
758
+ domains.push(PRISMA_MODELS.teams);
759
+ if (options.billing)
760
+ domains.push(PRISMA_MODELS.subscriptions);
761
+ return `// Prisma schema for ${options.name}.
762
+ //
763
+ // The blocks below the header are the reference models of the @basaltkit/*-prisma
764
+ // packages this app uses — exactly what \`basalt prisma:sync\` merges. Add another
765
+ // Basalt domain later by installing its \`*-prisma\` package and running
766
+ // \`pnpm basalt prisma:sync\` (with --cli), then \`pnpm db:migrate\`.
767
+ //
768
+ // Apply changes with \`pnpm db:migrate\` (\`prisma migrate dev\`) — never
769
+ // \`prisma db push\`: push writes no _prisma_migrations table, and src/app.ts
770
+ // boots with prismaPlugin({ assertMigrated: true }), which requires it.
771
+
772
+ generator client {
773
+ provider = "prisma-client-js"
774
+ output = "../src/generated/prisma"
775
+ }
776
+
777
+ datasource db {
778
+ // The URL lives in prisma.config.ts (Prisma 7) — and in src/env.ts for the app.
779
+ provider = "postgresql"
780
+ }
781
+ ${domains.map((block) => `\n${block}\n`).join('')}
782
+ // ---------------------------------------------------------------------------
783
+ // Your models.${options.tenancy
784
+ ? ` \`tenantId\` is the column tenancyExtension() (src/db.ts)
785
+ // forces onto every query and every write, so keep it on everything a tenant
786
+ // owns — and make cross-model foreign keys composite (@@unique([tenantId, id])
787
+ // on the target) so the database refuses a cross-tenant link too.`
788
+ : ''}
789
+
790
+ model Project {
791
+ id String @id @default(uuid())${options.tenancy ? '\n tenantId String' : ''}
792
+ name String
793
+ createdAt DateTime @default(now())
794
+ ${options.tenancy ? '\n @@index([tenantId])' : ''}
795
+ @@map("projects")
796
+ }
797
+ `;
798
+ }
799
+ /**
800
+ * `prisma.config.ts` — Prisma 7 reads the connection URL (and the seed command)
801
+ * from here instead of from schema.prisma. It applies the SAME precedence as
802
+ * src/env.ts: the app-prefixed name first, the bare `DATABASE_URL` only as a
803
+ * fallback, so the CLI and the running app can never mean different databases.
804
+ */
805
+ export function prismaConfigTs(options) {
806
+ const prefix = envPrefix(options.name);
807
+ return `import { loadEnvFile } from 'node:process'
808
+ import { defineConfig } from 'prisma/config'
809
+
810
+ // Prisma loads no .env for you (neither does the app — see src/env.ts). This
811
+ // mirrors \`node --env-file=.env\`: a variable already exported in your shell
812
+ // still wins, the file only fills in what is missing.
813
+ try {
814
+ loadEnvFile('.env')
815
+ } catch {
816
+ // No .env file — the variables come from the environment.
817
+ }
818
+
819
+ export default defineConfig({
820
+ schema: 'prisma/schema.prisma',${options.tenancy
821
+ ? `
822
+ // Runs after \`prisma migrate dev\` / \`prisma migrate reset\`.
823
+ migrations: { seed: 'tsx prisma/seed.ts' },`
824
+ : ''}
825
+ datasource: {
826
+ // ${prefix}_DATABASE_URL first, bare DATABASE_URL only as a fallback: a
827
+ // stray DATABASE_URL from another project must never decide which database
828
+ // gets migrated.
829
+ url: process.env['${prefix}_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? '',
830
+ },
831
+ })
832
+ `;
833
+ }
834
+ /**
835
+ * `src/db.ts` — the Prisma client(s). Two bindings on purpose: the unscoped
836
+ * `prisma` for the framework stores (they key their own rows and run outside a
837
+ * tenant context) and, with tenancy, the `db` client the whole application uses,
838
+ * where every query is filtered by the request's tenant.
839
+ */
840
+ export function dbTs(options) {
841
+ return `import { PrismaPg } from '@prisma/adapter-pg'${options.tenancy ? `\nimport { tenancyExtension } from '@basaltkit/prisma'` : ''}
842
+ import { PrismaClient } from './generated/prisma/client.js'
843
+ import { env } from './env.js'
844
+
845
+ /**
846
+ * The base client. Prisma 7 talks to PostgreSQL through a driver adapter, so the
847
+ * connection string is passed here; prisma.config.ts hands the CLI the same one.
848
+ *
849
+ * ${options.tenancy
850
+ ? `This client is NOT tenant-scoped: the framework stores (auth, tenancy,
851
+ * teams, …) read and write their own tables, keyed by their own columns, and do
852
+ * it before a tenant is known (a login has no tenant yet). Application code
853
+ * should use \`db\` below — or \`db<PrismaClient>()\` from @basaltkit/prisma.`
854
+ : `Application code can also reach it with \`db<PrismaClient>()\` from
855
+ * @basaltkit/prisma, which returns the client registered by prismaPlugin.`}
856
+ */
857
+ export const prisma = new PrismaClient({
858
+ adapter: new PrismaPg({ connectionString: env.DATABASE_URL }),
859
+ })
860
+ ${options.tenancy
861
+ ? `
862
+ /**
863
+ * The application client: tenancyExtension() forces the current tenant's
864
+ * \`tenantId\` onto every read, stamps it onto every write, and fails closed
865
+ * (MissingTenantError) when there is no tenant in context — application code
866
+ * cannot forget the filter. src/app.ts registers it with prismaPlugin, so
867
+ * \`db<PrismaClient>()\` inside a handler returns this one.
868
+ */
869
+ export const db = prisma.$extends(tenancyExtension())
870
+ `
871
+ : `
872
+ /** Registered by prismaPlugin in src/app.ts. */
873
+ export const db = prisma
874
+ `}`;
875
+ }
876
+ /**
877
+ * `prisma/seed.ts` — creates the `demo` tenant the scaffolded resolvers expect
878
+ * (`x-tenant-id: demo`, `demo.localhost`). Runs from `pnpm db:seed` and after
879
+ * `prisma migrate dev` (prisma.config.ts declares it).
880
+ */
881
+ export function prismaSeedTs(options) {
882
+ return `import { prismaTenantSource } from '@basaltkit/tenancy-prisma'
883
+ import { prisma } from '../src/db.js'
884
+
885
+ // The demo tenant the header and subdomain resolvers look for out of the box.
886
+ // \`save\` is an upsert, so re-seeding is safe. Tenants are open records: add
887
+ // whatever per-tenant fields you like — they round-trip unchanged.
888
+ const tenants = prismaTenantSource(prisma)
889
+ await tenants.save({ id: 'demo', name: 'Demo Tenant' })
890
+ console.log('Seeded tenant "demo" for ${options.name}.')
891
+
892
+ await prisma.$disconnect()
893
+ `;
894
+ }
426
895
  export function appTest(options) {
896
+ const prefix = envPrefix(options.name);
897
+ // With a database the app cannot boot without one: src/env.ts requires
898
+ // DATABASE_URL and prismaPlugin({ assertMigrated: true }) checks the schema.
899
+ // So the suite is gated on a configured database and imports src/app.ts
900
+ // lazily — an eager import would throw at module load instead of skipping.
427
901
  return `import { describe, expect, it } from 'vitest'
428
902
  import { FASTIFY } from '@basaltkit/fastify'
429
- import { buildApp } from '../src/app.js'
903
+ ${options.prisma
904
+ ? `
905
+ // Needs a migrated database: set ${prefix}_DATABASE_URL (see .env.example), then
906
+ // \`pnpm db:migrate${options.tenancy ? ' && pnpm db:seed' : ''}\`. Without one the suite skips instead of failing.
907
+ const database = process.env['${prefix}_DATABASE_URL'] ?? process.env['DATABASE_URL']
908
+
909
+ describe.skipIf(!database)('app', () => {
910
+ it('boots and responds on /health', async () => {
911
+ const { buildApp } = await import('../src/app.js')
912
+ const app = await buildApp({ logLevel: 'silent' }).boot()`
913
+ : `import { buildApp } from '../src/app.js'
430
914
 
431
915
  describe('app', () => {
432
916
  it('boots and responds on /health', async () => {
433
- const app = await buildApp({ logLevel: 'silent' }).boot()
917
+ const app = await buildApp({ logLevel: 'silent' }).boot()`}
434
918
  const server = app.container.get(FASTIFY)
435
919
 
436
920
  const index = await server.inject({ method: 'GET', url: '/' })
@@ -459,6 +943,7 @@ export function readme(options) {
459
943
  'typed routes with Zod validation',
460
944
  'structured logging with request/tenant context',
461
945
  'typed domain events',
946
+ ...(options.prisma ? ['PostgreSQL persistence through Prisma (migrations + tenant-scoped client)'] : []),
462
947
  ...(options.tenancy ? ['multi-tenancy (header + subdomain resolvers)'] : []),
463
948
  ...(options.auth ? ['authentication (register/login/refresh/me)'] : []),
464
949
  ...(options.billing ? ['subscriptions with plans and feature limits'] : []),
@@ -475,11 +960,64 @@ Included: ${features.join(' · ')}.
475
960
  ## Getting started
476
961
 
477
962
  \`\`\`bash
478
- pnpm install
963
+ pnpm install${options.prisma
964
+ ? `
965
+ # point ${envPrefix(options.name)}_DATABASE_URL at a PostgreSQL database (.env.example), then
966
+ pnpm db:migrate # create the tables${options.tenancy ? ` (runs the seed too)` : ''}`
967
+ : ''}
479
968
  pnpm dev # API on http://localhost:3000
480
969
  pnpm test
481
970
  \`\`\`
971
+ ${options.prisma
972
+ ? `
973
+ ## Database
974
+
975
+ PostgreSQL through [Prisma](https://www.prisma.io). \`prisma/schema.prisma\` holds
976
+ the models this app needs — the reference models of the \`@basaltkit/*-prisma\`
977
+ packages it uses, plus your own \`Project\` at the end.
978
+
979
+ \`\`\`bash
980
+ pnpm db:migrate # prisma migrate dev — change the schema, write a migration${options.tenancy
981
+ ? `
982
+ pnpm db:seed # the 'demo' tenant the resolvers expect (also run by db:migrate)`
983
+ : ''}
984
+ pnpm db:generate # prisma generate — refresh src/generated/prisma (also runs on install)
985
+ pnpm db:deploy # prisma migrate deploy — apply pending migrations in production
986
+ \`\`\`
987
+
988
+ **Never use \`prisma db push\` on this project.** \`src/app.ts\` boots with
989
+ \`prismaPlugin({ client: db, assertMigrated: true })\`, which refuses to start
990
+ unless the database it reached has the \`_prisma_migrations\` table — the one
991
+ \`migrate dev\` / \`migrate deploy\` write and \`db push\` does not. That check is
992
+ what turns "wrong \`DATABASE_URL\`" (a shell that exported another project's, a
993
+ typo in the database name) into a boot error naming the database and host,
994
+ instead of a 500 on the first request that touches a missing table.
995
+
996
+ \`src/db.ts\` exports two clients:
482
997
 
998
+ - \`prisma\` — unscoped. The framework stores (auth${options.tenancy ? ', tenancy' : ''}${enforcesMembership(options) ? ', teams' : ''}${options.billing ? ', subscriptions' : ''}) use it: they key
999
+ their own tables and run before a tenant is known.${options.tenancy
1000
+ ? `
1001
+ - \`db\` — \`prisma.$extends(tenancyExtension())\`. Registered with \`prismaPlugin\`,
1002
+ so \`db<PrismaClient>()\` inside a handler returns a client that filters every
1003
+ read by the request's tenant, stamps every write, and throws instead of running
1004
+ unscoped. Keep \`tenantId\` on every model a tenant owns.`
1005
+ : ''}
1006
+ ${options.cli
1007
+ ? `
1008
+ Adding another Basalt domain later (audit, comments, notifications, …): install
1009
+ its \`@basaltkit/<domain>-prisma\` package, run \`pnpm basalt prisma:sync\` to merge
1010
+ its models into the schema, then \`pnpm db:migrate\`.
1011
+ `
1012
+ : `
1013
+ Adding another Basalt domain later (audit, comments, notifications, …): install
1014
+ its \`@basaltkit/<domain>-prisma\` package, copy the models from its
1015
+ \`schema.prisma\` into yours, then \`pnpm db:migrate\`. (With the \`basalt\` CLI —
1016
+ scaffold with \`--cli\` — \`pnpm basalt prisma:sync\` does the merge for you.)
1017
+ `}
1018
+ \`pnpm test\` skips the generated suite when no database is configured.
1019
+ `
1020
+ : ''}
483
1021
  ## Environment
484
1022
 
485
1023
  \`src/env.ts\` validates \`process.env\` — nothing loads \`.env\` for you. Copy
@@ -487,7 +1025,7 @@ pnpm test
487
1025
  export the variables.
488
1026
 
489
1027
  The variables are **app-prefixed**: \`${envPrefix(options.name)}_PORT\`,
490
- \`${envPrefix(options.name)}_HOST\`, \`${envPrefix(options.name)}_LOG_LEVEL\`${options.auth ? `, \`${envPrefix(options.name)}_APP_SECRET\`` : ''}. \`NODE_ENV\` is never prefixed.
1028
+ \`${envPrefix(options.name)}_HOST\`, \`${envPrefix(options.name)}_LOG_LEVEL\`${options.auth ? `, \`${envPrefix(options.name)}_APP_SECRET\`` : ''}${options.prisma ? `, \`${envPrefix(options.name)}_DATABASE_URL\`` : ''}. \`NODE_ENV\` is never prefixed.
491
1029
 
492
1030
  Why: **\`--env-file\` never overrides a variable that is already exported.** In a
493
1031
  shell where another project exported \`DATABASE_URL\` (or \`PORT\`), an app reading
@@ -495,8 +1033,13 @@ the generic name boots against THAT value and only fails on the first request
495
1033
  that touches it. \`src/env.ts\` therefore passes \`prefix: '${envPrefix(options.name)}'\` to
496
1034
  \`defineEnv\`: each variable is read as \`${envPrefix(options.name)}_<NAME>\` first and
497
1035
  **falls back** to the bare \`<NAME>\`, so a deployment that already exports the
498
- generic names keeps booting while a stray one in your shell loses. Add a
499
- database and \`DATABASE_URL\` is read as \`${envPrefix(options.name)}_DATABASE_URL\`.
1036
+ generic names keeps booting while a stray one in your shell loses.${options.prisma
1037
+ ? ` That is
1038
+ exactly how \`DATABASE_URL\` is read here (\`${envPrefix(options.name)}_DATABASE_URL\` first), and
1039
+ \`prisma.config.ts\` applies the same precedence — so \`pnpm db:migrate\` and the
1040
+ running app can never mean two different databases.`
1041
+ : ` Add a
1042
+ database and \`DATABASE_URL\` is read as \`${envPrefix(options.name)}_DATABASE_URL\`.`}
500
1043
 
501
1044
  To drop the fallback entirely (prefixed names only), write
502
1045
  \`prefix: { value: '${envPrefix(options.name)}', fallback: false }\`. When in doubt about what
@@ -607,14 +1150,18 @@ export function dockerignore() {
607
1150
  **/.DS_Store
608
1151
  `;
609
1152
  }
610
- export function gitignore() {
1153
+ export function gitignore(options) {
611
1154
  return `node_modules/
612
1155
  dist/
613
1156
  .env
614
1157
  .env.*
615
1158
  !.env.example
616
1159
  *.log
617
- `;
1160
+ ${options.prisma
1161
+ ? `# Generated by \`prisma generate\` (runs on install) — never edit or commit it.
1162
+ src/generated/
1163
+ `
1164
+ : ''}`;
618
1165
  }
619
1166
  /**
620
1167
  * pnpm settings: esbuild's build script is required by tsx;
package/dist/versions.js CHANGED
@@ -4,21 +4,25 @@
4
4
  export const SCAFFOLD_VERSIONS = {
5
5
  '@basaltkit/admin': '^2.0.0',
6
6
  '@basaltkit/admin-shadcn': '^1.1.0',
7
- '@basaltkit/ai-mcp': '^0.1.0',
8
- '@basaltkit/auth': '^3.1.0',
7
+ '@basaltkit/ai-mcp': '^0.2.0',
8
+ '@basaltkit/auth': '^4.0.0',
9
+ '@basaltkit/auth-prisma': '^2.0.0',
9
10
  '@basaltkit/cli': '^1.2.0',
10
11
  '@basaltkit/config': '^1.1.0',
11
- '@basaltkit/core': '^1.4.0',
12
+ '@basaltkit/core': '^1.5.0',
12
13
  '@basaltkit/env': '^3.1.0',
13
14
  '@basaltkit/events': '^1.3.0',
14
- '@basaltkit/fastify': '^2.2.0',
15
+ '@basaltkit/fastify': '^2.5.0',
15
16
  '@basaltkit/generator': '^1.5.0',
16
17
  '@basaltkit/logger': '^1.3.0',
17
- '@basaltkit/mcp': '^3.0.0',
18
- '@basaltkit/prisma': '^2.1.0',
18
+ '@basaltkit/mcp': '^4.0.0',
19
+ '@basaltkit/prisma': '^3.0.0',
19
20
  '@basaltkit/sdk': '^2.1.0',
20
- '@basaltkit/subscriptions': '^4.0.0',
21
- '@basaltkit/teams': '^3.0.0',
22
- '@basaltkit/tenancy': '^2.0.0',
21
+ '@basaltkit/subscriptions': '^5.0.0',
22
+ '@basaltkit/subscriptions-prisma': '^3.0.0',
23
+ '@basaltkit/teams': '^4.0.0',
24
+ '@basaltkit/teams-prisma': '^1.1.0',
25
+ '@basaltkit/tenancy': '^3.0.0',
26
+ '@basaltkit/tenancy-prisma': '^2.0.0',
23
27
  '@basaltkit/testing': '^2.0.0',
24
28
  };
package/dist/wizard.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { PackageManager } from './index.js';
2
2
  import { type Choice, type Prompter } from './prompt.js';
3
- export type FeatureKey = 'tenancy' | 'auth' | 'billing' | 'ui' | 'cli' | 'mcp';
3
+ export type FeatureKey = 'tenancy' | 'auth' | 'billing' | 'ui' | 'cli' | 'mcp' | 'prisma';
4
4
  /** The feature toggles offered in the "custom" preset (and shown in the summary). */
5
5
  export declare const FEATURES: Choice<FeatureKey>[];
6
6
  interface Preset {
@@ -21,6 +21,7 @@ export interface WizardResult {
21
21
  ui: boolean;
22
22
  cli: boolean;
23
23
  mcp: boolean;
24
+ prisma: boolean;
24
25
  pm: PackageManager;
25
26
  install: boolean;
26
27
  git: boolean;
package/dist/wizard.js CHANGED
@@ -7,11 +7,12 @@ export const FEATURES = [
7
7
  { value: 'ui', label: 'Web UI', hint: 'React + shadcn + SDK (pnpm workspace)' },
8
8
  { value: 'cli', label: 'basalt CLI', hint: 'code generators + commands' },
9
9
  { value: 'mcp', label: 'MCP server', hint: 'expose routes as AI-agent tools (/mcp)' },
10
+ { value: 'prisma', label: 'Database', hint: 'PostgreSQL via Prisma (migrations + real stores)' },
10
11
  ];
11
12
  export const PRESETS = [
12
- { value: 'saas', label: 'SaaS starter', hint: 'tenancy + auth + billing + CLI', features: ['tenancy', 'auth', 'billing', 'cli'] },
13
+ { value: 'saas', label: 'SaaS starter', hint: 'tenancy + auth + billing + database + CLI', features: ['tenancy', 'auth', 'billing', 'prisma', 'cli'] },
13
14
  { value: 'api', label: 'API only', hint: 'auth + MCP, no tenancy or UI', features: ['auth', 'mcp'] },
14
- { value: 'full', label: 'Full stack', hint: 'everything + web UI', features: ['tenancy', 'auth', 'billing', 'ui', 'cli', 'mcp'] },
15
+ { value: 'full', label: 'Full stack', hint: 'everything + web UI', features: ['tenancy', 'auth', 'billing', 'prisma', 'ui', 'cli', 'mcp'] },
15
16
  { value: 'minimal', label: 'Minimal', hint: 'no batteries, add them later', features: [] },
16
17
  { value: 'custom', label: 'Custom', hint: 'pick features yourself', features: null },
17
18
  ];
@@ -90,6 +91,7 @@ export async function runWizard(prompter, options = {}) {
90
91
  ui: featureSet.has('ui'),
91
92
  cli: featureSet.has('cli'),
92
93
  mcp: featureSet.has('mcp'),
94
+ prisma: featureSet.has('prisma'),
93
95
  pm,
94
96
  install,
95
97
  git,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-basalt",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },
@@ -21,26 +21,35 @@
21
21
  "dist"
22
22
  ],
23
23
  "devDependencies": {
24
+ "@prisma/adapter-pg": "^7.10.0",
25
+ "@prisma/client": "^7.10.0",
24
26
  "@types/node": "^26.6.1",
27
+ "@types/pg": "^8.23.1",
28
+ "pg": "^8.23.0",
29
+ "prisma": "^7.10.0",
25
30
  "typescript": "^7.0.2",
26
31
  "vitest": "^5.0.1",
27
32
  "zod": "^4.6.5",
28
- "@basaltkit/config": "^1.1.2",
29
- "@basaltkit/auth": "^3.1.0",
33
+ "@basaltkit/auth": "^4.0.0",
34
+ "@basaltkit/auth-prisma": "^2.0.0",
30
35
  "@basaltkit/cli": "^1.2.5",
31
- "@basaltkit/env": "^3.1.0",
32
- "@basaltkit/core": "^1.4.0",
36
+ "@basaltkit/config": "^1.1.2",
37
+ "@basaltkit/core": "^1.5.0",
38
+ "@basaltkit/env": "^3.1.1",
33
39
  "@basaltkit/events": "^1.3.0",
34
- "@basaltkit/fastify": "^2.2.0",
40
+ "@basaltkit/fastify": "^2.5.0",
35
41
  "@basaltkit/generator": "^1.5.0",
36
42
  "@basaltkit/logger": "^1.3.0",
37
- "@basaltkit/mcp": "^3.0.0",
38
- "@basaltkit/subscriptions": "^4.0.0",
39
- "@basaltkit/teams": "^3.0.1",
40
- "@basaltkit/tenancy": "^2.0.0",
41
- "@basaltkit/tsconfig": "^0.24.0",
42
- "@basaltkit/testing": "^2.0.2",
43
- "@basaltkit/prisma": "^2.1.0"
43
+ "@basaltkit/mcp": "^4.0.0",
44
+ "@basaltkit/prisma": "^3.0.0",
45
+ "@basaltkit/subscriptions": "^5.0.0",
46
+ "@basaltkit/subscriptions-prisma": "^3.0.0",
47
+ "@basaltkit/teams": "^4.0.0",
48
+ "@basaltkit/teams-prisma": "^1.1.0",
49
+ "@basaltkit/tenancy": "^3.0.0",
50
+ "@basaltkit/tenancy-prisma": "^2.0.0",
51
+ "@basaltkit/testing": "^2.0.3",
52
+ "@basaltkit/tsconfig": "^0.24.0"
44
53
  },
45
54
  "publishConfig": {
46
55
  "access": "public"