@beignet/cli 0.0.3 → 0.0.4

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/README.md +379 -61
  3. package/dist/ansi.d.ts +10 -0
  4. package/dist/ansi.d.ts.map +1 -0
  5. package/dist/ansi.js +20 -0
  6. package/dist/ansi.js.map +1 -0
  7. package/dist/choices.d.ts +72 -0
  8. package/dist/choices.d.ts.map +1 -0
  9. package/dist/choices.js +88 -0
  10. package/dist/choices.js.map +1 -0
  11. package/dist/completion.d.ts +47 -0
  12. package/dist/completion.d.ts.map +1 -0
  13. package/dist/completion.js +123 -0
  14. package/dist/completion.js.map +1 -0
  15. package/dist/config.d.ts +8 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +8 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/create-prompts.d.ts +42 -0
  20. package/dist/create-prompts.d.ts.map +1 -0
  21. package/dist/create-prompts.js +136 -0
  22. package/dist/create-prompts.js.map +1 -0
  23. package/dist/create.d.ts +4 -1
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +16 -26
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +1 -0
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +37 -2
  30. package/dist/db.js.map +1 -1
  31. package/dist/github-annotations.d.ts +18 -0
  32. package/dist/github-annotations.d.ts.map +1 -0
  33. package/dist/github-annotations.js +22 -0
  34. package/dist/github-annotations.js.map +1 -0
  35. package/dist/index.d.ts +1 -9
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +657 -588
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +21 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +1938 -131
  42. package/dist/inspect.js.map +1 -1
  43. package/dist/lib.d.ts +20 -0
  44. package/dist/lib.d.ts.map +1 -0
  45. package/dist/lib.js +17 -0
  46. package/dist/lib.js.map +1 -0
  47. package/dist/lint.d.ts +10 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +340 -33
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +20 -3
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +1756 -394
  54. package/dist/make.js.map +1 -1
  55. package/dist/outbox.d.ts +24 -0
  56. package/dist/outbox.d.ts.map +1 -0
  57. package/dist/outbox.js +138 -0
  58. package/dist/outbox.js.map +1 -0
  59. package/dist/schedule.d.ts +36 -0
  60. package/dist/schedule.d.ts.map +1 -0
  61. package/dist/schedule.js +155 -0
  62. package/dist/schedule.js.map +1 -0
  63. package/dist/task.d.ts +26 -0
  64. package/dist/task.d.ts.map +1 -0
  65. package/dist/task.js +106 -0
  66. package/dist/task.js.map +1 -0
  67. package/dist/templates.d.ts +3 -32
  68. package/dist/templates.d.ts.map +1 -1
  69. package/dist/templates.js +1002 -527
  70. package/dist/templates.js.map +1 -1
  71. package/dist/version.d.ts +8 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +18 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +9 -8
  76. package/src/ansi.ts +30 -0
  77. package/src/choices.ts +137 -0
  78. package/src/completion.ts +169 -0
  79. package/src/config.ts +16 -0
  80. package/src/create-prompts.ts +182 -0
  81. package/src/create.ts +24 -31
  82. package/src/db.ts +60 -4
  83. package/src/github-annotations.ts +37 -0
  84. package/src/index.ts +1067 -803
  85. package/src/inspect.ts +2859 -115
  86. package/src/lib.ts +45 -0
  87. package/src/lint.ts +493 -39
  88. package/src/make.ts +2181 -405
  89. package/src/outbox.ts +249 -0
  90. package/src/schedule.ts +272 -0
  91. package/src/task.ts +169 -0
  92. package/src/templates.ts +1073 -567
  93. package/src/version.ts +20 -0
  94. package/dist/create-bin.d.ts +0 -3
  95. package/dist/create-bin.d.ts.map +0 -1
  96. package/dist/create-bin.js +0 -9
  97. package/dist/create-bin.js.map +0 -1
  98. package/src/create-bin.ts +0 -11
package/src/templates.ts CHANGED
@@ -1,29 +1,22 @@
1
- /**
2
- * Package managers supported by the app template generator.
3
- */
4
- export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
5
- /**
6
- * Starter presets supported by the app template generator.
7
- */
8
- export type PresetName = "minimal" | "standard";
9
- /**
10
- * Application templates supported by the app template generator.
11
- */
12
- export type TemplateName = "next";
13
- /**
14
- * Optional first-party features that can be scaffolded into a new app.
15
- */
16
- export type FeatureName = "client" | "react-query" | "forms" | "openapi";
17
- /**
18
- * Optional provider integrations that can be scaffolded into a new app.
19
- */
20
- export type IntegrationName =
21
- | "better-auth"
22
- | "drizzle-turso"
23
- | "inngest"
24
- | "pino"
25
- | "resend"
26
- | "upstash-rate-limit";
1
+ import type {
2
+ FeatureName,
3
+ IntegrationName,
4
+ PackageManager,
5
+ PresetName,
6
+ } from "./choices.js";
7
+
8
+ export type {
9
+ FeatureName,
10
+ IntegrationName,
11
+ PackageManager,
12
+ PresetName,
13
+ TemplateName,
14
+ } from "./choices.js";
15
+ export {
16
+ featureChoices,
17
+ integrationChoices,
18
+ presetChoices,
19
+ } from "./choices.js";
27
20
 
