create-basalt 1.7.0 → 1.8.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,338 @@ 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
+ teams: `model TeamMembership {
659
+ tenantId String
660
+ userId String
661
+ role String
662
+ createdAt DateTime
663
+
664
+ @@id([tenantId, userId])
665
+ @@map("team_memberships")
666
+ }
667
+
668
+ model TeamInvitation {
669
+ id String @id
670
+ tenantId String
671
+ email String
672
+ role String
673
+ token String @unique
674
+ invitedBy String?
675
+ expiresAt DateTime
676
+ acceptedAt DateTime?
677
+ revokedAt DateTime?
678
+
679
+ @@index([tenantId, email])
680
+ @@map("team_invitations")
681
+ }`,
682
+ subscriptions: `model Subscription {
683
+ billableId String @id
684
+ plan String
685
+ period String
686
+ status String
687
+ trialEndsAt DateTime?
688
+ cancelAtPeriodEnd Boolean?
689
+ canceledAt DateTime?
690
+ gatewayRef String?
691
+ pendingPlan String?
692
+ pendingPeriod String?
693
+
694
+ @@map("subscriptions")
695
+ }
696
+
697
+ model UsageCounter {
698
+ billableId String
699
+ feature String
700
+ periodKey String
701
+ value Int @default(0)
702
+
703
+ @@id([billableId, feature, periodKey])
704
+ @@map("usage_counters")
705
+ }
706
+
707
+ model WebhookEvent {
708
+ id String @id
709
+ seenAt DateTime @default(now())
710
+
711
+ @@map("webhook_events")
712
+ }`,
713
+ };
714
+ /**
715
+ * `prisma/schema.prisma` — the datasource, the client generator, the models of
716
+ * every Basalt domain this app enables, and the app's own first model.
717
+ *
718
+ * Prisma 7 no longer takes the connection URL here (prisma.config.ts does), and
719
+ * the client needs an explicit `output`; src/db.ts imports it from there.
720
+ */
721
+ export function prismaSchema(options) {
722
+ const domains = [];
723
+ if (options.tenancy)
724
+ domains.push(PRISMA_MODELS.tenancy);
725
+ if (options.auth)
726
+ domains.push(PRISMA_MODELS.auth);
727
+ if (enforcesMembership(options))
728
+ domains.push(PRISMA_MODELS.teams);
729
+ if (options.billing)
730
+ domains.push(PRISMA_MODELS.subscriptions);
731
+ return `// Prisma schema for ${options.name}.
732
+ //
733
+ // The blocks below the header are the reference models of the @basaltkit/*-prisma
734
+ // packages this app uses — exactly what \`basalt prisma:sync\` merges. Add another
735
+ // Basalt domain later by installing its \`*-prisma\` package and running
736
+ // \`pnpm basalt prisma:sync\` (with --cli), then \`pnpm db:migrate\`.
737
+ //
738
+ // Apply changes with \`pnpm db:migrate\` (\`prisma migrate dev\`) — never
739
+ // \`prisma db push\`: push writes no _prisma_migrations table, and src/app.ts
740
+ // boots with prismaPlugin({ assertMigrated: true }), which requires it.
741
+
742
+ generator client {
743
+ provider = "prisma-client-js"
744
+ output = "../src/generated/prisma"
745
+ }
746
+
747
+ datasource db {
748
+ // The URL lives in prisma.config.ts (Prisma 7) — and in src/env.ts for the app.
749
+ provider = "postgresql"
750
+ }
751
+ ${domains.map((block) => `\n${block}\n`).join('')}
752
+ // ---------------------------------------------------------------------------
753
+ // Your models.${options.tenancy
754
+ ? ` \`tenantId\` is the column tenancyExtension() (src/db.ts)
755
+ // forces onto every query and every write, so keep it on everything a tenant
756
+ // owns — and make cross-model foreign keys composite (@@unique([tenantId, id])
757
+ // on the target) so the database refuses a cross-tenant link too.`
758
+ : ''}
759
+
760
+ model Project {
761
+ id String @id @default(uuid())${options.tenancy ? '\n tenantId String' : ''}
762
+ name String
763
+ createdAt DateTime @default(now())
764
+ ${options.tenancy ? '\n @@index([tenantId])' : ''}
765
+ @@map("projects")
766
+ }
767
+ `;
768
+ }
769
+ /**
770
+ * `prisma.config.ts` — Prisma 7 reads the connection URL (and the seed command)
771
+ * from here instead of from schema.prisma. It applies the SAME precedence as
772
+ * src/env.ts: the app-prefixed name first, the bare `DATABASE_URL` only as a
773
+ * fallback, so the CLI and the running app can never mean different databases.
774
+ */
775
+ export function prismaConfigTs(options) {
776
+ const prefix = envPrefix(options.name);
777
+ return `import { loadEnvFile } from 'node:process'
778
+ import { defineConfig } from 'prisma/config'
779
+
780
+ // Prisma loads no .env for you (neither does the app — see src/env.ts). This
781
+ // mirrors \`node --env-file=.env\`: a variable already exported in your shell
782
+ // still wins, the file only fills in what is missing.
783
+ try {
784
+ loadEnvFile('.env')
785
+ } catch {
786
+ // No .env file — the variables come from the environment.
787
+ }
788
+
789
+ export default defineConfig({
790
+ schema: 'prisma/schema.prisma',${options.tenancy
791
+ ? `
792
+ // Runs after \`prisma migrate dev\` / \`prisma migrate reset\`.
793
+ migrations: { seed: 'tsx prisma/seed.ts' },`
794
+ : ''}
795
+ datasource: {
796
+ // ${prefix}_DATABASE_URL first, bare DATABASE_URL only as a fallback: a
797
+ // stray DATABASE_URL from another project must never decide which database
798
+ // gets migrated.
799
+ url: process.env['${prefix}_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? '',
800
+ },
801
+ })
802
+ `;
803
+ }
804
+ /**
805
+ * `src/db.ts` — the Prisma client(s). Two bindings on purpose: the unscoped
806
+ * `prisma` for the framework stores (they key their own rows and run outside a
807
+ * tenant context) and, with tenancy, the `db` client the whole application uses,
808
+ * where every query is filtered by the request's tenant.
809
+ */
810
+ export function dbTs(options) {
811
+ return `import { PrismaPg } from '@prisma/adapter-pg'${options.tenancy ? `\nimport { tenancyExtension } from '@basaltkit/prisma'` : ''}
812
+ import { PrismaClient } from './generated/prisma/client.js'
813
+ import { env } from './env.js'
814
+
815
+ /**
816
+ * The base client. Prisma 7 talks to PostgreSQL through a driver adapter, so the
817
+ * connection string is passed here; prisma.config.ts hands the CLI the same one.
818
+ *
819
+ * ${options.tenancy
820
+ ? `This client is NOT tenant-scoped: the framework stores (auth, tenancy,
821
+ * teams, …) read and write their own tables, keyed by their own columns, and do
822
+ * it before a tenant is known (a login has no tenant yet). Application code
823
+ * should use \`db\` below — or \`db<PrismaClient>()\` from @basaltkit/prisma.`
824
+ : `Application code can also reach it with \`db<PrismaClient>()\` from
825
+ * @basaltkit/prisma, which returns the client registered by prismaPlugin.`}
826
+ */
827
+ export const prisma = new PrismaClient({
828
+ adapter: new PrismaPg({ connectionString: env.DATABASE_URL }),
829
+ })
830
+ ${options.tenancy
831
+ ? `
832
+ /**
833
+ * The application client: tenancyExtension() forces the current tenant's
834
+ * \`tenantId\` onto every read, stamps it onto every write, and fails closed
835
+ * (MissingTenantError) when there is no tenant in context — application code
836
+ * cannot forget the filter. src/app.ts registers it with prismaPlugin, so
837
+ * \`db<PrismaClient>()\` inside a handler returns this one.
838
+ */
839
+ export const db = prisma.$extends(tenancyExtension())
840
+ `
841
+ : `
842
+ /** Registered by prismaPlugin in src/app.ts. */
843
+ export const db = prisma
844
+ `}`;
845
+ }
846
+ /**
847
+ * `prisma/seed.ts` — creates the `demo` tenant the scaffolded resolvers expect
848
+ * (`x-tenant-id: demo`, `demo.localhost`). Runs from `pnpm db:seed` and after
849
+ * `prisma migrate dev` (prisma.config.ts declares it).
850
+ */
851
+ export function prismaSeedTs(options) {
852
+ return `import { prismaTenantSource } from '@basaltkit/tenancy-prisma'
853
+ import { prisma } from '../src/db.js'
854
+
855
+ // The demo tenant the header and subdomain resolvers look for out of the box.
856
+ // \`save\` is an upsert, so re-seeding is safe. Tenants are open records: add
857
+ // whatever per-tenant fields you like — they round-trip unchanged.
858
+ const tenants = prismaTenantSource(prisma)
859
+ await tenants.save({ id: 'demo', name: 'Demo Tenant' })
860
+ console.log('Seeded tenant "demo" for ${options.name}.')
861
+
862
+ await prisma.$disconnect()
863
+ `;
864
+ }
426
865
  export function appTest(options) {
866
+ const prefix = envPrefix(options.name);
867
+ // With a database the app cannot boot without one: src/env.ts requires
868
+ // DATABASE_URL and prismaPlugin({ assertMigrated: true }) checks the schema.
869
+ // So the suite is gated on a configured database and imports src/app.ts
870
+ // lazily — an eager import would throw at module load instead of skipping.
427
871
  return `import { describe, expect, it } from 'vitest'
428
872
  import { FASTIFY } from '@basaltkit/fastify'
429
- import { buildApp } from '../src/app.js'
873
+ ${options.prisma
874
+ ? `
875
+ // Needs a migrated database: set ${prefix}_DATABASE_URL (see .env.example), then
876
+ // \`pnpm db:migrate${options.tenancy ? ' && pnpm db:seed' : ''}\`. Without one the suite skips instead of failing.
877
+ const database = process.env['${prefix}_DATABASE_URL'] ?? process.env['DATABASE_URL']
878
+
879
+ describe.skipIf(!database)('app', () => {
880
+ it('boots and responds on /health', async () => {
881
+ const { buildApp } = await import('../src/app.js')
882
+ const app = await buildApp({ logLevel: 'silent' }).boot()`
883
+ : `import { buildApp } from '../src/app.js'
430
884
 
431
885
  describe('app', () => {
432
886
  it('boots and responds on /health', async () => {
433
- const app = await buildApp({ logLevel: 'silent' }).boot()
887
+ const app = await buildApp({ logLevel: 'silent' }).boot()`}
434
888
  const server = app.container.get(FASTIFY)
435
889
 
436
890
  const index = await server.inject({ method: 'GET', url: '/' })
@@ -459,6 +913,7 @@ export function readme(options) {
459
913
  'typed routes with Zod validation',
460
914
  'structured logging with request/tenant context',
461
915
  'typed domain events',
916
+ ...(options.prisma ? ['PostgreSQL persistence through Prisma (migrations + tenant-scoped client)'] : []),
462
917
  ...(options.tenancy ? ['multi-tenancy (header + subdomain resolvers)'] : []),
463
918
  ...(options.auth ? ['authentication (register/login/refresh/me)'] : []),
464
919
  ...(options.billing ? ['subscriptions with plans and feature limits'] : []),
@@ -475,11 +930,64 @@ Included: ${features.join(' · ')}.
475
930
  ## Getting started
476
931
 
477
932
  \`\`\`bash
478
- pnpm install
933
+ pnpm install${options.prisma
934
+ ? `
935
+ # point ${envPrefix(options.name)}_DATABASE_URL at a PostgreSQL database (.env.example), then
936
+ pnpm db:migrate # create the tables${options.tenancy ? ` (runs the seed too)` : ''}`
937
+ : ''}
479
938
  pnpm dev # API on http://localhost:3000
480
939
  pnpm test
481
940
  \`\`\`
941
+ ${options.prisma
942
+ ? `
943
+ ## Database
944
+
945
+ PostgreSQL through [Prisma](https://www.prisma.io). \`prisma/schema.prisma\` holds
946
+ the models this app needs — the reference models of the \`@basaltkit/*-prisma\`
947
+ packages it uses, plus your own \`Project\` at the end.
948
+
949
+ \`\`\`bash
950
+ pnpm db:migrate # prisma migrate dev — change the schema, write a migration${options.tenancy
951
+ ? `
952
+ pnpm db:seed # the 'demo' tenant the resolvers expect (also run by db:migrate)`
953
+ : ''}
954
+ pnpm db:generate # prisma generate — refresh src/generated/prisma (also runs on install)
955
+ pnpm db:deploy # prisma migrate deploy — apply pending migrations in production
956
+ \`\`\`
957
+
958
+ **Never use \`prisma db push\` on this project.** \`src/app.ts\` boots with
959
+ \`prismaPlugin({ client: db, assertMigrated: true })\`, which refuses to start
960
+ unless the database it reached has the \`_prisma_migrations\` table — the one
961
+ \`migrate dev\` / \`migrate deploy\` write and \`db push\` does not. That check is
962
+ what turns "wrong \`DATABASE_URL\`" (a shell that exported another project's, a
963
+ typo in the database name) into a boot error naming the database and host,
964
+ instead of a 500 on the first request that touches a missing table.
965
+
966
+ \`src/db.ts\` exports two clients:
482
967
 
968
+ - \`prisma\` — unscoped. The framework stores (auth${options.tenancy ? ', tenancy' : ''}${enforcesMembership(options) ? ', teams' : ''}${options.billing ? ', subscriptions' : ''}) use it: they key
969
+ their own tables and run before a tenant is known.${options.tenancy
970
+ ? `
971
+ - \`db\` — \`prisma.$extends(tenancyExtension())\`. Registered with \`prismaPlugin\`,
972
+ so \`db<PrismaClient>()\` inside a handler returns a client that filters every
973
+ read by the request's tenant, stamps every write, and throws instead of running
974
+ unscoped. Keep \`tenantId\` on every model a tenant owns.`
975
+ : ''}
976
+ ${options.cli
977
+ ? `
978
+ Adding another Basalt domain later (audit, comments, notifications, …): install
979
+ its \`@basaltkit/<domain>-prisma\` package, run \`pnpm basalt prisma:sync\` to merge
980
+ its models into the schema, then \`pnpm db:migrate\`.
981
+ `
982
+ : `
983
+ Adding another Basalt domain later (audit, comments, notifications, …): install
984
+ its \`@basaltkit/<domain>-prisma\` package, copy the models from its
985
+ \`schema.prisma\` into yours, then \`pnpm db:migrate\`. (With the \`basalt\` CLI —
986
+ scaffold with \`--cli\` — \`pnpm basalt prisma:sync\` does the merge for you.)
987
+ `}
988
+ \`pnpm test\` skips the generated suite when no database is configured.
989
+ `
990
+ : ''}
483
991
  ## Environment
