create-basalt 1.6.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/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,29 +141,53 @@ 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) {
151
+ const prefix = envPrefix(options.name);
108
152
  return `import { defineEnv${options.auth ? ', secret' : ''} } from '@basaltkit/env'
109
153
  import { LOG_LEVELS } from '@basaltkit/logger'
110
154
  import { z } from 'zod'
111
155
 
112
- export const env = defineEnv({
113
- PORT: z.coerce.number().default(3000),
114
- HOST: z.string().default('0.0.0.0'),
115
- // Typed against the logger's LogLevel union — a free-form string here fails
116
- // \`pnpm typecheck\` where loggerPlugin({ level }) consumes it.
117
- LOG_LEVEL: z.enum(LOG_LEVELS).default('info'),
118
- // Unset counts as production (fail-closed); \`pnpm dev\` sets development.
119
- NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),${options.auth
156
+ export const env = defineEnv(
157
+ {
158
+ PORT: z.coerce.number().default(3000),
159
+ HOST: z.string().default('0.0.0.0'),
160
+ // Typed against the logger's LogLevel union — a free-form string here fails
161
+ // \`pnpm typecheck\` where loggerPlugin({ level }) consumes it.
162
+ LOG_LEVEL: z.enum(LOG_LEVELS).default('info'),
163
+ // Unset counts as production (fail-closed); \`pnpm dev\` sets development.
164
+ // NODE_ENV is a Node-wide convention and is never prefixed.
165
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),${options.prisma
120
166
  ? `
121
- // Signs JWTs and sessions. secret() is fail-closed: required unless NODE_ENV
122
- // is explicitly development/test (no fallback when NODE_ENV is unset),
123
- // rejected if it looks like a placeholder. \`pnpm dev\` uses devDefault.
124
- APP_SECRET: secret({ minLength: 32, devDefault: 'dev-only-insecure-secret-please-change-me' }),`
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
173
+ ? `
174
+ // Signs JWTs and sessions. secret() is fail-closed: required unless NODE_ENV
175
+ // is explicitly development/test (no fallback when NODE_ENV is unset),
176
+ // rejected if it looks like a placeholder. \`pnpm dev\` uses devDefault.
177
+ APP_SECRET: secret({ minLength: 32, devDefault: 'dev-only-insecure-secret-please-change-me' }),`
125
178
  : ''}
126
- })
179
+ },
180
+ {
181
+ // Every variable is read as ${prefix}_<NAME> first (${prefix}_PORT before PORT),
182
+ // falling back to the bare name. \`--env-file\` never overrides a variable
183
+ // already exported in your shell, so without the prefix another project's
184
+ // exported PORT / DATABASE_URL would silently win. The keys above stay bare:
185
+ // the rest of the app still reads env.PORT.
186
+ // To require the prefixed names (no bare fallback at all), write:
187
+ // prefix: { value: '${prefix}', fallback: false }
188
+ prefix: '${prefix}',
189
+ },
190
+ )
127
191
  `;
128
192
  }