28
21
  /**
29
22
  * One generated file emitted by the template generator.
@@ -58,9 +51,9 @@ const externalVersions = {
58
51
  tanstackReactQuery: "^5.100.7",
59
52
  hookformResolvers: "^5.0.0",
60
53
  reactHookForm: "^7.74.0",
61
- betterAuth: "^1.3.26",
62
- drizzleKit: "^0.30.0",
63
- drizzleOrm: "^0.38.0",
54
+ betterAuth: "1.6.11",
55
+ drizzleKit: "^0.31.10",
56
+ drizzleOrm: "^0.45.2",
64
57
  inngest: "^3.0.0",
65
58
  libsqlClient: "^0.14.0",
66
59
  pino: "^9.7.0",
@@ -80,46 +73,16 @@ function hasDrizzleTurso(ctx: TemplateContext): boolean {
80
73
  function packageRunner(ctx: TemplateContext): string {
81
74
  switch (ctx.packageManager) {
82
75
  case "npm":
83
- return "npx -p @beignet/cli beignet";
76
+ return "npm run beignet --";
84
77
  case "pnpm":
85
- return "pnpm dlx @beignet/cli beignet";
78
+ return "pnpm beignet";
86
79
  case "yarn":
87
- return "yarn dlx @beignet/cli beignet";
80
+ return "yarn beignet";
88
81
  default:
89
- return "bunx -p @beignet/cli beignet";
82
+ return "bun beignet";
90
83
  }
91
84
  }
92
85
 
93
- /**
94
- * Supported scaffold feature choices.
95
- */
96
- export const featureChoices = [
97
- "client",
98
- "react-query",
99
- "forms",
100
- "openapi",
101
- ] as const satisfies readonly FeatureName[];
102
-
103
- /**
104
- * Supported scaffold preset choices.
105
- */
106
- export const presetChoices = [
107
- "minimal",
108
- "standard",
109
- ] as const satisfies readonly PresetName[];
110
-
111
- /**
112
- * Supported scaffold integration choices.
113
- */
114
- export const integrationChoices = [
115
- "better-auth",
116
- "drizzle-turso",
117
- "inngest",
118
- "pino",
119
- "resend",
120
- "upstash-rate-limit",
121
- ] as const satisfies readonly IntegrationName[];
122
-
123
86
  const integrationDeps: Record<IntegrationName, Record<string, string>> = {
124
87
  "better-auth": {
125
88
  "@beignet/provider-auth-better-auth": "",
@@ -233,6 +196,7 @@ function packageJson(ctx: TemplateContext): string {
233
196
  }
234
197
 
235
198
  const devDependencies: Record<string, string> = {
199
+ "@beignet/cli": ctx.beignetVersion,
236
200
  "@types/bun": externalVersions.typesBun,
237
201
  "@types/node": externalVersions.typesNode,
238
202
  "@types/react": externalVersions.typesReact,
@@ -249,6 +213,7 @@ function packageJson(ctx: TemplateContext): string {
249
213
  }
250
214
 
251
215
  const scripts: Record<string, string> = {
216
+ beignet: "beignet",
252
217
  dev: "next dev",
253
218
  build: "next build",
254
219
  start: "next start",
@@ -259,8 +224,13 @@ function packageJson(ctx: TemplateContext): string {
259
224
  if (hasDrizzleTurso(ctx)) {
260
225
  scripts["db:generate"] = "drizzle-kit generate";
261
226
  scripts["db:migrate"] = "drizzle-kit migrate";
262
- scripts["db:seed"] = "bun infra/db/seed.ts";
263
- scripts["db:reset"] = "bun infra/db/reset.ts";
227
+
228
+ // The minimal preset does not scaffold infra/db/seed.ts or
229
+ // infra/db/reset.ts, so only the standard preset gets their scripts.
230
+ if (isStandardPreset(ctx)) {
231
+ scripts["db:seed"] = "bun infra/db/seed.ts";
232
+ scripts["db:reset"] = "bun infra/db/reset.ts";
233
+ }
264
234
  }
265
235
 
266
236
  return json({
@@ -284,6 +254,9 @@ function readme(ctx: TemplateContext): string {
284
254
  ctx.packageManager === "npm"
285
255
  ? "npm run dev"
286
256
  : `${ctx.packageManager} run dev`;
257
+ const cli = packageRunner(ctx);
258
+ const envSetup =
259
+ ctx.integrations.length > 0 ? "cp .env.example .env.local\n" : "";
287
260
  const featureList =
288
261
  ctx.features.length > 0
289
262
  ? ctx.features.map((feature) => `- \`${feature}\``).join("\n")
@@ -292,6 +265,20 @@ function readme(ctx: TemplateContext): string {
292
265
  ctx.integrations.length > 0
293
266
  ? ctx.integrations.map((integration) => `- \`${integration}\``).join("\n")
294
267
  : "- None";
268
+ const databaseSection = hasDrizzleTurso(ctx)
269
+ ? `
270
+ ## Database
271
+
272
+ The drizzle-turso integration persists todos with Drizzle on libSQL/Turso. The server creates and seeds the starter \`todos\` table at startup using \`TURSO_DB_URL\` (defaults to \`file:local.db\`).
273
+
274
+ Regenerate and apply migrations after changing \`infra/db/schema/index.ts\`:
275
+
276
+ \`\`\`bash
277
+ ${cli} db generate
278
+ ${cli} db migrate
279
+ \`\`\`
280
+ `
281
+ : "";
295
282
 
296
283
  return `# ${ctx.name}
297
284
 
@@ -301,19 +288,21 @@ Beignet app scaffolded with \`@beignet/cli\`.
301
288
 
302
289
  \`\`\`bash
303
290
  ${install}
304
- ${run}
291
+ ${envSetup}${run}
305
292
  \`\`\`
306
293
 
307
- Open http://localhost:3000 and start from the todos workflow.
308
-
294
+ Open http://localhost:3000 and start from the todos workflow. The homepage links to the health check, OpenAPI document, and Beignet docs.
295
+ ${databaseSection}
309
296
  ## Files to know
310
297
 
311
298
  - \`features/todos/contracts.ts\` defines the API contract.
312
- - \`features/todos/use-cases.ts\` contains application logic with input and output validation.
299
+ - \`features/todos/use-cases/\` contains application logic with input and output validation.
313
300
  - \`ports/index.ts\` defines the app's external dependencies.
314
- - \`server/index.ts\` wires contracts to handlers.
301
+ - \`features/todos/routes.ts\` binds contracts to use cases.
302
+ - \`server/context.ts\` declares the context blueprint shared by the server and tests.
303
+ - \`server/index.ts\` composes the server from ports, context, and routes.
315
304
  - \`app/api/[[...path]]/route.ts\` exposes contract-backed API routes to Next.js.
316
- ${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
305
+ ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/index.ts` owns the Drizzle schema and `infra/db/provider.ts` wires the Drizzle todo repository into the `todos` port.\n" : ""}${hasIntegration(ctx, "better-auth") ? "- `lib/better-auth.ts` owns the Better Auth instance served at `/api/auth/*`.\n" : ""}${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
317
306
 
318
307
  ## Selected preset
319
308
 
@@ -336,6 +325,10 @@ function standardReadme(ctx: TemplateContext): string {
336
325
  ctx.packageManager === "npm"
337
326
  ? "npm run dev"
338
327
  : `${ctx.packageManager} run dev`;
328
+ const test =
329
+ ctx.packageManager === "npm"
330
+ ? "npm run test"
331
+ : `${ctx.packageManager} run test`;
339
332
  const typecheck =
340
333
  ctx.packageManager === "npm"
341
334
  ? "npm run typecheck"
@@ -358,10 +351,24 @@ Beignet app scaffolded with \`@beignet/cli\`.
358
351
  \`\`\`bash
359
352
  ${install}
360
353
  cp .env.example .env.local
354
+ \`\`\`
355
+
356
+ ## Prepare the database
357
+
358
+ \`\`\`bash
359
+ ${cli} db generate
360
+ ${cli} db migrate
361
+ \`\`\`
362
+
363
+ \`db generate\` creates Drizzle migrations from \`infra/db/schema/\`. \`db migrate\` applies them to the local SQLite database configured in \`.env.local\`.
364
+
365
+ ## Start the app
366
+
367
+ \`\`\`bash
361
368
  ${run}
362
369
  \`\`\`
363
370
 
364
- Open http://localhost:3000 for the todo workflow, http://localhost:3000/api/health for health checks, and http://localhost:3000/api/devtools while developing.
371
+ Open http://localhost:3000 for the todo workflow. The homepage links to the health check, OpenAPI document, devtools, and Beignet docs.
365
372
 
366
373
  ## First checks
367
374
 
@@ -375,15 +382,32 @@ ${typecheck}
375
382
 
376
383
  \`routes\` shows the contracts Beignet can inspect. \`lint\` checks dependency direction. \`doctor\` catches route, OpenAPI, and resource drift.
377
384
 
385
+ ## Generate a feature
386
+
387
+ \`\`\`bash
388
+ ${cli} make feature projects
389
+ ${cli} db generate
390
+ ${cli} db migrate
391
+ ${test}
392
+ ${typecheck}
393
+ ${cli} lint
394
+ ${cli} doctor
395
+ \`\`\`
396
+
397
+ \`make feature\` creates a contract-to-test vertical slice. In the standard starter it also adds Drizzle schema and repository files, so regenerate and migrate the database before running the app against the new feature.
398
+
378
399
  ## App map
379
400
 
380
- - \`features/todos/contracts.ts\` owns the HTTP contract and reuses use case schemas.
401
+ - \`features/todos/contracts.ts\` owns the HTTP contract and reuses shared feature schemas.
402
+ - \`features/todos/schemas.ts\` owns shared DTO and validation schemas used by contracts, use cases, ports, tests, and clients.
381
403
  - \`features/todos/use-cases/\` owns application behavior and validation.
382
404
  - \`ports/\` defines app-owned dependencies.
383
405
  - \`infra/\` implements ports for the selected runtime.
384
406
  - \`server/routes.ts\` keeps the central route registry and OpenAPI contract list.
407
+ - \`server/context.ts\` declares the context blueprint shared by the server and route tests.
385
408
  - \`server/\` wires context, providers, routes, hooks, and error handling.
386
- - \`server/outbox.ts\` registers durable events and jobs for the outbox drain route.
409
+ - \`server/outbox.ts\` registers durable events and jobs for the outbox drain route and CLI drain runner.
410
+ - \`server/schedules.ts\` registers schedules for cron routes and the CLI schedule runner.
387
411
  - \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
388
412
  - \`features/todos/domain/events/\`, \`jobs/\`, \`listeners/\`, \`notifications/\`, \`schedules/\`, and \`uploads/\` show the feature-owned background workflow shape.
389
413
  - \`app/storage/[...key]/route.ts\` serves public local storage objects.
@@ -400,6 +424,8 @@ ${beforeDeploy}
400
424
  - Keep \`/api/devtools\` development-only unless you add authentication and stricter redaction.
401
425
  - Set \`APP_URL\`, \`BETTER_AUTH_SECRET\`, \`CRON_SECRET\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
402
426
  - Configure a scheduled request to \`/api/cron/outbox/drain\` with \`Authorization: Bearer <CRON_SECRET>\`.
427
+ - Use \`${cli} outbox drain\` for bounded local, CI, or worker-hosted drain runs.
428
+ - Use \`${cli} schedule run todos.log-daily-summary\` for bounded local, CI, or worker-hosted schedule runs.
403
429
  - Review the starter authorization policy before exposing user-owned data.
404
430
  ${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
405
431
  `;
@@ -420,10 +446,17 @@ function integrationsDoc(ctx: TemplateContext): string {
420
446
  "",
421
447
  "- Package: `@beignet/provider-auth-better-auth`",
422
448
  "- Peer dependency: `better-auth`",
423
- "- The standard starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.",
424
- "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
425
- "- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
426
- "- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.",
449
+ "- The starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.",
450
+ ...(isStandardPreset(ctx)
451
+ ? [
452
+ "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
453
+ "- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
454
+ "- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.",
455
+ ]
456
+ : [
457
+ "- Keep auth behind the shared `AuthPort`, then read `ctx.auth` in use cases that need a signed-in user.",
458
+ "- The minimal starter uses Better Auth's in-memory adapter, so users and sessions reset on restart. Switch to a database adapter such as `better-auth/adapters/drizzle` before production.",
459
+ ]),
427
460
  "",
428
461
  );
429
462
  }
@@ -436,9 +469,19 @@ function integrationsDoc(ctx: TemplateContext): string {
436
469
  "- Peer dependencies: `@libsql/client` and `drizzle-orm`",
437
470
  "- Dev dependency: `drizzle-kit`",
438
471
  "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.",
439
- "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
472
+ ...(isStandardPreset(ctx)
473
+ ? [
474
+ "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
475
+ ]
476
+ : [
477
+ "- The scaffold wires a typed Drizzle repository behind the `todos` port through `infra/db/provider.ts`, which also creates and seeds the starter table at startup.",
478
+ ]),
440
479
  `- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`,
441
- `- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`,
480
+ ...(isStandardPreset(ctx)
481
+ ? [
482
+ `- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`,
483
+ ]
484
+ : []),
442
485
  "",
443
486
  );
444
487
  }
@@ -451,7 +494,7 @@ function integrationsDoc(ctx: TemplateContext): string {
451
494
  "- Package: `@beignet/provider-inngest`",
452
495
  "- Peer dependency: `inngest`",
453
496
  "- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.",
454
- "- Define jobs with `createJobHandlers(...).defineJob(...)` and dispatch them through `ctx.ports.jobs.dispatch(...)`.",
497
+ "- Define jobs with `defineJob(...)` from `lib/jobs.ts` and dispatch them through `ctx.ports.jobs.dispatch(...)`.",
455
498
  "- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.",
456
499
  "",
457
500
  );
@@ -544,7 +587,7 @@ function envExample(ctx: TemplateContext): string {
544
587
  lines.push(
545
588
  "UPSTASH_REDIS_REST_URL=",
546
589
  "UPSTASH_REDIS_REST_TOKEN=",
547
- "UPSTASH_PREFIX=ck:ratelimit",
590
+ "UPSTASH_PREFIX=beignet:ratelimit",
548
591
  "",
549
592
  );
550
593
  }
@@ -621,6 +664,11 @@ function page(ctx: TemplateContext): string {
621
664
  <p>
622
665
  <a href="/api/todos">Open todos API</a>
623
666
  </p>
667
+ <nav className="starter-links" aria-label="Starter links">
668
+ <a href="/api/health">Health</a>
669
+ <a href="/api/openapi">OpenAPI</a>
670
+ <a href="https://beignetjs.com">Docs</a>
671
+ </nav>
624
672
  </section>
625
673
  </div>
626
674
  </main>
@@ -645,6 +693,14 @@ export default function HomePage() {
645
693
  </p>
646
694
  </header>
647
695
 
696
+ <nav className="starter-links" aria-label="Starter links">
697
+ <a href="#todos">Todo feature</a>
698
+ <a href="/api/health">Health</a>
699
+ <a href="/api/openapi">OpenAPI</a>
700
+ <a href="/api/devtools">Devtools</a>
701
+ <a href="https://beignetjs.com">Docs</a>
702
+ </nav>
703
+
648
704
  <TodoApp />
649
705
  </div>
650
706
  </main>
@@ -654,64 +710,126 @@ export default function HomePage() {
654
710
  }
655
711
 
656
712
  function serverProviders(ctx: TemplateContext): string {
657
- const imports: string[] = [];
713
+ const packageImports: string[] = [];
714
+ const appImports: string[] = [];
715
+ const declarations: string[] = [];
658
716
  const entries: string[] = [];
659
717
 
718
+ if (hasIntegration(ctx, "better-auth")) {
719
+ packageImports.push(
720
+ 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";',
721
+ );
722
+ appImports.push(
723
+ 'import { auth } from "@/lib/better-auth";',
724
+ 'import type { AuthSessionMetadata, AuthUser } from "@/ports";',
725
+ );
726
+ entries.push(
727
+ "createAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth)",
728
+ );
729
+ }
730
+
660
731
  if (hasIntegration(ctx, "pino")) {
661
- imports.push(
732
+ packageImports.push(
662
733
  'import { loggerPinoProvider } from "@beignet/provider-logger-pino";',
663
734
  );
664
735
  entries.push("loggerPinoProvider");
665
736
  }
666
737
 
738
+ if (hasDrizzleTurso(ctx)) {
739
+ packageImports.push(
740
+ 'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";',
741
+ );
742
+ appImports.unshift(
743
+ 'import { starterDatabaseProvider } from "@/infra/db/provider";',
744
+ 'import * as schema from "@/infra/db/schema";',
745
+ );
746
+ declarations.push(
747
+ "const drizzleTursoProvider = createDrizzleTursoProvider({ schema });",
748
+ );
749
+ entries.push("drizzleTursoProvider", "starterDatabaseProvider");
750
+ }
751
+
667
752
  if (hasIntegration(ctx, "inngest")) {
668
- imports.push(
753
+ packageImports.push(
669
754
  'import { inngestProvider } from "@beignet/provider-inngest";',
670
755
  );
671
756
  entries.push("inngestProvider");
672
757
  }
673
758
 
674
759
  if (hasIntegration(ctx, "resend")) {
675
- imports.push(
760
+ packageImports.push(
676
761
  'import { mailResendProvider } from "@beignet/provider-mail-resend";',
677
762
  );
678
763
  entries.push("mailResendProvider");
679
764
  }
680
765
 
681
766
  if (hasIntegration(ctx, "upstash-rate-limit")) {
682
- imports.push(
767
+ packageImports.push(
683
768
  'import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";',
684
769
  );
685
770
  entries.push("upstashRateLimitProvider");
686
771
  }
687
772
 
688
- return `${imports.join("\n")}
773
+ const importBlock = [...packageImports.sort(), ...appImports].join("\n");
774
+ const declarationBlock =
775
+ declarations.length > 0 ? `\n${declarations.join("\n")}\n` : "";
776
+
777
+ return `${importBlock}
778
+ ${declarationBlock}
689
779
  export const providers = [
690
780
  ${entries.join(",\n\t")},
691
781
  ];
692
782
  `;
693
783
  }
694
784
 
785
+ function serverContext(ctx: TemplateContext): string {
786
+ const blueprint = hasIntegration(ctx, "better-auth")
787
+ ? ` async ({ req, ports }) => ({
788
+ requestId: crypto.randomUUID(),
789
+ auth: await ports.auth.getSession(req),
790
+ ports,
791
+ }),`
792
+ : ` async ({ ports }) => ({
793
+ requestId: crypto.randomUUID(),
794
+ ports,
795
+ }),`;
796
+
797
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
798
+ import { defineServerContext } from "@beignet/core/server";
799
+ import type { AppContext } from "@/app-context";
800
+ import type { AppPorts } from "@/ports";
801
+
802
+ /**
803
+ * Context blueprint shared by the runtime server and route tests.
804
+ */
805
+ export const appContext = defineServerContext<AppContext, AppPorts>()(
806
+ ${blueprint}
807
+ );
808
+ `;
809
+ }
810
+
695
811
  function server(ctx: TemplateContext): string {
696
- const hasProviders = ctx.integrations.some((integration) =>
697
- ["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration),
698
- );
812
+ const hasProviders = ctx.integrations.length > 0;
813
+ const providerConfigBlock = hasDrizzleTurso(ctx)
814
+ ? ` providerConfig: {
815
+ "drizzle-turso": {
816
+ DB_URL: process.env.TURSO_DB_URL ?? "file:local.db",
817
+ DB_AUTH_TOKEN: process.env.TURSO_DB_AUTH_TOKEN,
818
+ },
819
+ },
820
+ `
821
+ : "";
699
822
 
700
- return `import { createNextServer } from "@beignet/next";
823
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
824
+ import { createNextServer } from "@beignet/next";
825
+ import { appContext } from "./context";
701
826
  ${hasProviders ? 'import { providers } from "./providers";\n' : ""}import { routes } from "./routes";
827
+ import type { AppContext } from "@/app-context";
702
828
  import { appPorts, type AppPorts } from "@/ports";
703
829
 
704
- export type AppContext = {
705
- requestId: string;
706
- ports: AppPorts;
707
- };
708
-
709
830
  export const server = await createNextServer<AppContext, AppPorts>({
710
831
  ports: appPorts,
711
- ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
712
- requestId: crypto.randomUUID(),
713
- ports,
714
- }),
832
+ ${hasProviders ? "\tproviders,\n" : ""}${providerConfigBlock} context: appContext,
715
833
  routes,
716
834
  mapUnhandledError: ({ err, ctx }) => {
717
835
  const ports = ctx?.ports as
@@ -753,6 +871,127 @@ ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
753
871
  `;
754
872
  }
755
873
 
874
+ function minimalPorts(ctx: TemplateContext): string {
875
+ const usesDrizzleTurso = hasDrizzleTurso(ctx);
876
+ const usesBetterAuth = hasIntegration(ctx, "better-auth");
877
+
878
+ if (!usesDrizzleTurso && !usesBetterAuth) {
879
+ return files.ports;
880
+ }
881
+
882
+ const imports = [
883
+ 'import { definePorts } from "@beignet/core/ports";',
884
+ ...(usesBetterAuth
885
+ ? [
886
+ `import type {
887
+ AuthPort as BeignetAuthPort,
888
+ AuthSession as BeignetAuthSession,
889
+ } from "@beignet/core/ports";`,
890
+ ]
891
+ : []),
892
+ 'import type { z } from "zod";',
893
+ 'import type { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";',
894
+ ].join("\n");
895
+
896
+ const authTypes = usesBetterAuth
897
+ ? `
898
+
899
+ export type AuthUser = {
900
+ id: string;
901
+ email?: string;
902
+ name?: string;
903
+ };
904
+
905
+ export type AuthSessionMetadata = {
906
+ id?: string;
907
+ };
908
+
909
+ export type AuthSession = BeignetAuthSession<AuthUser, AuthSessionMetadata>;
910
+
911
+ export type AuthPort = BeignetAuthPort<AuthUser, AuthSessionMetadata>;`
912
+ : "";
913
+
914
+ const appPortsType = [
915
+ "export type AppPorts = {",
916
+ ...(usesBetterAuth ? ["\tauth: AuthPort;"] : []),
917
+ "\ttodos: TodoRepository;",
918
+ "};",
919
+ ].join("\n");
920
+
921
+ const inMemoryTodos = usesDrizzleTurso
922
+ ? ""
923
+ : `
924
+
925
+ const todos = new Map<string, Todo>();
926
+
927
+ function createTodo(input: CreateTodoInput): Todo {
928
+ const todo = {
929
+ id: crypto.randomUUID(),
930
+ title: input.title,
931
+ completed: false,
932
+ createdAt: new Date().toISOString(),
933
+ };
934
+ todos.set(todo.id, todo);
935
+ return todo;
936
+ }
937
+
938
+ createTodo({ title: "Read the generated contract" });
939
+ createTodo({ title: "Add your first use case" });`;
940
+
941
+ const bound = usesDrizzleTurso
942
+ ? "\tbound: {},"
943
+ : ` bound: {
944
+ todos: {
945
+ list: async () => Array.from(todos.values()),
946
+ create: async (input: CreateTodoInput) => createTodo(input),
947
+ },
948
+ },`;
949
+ const deferred = [
950
+ ...(usesBetterAuth ? ['"auth"'] : []),
951
+ ...(usesDrizzleTurso ? ['"todos"'] : []),
952
+ ].join(", ");
953
+
954
+ return `${imports}
955
+
956
+ export type Todo = z.infer<typeof TodoSchema>;
957
+ export type CreateTodoInput = z.infer<typeof CreateTodoSchema>;
958
+
959
+ export interface TodoRepository {
960
+ list(): Promise<Todo[]>;
961
+ create(input: CreateTodoInput): Promise<Todo>;
962
+ }${authTypes}
963
+
964
+ ${appPortsType}${inMemoryTodos}
965
+
966
+ /**
967
+ * App ports the server boots with.
968
+ *
969
+ * Bound ports are app-owned. Every deferred key is contributed by a provider
970
+ * in server/providers.ts during startup; createNextServer(...) fails boot if
971
+ * any of them is still unbound after providers have started.
972
+ */
973
+ export const appPorts = definePorts<AppPorts>()({
974
+ ${bound}
975
+ deferred: [${deferred}],
976
+ });
977
+ `;
978
+ }
979
+
980
+ function minimalAppContext(ctx: TemplateContext): string {
981
+ if (!hasIntegration(ctx, "better-auth")) {
982
+ return files.appContext;
983
+ }
984
+
985
+ return `import type { AppPorts, AuthSession } from "@/ports";
986
+
987
+ export type AppContext = {
988
+ requestId: string;
989
+ auth: AuthSession | null;
990
+ ports: AppPorts;
991
+ };
992
+ `;
993
+ }
994
+
756
995
  function todoApp(ctx: TemplateContext): string {
757
996
  const listTodosQueryOptions =
758
997
  ctx.preset === "standard" ? "{ query: {} }" : "";
@@ -760,12 +999,12 @@ function todoApp(ctx: TemplateContext): string {
760
999
  if (hasFeature(ctx, "forms")) {
761
1000
  return `"use client";
762
1001
 
763
- import { createReactHookForm } from "@beignet/react-hook-form";
1002
+ import { rootFormError } from "@beignet/react-hook-form";
764
1003
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1004
+ import { rq } from "@/client";
1005
+ import { rhf } from "@/client/forms";
765
1006
  import { createTodo, listTodos } from "@/features/todos/contracts";
766
- import { rq } from "@/client/rq";
767
1007
 
768
- const rhf = createReactHookForm();
769
1008
  const createTodoForm = rhf(createTodo);
770
1009
 
771
1010
  export function TodoApp() {
@@ -778,19 +1017,21 @@ export function TodoApp() {
778
1017
  rq(createTodo).mutationOptions({
779
1018
  onSuccess: async () => {
780
1019
  form.reset();
781
- await queryClient.invalidateQueries({
782
- queryKey: rq(listTodos).key(),
783
- });
1020
+ await rq(listTodos).invalidate(queryClient);
1021
+ },
1022
+ onError: (error) => {
1023
+ form.setError("root", rootFormError(error, "Could not create the todo."));
784
1024
  },
785
1025
  }),
786
1026
  );
787
1027
 
788
1028
  const onSubmit = form.handleSubmit((body) => {
1029
+ form.clearErrors("root");
789
1030
  createTodoMutation.mutate({ body });
790
1031
  });
791
1032
 
792
1033
  return (
793
- <section className="workspace">
1034
+ <section id="todos" className="workspace">
794
1035
  <form className="composer" onSubmit={onSubmit}>
795
1036
  <label htmlFor="todo-title">New todo</label>
796
1037
  <div className="composer-row">
@@ -806,6 +1047,9 @@ export function TodoApp() {
806
1047
  {form.formState.errors.title ? (
807
1048
  <p className="error">{form.formState.errors.title.message}</p>
808
1049
  ) : null}
1050
+ {form.formState.errors.root ? (
1051
+ <p className="error">{form.formState.errors.root.message}</p>
1052
+ ) : null}
809
1053
  </form>
810
1054
 
811
1055
  <div className="todos-panel">
@@ -836,8 +1080,8 @@ export function TodoApp() {
836
1080
 
837
1081
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
838
1082
  import { useState } from "react";
1083
+ import { rq } from "@/client";
839
1084
  import { createTodo, listTodos } from "@/features/todos/contracts";
840
- import { rq } from "@/client/rq";
841
1085
 
842
1086
  export function TodoApp() {
843
1087
  const [title, setTitle] = useState("");
@@ -847,15 +1091,13 @@ export function TodoApp() {
847
1091
  rq(createTodo).mutationOptions({
848
1092
  onSuccess: async () => {
849
1093
  setTitle("");
850
- await queryClient.invalidateQueries({
851
- queryKey: rq(listTodos).key(),
852
- });
1094
+ await rq(listTodos).invalidate(queryClient);
853
1095
  },
854
1096
  }),
855
1097
  );
856
1098
 
857
1099
  return (
858
- <section className="workspace">
1100
+ <section id="todos" className="workspace">
859
1101
  <form
860
1102
  className="composer"
861
1103
  onSubmit={(event) => {
@@ -897,6 +1139,58 @@ export function TodoApp() {
897
1139
  `;
898
1140
  }
899
1141
 
1142
+ function clientIndex(ctx: TemplateContext): string {
1143
+ const imports = [
1144
+ 'import { createClient } from "@beignet/core/client";',
1145
+ ...(hasFeature(ctx, "react-query")
1146
+ ? [
1147
+ 'import { createReactQuery } from "@beignet/react-query";',
1148
+ 'import { QueryClient } from "@tanstack/react-query";',
1149
+ ]
1150
+ : []),
1151
+ ...(isStandardPreset(ctx)
1152
+ ? [
1153
+ 'import { createUploadClient } from "@beignet/core/uploads/client";',
1154
+ 'import type { todoUploads } from "@/features/todos/uploads";',
1155
+ ]
1156
+ : []),
1157
+ ].join("\n");
1158
+
1159
+ const exports = [
1160
+ `export const apiClient = createClient({
1161
+ \tvalidateInput: true,
1162
+ });`,
1163
+ ...(hasFeature(ctx, "react-query")
1164
+ ? ["export const rq = createReactQuery(apiClient);"]
1165
+ : []),
1166
+ ...(isStandardPreset(ctx)
1167
+ ? [
1168
+ `export const uploads = createUploadClient<typeof todoUploads>({
1169
+ \tbaseUrl: "/api/uploads",
1170
+ });`,
1171
+ ]
1172
+ : []),
1173
+ ...(hasFeature(ctx, "react-query")
1174
+ ? [
1175
+ `export function makeQueryClient() {
1176
+ \treturn new QueryClient({
1177
+ \t\tdefaultOptions: {
1178
+ \t\t\tqueries: {
1179
+ \t\t\t\tstaleTime: 60 * 1000,
1180
+ \t\t\t},
1181
+ \t\t},
1182
+ \t});
1183
+ }`,
1184
+ ]
1185
+ : []),
1186
+ ].join("\n\n");
1187
+
1188
+ return `${imports}
1189
+
1190
+ ${exports}
1191
+ `;
1192
+ }
1193
+
900
1194
  const files = {
901
1195
  gitignore: `.next
902
1196
  node_modules
@@ -997,6 +1291,29 @@ input {
997
1291
  color: #475569;
998
1292
  }
999
1293
 
1294
+ .starter-links {
1295
+ display: flex;
1296
+ flex-wrap: wrap;
1297
+ gap: 10px;
1298
+ margin: 22px 0;
1299
+ }
1300
+
1301
+ .starter-links a {
1302
+ border: 1px solid #cbd5e1;
1303
+ border-radius: 8px;
1304
+ padding: 8px 10px;
1305
+ background: #ffffff;
1306
+ color: #172033;
1307
+ font-size: 14px;
1308
+ font-weight: 700;
1309
+ text-decoration: none;
1310
+ }
1311
+
1312
+ .starter-links a:hover {
1313
+ border-color: #4f46e5;
1314
+ color: #4f46e5;
1315
+ }
1316
+
1000
1317
  .panel {
1001
1318
  margin-top: 24px;
1002
1319
  padding: 20px;
@@ -1123,32 +1440,26 @@ input {
1123
1440
  `,
1124
1441
  appProviders: `"use client";
1125
1442
 
1126
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1443
+ import { QueryClientProvider } from "@tanstack/react-query";
1127
1444
  import { type ReactNode, useState } from "react";
1445
+ import { makeQueryClient } from "@/client";
1128
1446
 
1129
1447
  export function Providers({ children }: { children: ReactNode }) {
1130
- const [queryClient] = useState(() => new QueryClient());
1448
+ const [queryClient] = useState(() => makeQueryClient());
1131
1449
 
1132
1450
  return (
1133
1451
  <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
1134
1452
  );
1135
1453
  }
1136
1454
  `,
1137
- apiClient: `import { createNextClient } from "@beignet/next";
1455
+ formsClient: `import { createReactHookForm } from "@beignet/react-hook-form";
1138
1456
 
1139
- export const apiClient = createNextClient({
1140
- validate: true,
1141
- });
1457
+ export const rhf = createReactHookForm();
1142
1458
  `,
1143
- rq: `import { createReactQuery } from "@beignet/react-query";
1144
- import { apiClient } from "./api-client";
1145
-
1146
- export const rq = createReactQuery(apiClient);
1147
- `,
1148
- contractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
1459
+ contractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
1149
1460
  import { z } from "zod";
1150
1461
 
1151
- const todos = createContractGroup().namespace("todos");
1462
+ const todos = defineContractGroup().namespace("todos");
1152
1463
 
1153
1464
  export const TodoSchema = z.object({
1154
1465
  id: z.string(),
@@ -1213,15 +1524,145 @@ export const appPorts = definePorts({
1213
1524
 
1214
1525
  export type AppPorts = typeof appPorts;
1215
1526
  `,
1216
- useCasesTodos: `import { createUseCase } from "@beignet/core/application";
1217
- import { z } from "zod";
1218
- import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
1219
- import type { AppPorts } from "@/ports";
1527
+ appContext: `import type { AppPorts } from "@/ports";
1220
1528
 
1221
- type AppContext = {
1529
+ export type AppContext = {
1222
1530
  requestId: string;
1223
1531
  ports: AppPorts;
1224
1532
  };
1533
+ `,
1534
+ minimalBetterAuth: `import { betterAuth } from "better-auth";
1535
+ import { memoryAdapter, type MemoryDB } from "better-auth/adapters/memory";
1536
+
1537
+ // The minimal starter keeps auth storage in memory, matching its in-memory
1538
+ // ports. Users and sessions reset on restart; switch to a database adapter
1539
+ // such as better-auth/adapters/drizzle before production.
1540
+ const database: MemoryDB = {
1541
+ account: [],
1542
+ session: [],
1543
+ user: [],
1544
+ verification: [],
1545
+ };
1546
+
1547
+ export const auth = betterAuth({
1548
+ baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
1549
+ secret:
1550
+ process.env.BETTER_AUTH_SECRET ?? "local-dev-better-auth-secret-change-me",
1551
+ database: memoryAdapter(database),
1552
+ emailAndPassword: {
1553
+ enabled: true,
1554
+ },
1555
+ });
1556
+ `,
1557
+ minimalDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1558
+
1559
+ export const todos = sqliteTable("todos", {
1560
+ id: text("id").primaryKey(),
1561
+ title: text("title").notNull(),
1562
+ completed: integer("completed", { mode: "boolean" }).notNull().default(false),
1563
+ createdAt: text("created_at").notNull(),
1564
+ });
1565
+ `,
1566
+ minimalDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
1567
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1568
+ import { asc } from "drizzle-orm";
1569
+ import * as schema from "@/infra/db/schema";
1570
+ import type { CreateTodoInput, TodoRepository } from "@/ports";
1571
+
1572
+ export function createDrizzleTodoRepository(
1573
+ db: DrizzleTursoDatabase<typeof schema>,
1574
+ ): TodoRepository {
1575
+ return {
1576
+ async list() {
1577
+ return db
1578
+ .select()
1579
+ .from(schema.todos)
1580
+ .orderBy(asc(schema.todos.createdAt));
1581
+ },
1582
+ async create(input: CreateTodoInput) {
1583
+ const todo = {
1584
+ id: crypto.randomUUID(),
1585
+ title: input.title,
1586
+ completed: false,
1587
+ createdAt: new Date().toISOString(),
1588
+ };
1589
+ const [row] = await db.insert(schema.todos).values(todo).returning();
1590
+
1591
+ if (!row) {
1592
+ throw new Error("Failed to create todo");
1593
+ }
1594
+
1595
+ return row;
1596
+ },
1597
+ };
1598
+ }
1599
+ `,
1600
+ minimalDbProvider: `import type { beignetServerOnly } from "@beignet/core/server-only";
1601
+ import { createProvider } from "@beignet/core/providers";
1602
+ import type { DbPort } from "@beignet/provider-drizzle-turso";
1603
+ import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
1604
+ import type { AppPorts } from "@/ports";
1605
+ import * as schema from "./schema";
1606
+
1607
+ const setupStatements = [
1608
+ \`CREATE TABLE IF NOT EXISTS todos (
1609
+ id text PRIMARY KEY NOT NULL,
1610
+ title text NOT NULL,
1611
+ completed integer DEFAULT false NOT NULL,
1612
+ created_at text NOT NULL
1613
+ )\`,
1614
+ ];
1615
+
1616
+ const seedTitles = [
1617
+ "Read the generated contract",
1618
+ "Add your first use case",
1619
+ ] as const;
1620
+
1621
+ export const starterDatabaseProvider = createProvider<{
1622
+ db: DbPort<typeof schema>;
1623
+ }>()({
1624
+ name: "starter-database",
1625
+
1626
+ async setup({ ports }) {
1627
+ const dbPort = ports.db;
1628
+ if (!dbPort) {
1629
+ throw new Error(
1630
+ "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
1631
+ );
1632
+ }
1633
+
1634
+ const providedPorts: Pick<AppPorts, "todos"> = {
1635
+ todos: createDrizzleTodoRepository(dbPort.db),
1636
+ };
1637
+
1638
+ return {
1639
+ ports: providedPorts,
1640
+ async start() {
1641
+ for (const statement of setupStatements) {
1642
+ await dbPort.client.execute(statement);
1643
+ }
1644
+
1645
+ const result = await dbPort.client.execute(
1646
+ "SELECT count(*) as total FROM todos",
1647
+ );
1648
+ const total = Number(result.rows[0]?.total ?? 0);
1649
+ if (total > 0) return;
1650
+
1651
+ for (const title of seedTitles) {
1652
+ await dbPort.client.execute({
1653
+ sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
1654
+ args: [crypto.randomUUID(), title, 0, new Date().toISOString()],
1655
+ });
1656
+ }
1657
+ },
1658
+ };
1659
+ },
1660
+ });
1661
+ `,
1662
+ useCasesTodos: `import { createUseCase } from "@beignet/core/application";
1663
+ import { z } from "zod";
1664
+ import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
1665
+ import type { AppContext } from "@/app-context";
1225
1666
 
1226
1667
  const useCase = createUseCase<AppContext>();
1227
1668
 
@@ -1238,7 +1679,7 @@ export const ListTodosOutputSchema = z.object({
1238
1679
 
1239
1680
  export const listTodos = useCase
1240
1681
  .query("listTodos")
1241
- .input(z.void())
1682
+ .input(z.object({}))
1242
1683
  .output(ListTodosOutputSchema)
1243
1684
  .run(async ({ ctx }) => {
1244
1685
  const todos = await ctx.ports.todos.list();
@@ -1260,33 +1701,23 @@ export const createTodo = useCase
1260
1701
  .output(TodoSchema)
1261
1702
  .run(async ({ ctx, input }) => ctx.ports.todos.create(input));
1262
1703
  `,
1263
- todoRoutes: `import { defineRouteGroup } from "@beignet/next";
1264
- import type { AppContext } from "@/server";
1704
+ todoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
1705
+ import { defineRouteGroup } from "@beignet/next";
1706
+ import type { AppContext } from "@/app-context";
1265
1707
  import { createTodo, listTodos } from "@/features/todos/use-cases";
1266
1708
  import * as contracts from "@/features/todos/contracts";
1267
1709
 
1268
1710
  export const todoRoutes = defineRouteGroup<AppContext>({
1269
1711
  name: "todos",
1270
1712
  routes: [
1271
- {
1272
- contract: contracts.listTodos,
1273
- handle: async ({ ctx }) => ({
1274
- status: 200,
1275
- body: await listTodos.run({ ctx, input: undefined }),
1276
- }),
1277
- },
1278
- {
1279
- contract: contracts.createTodo,
1280
- handle: async ({ ctx, body }) => ({
1281
- status: 201,
1282
- body: await createTodo.run({ ctx, input: body }),
1283
- }),
1284
- },
1713
+ { contract: contracts.listTodos, useCase: listTodos },
1714
+ { contract: contracts.createTodo, useCase: createTodo },
1285
1715
  ],
1286
1716
  });
1287
1717
  `,
1288
- serverRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1289
- import type { AppContext } from "@/server";
1718
+ serverRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
1719
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1720
+ import type { AppContext } from "@/app-context";
1290
1721
  import { todoRoutes } from "@/features/todos/routes";
1291
1722
 
1292
1723
  export const routes = defineRoutes<AppContext>([
@@ -1344,24 +1775,28 @@ export const env = createEnv({
1344
1775
 
1345
1776
  export const isProduction = env.NODE_ENV === "production";
1346
1777
  `,
1347
- productionAppContext: `import type { DevtoolsPort, DevtoolsTraceContext } from "@beignet/devtools";
1348
- import type { ActivityActor, ActivityTenant, StoragePort } from "@beignet/core/ports";
1778
+ productionAppContext: `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
1779
+ import type { InferProviderPorts } from "@beignet/core/providers";
1780
+ import type { TraceContext } from "@beignet/core/tracing";
1349
1781
  import type { AppGate, AppPorts } from "@/ports";
1350
1782
  import type { AuthSession } from "@/ports/auth";
1783
+ import type { providers } from "@/server/providers";
1784
+
1785
+ /**
1786
+ * App ports plus the ports contributed by the server's providers at startup.
1787
+ */
1788
+ export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
1351
1789
 
1352
1790
  export type AppContext = {
1353
1791
  requestId: string;
1354
1792
  actor: ActivityActor;
1355
1793
  auth: AuthSession | null;
1356
1794
  gate: AppGate;
1357
- ports: AppPorts & {
1358
- devtools: DevtoolsPort;
1359
- storage: StoragePort;
1360
- };
1795
+ ports: AppRuntimePorts;
1361
1796
  tenant?: ActivityTenant;
1362
- } & Partial<DevtoolsTraceContext>;
1797
+ } & Partial<TraceContext>;
1363
1798
  `,
1364
- productimapUnhandledErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
1799
+ productionSharedErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
1365
1800
 
1366
1801
  export const errors = defineErrors({
1367
1802
  Unauthorized: {
@@ -1420,13 +1855,36 @@ export type Todo = z.infer<typeof TodoSchema>;
1420
1855
  export type CreateTodoInput = z.infer<typeof CreateTodoInputSchema>;
1421
1856
  export type ListTodosInput = z.infer<typeof ListTodosInputSchema>;
1422
1857
  `,
1423
- productionUseCaseBuilder: `import { createUseCase } from "@beignet/core/application";
1424
- import { createDevtoolsUseCaseObserver } from "@beignet/devtools";
1858
+ productionUseCaseBuilder: `import type { beignetServerOnly } from "@beignet/core/server-only";
1859
+ import { createUseCase } from "@beignet/core/application";
1425
1860
  import type { AppContext } from "@/app-context";
1426
1861
 
1427
- export const useCase = createUseCase<AppContext>({
1428
- onRun: createDevtoolsUseCaseObserver<AppContext>(),
1429
- });
1862
+ export const useCase = createUseCase<AppContext>();
1863
+ `,
1864
+ productionListenersBuilder: `import { createListeners } from "@beignet/core/events";
1865
+ import type { AppContext } from "@/app-context";
1866
+
1867
+ export const { defineListener } = createListeners<AppContext>();
1868
+ `,
1869
+ productionJobsBuilder: `import { createJobs } from "@beignet/core/jobs";
1870
+ import type { AppContext } from "@/app-context";
1871
+
1872
+ export const { defineJob } = createJobs<AppContext>();
1873
+ `,
1874
+ productionSchedulesBuilder: `import { createSchedules } from "@beignet/core/schedules";
1875
+ import type { AppContext } from "@/app-context";
1876
+
1877
+ export const { defineSchedule } = createSchedules<AppContext>();
1878
+ `,
1879
+ productionNotificationsBuilder: `import { createNotifications } from "@beignet/core/notifications";
1880
+ import type { AppContext } from "@/app-context";
1881
+
1882
+ export const { defineNotification } = createNotifications<AppContext>();
1883
+ `,
1884
+ productionTasksBuilder: `import { createTasks } from "@beignet/core/tasks";
1885
+ import type { AppContext } from "@/app-context";
1886
+
1887
+ export const { defineTask } = createTasks<AppContext>();
1430
1888
  `,
1431
1889
  productionAuthHelpers: `import type { AppContext } from "@/app-context";
1432
1890
  import type { AuthSession, AuthUser } from "@/ports/auth";
@@ -1444,9 +1902,10 @@ export function requireUser(ctx: AppContext): AuthUser {
1444
1902
  return requireSession(ctx).user;
1445
1903
  }
1446
1904
  `,
1447
- productionListTodosUseCase: `import { normalizeOffsetPage } from "@beignet/core/pagination";
1905
+ productionListTodosUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1906
+ import { normalizeOffsetPage } from "@beignet/core/pagination";
1448
1907
  import { useCase } from "@/lib/use-case";
1449
- import { ListTodosInputSchema, ListTodosOutputSchema } from "./schemas";
1908
+ import { ListTodosInputSchema, ListTodosOutputSchema } from "../schemas";
1450
1909
 
1451
1910
  export const listTodosUseCase = useCase
1452
1911
  .query("todos.list")
@@ -1461,9 +1920,10 @@ export const listTodosUseCase = useCase
1461
1920
  return ctx.ports.todos.list(page);
1462
1921
  });
1463
1922
  `,
1464
- productionGetTodoUseCase: `import { appError } from "@/features/shared/errors";
1923
+ productionGetTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1924
+ import { appError } from "@/features/shared/errors";
1465
1925
  import { useCase } from "@/lib/use-case";
1466
- import { GetTodoInputSchema, TodoSchema } from "./schemas";
1926
+ import { GetTodoInputSchema, TodoSchema } from "../schemas";
1467
1927
 
1468
1928
  export const getTodoUseCase = useCase
1469
1929
  .query("todos.get")
@@ -1478,10 +1938,11 @@ export const getTodoUseCase = useCase
1478
1938
  return todo;
1479
1939
  });
1480
1940
  `,
1481
- productionCreateTodoUseCase: `import { TodoCreated } from "@/features/todos/domain/events";
1941
+ productionCreateTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1942
+ import { TodoCreated } from "@/features/todos/domain/events";
1482
1943
  import { auditEntry } from "@/lib/audit";
1483
1944
  import { useCase } from "@/lib/use-case";
1484
- import { CreateTodoInputSchema, TodoSchema } from "./schemas";
1945
+ import { CreateTodoInputSchema, TodoSchema } from "../schemas";
1485
1946
 
1486
1947
  export const createTodoUseCase = useCase
1487
1948
  .command("todos.create")
@@ -1521,7 +1982,7 @@ export {
1521
1982
  type CreateTodoInput,
1522
1983
  type ListTodosInput,
1523
1984
  type Todo,
1524
- } from "./schemas";
1985
+ } from "../schemas";
1525
1986
  `,
1526
1987
  productionTodoRepositoryPort: `import type {
1527
1988
  OffsetPage,
@@ -1531,7 +1992,7 @@ export {
1531
1992
  import type {
1532
1993
  CreateTodoInput,
1533
1994
  Todo,
1534
- } from "@/features/todos/use-cases/schemas";
1995
+ } from "@/features/todos/schemas";
1535
1996
 
1536
1997
  export type ListTodosResult = PageResult<Todo, OffsetPageInfo>;
1537
1998
 
@@ -1624,11 +2085,12 @@ export const todoPolicy = definePolicy({
1624
2085
  },
1625
2086
  });
1626
2087
  `,
1627
- productionInMemoryTodoRepository: `import { offsetPageResult } from "@beignet/core/pagination";
2088
+ productionInMemoryTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
2089
+ import { offsetPageResult } from "@beignet/core/pagination";
1628
2090
  import type {
1629
2091
  CreateTodoInput,
1630
2092
  Todo,
1631
- } from "@/features/todos/use-cases/schemas";
2093
+ } from "@/features/todos/schemas";
1632
2094
  import type {
1633
2095
  TodoAttachmentRepository,
1634
2096
  TodoRepository,
@@ -1712,26 +2174,6 @@ export const appPorts = definePorts({
1712
2174
  todos,
1713
2175
  })),
1714
2176
  });
1715
- `,
1716
- productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
1717
- import { todoPolicy } from "@/features/todos/policy";
1718
- import { appError } from "@/features/shared/errors";
1719
- import { fallbackLogger } from "./logger";
1720
-
1721
- const gate = createGate({
1722
- policies: [todoPolicy],
1723
- onDeny(decision) {
1724
- return appError("Forbidden", {
1725
- message: decision.reason ?? "Forbidden",
1726
- details: decision.details,
1727
- });
1728
- },
1729
- });
1730
-
1731
- export const appPorts = definePorts({
1732
- gate,
1733
- logger: fallbackLogger,
1734
- });
1735
2177
  `,
1736
2178
  productionDrizzleConfig: `export default {
1737
2179
  schema: "./infra/db/schema/index.ts",
@@ -1902,8 +2344,34 @@ export const outboxMessages = sqliteTable(
1902
2344
  ),
1903
2345
  }),
1904
2346
  );
2347
+
2348
+ export const idempotencyRecords = sqliteTable(
2349
+ "idempotency_records",
2350
+ {
2351
+ storageKey: text("storage_key").primaryKey(),
2352
+ namespace: text("namespace").notNull(),
2353
+ idempotencyKey: text("idempotency_key").notNull(),
2354
+ scopeKey: text("scope_key").notNull(),
2355
+ fingerprint: text("fingerprint").notNull(),
2356
+ status: text("status", { enum: ["in-progress", "completed"] }).notNull(),
2357
+ resultJson: text("result_json"),
2358
+ reservedAt: text("reserved_at").notNull(),
2359
+ completedAt: text("completed_at"),
2360
+ expiresAt: text("expires_at"),
2361
+ updatedAt: text("updated_at").notNull(),
2362
+ },
2363
+ (table) => ({
2364
+ lookupIdx: index("idempotency_records_lookup_idx").on(
2365
+ table.namespace,
2366
+ table.scopeKey,
2367
+ table.idempotencyKey,
2368
+ ),
2369
+ expiresIdx: index("idempotency_records_expires_idx").on(table.expiresAt),
2370
+ }),
2371
+ );
1905
2372
  `,
1906
- productionDrizzleTodoRepository: `import { offsetPageResult } from "@beignet/core/pagination";
2373
+ productionDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
2374
+ import { offsetPageResult } from "@beignet/core/pagination";
1907
2375
  import { count, desc, eq } from "drizzle-orm";
1908
2376
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1909
2377
  import type {
@@ -1913,7 +2381,7 @@ import type {
1913
2381
  import type {
1914
2382
  CreateTodoInput,
1915
2383
  Todo,
1916
- } from "@/features/todos/use-cases/schemas";
2384
+ } from "@/features/todos/schemas";
1917
2385
  import * as schema from "@/infra/db/schema";
1918
2386
 
1919
2387
  type TodoRow = typeof schema.todos.$inferSelect;
@@ -2027,7 +2495,10 @@ import * as schema from "./schema";
2027
2495
 
2028
2496
  export function createRepositories(
2029
2497
  db: DrizzleTursoDatabase<typeof schema>,
2030
- ): Omit<AppTransactionPorts, "audit" | "events" | "jobs" | "outbox"> {
2498
+ ): Omit<
2499
+ AppTransactionPorts,
2500
+ "audit" | "events" | "idempotency" | "jobs" | "outbox"
2501
+ > {
2031
2502
  return {
2032
2503
  todoAttachments: createDrizzleTodoAttachmentRepository(db),
2033
2504
  todos: createDrizzleTodoRepository(db),
@@ -2080,7 +2551,7 @@ export function createDrizzleAuditLog(
2080
2551
  };
2081
2552
  }
2082
2553
  `,
2083
- productionDbBootstrap: `import { createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
2554
+ productionDbBootstrap: `import { createDrizzleTursoIdempotencySetupStatements, createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
2084
2555
  import type { Client } from "@libsql/client";
2085
2556
 
2086
2557
  type BootstrapOptions = {
@@ -2174,6 +2645,7 @@ const setupStatements = [
2174
2645
  "CREATE INDEX IF NOT EXISTS audit_log_request_idx ON audit_log (request_id)",
2175
2646
  "CREATE INDEX IF NOT EXISTS audit_log_resource_idx ON audit_log (resource_type, resource_id)",
2176
2647
  "CREATE INDEX IF NOT EXISTS audit_log_tenant_idx ON audit_log (tenant_id)",
2648
+ ...createDrizzleTursoIdempotencySetupStatements(),
2177
2649
  ...createDrizzleTursoOutboxSetupStatements(),
2178
2650
  ];
2179
2651
 
@@ -2200,6 +2672,7 @@ const resetTables = [
2200
2672
  "todo_attachments",
2201
2673
  "todos",
2202
2674
  "audit_log",
2675
+ "idempotency_records",
2203
2676
  "outbox_messages",
2204
2677
  ] as const;
2205
2678
 
@@ -2238,7 +2711,6 @@ export async function resetStarterDatabase(client: Client): Promise<void> {
2238
2711
  }
2239
2712
  `,
2240
2713
  productionDbProvider: `import { registerListeners } from "@beignet/core/events";
2241
- import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
2242
2714
  import { createInlineJobDispatcher } from "@beignet/core/jobs";
2243
2715
  import { createMemoryMailer } from "@beignet/core/mail";
2244
2716
  import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
@@ -2247,19 +2719,15 @@ import {
2247
2719
  createOutboxJobDispatcher,
2248
2720
  } from "@beignet/core/outbox";
2249
2721
  import {
2722
+ createInstrumentedAuditLog,
2250
2723
  createSystemActor,
2251
- createTenant,
2252
2724
  } from "@beignet/core/ports";
2253
2725
  import {
2254
2726
  createProvider,
2255
2727
  createProviderInstrumentation,
2256
2728
  } from "@beignet/core/providers";
2257
2729
  import {
2258
- createDevtoolsAuditLog,
2259
- createDevtoolsTraceContext,
2260
- type DevtoolsPort,
2261
- } from "@beignet/devtools";
2262
- import {
2730
+ createDrizzleTursoIdempotencyPort,
2263
2731
  createDrizzleTursoOutboxPort,
2264
2732
  createDrizzleTursoUnitOfWork,
2265
2733
  type DbPort,
@@ -2269,42 +2737,30 @@ import type { AppContext } from "@/app-context";
2269
2737
  import { todoListeners } from "@/features/todos/listeners";
2270
2738
  import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
2271
2739
  import type { AppPorts } from "@/ports";
2740
+ import type { AppServiceContextInput } from "@/server/context";
2272
2741
  import { ensureStarterDatabase } from "./bootstrap";
2273
2742
  import { createRepositories } from "./repositories";
2274
2743
  import type * as schema from "./schema";
2275
2744
 
2276
- export const starterDatabaseProvider = createProvider({
2745
+ export const starterDatabaseProvider = createProvider<
2746
+ { db: DbPort<typeof schema> },
2747
+ AppContext,
2748
+ AppServiceContextInput
2749
+ >()({
2277
2750
  name: "starter-database",
2278
2751
 
2279
- async setup({ ports }) {
2280
- const dbPort = (ports as { db: DbPort<typeof schema> }).db;
2281
- const devtools = (ports as { devtools?: DevtoolsPort }).devtools;
2752
+ async setup({ ports, createServiceContext }) {
2753
+ const dbPort = ports.db;
2282
2754
  if (!dbPort) {
2283
2755
  throw new Error(
2284
2756
  "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
2285
2757
  );
2286
2758
  }
2287
2759
 
2288
- let currentPorts: AppContext["ports"] | undefined;
2289
- const createBackgroundContext = (): AppContext => {
2290
- if (!currentPorts) {
2291
- throw new Error("Starter background context is not ready.");
2292
- }
2293
-
2294
- const actor = createSystemActor("starter-background");
2295
- const tenant = createTenant("tenant_default");
2296
- const traceContext = createDevtoolsTraceContext();
2297
-
2298
- return {
2299
- actor,
2300
- auth: null,
2301
- gate: currentPorts.gate.bind({ actor, auth: null }),
2302
- requestId: crypto.randomUUID(),
2303
- ...traceContext,
2304
- ports: currentPorts,
2305
- tenant,
2306
- };
2307
- };
2760
+ const createBackgroundContext = () =>
2761
+ createServiceContext({
2762
+ actor: createSystemActor("starter-background"),
2763
+ });
2308
2764
 
2309
2765
  const mailInstrumentation = createProviderInstrumentation(ports, {
2310
2766
  providerName: "memory-mailer",
@@ -2338,12 +2794,12 @@ export const starterDatabaseProvider = createProvider({
2338
2794
  });
2339
2795
  },
2340
2796
  });
2341
- const audit = createDevtoolsAuditLog({
2797
+ const audit = createInstrumentedAuditLog({
2342
2798
  audit: createDrizzleAuditLog(dbPort.db),
2343
- devtools,
2799
+ instrumentation: ports,
2344
2800
  });
2345
2801
  const repositories = createRepositories(dbPort.db);
2346
- const idempotency = createMemoryIdempotencyStore();
2802
+ const idempotency = createDrizzleTursoIdempotencyPort(dbPort.db);
2347
2803
  const outbox = createDrizzleTursoOutboxPort(dbPort.db);
2348
2804
  const unregisterListeners = registerListeners(eventBus, todoListeners, {
2349
2805
  ctx: createBackgroundContext,
@@ -2355,34 +2811,52 @@ export const starterDatabaseProvider = createProvider({
2355
2811
  },
2356
2812
  });
2357
2813
 
2358
- return {
2359
- ports: {
2360
- audit,
2361
- ...repositories,
2362
- eventBus,
2363
- idempotency,
2364
- jobs,
2365
- mailer,
2366
- notifications,
2367
- outbox,
2368
- uow: createDrizzleTursoUnitOfWork({
2369
- db: dbPort.db,
2370
- createTransactionPorts: (tx) => {
2371
- const txOutbox = createDrizzleTursoOutboxPort(tx);
2372
-
2373
- return {
2814
+ const providedPorts: Pick<
2815
+ AppPorts,
2816
+ | "audit"
2817
+ | "eventBus"
2818
+ | "idempotency"
2819
+ | "jobs"
2820
+ | "mailer"
2821
+ | "notifications"
2822
+ | "outbox"
2823
+ | "todoAttachments"
2824
+ | "todos"
2825
+ | "uow"
2826
+ > = {
2827
+ audit,
2828
+ ...repositories,
2829
+ eventBus,
2830
+ idempotency,
2831
+ jobs,
2832
+ mailer,
2833
+ notifications,
2834
+ outbox,
2835
+ uow: createDrizzleTursoUnitOfWork({
2836
+ db: dbPort.db,
2837
+ createTransactionPorts: (tx) => {
2838
+ const txIdempotency = createDrizzleTursoIdempotencyPort(tx);
2839
+ const txOutbox = createDrizzleTursoOutboxPort(tx);
2840
+
2841
+ return {
2842
+ audit: createInstrumentedAuditLog({
2374
2843
  audit: createDrizzleAuditLog(tx),
2375
- ...createRepositories(tx),
2376
- events: createOutboxEventRecorder(txOutbox),
2377
- jobs: createOutboxJobDispatcher(txOutbox),
2378
- outbox: txOutbox,
2379
- };
2380
- },
2381
- }),
2382
- } satisfies Partial<AppPorts>,
2383
- async start(ctx) {
2844
+ instrumentation: ports,
2845
+ }),
2846
+ ...createRepositories(tx),
2847
+ events: createOutboxEventRecorder(txOutbox),
2848
+ idempotency: txIdempotency,
2849
+ jobs: createOutboxJobDispatcher(txOutbox),
2850
+ outbox: txOutbox,
2851
+ };
2852
+ },
2853
+ }),
2854
+ };
2855
+
2856
+ return {
2857
+ ports: providedPorts,
2858
+ async start() {
2384
2859
  await ensureStarterDatabase(dbPort.client);
2385
- currentPorts = ctx.ports as AppContext["ports"];
2386
2860
  },
2387
2861
  stop() {
2388
2862
  unregisterListeners();
@@ -2391,14 +2865,16 @@ export const starterDatabaseProvider = createProvider({
2391
2865
  },
2392
2866
  });
2393
2867
  `,
2394
- productionContractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
2868
+ productionContractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
2395
2869
  import { z } from "zod";
2396
2870
  import { errors } from "@/features/shared/errors";
2397
2871
  import {
2398
- createTodoUseCase,
2399
- getTodoUseCase,
2400
- listTodosUseCase,
2401
- } from "@/features/todos/use-cases";
2872
+ CreateTodoInputSchema,
2873
+ GetTodoInputSchema,
2874
+ ListTodosInputSchema,
2875
+ ListTodosOutputSchema,
2876
+ TodoSchema,
2877
+ } from "@/features/todos/schemas";
2402
2878
 
2403
2879
  const ErrorResponseSchema = z.object({
2404
2880
  code: z.string(),
@@ -2406,7 +2882,7 @@ const ErrorResponseSchema = z.object({
2406
2882
  requestId: z.string().optional(),
2407
2883
  });
2408
2884
 
2409
- const todos = createContractGroup()
2885
+ const todos = defineContractGroup()
2410
2886
  .namespace("todos")
2411
2887
  .errors({ Unauthorized: errors.Unauthorized })
2412
2888
  .responses({
@@ -2415,28 +2891,29 @@ const todos = createContractGroup()
2415
2891
 
2416
2892
  export const listTodos = todos
2417
2893
  .get("/api/todos")
2418
- .query(listTodosUseCase.inputSchema)
2894
+ .query(ListTodosInputSchema)
2419
2895
  .responses({
2420
- 200: listTodosUseCase.outputSchema,
2896
+ 200: ListTodosOutputSchema,
2421
2897
  });
2422
2898
 
2423
2899
  export const createTodo = todos
2424
2900
  .post("/api/todos")
2425
- .body(createTodoUseCase.inputSchema)
2901
+ .body(CreateTodoInputSchema)
2426
2902
  .errors({ Forbidden: errors.Forbidden })
2427
2903
  .responses({
2428
- 201: createTodoUseCase.outputSchema,
2904
+ 201: TodoSchema,
2429
2905
  });
2430
2906
 
2431
2907
  export const getTodo = todos
2432
2908
  .get("/api/todos/:id")
2433
- .pathParams(getTodoUseCase.inputSchema)
2909
+ .pathParams(GetTodoInputSchema)
2434
2910
  .errors({ TodoNotFound: errors.TodoNotFound })
2435
2911
  .responses({
2436
- 200: getTodoUseCase.outputSchema,
2912
+ 200: TodoSchema,
2437
2913
  });
2438
2914
  `,
2439
- productionTodoRoutes: `import { defineRouteGroup } from "@beignet/next";
2915
+ productionTodoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
2916
+ import { defineRouteGroup } from "@beignet/next";
2440
2917
  import type { AppContext } from "@/app-context";
2441
2918
  import { createTodo, getTodo, listTodos } from "@/features/todos/contracts";
2442
2919
  import {
@@ -2448,31 +2925,14 @@ import {
2448
2925
  export const todoRoutes = defineRouteGroup<AppContext>({
2449
2926
  name: "todos",
2450
2927
  routes: [
2451
- {
2452
- contract: listTodos,
2453
- handle: async ({ ctx, query }) => ({
2454
- status: 200,
2455
- body: await listTodosUseCase.run({ ctx, input: query }),
2456
- }),
2457
- },
2458
- {
2459
- contract: createTodo,
2460
- handle: async ({ ctx, body }) => ({
2461
- status: 201,
2462
- body: await createTodoUseCase.run({ ctx, input: body }),
2463
- }),
2464
- },
2465
- {
2466
- contract: getTodo,
2467
- handle: async ({ ctx, path }) => ({
2468
- status: 200,
2469
- body: await getTodoUseCase.run({ ctx, input: path }),
2470
- }),
2471
- },
2928
+ { contract: listTodos, useCase: listTodosUseCase },
2929
+ { contract: createTodo, useCase: createTodoUseCase },
2930
+ { contract: getTodo, useCase: getTodoUseCase },
2472
2931
  ],
2473
2932
  });
2474
2933
  `,
2475
- productionServerRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
2934
+ productionServerRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
2935
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
2476
2936
  import type { AppContext } from "@/app-context";
2477
2937
  import { todoRoutes } from "@/features/todos/routes";
2478
2938
 
@@ -2481,16 +2941,72 @@ export const routes = defineRoutes<AppContext>([
2481
2941
  ]);
2482
2942
  export const contracts = contractsFromRoutes(routes);
2483
2943
  `,
2484
- productionServer: `import { createDevtoolsHooks } from "@beignet/devtools";
2485
- import { createNextServer } from "@beignet/next";
2944
+ productionServerContext: `import type { beignetServerOnly } from "@beignet/core/server-only";
2486
2945
  import {
2946
+ type ActivityActor,
2487
2947
  createAnonymousActor,
2948
+ createServiceActor,
2488
2949
  createTenant,
2489
2950
  createUserActor,
2490
2951
  } from "@beignet/core/ports";
2952
+ import { defineServerContext } from "@beignet/core/server";
2953
+ import type { TraceContext } from "@beignet/core/tracing";
2954
+ import type { AppContext, AppRuntimePorts } from "@/app-context";
2955
+
2956
+ export type AppServiceContextInput =
2957
+ | {
2958
+ actor?: ActivityActor;
2959
+ tenantId?: string;
2960
+ }
2961
+ | undefined;
2962
+
2963
+ /**
2964
+ * Context blueprint shared by the runtime server and route tests.
2965
+ */
2966
+ export const appContext = defineServerContext<AppContext, AppRuntimePorts>()({
2967
+ gate: (ports) => ports.gate,
2968
+ request: async ({ req, ports, requestId, trace }) => {
2969
+ const auth = await ports.auth.getSession(req);
2970
+ const tenantId = req.headers.get("x-tenant-id") || undefined;
2971
+
2972
+ return {
2973
+ requestId,
2974
+ actor: auth
2975
+ ? createUserActor(auth.user.id, { displayName: auth.user.name })
2976
+ : createAnonymousActor(),
2977
+ auth,
2978
+ ...trace,
2979
+ ports,
2980
+ tenant: tenantId ? createTenant(tenantId) : undefined,
2981
+ };
2982
+ },
2983
+ service: ({
2984
+ ports,
2985
+ input,
2986
+ requestId,
2987
+ trace,
2988
+ }: {
2989
+ ports: AppRuntimePorts;
2990
+ input: AppServiceContextInput;
2991
+ requestId: string;
2992
+ trace: TraceContext;
2993
+ }) => ({
2994
+ requestId,
2995
+ actor: input?.actor ?? createServiceActor("beignet-service"),
2996
+ auth: null,
2997
+ ...trace,
2998
+ ports,
2999
+ tenant: createTenant(input?.tenantId ?? "tenant_default"),
3000
+ }),
3001
+ });
3002
+ `,
3003
+ productionServer: `import type { beignetServerOnly } from "@beignet/core/server-only";
3004
+ import { createNextServer } from "@beignet/next";
3005
+ import { createIdempotencyHooks } from "@beignet/core/server";
2491
3006
  import type { AppContext } from "@/app-context";
2492
3007
  import { appPorts } from "@/infra/app-ports";
2493
3008
  import { env } from "@/lib/env";
3009
+ import { appContext } from "./context";
2494
3010
  import { providers } from "./providers";
2495
3011
  import { routes } from "./routes";
2496
3012
 
@@ -2503,29 +3019,8 @@ export const server = await createNextServer({
2503
3019
  DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2504
3020
  },
2505
3021
  },
2506
- hooks: [
2507
- createDevtoolsHooks<AppContext>({
2508
- requestIdHeader: "x-request-id",
2509
- }),
2510
- ],
2511
- createContext: async ({ req, ports }) => {
2512
- const auth = await ports.auth.getSession(req);
2513
- const tenantId = req.headers.get("x-tenant-id") || undefined;
2514
- const context = {
2515
- requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
2516
- actor: auth
2517
- ? createUserActor(auth.user.id, { displayName: auth.user.name })
2518
- : createAnonymousActor(),
2519
- auth,
2520
- ports,
2521
- tenant: tenantId ? createTenant(tenantId) : undefined,
2522
- };
2523
-
2524
- return {
2525
- ...context,
2526
- gate: ports.gate.bind(context),
2527
- };
2528
- },
3022
+ hooks: [createIdempotencyHooks<AppContext>()],
3023
+ context: appContext,
2529
3024
  routes,
2530
3025
  mapUnhandledError: ({ err, ctx }) => {
2531
3026
  ctx?.ports.logger.error("Unhandled API error", {
@@ -2545,16 +3040,13 @@ export const server = await createNextServer({
2545
3040
  },
2546
3041
  });
2547
3042
  `,
2548
- productionServerWithDrizzleTurso: `import { createDevtoolsHooks } from "@beignet/devtools";
3043
+ productionServerWithDrizzleTurso: `import type { beignetServerOnly } from "@beignet/core/server-only";
2549
3044
  import { createNextServer } from "@beignet/next";
2550
- import {
2551
- createAnonymousActor,
2552
- createTenant,
2553
- createUserActor,
2554
- } from "@beignet/core/ports";
3045
+ import { createIdempotencyHooks } from "@beignet/core/server";
2555
3046
  import type { AppContext } from "@/app-context";
2556
3047
  import { appPorts } from "@/infra/app-ports";
2557
3048
  import { env } from "@/lib/env";
3049
+ import { appContext } from "./context";
2558
3050
  import { providers } from "./providers";
2559
3051
  import { routes } from "./routes";
2560
3052
 
@@ -2567,30 +3059,8 @@ export const server = await createNextServer({
2567
3059
  DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2568
3060
  },
2569
3061
  },
2570
- hooks: [
2571
- createDevtoolsHooks<AppContext>({
2572
- requestIdHeader: "x-request-id",
2573
- }),
2574
- ],
2575
- createContext: async ({ req, ports }) => {
2576
- const auth = await ports.auth.getSession(req);
2577
- const tenantId = req.headers.get("x-tenant-id") || undefined;
2578
-
2579
- const context = {
2580
- requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
2581
- actor: auth
2582
- ? createUserActor(auth.user.id, { displayName: auth.user.name })
2583
- : createAnonymousActor(),
2584
- auth,
2585
- ports,
2586
- tenant: tenantId ? createTenant(tenantId) : undefined,
2587
- };
2588
-
2589
- return {
2590
- ...context,
2591
- gate: ports.gate.bind(context),
2592
- };
2593
- },
3062
+ hooks: [createIdempotencyHooks<AppContext>()],
3063
+ context: appContext,
2594
3064
  routes,
2595
3065
  mapUnhandledError: ({ err, ctx }) => {
2596
3066
  ctx?.ports.logger.error("Unhandled API error", {
@@ -2739,6 +3209,55 @@ try {
2739
3209
  } finally {
2740
3210
  client.close();
2741
3211
  }
3212
+ `,
3213
+ productionDbTestDatabase: `import { existsSync, unlinkSync } from "node:fs";
3214
+ import { tmpdir } from "node:os";
3215
+ import { join } from "node:path";
3216
+ import type { OutboxPort } from "@beignet/core/outbox";
3217
+ import { createDrizzleTursoOutboxPort } from "@beignet/provider-drizzle-turso";
3218
+ import { type Client, createClient } from "@libsql/client";
3219
+ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
3220
+ import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
3221
+ import { ensureStarterDatabase, resetStarterDatabase } from "./bootstrap";
3222
+ import { createRepositories } from "./repositories";
3223
+ import * as schema from "./schema";
3224
+
3225
+ export type TestDatabase = {
3226
+ \tclient: Client;
3227
+ \tdb: LibSQLDatabase<typeof schema>;
3228
+ \taudit: ReturnType<typeof createDrizzleAuditLog>;
3229
+ \toutbox: OutboxPort;
3230
+ \trepositories: ReturnType<typeof createRepositories>;
3231
+ \tpath: string;
3232
+ \treset(): Promise<void>;
3233
+ \tclose(): Promise<void>;
3234
+ };
3235
+
3236
+ export async function createTestDatabase(): Promise<TestDatabase> {
3237
+ \tconst path = join(tmpdir(), "beignet-test-" + crypto.randomUUID() + ".db");
3238
+ \tconst client = createClient({ url: "file:" + path });
3239
+ \tawait ensureStarterDatabase(client, { seed: false });
3240
+
3241
+ \tconst db = drizzle(client, { schema });
3242
+
3243
+ \treturn {
3244
+ \t\tclient,
3245
+ \t\tdb,
3246
+ \t\taudit: createDrizzleAuditLog(db),
3247
+ \t\toutbox: createDrizzleTursoOutboxPort(db),
3248
+ \t\trepositories: createRepositories(db),
3249
+ \t\tpath,
3250
+ \t\treset: async () => {
3251
+ \t\t\tawait resetStarterDatabase(client);
3252
+ \t\t},
3253
+ \t\tclose: async () => {
3254
+ \t\t\tclient.close();
3255
+ \t\t\tif (existsSync(path)) {
3256
+ \t\t\t\tunlinkSync(path);
3257
+ \t\t\t}
3258
+ \t\t},
3259
+ \t};
3260
+ }
2742
3261
  `,
2743
3262
  productionAuditHelper: `import type { AuditLogEntryInput } from "@beignet/core/ports";
2744
3263
  import type { AppContext } from "@/app-context";
@@ -2771,14 +3290,12 @@ export const TodoCreated = defineEvent("todos.created", {
2771
3290
 
2772
3291
  export const todoEvents = [TodoCreated] as const;
2773
3292
  `,
2774
- productionTodoJobs: `import { createJobHandlers, retry } from "@beignet/core/jobs";
3293
+ productionTodoJobs: `import { retry } from "@beignet/core/jobs";
2775
3294
  import { z } from "zod";
2776
- import type { AppContext } from "@/app-context";
2777
3295
  import { auditEntry } from "@/lib/audit";
3296
+ import { defineJob } from "@/lib/jobs";
2778
3297
 
2779
- const jobs = createJobHandlers<AppContext>();
2780
-
2781
- export const LogTodoCreatedJob = jobs.defineJob("todos.log-created", {
3298
+ export const LogTodoCreatedJob = defineJob("todos.log-created", {
2782
3299
  payload: z.object({
2783
3300
  todoId: z.string().uuid(),
2784
3301
  title: z.string(),
@@ -2804,16 +3321,11 @@ export const LogTodoCreatedJob = jobs.defineJob("todos.log-created", {
2804
3321
 
2805
3322
  export const todoJobs = [LogTodoCreatedJob] as const;
2806
3323
  `,
2807
- productionTodoNotifications: `import {
2808
- createNotificationHandlers,
2809
- defineMailNotificationChannel,
2810
- } from "@beignet/core/notifications";
3324
+ productionTodoNotifications: `import { defineMailNotificationChannel } from "@beignet/core/notifications";
2811
3325
  import { z } from "zod";
2812
- import type { AppContext } from "@/app-context";
3326
+ import { defineNotification } from "@/lib/notifications";
2813
3327
 
2814
- const notifications = createNotificationHandlers<AppContext>();
2815
-
2816
- export const TodoCreatedNotification = notifications.defineNotification(
3328
+ export const TodoCreatedNotification = defineNotification(
2817
3329
  "todos.created",
2818
3330
  {
2819
3331
  payload: z.object({
@@ -2830,16 +3342,13 @@ export const TodoCreatedNotification = notifications.defineNotification(
2830
3342
  },
2831
3343
  );
2832
3344
  `,
2833
- productionTodoListeners: `import { createEventHandlers } from "@beignet/core/events";
2834
- import type { AppContext } from "@/app-context";
2835
- import { TodoCreated } from "@/features/todos/domain/events";
3345
+ productionTodoListeners: `import { TodoCreated } from "@/features/todos/domain/events";
2836
3346
  import { LogTodoCreatedJob } from "@/features/todos/jobs";
2837
3347
  import { TodoCreatedNotification } from "@/features/todos/notifications";
2838
3348
  import { auditEntry } from "@/lib/audit";
3349
+ import { defineListener } from "@/lib/listeners";
2839
3350
 
2840
- const listeners = createEventHandlers<AppContext>();
2841
-
2842
- export const enqueueTodoCreatedWork = listeners.defineListener(TodoCreated, {
3351
+ export const enqueueTodoCreatedWork = defineListener(TodoCreated, {
2843
3352
  name: "todos.enqueue-created-work",
2844
3353
  async handle({ ctx, payload }) {
2845
3354
  await ctx.ports.jobs.dispatch(LogTodoCreatedJob, payload);
@@ -2861,14 +3370,11 @@ export const enqueueTodoCreatedWork = listeners.defineListener(TodoCreated, {
2861
3370
  export const todoListeners = [enqueueTodoCreatedWork] as const;
2862
3371
  `,
2863
3372
  productionTodoSchedules: `import { normalizeOffsetPage } from "@beignet/core/pagination";
2864
- import { createScheduleHandlers } from "@beignet/core/schedules";
2865
3373
  import { z } from "zod";
2866
- import type { AppContext } from "@/app-context";
2867
3374
  import { auditEntry } from "@/lib/audit";
3375
+ import { defineSchedule } from "@/lib/schedules";
2868
3376
 
2869
- const schedules = createScheduleHandlers<AppContext>();
2870
-
2871
- export const LogDailyTodoSummarySchedule = schedules.defineSchedule(
3377
+ export const LogDailyTodoSummarySchedule = defineSchedule(
2872
3378
  "todos.log-daily-summary",
2873
3379
  {
2874
3380
  cron: "0 9 * * *",
@@ -2912,6 +3418,23 @@ export const LogDailyTodoSummarySchedule = schedules.defineSchedule(
2912
3418
  );
2913
3419
 
2914
3420
  export const todoSchedules = [LogDailyTodoSummarySchedule] as const;
3421
+ `,
3422
+ productionSchedules: `import { createServiceActor } from "@beignet/core/ports";
3423
+ import type { AppContext } from "@/app-context";
3424
+ import { todoSchedules } from "@/features/todos/schedules";
3425
+ import { server } from "./index.js";
3426
+
3427
+ export const schedules = [...todoSchedules] as const;
3428
+
3429
+ export async function createScheduleContext(): Promise<AppContext> {
3430
+ return server.createServiceContext({
3431
+ actor: createServiceActor("beignet-schedule"),
3432
+ });
3433
+ }
3434
+
3435
+ export async function stopScheduleContext(): Promise<void> {
3436
+ await server.stop();
3437
+ }
2915
3438
  `,
2916
3439
  productionTodoUploads: `import { defineUpload, defineUploads } from "@beignet/core/uploads";
2917
3440
  import { z } from "zod";
@@ -2993,13 +3516,26 @@ export const todoUploads = defineUploads({
2993
3516
  });
2994
3517
  `,
2995
3518
  productionOutbox: `import { defineOutboxRegistry } from "@beignet/core/outbox";
3519
+ import { createServiceActor } from "@beignet/core/ports";
3520
+ import type { AppContext } from "@/app-context";
2996
3521
  import { todoEvents } from "@/features/todos/domain/events";
2997
3522
  import { todoJobs } from "@/features/todos/jobs";
3523
+ import { server } from "./index.js";
2998
3524
 
2999
3525
  export const outboxRegistry = defineOutboxRegistry({
3000
3526
  events: todoEvents,
3001
3527
  jobs: todoJobs,
3002
3528
  });
3529
+
3530
+ export async function createOutboxDrainContext(): Promise<AppContext> {
3531
+ return server.createServiceContext({
3532
+ actor: createServiceActor("beignet-outbox"),
3533
+ });
3534
+ }
3535
+
3536
+ export async function stopOutboxDrainContext(): Promise<void> {
3537
+ await server.stop();
3538
+ }
3003
3539
  `,
3004
3540
  productionOutboxDrainRoute: `import { createOutboxDrainRoute } from "@beignet/next";
3005
3541
  import { env } from "@/lib/env";
@@ -3014,111 +3550,23 @@ export const { GET, POST } = createOutboxDrainRoute({
3014
3550
  secret: env.CRON_SECRET,
3015
3551
  });
3016
3552
  `,
3017
- productionScheduleRoute: `import { createInlineScheduleRunner } from "@beignet/core/schedules";
3018
- import type { AppContext } from "@/app-context";
3019
- import { LogDailyTodoSummarySchedule } from "@/features/todos/schedules";
3553
+ productionScheduleRoute: `import { createScheduleRoute } from "@beignet/next";
3020
3554
  import { env } from "@/lib/env";
3021
3555
  import { server } from "@/server";
3556
+ import { schedules } from "@/server/schedules";
3022
3557
 
3023
- async function runDailyTodoSummary(request: Request) {
3024
- if (!env.CRON_SECRET) {
3025
- return Response.json(
3026
- {
3027
- ok: false,
3028
- error: "CRON_SECRET is not configured.",
3029
- scheduleName: LogDailyTodoSummarySchedule.name,
3030
- },
3031
- { status: 500 },
3032
- );
3033
- }
3034
-
3035
- if (request.headers.get("authorization") !== \`Bearer \${env.CRON_SECRET}\`) {
3036
- return Response.json({ error: "Unauthorized" }, { status: 401 });
3037
- }
3038
-
3039
- const ctx = await server.createContextFromNext();
3040
- const runner = createInlineScheduleRunner<AppContext>({
3041
- ctx,
3042
- onStart({ run, schedule }) {
3043
- ctx.ports.devtools.record({
3044
- type: "schedule",
3045
- watcher: "schedules",
3046
- requestId: ctx.requestId,
3047
- traceId: ctx.traceId,
3048
- scheduleName: schedule.name,
3049
- status: "started",
3050
- cron: schedule.cron,
3051
- timezone: schedule.timezone,
3052
- details: {
3053
- source: run.source,
3054
- scheduledAt: run.scheduledAt?.toISOString(),
3055
- },
3056
- });
3057
- },
3058
- onSuccess({ run, schedule }) {
3059
- ctx.ports.devtools.record({
3060
- type: "schedule",
3061
- watcher: "schedules",
3062
- requestId: ctx.requestId,
3063
- traceId: ctx.traceId,
3064
- scheduleName: schedule.name,
3065
- status: "completed",
3066
- cron: schedule.cron,
3067
- timezone: schedule.timezone,
3068
- details: {
3069
- source: run.source,
3070
- scheduledAt: run.scheduledAt?.toISOString(),
3071
- },
3072
- });
3073
- },
3074
- onError({ error, run, schedule }) {
3075
- ctx.ports.devtools.record({
3076
- type: "schedule",
3077
- watcher: "schedules",
3078
- requestId: ctx.requestId,
3079
- traceId: ctx.traceId,
3080
- scheduleName: schedule.name,
3081
- status: "failed",
3082
- cron: schedule.cron,
3083
- timezone: schedule.timezone,
3084
- details: {
3085
- error,
3086
- source: run.source,
3087
- scheduledAt: run.scheduledAt?.toISOString(),
3088
- },
3089
- });
3090
- ctx.ports.logger.error("Daily todo summary schedule failed", {
3091
- error,
3092
- scheduleName: schedule.name,
3093
- });
3094
- },
3095
- });
3096
-
3097
- try {
3098
- await runner.run(LogDailyTodoSummarySchedule, {
3099
- source: "starter-cron-route",
3100
- });
3101
- } catch {
3102
- return Response.json(
3103
- {
3104
- ok: false,
3105
- error: "Schedule failed",
3106
- scheduleName: LogDailyTodoSummarySchedule.name,
3107
- },
3108
- { status: 500 },
3109
- );
3110
- }
3111
-
3112
- return Response.json({
3113
- ok: true,
3114
- scheduleName: LogDailyTodoSummarySchedule.name,
3115
- });
3116
- }
3558
+ export const runtime = "nodejs";
3117
3559
 
3118
- export const GET = runDailyTodoSummary;
3119
- export const POST = runDailyTodoSummary;
3560
+ export const { GET, POST } = createScheduleRoute({
3561
+ server,
3562
+ schedules,
3563
+ schedule: "todos.log-daily-summary",
3564
+ secret: env.CRON_SECRET,
3565
+ source: "starter-cron-route",
3566
+ });
3120
3567
  `,
3121
- productionUploadsRoute: `import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
3568
+ productionUploadsRoute: `import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
3569
+ import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
3122
3570
  import { createUploadRoute } from "@beignet/next";
3123
3571
  import type { AppContext } from "@/app-context";
3124
3572
  import { todoUploads } from "@/features/todos/uploads";
@@ -3128,17 +3576,10 @@ const uploadRouter = createUploadRouter<AppContext>({
3128
3576
  uploads: uploadsFromRegistry(todoUploads),
3129
3577
  ctx: () => server.createContextFromNext(),
3130
3578
  storage: server.ports.storage,
3131
- instrumentation: server.ports.devtools,
3579
+ instrumentation: resolveProviderInstrumentationPort(server.ports),
3132
3580
  });
3133
3581
 
3134
3582
  export const { POST } = createUploadRoute(uploadRouter);
3135
- `,
3136
- productionUploadClient: `import { createUploadClient } from "@beignet/core/uploads/client";
3137
- import type { todoUploads } from "@/features/todos/uploads";
3138
-
3139
- export const uploads = createUploadClient<typeof todoUploads>({
3140
- baseUrl: "/api/uploads",
3141
- });
3142
3583
  `,
3143
3584
  };
3144
3585
 
@@ -3158,6 +3599,73 @@ function productionProviderPortFields(ctx: TemplateContext): string[] {
3158
3599
  return fields;
3159
3600
  }
3160
3601
 
3602
+ function productionInfrastructurePortsWithDrizzleTurso(
3603
+ ctx: TemplateContext,
3604
+ ): string {
3605
+ const deferred = [
3606
+ '\t\t"audit",',
3607
+ hasIntegration(ctx, "better-auth") ? '\t\t"auth",' : undefined,
3608
+ '\t\t"eventBus",',
3609
+ '\t\t"idempotency",',
3610
+ '\t\t"jobs",',
3611
+ '\t\t"logger",',
3612
+ '\t\t"mailer",',
3613
+ '\t\t"notifications",',
3614
+ '\t\t"outbox",',
3615
+ hasIntegration(ctx, "upstash-rate-limit") ? '\t\t"rateLimit",' : undefined,
3616
+ '\t\t"storage",',
3617
+ '\t\t"todoAttachments",',
3618
+ '\t\t"todos",',
3619
+ '\t\t"uow",',
3620
+ ]
3621
+ .filter((entry): entry is string => Boolean(entry))
3622
+ .sort();
3623
+ const bound = [
3624
+ hasIntegration(ctx, "better-auth")
3625
+ ? undefined
3626
+ : "\t\tauth: createAnonymousAuth(),",
3627
+ "\t\tgate,",
3628
+ ].filter((entry): entry is string => Boolean(entry));
3629
+ const imports = [
3630
+ 'import { createGate, definePorts } from "@beignet/core/ports";',
3631
+ 'import { todoPolicy } from "@/features/todos/policy";',
3632
+ 'import { appError } from "@/features/shared/errors";',
3633
+ 'import type { AppPorts } from "@/ports";',
3634
+ hasIntegration(ctx, "better-auth")
3635
+ ? undefined
3636
+ : 'import { createAnonymousAuth } from "./auth/anonymous-auth";',
3637
+ ].filter((entry): entry is string => Boolean(entry));
3638
+
3639
+ return `${imports.join("\n")}
3640
+
3641
+ const gate = createGate({
3642
+ policies: [todoPolicy],
3643
+ onDeny(decision) {
3644
+ return appError("Forbidden", {
3645
+ message: decision.reason ?? "Forbidden",
3646
+ details: decision.details,
3647
+ });
3648
+ },
3649
+ });
3650
+
3651
+ /**
3652
+ * App ports the server boots with.
3653
+ *
3654
+ * Bound ports are app-owned. Every deferred key is contributed by a provider
3655
+ * in server/providers.ts during startup; createServer(...) fails boot if any
3656
+ * of them is still unbound after providers have started.
3657
+ */
3658
+ export const appPorts = definePorts<AppPorts>()({
3659
+ bound: {
3660
+ ${bound.join("\n")}
3661
+ },
3662
+ deferred: [
3663
+ ${deferred.join("\n")}
3664
+ ],
3665
+ });
3666
+ `;
3667
+ }
3668
+
3161
3669
  function productionPorts(ctx: TemplateContext): string {
3162
3670
  const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
3163
3671
  const imports = [
@@ -3186,6 +3694,7 @@ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/
3186
3694
  export type AppTransactionPorts = {
3187
3695
  audit: AuditLogPort;
3188
3696
  events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3697
+ idempotency: IdempotencyPort;
3189
3698
  jobs: JobDispatcherPort;
3190
3699
  outbox: OutboxPort;
3191
3700
  todoAttachments: TodoAttachmentRepository;
@@ -3238,14 +3747,10 @@ import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
3238
3747
  import type { AuthPort } from "./auth";
3239
3748
  import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
3240
3749
 
3241
- export type AppDatabasePort = {
3242
- db: unknown;
3243
- client: unknown;
3244
- };
3245
-
3246
3750
  export type AppTransactionPorts = {
3247
3751
  audit: AuditLogPort;
3248
3752
  events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3753
+ idempotency: IdempotencyPort;
3249
3754
  jobs: JobDispatcherPort;
3250
3755
  outbox: OutboxPort;
3251
3756
  todoAttachments: TodoAttachmentRepository;
@@ -3257,7 +3762,6 @@ export type AppGate = BoundGate<[typeof todoPolicy]>;
3257
3762
  export type AppPorts = {
3258
3763
  audit: AuditLogPort;
3259
3764
  auth: AuthPort;
3260
- db: AppDatabasePort;
3261
3765
  eventBus: EventBusPort;
3262
3766
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
3263
3767
  idempotency: IdempotencyPort;
@@ -3276,6 +3780,7 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
3276
3780
 
3277
3781
  function productionServerProviders(ctx: TemplateContext): string {
3278
3782
  const imports = [
3783
+ 'import type { beignetServerOnly } from "@beignet/core/server-only";',
3279
3784
  'import { createDevtoolsProvider } from "@beignet/devtools";',
3280
3785
  hasIntegration(ctx, "better-auth")
3281
3786
  ? 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";'
@@ -3329,40 +3834,21 @@ function productionServerProviders(ctx: TemplateContext): string {
3329
3834
  ${declarations}
3330
3835
  export const providers = [
3331
3836
  ${entries.join("\n")}
3332
- ];
3837
+ ] as const;
3333
3838
  `;
3334
3839
  }
3335
3840
 
3336
- function productionTodosTest(ctx: TemplateContext): string {
3337
- const portImports = [
3338
- hasIntegration(ctx, "upstash-rate-limit")
3339
- ? "createMemoryRateLimiter"
3340
- : undefined,
3341
- "createMemoryStorage",
3342
- "createNoopUnitOfWork",
3343
- "createUserActor",
3344
- ].filter((item): item is string => Boolean(item));
3345
- const extraPorts = [
3346
- hasDrizzleTurso(ctx)
3347
- ? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
3348
- : undefined,
3349
- hasIntegration(ctx, "upstash-rate-limit")
3350
- ? "\t\t\trateLimit: createMemoryRateLimiter(),"
3351
- : undefined,
3352
- ].filter((entry): entry is string => Boolean(entry));
3353
-
3841
+ function productionTodosTest(_ctx: TemplateContext): string {
3354
3842
  return `import { describe, expect, it } from "bun:test";
3355
3843
  import { createUseCaseTester } from "@beignet/core/application";
3356
- import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
3357
- import { createMemoryMailer } from "@beignet/core/mail";
3358
- import { createMemoryNotificationPort } from "@beignet/core/notifications";
3359
- import { createMemoryOutbox } from "@beignet/core/outbox";
3360
3844
  import { offsetPageResult, type OffsetPage } from "@beignet/core/pagination";
3361
3845
  import { createInMemoryDevtools } from "@beignet/devtools";
3362
- import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
3363
- import { ${["createDomainEventRecorder", "createMemoryAuditLog", ...portImports].join(", ")} } from "@beignet/core/ports";
3846
+ import { createDomainEventRecorder } from "@beignet/core/ports";
3847
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
3848
+ import { createTestUserActor } from "@beignet/core/ports/testing";
3364
3849
  import type { AppContext } from "@/app-context";
3365
3850
  import { appPorts } from "@/infra/app-ports";
3851
+ import type { AppTransactionPorts } from "@/ports";
3366
3852
  import {
3367
3853
  createTodoUseCase,
3368
3854
  getTodoUseCase,
@@ -3427,14 +3913,6 @@ describe("todos resource", () => {
3427
3913
  it("creates, gets, and lists todos", async () => {
3428
3914
  const todos = createTestTodoRepository();
3429
3915
  const todoAttachments = createTestTodoAttachmentRepository();
3430
- const audit = createMemoryAuditLog();
3431
- const eventBus = createInMemoryEventBus();
3432
- const jobs = { dispatch: async () => undefined };
3433
- const mailer = createMemoryMailer({
3434
- defaultFrom: "Test <test@example.local>",
3435
- });
3436
- const notifications = createMemoryNotificationPort();
3437
- const outbox = createMemoryOutbox();
3438
3916
  const auth = {
3439
3917
  user: {
3440
3918
  id: "user_test",
@@ -3443,38 +3921,31 @@ describe("todos resource", () => {
3443
3921
  },
3444
3922
  session: { id: "session_test" },
3445
3923
  };
3446
- const actor = createUserActor(auth.user.id, {
3447
- displayName: auth.user.name,
3448
- });
3449
- const testPorts = {
3450
- ...appPorts,
3451
- audit,
3452
- eventBus,
3453
- idempotency: createMemoryIdempotencyStore(),
3454
- jobs,
3455
- mailer,
3456
- notifications,
3457
- outbox,
3458
- todoAttachments,
3459
- todos,
3460
- ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({
3461
- audit,
3462
- events: createDomainEventRecorder(),
3463
- jobs,
3464
- outbox,
3924
+ const testFixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
3925
+ base: appPorts,
3926
+ overrides: {
3927
+ gate: appPorts.gate,
3465
3928
  todoAttachments,
3466
3929
  todos,
3467
- })) as unknown as AppContext["ports"]["uow"],
3468
- devtools: createInMemoryDevtools(),
3469
- storage: createMemoryStorage(),
3470
- } as unknown as AppContext["ports"];
3471
- const tester = createUseCaseTester<AppContext>(() => ({
3472
- requestId: "test-request",
3473
- actor,
3930
+ devtools: createInMemoryDevtools(),
3931
+ },
3932
+ transaction: {
3933
+ ports: (ports) => ({
3934
+ ...ports,
3935
+ events: createDomainEventRecorder(),
3936
+ todoAttachments,
3937
+ todos,
3938
+ }),
3939
+ },
3940
+ });
3941
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
3942
+ ports: testFixture.ports,
3943
+ actor: createTestUserActor(auth.user.id, {
3944
+ displayName: auth.user.name,
3945
+ }),
3474
3946
  auth,
3475
- gate: testPorts.gate.bind({ actor, auth }),
3476
- ports: testPorts,
3477
- }));
3947
+ });
3948
+ const tester = createUseCaseTester<AppContext>(createTestContext);
3478
3949
 
3479
3950
  const ctx = await tester.ctx();
3480
3951
  const created = await tester.run(
@@ -3546,9 +4017,8 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
3546
4017
  path: "app/api/uploads/[uploadName]/[action]/route.ts",
3547
4018
  content: files.productionUploadsRoute,
3548
4019
  },
3549
- { path: "client/api-client.ts", content: files.apiClient },
3550
- { path: "client/rq.ts", content: files.rq },
3551
- { path: "client/uploads.ts", content: files.productionUploadClient },
4020
+ { path: "client/index.ts", content: clientIndex(ctx) },
4021
+ { path: "client/forms.ts", content: files.formsClient },
3552
4022
  {
3553
4023
  path: "features/todos/contracts.ts",
3554
4024
  content: files.productionContractsTodos,
@@ -3559,7 +4029,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
3559
4029
  {
3560
4030
  path: "infra/app-ports.ts",
3561
4031
  content: usesDrizzleTurso
3562
- ? files.productionInfrastructurePortsWithDrizzleTurso
4032
+ ? productionInfrastructurePortsWithDrizzleTurso(ctx)
3563
4033
  : files.productionInfrastructurePorts,
3564
4034
  },
3565
4035
  { path: "lib/env.ts", content: files.productionEnv },
@@ -3609,7 +4079,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
3609
4079
  },
3610
4080
  {
3611
4081
  path: "features/shared/errors.ts",
3612
- content: files.productimapUnhandledErrors,
4082
+ content: files.productionSharedErrors,
3613
4083
  },
3614
4084
  {
3615
4085
  path: "server/index.ts",
@@ -3617,13 +4087,22 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
3617
4087
  ? files.productionServerWithDrizzleTurso
3618
4088
  : files.productionServer,
3619
4089
  },
4090
+ { path: "server/context.ts", content: files.productionServerContext },
3620
4091
  { path: "server/routes.ts", content: files.productionServerRoutes },
3621
4092
  { path: "server/outbox.ts", content: files.productionOutbox },
4093
+ { path: "server/schedules.ts", content: files.productionSchedules },
3622
4094
  {
3623
4095
  path: "server/providers.ts",
3624
4096
  content: productionServerProviders(ctx),
3625
4097
  },
3626
4098
  { path: "lib/use-case.ts", content: files.productionUseCaseBuilder },
4099
+ { path: "lib/listeners.ts", content: files.productionListenersBuilder },
4100
+ { path: "lib/jobs.ts", content: files.productionJobsBuilder },
4101
+ { path: "lib/schedules.ts", content: files.productionSchedulesBuilder },
4102
+ {
4103
+ path: "lib/notifications.ts",
4104
+ content: files.productionNotificationsBuilder,
4105
+ },
3627
4106
  {
3628
4107
  path: "features/todos/use-cases/create-todo.ts",
3629
4108
  content: files.productionCreateTodoUseCase,
@@ -3641,7 +4120,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
3641
4120
  content: files.productionUseCasesIndex,
3642
4121
  },
3643
4122
  {
3644
- path: "features/todos/use-cases/schemas.ts",
4123
+ path: "features/todos/schemas.ts",
3645
4124
  content: files.productionTodoSchemas,
3646
4125
  },
3647
4126
  {
@@ -3670,6 +4149,10 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
3670
4149
  path: "infra/db/reset.ts",
3671
4150
  content: files.productionDbReset,
3672
4151
  },
4152
+ {
4153
+ path: "infra/db/test-database.ts",
4154
+ content: files.productionDbTestDatabase,
4155
+ },
3673
4156
  {
3674
4157
  path: "infra/db/provider.ts",
3675
4158
  content: files.productionDbProvider,
@@ -3716,29 +4199,37 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
3716
4199
  { path: "app/layout.tsx", content: layout(ctx) },
3717
4200
  { path: "app/page.tsx", content: page(ctx) },
3718
4201
  { path: "app/api/[[...path]]/route.ts", content: files.apiCatchAllRoute },
4202
+ { path: "app-context.ts", content: minimalAppContext(ctx) },
3719
4203
  { path: "features/todos/contracts.ts", content: files.contractsTodos },
3720
4204
  { path: "features/todos/routes.ts", content: files.todoRoutes },
3721
- { path: "ports/index.ts", content: files.ports },
4205
+ { path: "ports/index.ts", content: minimalPorts(ctx) },
3722
4206
  { path: "server/index.ts", content: server(ctx) },
4207
+ { path: "server/context.ts", content: serverContext(ctx) },
3723
4208
  { path: "server/routes.ts", content: files.serverRoutes },
3724
- { path: "features/todos/use-cases.ts", content: files.useCasesTodos },
4209
+ { path: "features/todos/use-cases/index.ts", content: files.useCasesTodos },
3725
4210
  ];
3726
4211
 
3727
4212
  if (hasFeature(ctx, "client")) {
3728
4213
  templateFiles.push({
3729
- path: "client/api-client.ts",
3730
- content: files.apiClient,
4214
+ path: "client/index.ts",
4215
+ content: clientIndex(ctx),
3731
4216
  });
3732
4217
  }
3733
4218
 
3734
4219
  if (hasFeature(ctx, "react-query")) {
3735
4220
  templateFiles.push(
3736
4221
  { path: "app/providers.tsx", content: files.appProviders },
3737
- { path: "client/rq.ts", content: files.rq },
3738
4222
  { path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) },
3739
4223
  );
3740
4224
  }
3741
4225
 
4226
+ if (hasFeature(ctx, "forms")) {
4227
+ templateFiles.push({
4228
+ path: "client/forms.ts",
4229
+ content: files.formsClient,
4230
+ });
4231
+ }
4232
+
3742
4233
  if (hasFeature(ctx, "openapi")) {
3743
4234
  templateFiles.push({
3744
4235
  path: "app/api/openapi/route.ts",
@@ -3746,19 +4237,34 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
3746
4237
  });
3747
4238
  }
3748
4239
 
3749
- if (
3750
- ctx.integrations.some((integration) =>
3751
- ["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration),
3752
- )
3753
- ) {
3754
- templateFiles.push({
3755
- path: "server/providers.ts",
3756
- content: serverProviders(ctx),
3757
- });
4240
+ if (hasIntegration(ctx, "better-auth")) {
4241
+ templateFiles.push(
4242
+ { path: "lib/better-auth.ts", content: files.minimalBetterAuth },
4243
+ {
4244
+ path: "app/api/auth/[...all]/route.ts",
4245
+ content: files.productionAuthRoute,
4246
+ },
4247
+ );
4248
+ }
4249
+
4250
+ if (hasDrizzleTurso(ctx)) {
4251
+ templateFiles.push(
4252
+ { path: "drizzle.config.ts", content: files.productionDrizzleConfig },
4253
+ { path: "infra/db/provider.ts", content: files.minimalDbProvider },
4254
+ { path: "infra/db/schema/index.ts", content: files.minimalDbSchema },
4255
+ {
4256
+ path: "infra/todos/drizzle-todo-repository.ts",
4257
+ content: files.minimalDrizzleTodoRepository,
4258
+ },
4259
+ );
3758
4260
  }
3759
4261
 
3760
4262
  if (ctx.integrations.length > 0) {
3761
4263
  templateFiles.push(
4264
+ {
4265
+ path: "server/providers.ts",
4266
+ content: serverProviders(ctx),
4267
+ },
3762
4268
  {
3763
4269
  path: ".env.example",
3764
4270
  content: envExample(ctx),