484
992
 
485
993
  \`src/env.ts\` validates \`process.env\` — nothing loads \`.env\` for you. Copy
@@ -487,7 +995,7 @@ pnpm test
487
995
  export the variables.
488
996
 
489
997
  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.
998
+ \`${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
999
 
492
1000
  Why: **\`--env-file\` never overrides a variable that is already exported.** In a
493
1001
  shell where another project exported \`DATABASE_URL\` (or \`PORT\`), an app reading
@@ -495,8 +1003,13 @@ the generic name boots against THAT value and only fails on the first request
495
1003
  that touches it. \`src/env.ts\` therefore passes \`prefix: '${envPrefix(options.name)}'\` to
496
1004
  \`defineEnv\`: each variable is read as \`${envPrefix(options.name)}_<NAME>\` first and
497
1005
  **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\`.
1006
+ generic names keeps booting while a stray one in your shell loses.${options.prisma
1007
+ ? ` That is
1008
+ exactly how \`DATABASE_URL\` is read here (\`${envPrefix(options.name)}_DATABASE_URL\` first), and
1009
+ \`prisma.config.ts\` applies the same precedence — so \`pnpm db:migrate\` and the
1010
+ running app can never mean two different databases.`
1011
+ : ` Add a
1012
+ database and \`DATABASE_URL\` is read as \`${envPrefix(options.name)}_DATABASE_URL\`.`}
500
1013
 