129
193
  /**
@@ -151,13 +215,34 @@ export function envExample(options) {
151
215
  return `# Nothing loads this file for you: copy it to .env and start with
152
216
  # \`node --env-file=.env\` / \`tsx --env-file=.env\`, or export the variables.
153
217
  # --env-file NEVER overrides a variable already exported in your shell: if
154
- # another project exported DATABASE_URL, the app silently uses THAT one. Give
155
- # generic names an app-specific prefix, e.g. ${prefix}_DATABASE_URL.
156
- PORT=3000
157
- HOST=0.0.0.0
158
- LOG_LEVEL=info
218
+ # another project exported PORT or DATABASE_URL, an app reading the generic
219
+ # name would silently use THAT one. That is why src/env.ts reads app-prefixed
220
+ # names — ${prefix}_PORT wins over a stray PORT. The bare names still work as a
221
+ # fallback, so an existing deployment exporting them keeps booting.
222
+ ${prefix}_PORT=3000
223
+ ${prefix}_HOST=0.0.0.0
224
+ ${prefix}_LOG_LEVEL=info
225
+ # NODE_ENV is a Node-wide convention and is never prefixed.
159
226
  NODE_ENV=development
160
- ${options.auth ? '# Required unless NODE_ENV is development/test — `pnpm start` refuses to boot\n# without it. Generate a strong one: openssl rand -base64 48\n# APP_SECRET=\n' : ''}`;
227
+ ${options.auth
228
+ ? `# Required unless NODE_ENV is development/test — \`pnpm start\` refuses to boot
229
+ # without it. Generate a strong one: openssl rand -base64 48
230
+ # ${prefix}_APP_SECRET=
231
+ `
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):
240
+ # ${prefix}_DATABASE_URL=postgres://user:pass@localhost:5432/${options.name.replace(/^@[^/]+\//, '')}
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();
161
246
  }
162
247
  export function appTs(options) {
163
248
  const imports = [
@@ -182,24 +267,73 @@ export function appTs(options) {
182
267
  })`,
183
268
  ];
184
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
+ }
185
287
  if (options.tenancy) {
186
- imports.push(`import { headerResolver, MemoryTenantSource, subdomainResolver, tenancyPlugin } from '@basaltkit/tenancy'`);
187
- 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({
188
303
  // Replace MemoryTenantSource with your database-backed source.
189
304
  source: new MemoryTenantSource().add({ id: 'demo', name: 'Demo Tenant' }),
190
305
  resolvers: [headerResolver(), subdomainResolver({ base: 'localhost' })],
191
306
  })`);
307
+ }
192
308
  }
193
309
  if (options.auth) {
194
- imports.push(`import { authPlugin, authRoutes, mfaRoutes, MemoryUserSource } from '@basaltkit/auth'`);
195
- imports.push(`import { env } from './env.js'`);
196
- 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({
197
330
  // Replace MemoryUserSource with your database-backed source (e.g.
198
331
  // @basaltkit/auth-sqlite or @basaltkit/auth-prisma). The default in-memory
199
332
  // MFA store is enough for the ready-made TOTP flow below.
200
333
  users: new MemoryUserSource(),
201
334
  secret: env.APP_SECRET,
202
335
  })`);
336
+ }
203
337
  // authRoutes(): register, login, logout, refresh, me, email verification and
204
338
  // password recovery. mfaRoutes(): TOTP enroll/activate/status/disable.
205
339
  routesExpression = '[...appRoutes, ...authRoutes(), ...mfaRoutes()]';
@@ -212,10 +346,21 @@ export function appTs(options) {
212
346
  // meta: { central: true }.
213
347
  imports[0] = `import { createApp, definePlugin } from '@basaltkit/core'`;
214
348
  imports.push(`import { TEAMS, teamsPlugin, tenantMembershipPlugin } from '@basaltkit/teams'`);
215
- 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({
216
360
  // TODO: the default membership/invitation stores are in-memory — pass
217
361
  // persistent ones (memberships, invitations) before production.
218
362
  })`);
363
+ }
219
364
  plugins.push(`// Rejects authenticated requests for a tenant the user is not a member of.
220
365
  tenantMembershipPlugin()`);
221
366
  plugins.push(`// Dev-only seed: each new registrant joins the 'demo' tenant so the scaffold
@@ -225,12 +370,22 @@ export function appTs(options) {
225
370
  }
226
371
  if (options.billing) {
227
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
+ }
228
378
  plugins.push(`subscriptionsPlugin({
229
379
  plans: definePlans({
230
380
  free: { price: 0, features: { projects: 3 } },
231
381
  pro: { price: { monthly: 29, yearly: 290 }, trial: '14d', features: { projects: 50 } },
232
382
  }),
233
- fallbackPlan: 'free',
383
+ fallbackPlan: 'free',${options.prisma
384
+ ? `
385
+ store: subscriptionStores.store,
386
+ usage: subscriptionStores.usage,
387
+ webhooks: subscriptionStores.webhooks,`
388
+ : ''}
234
389
  })`);
235
390
  }
236
391
  if (options.cli) {
@@ -259,7 +414,7 @@ export function appTs(options) {
259
414
  : '';
260
415
  return `${imports.join('\n')}
261
416
  import { appRoutes } from './routes.js'
262
-
417
+ ${preamble.length > 0 ? `\n${preamble.join('\n\n')}\n` : ''}
263
418
  export interface BuildAppOptions {
264
419
  logLevel?: LogLevel
265
420
  pretty?: boolean${commandsField}
@@ -398,14 +553,338 @@ const app = buildApp({
398
553
  process.exit(await runCli({ app }))
399
554
  `;
400
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
+ }
401
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.
402
871
  return `import { describe, expect, it } from 'vitest'
403
872
  import { FASTIFY } from '@basaltkit/fastify'
404
- 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'
405
884
 
406
885
  describe('app', () => {
407
886
  it('boots and responds on /health', async () => {
408
- const app = await buildApp({ logLevel: 'silent' }).boot()
887
+ const app = await buildApp({ logLevel: 'silent' }).boot()`}
409
888
  const server = app.container.get(FASTIFY)
410
889
 
411
890
  const index = await server.inject({ method: 'GET', url: '/' })
@@ -434,6 +913,7 @@ export function readme(options) {
434
913
  'typed routes with Zod validation',
435
914
  'structured logging with request/tenant context',
436
915
  'typed domain events',
916
+ ...(options.prisma ? ['PostgreSQL persistence through Prisma (migrations + tenant-scoped client)'] : []),
437
917
  ...(options.tenancy ? ['multi-tenancy (header + subdomain resolvers)'] : []),
438
918
  ...(options.auth ? ['authentication (register/login/refresh/me)'] : []),
439
919
  ...(options.billing ? ['subscriptions with plans and feature limits'] : []),
@@ -450,22 +930,90 @@ Included: ${features.join(' · ')}.
450
930
  ## Getting started
451
931
 
452
932
  \`\`\`bash
453
- 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
+ : ''}
454
938
  pnpm dev # API on http://localhost:3000
455
939
  pnpm test
456
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
+ \`\`\`
457
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:
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
+ : ''}
458
991
  ## Environment
459
992
 
460
993
  \`src/env.ts\` validates \`process.env\` — nothing loads \`.env\` for you. Copy
461
994
  \`.env.example\` to \`.env\` and launch with \`--env-file=.env\` (Node/tsx), or
462
995
  export the variables.
463
996
 
464
- **\`--env-file\` never overrides a variable that is already exported.** In a shell
465
- where another project exported \`DATABASE_URL\` (or \`PORT\`), this app boots
466
- against THAT value and only fails on the first request that touches it. Give
467
- generic names an app-specific prefix — \`${envPrefix(options.name)}_DATABASE_URL\` rather than
468
- \`DATABASE_URL\` — and check \`env | grep DATABASE_URL\` when in doubt.
997
+ The variables are **app-prefixed**: \`${envPrefix(options.name)}_PORT\`,
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.
999
+
1000
+ Why: **\`--env-file\` never overrides a variable that is already exported.** In a
1001
+ shell where another project exported \`DATABASE_URL\` (or \`PORT\`), an app reading
1002
+ the generic name boots against THAT value and only fails on the first request
1003
+ that touches it. \`src/env.ts\` therefore passes \`prefix: '${envPrefix(options.name)}'\` to
1004
+ \`defineEnv\`: each variable is read as \`${envPrefix(options.name)}_<NAME>\` first and
1005
+ **falls back** to the bare \`<NAME>\`, so a deployment that already exports the
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\`.`}
1013
+
1014
+ To drop the fallback entirely (prefixed names only), write
1015
+ \`prefix: { value: '${envPrefix(options.name)}', fallback: false }\`. When in doubt about what
1016
+ your shell exports: \`env | grep DATABASE_URL\`.
469
1017
  ${options.cli
470
1018
  ? `
471
1019
  ## The \`basalt\` CLI
@@ -572,14 +1120,18 @@ export function dockerignore() {
572
1120
  **/.DS_Store
573
1121
  `;
574
1122
  }
575
- export function gitignore() {
1123
+ export function gitignore(options) {
576
1124
  return `node_modules/
577
1125
  dist/
578
1126
  .env
579
1127
  .env.*
580
1128
  !.env.example
581
1129
  *.log
582
- `;
1130
+ ${options.prisma
1131
+ ? `# Generated by \`prisma generate\` (runs on install) — never edit or commit it.
1132
+ src/generated/
1133
+ `
1134
+ : ''}`;
583
1135
  }
584
1136
  /**
585
1137
  * pnpm settings: esbuild's build script is required by tsx;