501
1014
  To drop the fallback entirely (prefixed names only), write
502
1015
  \`prefix: { value: '${envPrefix(options.name)}', fallback: false }\`. When in doubt about what
@@ -607,14 +1120,18 @@ export function dockerignore() {
607
1120
  **/.DS_Store
608
1121
  `;
609
1122
  }
610
- export function gitignore() {
1123
+ export function gitignore(options) {
611
1124
  return `node_modules/
612
1125
  dist/
613
1126
  .env
614
1127
  .env.*
615
1128
  !.env.example
616
1129
  *.log
617
- `;
1130
+ ${options.prisma
1131
+ ? `# Generated by \`prisma generate\` (runs on install) — never edit or commit it.
1132
+ src/generated/
1133
+ `
1134
+ : ''}`;
618
1135
  }
619
1136
  /**
620
1137
  * pnpm settings: esbuild's build script is required by tsx;
package/dist/versions.js CHANGED
@@ -6,19 +6,23 @@ export const SCAFFOLD_VERSIONS = {
6
6
  '@basaltkit/admin-shadcn': '^1.1.0',
7
7
  '@basaltkit/ai-mcp': '^0.1.0',
8
8
  '@basaltkit/auth': '^3.1.0',
9
+ '@basaltkit/auth-prisma': '^1.6.0',
9
10
  '@basaltkit/cli': '^1.2.0',
10
11
  '@basaltkit/config': '^1.1.0',
11
12
  '@basaltkit/core': '^1.4.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.3.0',
15
16
  '@basaltkit/generator': '^1.5.0',
16
17
  '@basaltkit/logger': '^1.3.0',
17
18
  '@basaltkit/mcp': '^3.0.0',
18
- '@basaltkit/prisma': '^2.1.0',
19
+ '@basaltkit/prisma': '^2.2.0',
19
20
  '@basaltkit/sdk': '^2.1.0',
20
21
  '@basaltkit/subscriptions': '^4.0.0',
22
+ '@basaltkit/subscriptions-prisma': '^2.2.0',
21
23
  '@basaltkit/teams': '^3.0.0',
24
+ '@basaltkit/teams-prisma': '^1.0.0',
22
25
  '@basaltkit/tenancy': '^2.0.0',
26
+ '@basaltkit/tenancy-prisma': '^1.2.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.8.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
+ "@basaltkit/auth-prisma": "^1.5.1",
25
+ "@basaltkit/subscriptions-prisma": "^2.2.3",
26
+ "@basaltkit/teams-prisma": "^1.0.3",
27
+ "@basaltkit/tenancy-prisma": "^1.2.0",
28
+ "@prisma/adapter-pg": "^7.10.0",
29
+ "@prisma/client": "^7.10.0",
24
30
  "@types/node": "^26.6.1",
31
+ "@types/pg": "^8.23.1",
32
+ "pg": "^8.23.0",
33
+ "prisma": "^7.10.0",
25
34
  "typescript": "^7.0.2",
26
35
  "vitest": "^5.0.1",
27
36
  "zod": "^4.6.5",
28
- "@basaltkit/config": "^1.1.2",
29
37
  "@basaltkit/auth": "^3.1.0",
30
38
  "@basaltkit/cli": "^1.2.5",
31
- "@basaltkit/env": "^3.1.0",
39
+ "@basaltkit/config": "^1.1.2",
32
40
  "@basaltkit/core": "^1.4.0",
41
+ "@basaltkit/env": "^3.1.0",
33
42
  "@basaltkit/events": "^1.3.0",
34
- "@basaltkit/fastify": "^2.2.0",
35
43
  "@basaltkit/generator": "^1.5.0",
44
+ "@basaltkit/fastify": "^2.3.0",
36
45
  "@basaltkit/logger": "^1.3.0",
37
46
  "@basaltkit/mcp": "^3.0.0",
47
+ "@basaltkit/prisma": "^2.2.0",
38
48
  "@basaltkit/subscriptions": "^4.0.0",
39
49
  "@basaltkit/teams": "^3.0.1",
40
50
  "@basaltkit/tenancy": "^2.0.0",
41
- "@basaltkit/tsconfig": "^0.24.0",
42
51
  "@basaltkit/testing": "^2.0.2",
43
- "@basaltkit/prisma": "^2.1.0"
52
+ "@basaltkit/tsconfig": "^0.24.0"
44
53
  },
45
54
  "publishConfig": {
46
55
  "access": "public"