@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/dist/templates.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { featureChoices, integrationChoices, presetChoices, } from "./choices.js";
1
2
  const externalVersions = {
2
3
  next: "^16.2.4",
3
4
  react: "^19.2.5",
@@ -11,9 +12,9 @@ const externalVersions = {
11
12
  tanstackReactQuery: "^5.100.7",
12
13
  hookformResolvers: "^5.0.0",
13
14
  reactHookForm: "^7.74.0",
14
- betterAuth: "^1.3.26",
15
- drizzleKit: "^0.30.0",
16
- drizzleOrm: "^0.38.0",
15
+ betterAuth: "1.6.11",
16
+ drizzleKit: "^0.31.10",
17
+ drizzleOrm: "^0.45.2",
17
18
  inngest: "^3.0.0",
18
19
  libsqlClient: "^0.14.0",
19
20
  pino: "^9.7.0",
@@ -30,42 +31,15 @@ function hasDrizzleTurso(ctx) {
30
31
  function packageRunner(ctx) {
31
32
  switch (ctx.packageManager) {
32
33
  case "npm":
33
- return "npx -p @beignet/cli beignet";
34
+ return "npm run beignet --";
34
35
  case "pnpm":
35
- return "pnpm dlx @beignet/cli beignet";
36
+ return "pnpm beignet";
36
37
  case "yarn":
37
- return "yarn dlx @beignet/cli beignet";
38
+ return "yarn beignet";
38
39
  default:
39
- return "bunx -p @beignet/cli beignet";
40
+ return "bun beignet";
40
41
  }
41
42
  }
42
- /**
43
- * Supported scaffold feature choices.
44
- */
45
- export const featureChoices = [
46
- "client",
47
- "react-query",
48
- "forms",
49
- "openapi",
50
- ];
51
- /**
52
- * Supported scaffold preset choices.
53
- */
54
- export const presetChoices = [
55
- "minimal",
56
- "standard",
57
- ];
58
- /**
59
- * Supported scaffold integration choices.
60
- */
61
- export const integrationChoices = [
62
- "better-auth",
63
- "drizzle-turso",
64
- "inngest",
65
- "pino",
66
- "resend",
67
- "upstash-rate-limit",
68
- ];
69
43
  const integrationDeps = {
70
44
  "better-auth": {
71
45
  "@beignet/provider-auth-better-auth": "",
@@ -158,6 +132,7 @@ function packageJson(ctx) {
158
132
  }
159
133
  }
160
134
  const devDependencies = {
135
+ "@beignet/cli": ctx.beignetVersion,
161
136
  "@types/bun": externalVersions.typesBun,
162
137
  "@types/node": externalVersions.typesNode,
163
138
  "@types/react": externalVersions.typesReact,
@@ -170,6 +145,7 @@ function packageJson(ctx) {
170
145
  }
171
146
  }
172
147
  const scripts = {
148
+ beignet: "beignet",
173
149
  dev: "next dev",
174
150
  build: "next build",
175
151
  start: "next start",
@@ -179,8 +155,12 @@ function packageJson(ctx) {
179
155
  if (hasDrizzleTurso(ctx)) {
180
156
  scripts["db:generate"] = "drizzle-kit generate";
181
157
  scripts["db:migrate"] = "drizzle-kit migrate";
182
- scripts["db:seed"] = "bun infra/db/seed.ts";
183
- scripts["db:reset"] = "bun infra/db/reset.ts";
158
+ // The minimal preset does not scaffold infra/db/seed.ts or
159
+ // infra/db/reset.ts, so only the standard preset gets their scripts.
160
+ if (isStandardPreset(ctx)) {
161
+ scripts["db:seed"] = "bun infra/db/seed.ts";
162
+ scripts["db:reset"] = "bun infra/db/reset.ts";
163
+ }
184
164
  }
185
165
  return json({
186
166
  name: ctx.name,
@@ -200,12 +180,28 @@ function readme(ctx) {
200
180
  const run = ctx.packageManager === "npm"
201
181
  ? "npm run dev"
202
182
  : `${ctx.packageManager} run dev`;
183
+ const cli = packageRunner(ctx);
184
+ const envSetup = ctx.integrations.length > 0 ? "cp .env.example .env.local\n" : "";
203
185
  const featureList = ctx.features.length > 0
204
186
  ? ctx.features.map((feature) => `- \`${feature}\``).join("\n")
205
187
  : "- API routes only";
206
188
  const integrationList = ctx.integrations.length > 0
207
189
  ? ctx.integrations.map((integration) => `- \`${integration}\``).join("\n")
208
190
  : "- None";
191
+ const databaseSection = hasDrizzleTurso(ctx)
192
+ ? `
193
+ ## Database
194
+
195
+ 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\`).
196
+
197
+ Regenerate and apply migrations after changing \`infra/db/schema/index.ts\`:
198
+
199
+ \`\`\`bash
200
+ ${cli} db generate
201
+ ${cli} db migrate
202
+ \`\`\`
203
+ `
204
+ : "";
209
205
  return `# ${ctx.name}
210
206
 
211
207
  Beignet app scaffolded with \`@beignet/cli\`.
@@ -214,19 +210,21 @@ Beignet app scaffolded with \`@beignet/cli\`.
214
210
 
215
211
  \`\`\`bash
216
212
  ${install}
217
- ${run}
213
+ ${envSetup}${run}
218
214
  \`\`\`
219
215
 
220
- Open http://localhost:3000 and start from the todos workflow.
221
-
216
+ Open http://localhost:3000 and start from the todos workflow. The homepage links to the health check, OpenAPI document, and Beignet docs.
217
+ ${databaseSection}
222
218
  ## Files to know
223
219
 
224
220
  - \`features/todos/contracts.ts\` defines the API contract.
225
- - \`features/todos/use-cases.ts\` contains application logic with input and output validation.
221
+ - \`features/todos/use-cases/\` contains application logic with input and output validation.
226
222
  - \`ports/index.ts\` defines the app's external dependencies.
227
- - \`server/index.ts\` wires contracts to handlers.
223
+ - \`features/todos/routes.ts\` binds contracts to use cases.
224
+ - \`server/context.ts\` declares the context blueprint shared by the server and tests.
225
+ - \`server/index.ts\` composes the server from ports, context, and routes.
228
226
  - \`app/api/[[...path]]/route.ts\` exposes contract-backed API routes to Next.js.
229
- ${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
227
+ ${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" : ""}
230
228
 
231
229
  ## Selected preset
232
230
 
@@ -247,6 +245,9 @@ function standardReadme(ctx) {
247
245
  const run = ctx.packageManager === "npm"
248
246
  ? "npm run dev"
249
247
  : `${ctx.packageManager} run dev`;
248
+ const test = ctx.packageManager === "npm"
249
+ ? "npm run test"
250
+ : `${ctx.packageManager} run test`;
250
251
  const typecheck = ctx.packageManager === "npm"
251
252
  ? "npm run typecheck"
252
253
  : `${ctx.packageManager} run typecheck`;
@@ -267,10 +268,24 @@ Beignet app scaffolded with \`@beignet/cli\`.
267
268
  \`\`\`bash
268
269
  ${install}
269
270
  cp .env.example .env.local
271
+ \`\`\`
272
+
273
+ ## Prepare the database
274
+
275
+ \`\`\`bash
276
+ ${cli} db generate
277
+ ${cli} db migrate
278
+ \`\`\`
279
+
280
+ \`db generate\` creates Drizzle migrations from \`infra/db/schema/\`. \`db migrate\` applies them to the local SQLite database configured in \`.env.local\`.
281
+
282
+ ## Start the app
283
+
284
+ \`\`\`bash
270
285
  ${run}
271
286
  \`\`\`
272
287
 
273
- Open http://localhost:3000 for the todo workflow, http://localhost:3000/api/health for health checks, and http://localhost:3000/api/devtools while developing.
288
+ Open http://localhost:3000 for the todo workflow. The homepage links to the health check, OpenAPI document, devtools, and Beignet docs.
274
289
 
275
290
  ## First checks
276
291
 
@@ -284,15 +299,32 @@ ${typecheck}
284
299
 
285
300
  \`routes\` shows the contracts Beignet can inspect. \`lint\` checks dependency direction. \`doctor\` catches route, OpenAPI, and resource drift.
286
301
 
302
+ ## Generate a feature
303
+
304
+ \`\`\`bash
305
+ ${cli} make feature projects
306
+ ${cli} db generate
307
+ ${cli} db migrate
308
+ ${test}
309
+ ${typecheck}
310
+ ${cli} lint
311
+ ${cli} doctor
312
+ \`\`\`
313
+
314
+ \`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.
315
+
287
316
  ## App map
288
317
 
289
- - \`features/todos/contracts.ts\` owns the HTTP contract and reuses use case schemas.
318
+ - \`features/todos/contracts.ts\` owns the HTTP contract and reuses shared feature schemas.
319
+ - \`features/todos/schemas.ts\` owns shared DTO and validation schemas used by contracts, use cases, ports, tests, and clients.
290
320
  - \`features/todos/use-cases/\` owns application behavior and validation.
291
321
  - \`ports/\` defines app-owned dependencies.
292
322
  - \`infra/\` implements ports for the selected runtime.
293
323
  - \`server/routes.ts\` keeps the central route registry and OpenAPI contract list.
324
+ - \`server/context.ts\` declares the context blueprint shared by the server and route tests.
294
325
  - \`server/\` wires context, providers, routes, hooks, and error handling.
295
- - \`server/outbox.ts\` registers durable events and jobs for the outbox drain route.
326
+ - \`server/outbox.ts\` registers durable events and jobs for the outbox drain route and CLI drain runner.
327
+ - \`server/schedules.ts\` registers schedules for cron routes and the CLI schedule runner.
296
328
  - \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
297
329
  - \`features/todos/domain/events/\`, \`jobs/\`, \`listeners/\`, \`notifications/\`, \`schedules/\`, and \`uploads/\` show the feature-owned background workflow shape.
298
330
  - \`app/storage/[...key]/route.ts\` serves public local storage objects.
@@ -309,6 +341,8 @@ ${beforeDeploy}
309
341
  - Keep \`/api/devtools\` development-only unless you add authentication and stricter redaction.
310
342
  - Set \`APP_URL\`, \`BETTER_AUTH_SECRET\`, \`CRON_SECRET\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
311
343
  - Configure a scheduled request to \`/api/cron/outbox/drain\` with \`Authorization: Bearer <CRON_SECRET>\`.
344
+ - Use \`${cli} outbox drain\` for bounded local, CI, or worker-hosted drain runs.
345
+ - Use \`${cli} schedule run todos.log-daily-summary\` for bounded local, CI, or worker-hosted schedule runs.
312
346
  - Review the starter authorization policy before exposing user-owned data.
313
347
  ${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
314
348
  `;
@@ -322,13 +356,32 @@ function integrationsDoc(ctx) {
322
356
  ];
323
357
  for (const integration of ctx.integrations) {
324
358
  if (integration === "better-auth") {
325
- lines.push("## Better Auth", "", "- Package: `@beignet/provider-auth-better-auth`", "- Peer dependency: `better-auth`", "- The standard starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.", "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.", "- Put repeated ownership or role rules in policies registered with `createGate(...)`.", "- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.", "");
359
+ lines.push("## Better Auth", "", "- Package: `@beignet/provider-auth-better-auth`", "- Peer dependency: `better-auth`", "- The starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.", ...(isStandardPreset(ctx)
360
+ ? [
361
+ "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
362
+ "- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
363
+ "- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.",
364
+ ]
365
+ : [
366
+ "- Keep auth behind the shared `AuthPort`, then read `ctx.auth` in use cases that need a signed-in user.",
367
+ "- 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.",
368
+ ]), "");
326
369
  }
327
370
  if (integration === "drizzle-turso") {
328
- lines.push("## Drizzle + Turso", "", "- Package: `@beignet/provider-drizzle-turso`", "- Peer dependencies: `@libsql/client` and `drizzle-orm`", "- Dev dependency: `drizzle-kit`", "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.", "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.", `- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`, `- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`, "");
371
+ lines.push("## Drizzle + Turso", "", "- Package: `@beignet/provider-drizzle-turso`", "- Peer dependencies: `@libsql/client` and `drizzle-orm`", "- Dev dependency: `drizzle-kit`", "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.", ...(isStandardPreset(ctx)
372
+ ? [
373
+ "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
374
+ ]
375
+ : [
376
+ "- 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.",
377
+ ]), `- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`, ...(isStandardPreset(ctx)
378
+ ? [
379
+ `- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`,
380
+ ]
381
+ : []), "");
329
382
  }
330
383
  if (integration === "inngest") {
331
- lines.push("## Inngest", "", "- Package: `@beignet/core` (`@beignet/core/events`, `@beignet/core/jobs`)", "- Package: `@beignet/provider-inngest`", "- Peer dependency: `inngest`", "- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.", "- Define jobs with `createJobHandlers(...).defineJob(...)` and dispatch them through `ctx.ports.jobs.dispatch(...)`.", "- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.", "");
384
+ lines.push("## Inngest", "", "- Package: `@beignet/core` (`@beignet/core/events`, `@beignet/core/jobs`)", "- Package: `@beignet/provider-inngest`", "- Peer dependency: `inngest`", "- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.", "- Define jobs with `defineJob(...)` from `lib/jobs.ts` and dispatch them through `ctx.ports.jobs.dispatch(...)`.", "- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.", "");
332
385
  }
333
386
  if (integration === "pino") {
334
387
  lines.push("## Pino logger", "", "- Package: `@beignet/provider-logger-pino`", "- Peer dependency: `pino`", "- Use this provider when you want structured request and handler logging.", "");
@@ -367,7 +420,7 @@ function envExample(ctx) {
367
420
  lines.push("RESEND_API_KEY=", "RESEND_FROM=", "");
368
421
  }
369
422
  if (hasIntegration(ctx, "upstash-rate-limit")) {
370
- lines.push("UPSTASH_REDIS_REST_URL=", "UPSTASH_REDIS_REST_TOKEN=", "UPSTASH_PREFIX=ck:ratelimit", "");
423
+ lines.push("UPSTASH_REDIS_REST_URL=", "UPSTASH_REDIS_REST_TOKEN=", "UPSTASH_PREFIX=beignet:ratelimit", "");
371
424
  }
372
425
  if (hasIntegration(ctx, "better-auth")) {
373
426
  lines.push("BETTER_AUTH_SECRET=local-dev-better-auth-secret-change-me", "BETTER_AUTH_URL=http://localhost:3000", "# BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000", "");
@@ -432,6 +485,11 @@ function page(ctx) {
432
485
  <p>
433
486
  <a href="/api/todos">Open todos API</a>
434
487
  </p>
488
+ <nav className="starter-links" aria-label="Starter links">
489
+ <a href="/api/health">Health</a>
490
+ <a href="/api/openapi">OpenAPI</a>
491
+ <a href="https://beignetjs.com">Docs</a>
492
+ </nav>
435
493
  </section>
436
494
  </div>
437
495
  </main>
@@ -455,6 +513,14 @@ export default function HomePage() {
455
513
  </p>
456
514
  </header>
457
515
 
516
+ <nav className="starter-links" aria-label="Starter links">
517
+ <a href="#todos">Todo feature</a>
518
+ <a href="/api/health">Health</a>
519
+ <a href="/api/openapi">OpenAPI</a>
520
+ <a href="/api/devtools">Devtools</a>
521
+ <a href="https://beignetjs.com">Docs</a>
522
+ </nav>
523
+
458
524
  <TodoApp />
459
525
  </div>
460
526
  </main>
@@ -463,47 +529,91 @@ export default function HomePage() {
463
529
  `;
464
530
  }
465
531
  function serverProviders(ctx) {
466
- const imports = [];
532
+ const packageImports = [];
533
+ const appImports = [];
534
+ const declarations = [];
467
535
  const entries = [];
536
+ if (hasIntegration(ctx, "better-auth")) {
537
+ packageImports.push('import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";');
538
+ appImports.push('import { auth } from "@/lib/better-auth";', 'import type { AuthSessionMetadata, AuthUser } from "@/ports";');
539
+ entries.push("createAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth)");
540
+ }
468
541
  if (hasIntegration(ctx, "pino")) {
469
- imports.push('import { loggerPinoProvider } from "@beignet/provider-logger-pino";');
542
+ packageImports.push('import { loggerPinoProvider } from "@beignet/provider-logger-pino";');
470
543
  entries.push("loggerPinoProvider");
471
544
  }
545
+ if (hasDrizzleTurso(ctx)) {
546
+ packageImports.push('import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";');
547
+ appImports.unshift('import { starterDatabaseProvider } from "@/infra/db/provider";', 'import * as schema from "@/infra/db/schema";');
548
+ declarations.push("const drizzleTursoProvider = createDrizzleTursoProvider({ schema });");
549
+ entries.push("drizzleTursoProvider", "starterDatabaseProvider");
550
+ }
472
551
  if (hasIntegration(ctx, "inngest")) {
473
- imports.push('import { inngestProvider } from "@beignet/provider-inngest";');
552
+ packageImports.push('import { inngestProvider } from "@beignet/provider-inngest";');
474
553
  entries.push("inngestProvider");
475
554
  }
476
555
  if (hasIntegration(ctx, "resend")) {
477
- imports.push('import { mailResendProvider } from "@beignet/provider-mail-resend";');
556
+ packageImports.push('import { mailResendProvider } from "@beignet/provider-mail-resend";');
478
557
  entries.push("mailResendProvider");
479
558
  }
480
559
  if (hasIntegration(ctx, "upstash-rate-limit")) {
481
- imports.push('import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";');
560
+ packageImports.push('import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";');
482
561
  entries.push("upstashRateLimitProvider");
483
562
  }
484
- return `${imports.join("\n")}
563
+ const importBlock = [...packageImports.sort(), ...appImports].join("\n");
564
+ const declarationBlock = declarations.length > 0 ? `\n${declarations.join("\n")}\n` : "";
565
+ return `${importBlock}
566
+ ${declarationBlock}
485
567
  export const providers = [
486
568
  ${entries.join(",\n\t")},
487
569
  ];
488
570
  `;
489
571
  }
572
+ function serverContext(ctx) {
573
+ const blueprint = hasIntegration(ctx, "better-auth")
574
+ ? ` async ({ req, ports }) => ({
575
+ requestId: crypto.randomUUID(),
576
+ auth: await ports.auth.getSession(req),
577
+ ports,
578
+ }),`
579
+ : ` async ({ ports }) => ({
580
+ requestId: crypto.randomUUID(),
581
+ ports,
582
+ }),`;
583
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
584
+ import { defineServerContext } from "@beignet/core/server";
585
+ import type { AppContext } from "@/app-context";
586
+ import type { AppPorts } from "@/ports";
587
+
588
+ /**
589
+ * Context blueprint shared by the runtime server and route tests.
590
+ */
591
+ export const appContext = defineServerContext<AppContext, AppPorts>()(
592
+ ${blueprint}
593
+ );
594
+ `;
595
+ }
490
596
  function server(ctx) {
491
- const hasProviders = ctx.integrations.some((integration) => ["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration));
492
- return `import { createNextServer } from "@beignet/next";
597
+ const hasProviders = ctx.integrations.length > 0;
598
+ const providerConfigBlock = hasDrizzleTurso(ctx)
599
+ ? ` providerConfig: {
600
+ "drizzle-turso": {
601
+ DB_URL: process.env.TURSO_DB_URL ?? "file:local.db",
602
+ DB_AUTH_TOKEN: process.env.TURSO_DB_AUTH_TOKEN,
603
+ },
604
+ },
605
+ `
606
+ : "";
607
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
608
+ import { createNextServer } from "@beignet/next";
609
+ import { appContext } from "./context";
493
610
  ${hasProviders ? 'import { providers } from "./providers";\n' : ""}import { routes } from "./routes";
611
+ import type { AppContext } from "@/app-context";
494
612
  import { appPorts, type AppPorts } from "@/ports";
495
613
 
496
- export type AppContext = {
497
- requestId: string;
498
- ports: AppPorts;
499
- };
500
-
501
614
  export const server = await createNextServer<AppContext, AppPorts>({
502
615
  ports: appPorts,
503
- ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
504
- requestId: crypto.randomUUID(),
505
- ports,
506
- }),
616
+ ${hasProviders ? "\tproviders,\n" : ""}${providerConfigBlock} context: appContext,
507
617
  routes,
508
618
  mapUnhandledError: ({ err, ctx }) => {
509
619
  const ports = ctx?.ports as
@@ -544,17 +654,128 @@ ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
544
654
  });
545
655
  `;
546
656
  }
657
+ function minimalPorts(ctx) {
658
+ const usesDrizzleTurso = hasDrizzleTurso(ctx);
659
+ const usesBetterAuth = hasIntegration(ctx, "better-auth");
660
+ if (!usesDrizzleTurso && !usesBetterAuth) {
661
+ return files.ports;
662
+ }
663
+ const imports = [
664
+ 'import { definePorts } from "@beignet/core/ports";',
665
+ ...(usesBetterAuth
666
+ ? [
667
+ `import type {
668
+ AuthPort as BeignetAuthPort,
669
+ AuthSession as BeignetAuthSession,
670
+ } from "@beignet/core/ports";`,
671
+ ]
672
+ : []),
673
+ 'import type { z } from "zod";',
674
+ 'import type { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";',
675
+ ].join("\n");
676
+ const authTypes = usesBetterAuth
677
+ ? `
678
+
679
+ export type AuthUser = {
680
+ id: string;
681
+ email?: string;
682
+ name?: string;
683
+ };
684
+
685
+ export type AuthSessionMetadata = {
686
+ id?: string;
687
+ };
688
+
689
+ export type AuthSession = BeignetAuthSession<AuthUser, AuthSessionMetadata>;
690
+
691
+ export type AuthPort = BeignetAuthPort<AuthUser, AuthSessionMetadata>;`
692
+ : "";
693
+ const appPortsType = [
694
+ "export type AppPorts = {",
695
+ ...(usesBetterAuth ? ["\tauth: AuthPort;"] : []),
696
+ "\ttodos: TodoRepository;",
697
+ "};",
698
+ ].join("\n");
699
+ const inMemoryTodos = usesDrizzleTurso
700
+ ? ""
701
+ : `
702
+
703
+ const todos = new Map<string, Todo>();
704
+
705
+ function createTodo(input: CreateTodoInput): Todo {
706
+ const todo = {
707
+ id: crypto.randomUUID(),
708
+ title: input.title,
709
+ completed: false,
710
+ createdAt: new Date().toISOString(),
711
+ };
712
+ todos.set(todo.id, todo);
713
+ return todo;
714
+ }
715
+
716
+ createTodo({ title: "Read the generated contract" });
717
+ createTodo({ title: "Add your first use case" });`;
718
+ const bound = usesDrizzleTurso
719
+ ? "\tbound: {},"
720
+ : ` bound: {
721
+ todos: {
722
+ list: async () => Array.from(todos.values()),
723
+ create: async (input: CreateTodoInput) => createTodo(input),
724
+ },
725
+ },`;
726
+ const deferred = [
727
+ ...(usesBetterAuth ? ['"auth"'] : []),
728
+ ...(usesDrizzleTurso ? ['"todos"'] : []),
729
+ ].join(", ");
730
+ return `${imports}
731
+
732
+ export type Todo = z.infer<typeof TodoSchema>;
733
+ export type CreateTodoInput = z.infer<typeof CreateTodoSchema>;
734
+
735
+ export interface TodoRepository {
736
+ list(): Promise<Todo[]>;
737
+ create(input: CreateTodoInput): Promise<Todo>;
738
+ }${authTypes}
739
+
740
+ ${appPortsType}${inMemoryTodos}
741
+
742
+ /**
743
+ * App ports the server boots with.
744
+ *
745
+ * Bound ports are app-owned. Every deferred key is contributed by a provider
746
+ * in server/providers.ts during startup; createNextServer(...) fails boot if
747
+ * any of them is still unbound after providers have started.
748
+ */
749
+ export const appPorts = definePorts<AppPorts>()({
750
+ ${bound}
751
+ deferred: [${deferred}],
752
+ });
753
+ `;
754
+ }
755
+ function minimalAppContext(ctx) {
756
+ if (!hasIntegration(ctx, "better-auth")) {
757
+ return files.appContext;
758
+ }
759
+ return `import type { AppPorts, AuthSession } from "@/ports";
760
+
761
+ export type AppContext = {
762
+ requestId: string;
763
+ auth: AuthSession | null;
764
+ ports: AppPorts;
765
+ };
766
+ `;
767
+ }
547
768
  function todoApp(ctx) {
548
769
  const listTodosQueryOptions = ctx.preset === "standard" ? "{ query: {} }" : "";
549
770
  if (hasFeature(ctx, "forms")) {
550
771
  return `"use client";
551
772
 
552
- import { createReactHookForm } from "@beignet/react-hook-form";
773
+ import { rootFormError } from "@beignet/react-hook-form";
553
774
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
775
+ import { rq } from "@/client";
776
+ import { rhf } from "@/client/forms";
554
777
  import { createTodo, listTodos } from "@/features/todos/contracts";
555
- import { rq } from "@/client/rq";
556
778
 
557
- const rhf = createReactHookForm();
558
779
  const createTodoForm = rhf(createTodo);
559
780
 
560
781
  export function TodoApp() {
@@ -567,19 +788,21 @@ export function TodoApp() {
567
788
  rq(createTodo).mutationOptions({
568
789
  onSuccess: async () => {
569
790
  form.reset();
570
- await queryClient.invalidateQueries({
571
- queryKey: rq(listTodos).key(),
572
- });
791
+ await rq(listTodos).invalidate(queryClient);
792
+ },
793
+ onError: (error) => {
794
+ form.setError("root", rootFormError(error, "Could not create the todo."));
573
795
  },
574
796
  }),
575
797
  );
576
798
 
577
799
  const onSubmit = form.handleSubmit((body) => {
800
+ form.clearErrors("root");
578
801
  createTodoMutation.mutate({ body });
579
802
  });
580
803
 
581
804
  return (
582
- <section className="workspace">
805
+ <section id="todos" className="workspace">
583
806
  <form className="composer" onSubmit={onSubmit}>
584
807
  <label htmlFor="todo-title">New todo</label>
585
808
  <div className="composer-row">
@@ -595,6 +818,9 @@ export function TodoApp() {
595
818
  {form.formState.errors.title ? (
596
819
  <p className="error">{form.formState.errors.title.message}</p>
597
820
  ) : null}
821
+ {form.formState.errors.root ? (
822
+ <p className="error">{form.formState.errors.root.message}</p>
823
+ ) : null}
598
824
  </form>
599
825
 
600
826
  <div className="todos-panel">
@@ -624,8 +850,8 @@ export function TodoApp() {
624
850
 
625
851
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
626
852
  import { useState } from "react";
853
+ import { rq } from "@/client";
627
854
  import { createTodo, listTodos } from "@/features/todos/contracts";
628
- import { rq } from "@/client/rq";
629
855
 
630
856
  export function TodoApp() {
631
857
  const [title, setTitle] = useState("");
@@ -635,15 +861,13 @@ export function TodoApp() {
635
861
  rq(createTodo).mutationOptions({
636
862
  onSuccess: async () => {
637
863
  setTitle("");
638
- await queryClient.invalidateQueries({
639
- queryKey: rq(listTodos).key(),
640
- });
864
+ await rq(listTodos).invalidate(queryClient);
641
865
  },
642
866
  }),
643
867
  );
644
868
 
645
869
  return (
646
- <section className="workspace">
870
+ <section id="todos" className="workspace">
647
871
  <form
648
872
  className="composer"
649
873
  onSubmit={(event) => {
@@ -684,6 +908,55 @@ export function TodoApp() {
684
908
  }
685
909
  `;
686
910
  }
911
+ function clientIndex(ctx) {
912
+ const imports = [
913
+ 'import { createClient } from "@beignet/core/client";',
914
+ ...(hasFeature(ctx, "react-query")
915
+ ? [
916
+ 'import { createReactQuery } from "@beignet/react-query";',
917
+ 'import { QueryClient } from "@tanstack/react-query";',
918
+ ]
919
+ : []),
920
+ ...(isStandardPreset(ctx)
921
+ ? [
922
+ 'import { createUploadClient } from "@beignet/core/uploads/client";',
923
+ 'import type { todoUploads } from "@/features/todos/uploads";',
924
+ ]
925
+ : []),
926
+ ].join("\n");
927
+ const exports = [
928
+ `export const apiClient = createClient({
929
+ \tvalidateInput: true,
930
+ });`,
931
+ ...(hasFeature(ctx, "react-query")
932
+ ? ["export const rq = createReactQuery(apiClient);"]
933
+ : []),
934
+ ...(isStandardPreset(ctx)
935
+ ? [
936
+ `export const uploads = createUploadClient<typeof todoUploads>({
937
+ \tbaseUrl: "/api/uploads",
938
+ });`,
939
+ ]
940
+ : []),
941
+ ...(hasFeature(ctx, "react-query")
942
+ ? [
943
+ `export function makeQueryClient() {
944
+ \treturn new QueryClient({
945
+ \t\tdefaultOptions: {
946
+ \t\t\tqueries: {
947
+ \t\t\t\tstaleTime: 60 * 1000,
948
+ \t\t\t},
949
+ \t\t},
950
+ \t});
951
+ }`,
952
+ ]
953
+ : []),
954
+ ].join("\n\n");
955
+ return `${imports}
956
+
957
+ ${exports}
958
+ `;
959
+ }
687
960
  const files = {
688
961
  gitignore: `.next
689
962
  node_modules
@@ -784,6 +1057,29 @@ input {
784
1057
  color: #475569;
785
1058
  }
786
1059
 
1060
+ .starter-links {
1061
+ display: flex;
1062
+ flex-wrap: wrap;
1063
+ gap: 10px;
1064
+ margin: 22px 0;
1065
+ }
1066
+
1067
+ .starter-links a {
1068
+ border: 1px solid #cbd5e1;
1069
+ border-radius: 8px;
1070
+ padding: 8px 10px;
1071
+ background: #ffffff;
1072
+ color: #172033;
1073
+ font-size: 14px;
1074
+ font-weight: 700;
1075
+ text-decoration: none;
1076
+ }
1077
+
1078
+ .starter-links a:hover {
1079
+ border-color: #4f46e5;
1080
+ color: #4f46e5;
1081
+ }
1082
+
787
1083
  .panel {
788
1084
  margin-top: 24px;
789
1085
  padding: 20px;
@@ -910,32 +1206,26 @@ input {
910
1206
  `,
911
1207
  appProviders: `"use client";
912
1208
 
913
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1209
+ import { QueryClientProvider } from "@tanstack/react-query";
914
1210
  import { type ReactNode, useState } from "react";
1211
+ import { makeQueryClient } from "@/client";
915
1212
 
916
1213
  export function Providers({ children }: { children: ReactNode }) {
917
- const [queryClient] = useState(() => new QueryClient());
1214
+ const [queryClient] = useState(() => makeQueryClient());
918
1215
 
919
1216
  return (
920
1217
  <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
921
1218
  );
922
1219
  }
923
1220
  `,
924
- apiClient: `import { createNextClient } from "@beignet/next";
1221
+ formsClient: `import { createReactHookForm } from "@beignet/react-hook-form";
925
1222
 
926
- export const apiClient = createNextClient({
927
- validate: true,
928
- });
1223
+ export const rhf = createReactHookForm();
929
1224
  `,
930
- rq: `import { createReactQuery } from "@beignet/react-query";
931
- import { apiClient } from "./api-client";
932
-
933
- export const rq = createReactQuery(apiClient);
934
- `,
935
- contractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
1225
+ contractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
936
1226
  import { z } from "zod";
937
1227
 
938
- const todos = createContractGroup().namespace("todos");
1228
+ const todos = defineContractGroup().namespace("todos");
939
1229
 
940
1230
  export const TodoSchema = z.object({
941
1231
  id: z.string(),
@@ -1000,15 +1290,145 @@ export const appPorts = definePorts({
1000
1290
 
1001
1291
  export type AppPorts = typeof appPorts;
1002
1292
  `,
1003
- useCasesTodos: `import { createUseCase } from "@beignet/core/application";
1004
- import { z } from "zod";
1005
- import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
1006
- import type { AppPorts } from "@/ports";
1293
+ appContext: `import type { AppPorts } from "@/ports";
1007
1294
 
1008
- type AppContext = {
1295
+ export type AppContext = {
1009
1296
  requestId: string;
1010
1297
  ports: AppPorts;
1011
1298
  };
1299
+ `,
1300
+ minimalBetterAuth: `import { betterAuth } from "better-auth";
1301
+ import { memoryAdapter, type MemoryDB } from "better-auth/adapters/memory";
1302
+
1303
+ // The minimal starter keeps auth storage in memory, matching its in-memory
1304
+ // ports. Users and sessions reset on restart; switch to a database adapter
1305
+ // such as better-auth/adapters/drizzle before production.
1306
+ const database: MemoryDB = {
1307
+ account: [],
1308
+ session: [],
1309
+ user: [],
1310
+ verification: [],
1311
+ };
1312
+
1313
+ export const auth = betterAuth({
1314
+ baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
1315
+ secret:
1316
+ process.env.BETTER_AUTH_SECRET ?? "local-dev-better-auth-secret-change-me",
1317
+ database: memoryAdapter(database),
1318
+ emailAndPassword: {
1319
+ enabled: true,
1320
+ },
1321
+ });
1322
+ `,
1323
+ minimalDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1324
+
1325
+ export const todos = sqliteTable("todos", {
1326
+ id: text("id").primaryKey(),
1327
+ title: text("title").notNull(),
1328
+ completed: integer("completed", { mode: "boolean" }).notNull().default(false),
1329
+ createdAt: text("created_at").notNull(),
1330
+ });
1331
+ `,
1332
+ minimalDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
1333
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1334
+ import { asc } from "drizzle-orm";
1335
+ import * as schema from "@/infra/db/schema";
1336
+ import type { CreateTodoInput, TodoRepository } from "@/ports";
1337
+
1338
+ export function createDrizzleTodoRepository(
1339
+ db: DrizzleTursoDatabase<typeof schema>,
1340
+ ): TodoRepository {
1341
+ return {
1342
+ async list() {
1343
+ return db
1344
+ .select()
1345
+ .from(schema.todos)
1346
+ .orderBy(asc(schema.todos.createdAt));
1347
+ },
1348
+ async create(input: CreateTodoInput) {
1349
+ const todo = {
1350
+ id: crypto.randomUUID(),
1351
+ title: input.title,
1352
+ completed: false,
1353
+ createdAt: new Date().toISOString(),
1354
+ };
1355
+ const [row] = await db.insert(schema.todos).values(todo).returning();
1356
+
1357
+ if (!row) {
1358
+ throw new Error("Failed to create todo");
1359
+ }
1360
+
1361
+ return row;
1362
+ },
1363
+ };
1364
+ }
1365
+ `,
1366
+ minimalDbProvider: `import type { beignetServerOnly } from "@beignet/core/server-only";
1367
+ import { createProvider } from "@beignet/core/providers";
1368
+ import type { DbPort } from "@beignet/provider-drizzle-turso";
1369
+ import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
1370
+ import type { AppPorts } from "@/ports";
1371
+ import * as schema from "./schema";
1372
+
1373
+ const setupStatements = [
1374
+ \`CREATE TABLE IF NOT EXISTS todos (
1375
+ id text PRIMARY KEY NOT NULL,
1376
+ title text NOT NULL,
1377
+ completed integer DEFAULT false NOT NULL,
1378
+ created_at text NOT NULL
1379
+ )\`,
1380
+ ];
1381
+
1382
+ const seedTitles = [
1383
+ "Read the generated contract",
1384
+ "Add your first use case",
1385
+ ] as const;
1386
+
1387
+ export const starterDatabaseProvider = createProvider<{
1388
+ db: DbPort<typeof schema>;
1389
+ }>()({
1390
+ name: "starter-database",
1391
+
1392
+ async setup({ ports }) {
1393
+ const dbPort = ports.db;
1394
+ if (!dbPort) {
1395
+ throw new Error(
1396
+ "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
1397
+ );
1398
+ }
1399
+
1400
+ const providedPorts: Pick<AppPorts, "todos"> = {
1401
+ todos: createDrizzleTodoRepository(dbPort.db),
1402
+ };
1403
+
1404
+ return {
1405
+ ports: providedPorts,
1406
+ async start() {
1407
+ for (const statement of setupStatements) {
1408
+ await dbPort.client.execute(statement);
1409
+ }
1410
+
1411
+ const result = await dbPort.client.execute(
1412
+ "SELECT count(*) as total FROM todos",
1413
+ );
1414
+ const total = Number(result.rows[0]?.total ?? 0);
1415
+ if (total > 0) return;
1416
+
1417
+ for (const title of seedTitles) {
1418
+ await dbPort.client.execute({
1419
+ sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
1420
+ args: [crypto.randomUUID(), title, 0, new Date().toISOString()],
1421
+ });
1422
+ }
1423
+ },
1424
+ };
1425
+ },
1426
+ });
1427
+ `,
1428
+ useCasesTodos: `import { createUseCase } from "@beignet/core/application";
1429
+ import { z } from "zod";
1430
+ import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
1431
+ import type { AppContext } from "@/app-context";
1012
1432
 
1013
1433
  const useCase = createUseCase<AppContext>();
1014
1434
 
@@ -1025,7 +1445,7 @@ export const ListTodosOutputSchema = z.object({
1025
1445
 
1026
1446
  export const listTodos = useCase
1027
1447
  .query("listTodos")
1028
- .input(z.void())
1448
+ .input(z.object({}))
1029
1449
  .output(ListTodosOutputSchema)
1030
1450
  .run(async ({ ctx }) => {
1031
1451
  const todos = await ctx.ports.todos.list();
@@ -1047,33 +1467,23 @@ export const createTodo = useCase
1047
1467
  .output(TodoSchema)
1048
1468
  .run(async ({ ctx, input }) => ctx.ports.todos.create(input));
1049
1469
  `,
1050
- todoRoutes: `import { defineRouteGroup } from "@beignet/next";
1051
- import type { AppContext } from "@/server";
1470
+ todoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
1471
+ import { defineRouteGroup } from "@beignet/next";
1472
+ import type { AppContext } from "@/app-context";
1052
1473
  import { createTodo, listTodos } from "@/features/todos/use-cases";
1053
1474
  import * as contracts from "@/features/todos/contracts";
1054
1475
 
1055
1476
  export const todoRoutes = defineRouteGroup<AppContext>({
1056
1477
  name: "todos",
1057
1478
  routes: [
1058
- {
1059
- contract: contracts.listTodos,
1060
- handle: async ({ ctx }) => ({
1061
- status: 200,
1062
- body: await listTodos.run({ ctx, input: undefined }),
1063
- }),
1064
- },
1065
- {
1066
- contract: contracts.createTodo,
1067
- handle: async ({ ctx, body }) => ({
1068
- status: 201,
1069
- body: await createTodo.run({ ctx, input: body }),
1070
- }),
1071
- },
1479
+ { contract: contracts.listTodos, useCase: listTodos },
1480
+ { contract: contracts.createTodo, useCase: createTodo },
1072
1481
  ],
1073
1482
  });
1074
1483
  `,
1075
- serverRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1076
- import type { AppContext } from "@/server";
1484
+ serverRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
1485
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1486
+ import type { AppContext } from "@/app-context";
1077
1487
  import { todoRoutes } from "@/features/todos/routes";
1078
1488
 
1079
1489
  export const routes = defineRoutes<AppContext>([
@@ -1131,24 +1541,28 @@ export const env = createEnv({
1131
1541
 
1132
1542
  export const isProduction = env.NODE_ENV === "production";
1133
1543
  `,
1134
- productionAppContext: `import type { DevtoolsPort, DevtoolsTraceContext } from "@beignet/devtools";
1135
- import type { ActivityActor, ActivityTenant, StoragePort } from "@beignet/core/ports";
1544
+ productionAppContext: `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
1545
+ import type { InferProviderPorts } from "@beignet/core/providers";
1546
+ import type { TraceContext } from "@beignet/core/tracing";
1136
1547
  import type { AppGate, AppPorts } from "@/ports";
1137
1548
  import type { AuthSession } from "@/ports/auth";
1549
+ import type { providers } from "@/server/providers";
1550
+
1551
+ /**
1552
+ * App ports plus the ports contributed by the server's providers at startup.
1553
+ */
1554
+ export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
1138
1555
 
1139
1556
  export type AppContext = {
1140
1557
  requestId: string;
1141
1558
  actor: ActivityActor;
1142
1559
  auth: AuthSession | null;
1143
1560
  gate: AppGate;
1144
- ports: AppPorts & {
1145
- devtools: DevtoolsPort;
1146
- storage: StoragePort;
1147
- };
1561
+ ports: AppRuntimePorts;
1148
1562
  tenant?: ActivityTenant;
1149
- } & Partial<DevtoolsTraceContext>;
1563
+ } & Partial<TraceContext>;
1150
1564
  `,
1151
- productimapUnhandledErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
1565
+ productionSharedErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
1152
1566
 
1153
1567
  export const errors = defineErrors({
1154
1568
  Unauthorized: {
@@ -1207,13 +1621,36 @@ export type Todo = z.infer<typeof TodoSchema>;
1207
1621
  export type CreateTodoInput = z.infer<typeof CreateTodoInputSchema>;
1208
1622
  export type ListTodosInput = z.infer<typeof ListTodosInputSchema>;
1209
1623
  `,
1210
- productionUseCaseBuilder: `import { createUseCase } from "@beignet/core/application";
1211
- import { createDevtoolsUseCaseObserver } from "@beignet/devtools";
1624
+ productionUseCaseBuilder: `import type { beignetServerOnly } from "@beignet/core/server-only";
1625
+ import { createUseCase } from "@beignet/core/application";
1212
1626
  import type { AppContext } from "@/app-context";
1213
1627
 
1214
- export const useCase = createUseCase<AppContext>({
1215
- onRun: createDevtoolsUseCaseObserver<AppContext>(),
1216
- });
1628
+ export const useCase = createUseCase<AppContext>();
1629
+ `,
1630
+ productionListenersBuilder: `import { createListeners } from "@beignet/core/events";
1631
+ import type { AppContext } from "@/app-context";
1632
+
1633
+ export const { defineListener } = createListeners<AppContext>();
1634
+ `,
1635
+ productionJobsBuilder: `import { createJobs } from "@beignet/core/jobs";
1636
+ import type { AppContext } from "@/app-context";
1637
+
1638
+ export const { defineJob } = createJobs<AppContext>();
1639
+ `,
1640
+ productionSchedulesBuilder: `import { createSchedules } from "@beignet/core/schedules";
1641
+ import type { AppContext } from "@/app-context";
1642
+
1643
+ export const { defineSchedule } = createSchedules<AppContext>();
1644
+ `,
1645
+ productionNotificationsBuilder: `import { createNotifications } from "@beignet/core/notifications";
1646
+ import type { AppContext } from "@/app-context";
1647
+
1648
+ export const { defineNotification } = createNotifications<AppContext>();
1649
+ `,
1650
+ productionTasksBuilder: `import { createTasks } from "@beignet/core/tasks";
1651
+ import type { AppContext } from "@/app-context";
1652
+
1653
+ export const { defineTask } = createTasks<AppContext>();
1217
1654
  `,
1218
1655
  productionAuthHelpers: `import type { AppContext } from "@/app-context";
1219
1656
  import type { AuthSession, AuthUser } from "@/ports/auth";
@@ -1231,9 +1668,10 @@ export function requireUser(ctx: AppContext): AuthUser {
1231
1668
  return requireSession(ctx).user;
1232
1669
  }
1233
1670
  `,
1234
- productionListTodosUseCase: `import { normalizeOffsetPage } from "@beignet/core/pagination";
1671
+ productionListTodosUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1672
+ import { normalizeOffsetPage } from "@beignet/core/pagination";
1235
1673
  import { useCase } from "@/lib/use-case";
1236
- import { ListTodosInputSchema, ListTodosOutputSchema } from "./schemas";
1674
+ import { ListTodosInputSchema, ListTodosOutputSchema } from "../schemas";
1237
1675
 
1238
1676
  export const listTodosUseCase = useCase
1239
1677
  .query("todos.list")
@@ -1248,9 +1686,10 @@ export const listTodosUseCase = useCase
1248
1686
  return ctx.ports.todos.list(page);
1249
1687
  });
1250
1688
  `,
1251
- productionGetTodoUseCase: `import { appError } from "@/features/shared/errors";
1689
+ productionGetTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1690
+ import { appError } from "@/features/shared/errors";
1252
1691
  import { useCase } from "@/lib/use-case";
1253
- import { GetTodoInputSchema, TodoSchema } from "./schemas";
1692
+ import { GetTodoInputSchema, TodoSchema } from "../schemas";
1254
1693
 
1255
1694
  export const getTodoUseCase = useCase
1256
1695
  .query("todos.get")
@@ -1265,10 +1704,11 @@ export const getTodoUseCase = useCase
1265
1704
  return todo;
1266
1705
  });
1267
1706
  `,
1268
- productionCreateTodoUseCase: `import { TodoCreated } from "@/features/todos/domain/events";
1707
+ productionCreateTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1708
+ import { TodoCreated } from "@/features/todos/domain/events";
1269
1709
  import { auditEntry } from "@/lib/audit";
1270
1710
  import { useCase } from "@/lib/use-case";
1271
- import { CreateTodoInputSchema, TodoSchema } from "./schemas";
1711
+ import { CreateTodoInputSchema, TodoSchema } from "../schemas";
1272
1712
 
1273
1713
  export const createTodoUseCase = useCase
1274
1714
  .command("todos.create")
@@ -1308,7 +1748,7 @@ export {
1308
1748
  type CreateTodoInput,
1309
1749
  type ListTodosInput,
1310
1750
  type Todo,
1311
- } from "./schemas";
1751
+ } from "../schemas";
1312
1752
  `,
1313
1753
  productionTodoRepositoryPort: `import type {
1314
1754
  OffsetPage,
@@ -1318,7 +1758,7 @@ export {
1318
1758
  import type {
1319
1759
  CreateTodoInput,
1320
1760
  Todo,
1321
- } from "@/features/todos/use-cases/schemas";
1761
+ } from "@/features/todos/schemas";
1322
1762
 
1323
1763
  export type ListTodosResult = PageResult<Todo, OffsetPageInfo>;
1324
1764
 
@@ -1411,11 +1851,12 @@ export const todoPolicy = definePolicy({
1411
1851
  },
1412
1852
  });
1413
1853
  `,
1414
- productionInMemoryTodoRepository: `import { offsetPageResult } from "@beignet/core/pagination";
1854
+ productionInMemoryTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
1855
+ import { offsetPageResult } from "@beignet/core/pagination";
1415
1856
  import type {
1416
1857
  CreateTodoInput,
1417
1858
  Todo,
1418
- } from "@/features/todos/use-cases/schemas";
1859
+ } from "@/features/todos/schemas";
1419
1860
  import type {
1420
1861
  TodoAttachmentRepository,
1421
1862
  TodoRepository,
@@ -1497,27 +1938,7 @@ export const appPorts = definePorts({
1497
1938
  logger: fallbackLogger,
1498
1939
  uow: createNoopUnitOfWork(() => ({
1499
1940
  todos,
1500
- })),
1501
- });
1502
- `,
1503
- productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
1504
- import { todoPolicy } from "@/features/todos/policy";
1505
- import { appError } from "@/features/shared/errors";
1506
- import { fallbackLogger } from "./logger";
1507
-
1508
- const gate = createGate({
1509
- policies: [todoPolicy],
1510
- onDeny(decision) {
1511
- return appError("Forbidden", {
1512
- message: decision.reason ?? "Forbidden",
1513
- details: decision.details,
1514
- });
1515
- },
1516
- });
1517
-
1518
- export const appPorts = definePorts({
1519
- gate,
1520
- logger: fallbackLogger,
1941
+ })),
1521
1942
  });
1522
1943
  `,
1523
1944
  productionDrizzleConfig: `export default {
@@ -1689,8 +2110,34 @@ export const outboxMessages = sqliteTable(
1689
2110
  ),
1690
2111
  }),
1691
2112
  );
2113
+
2114
+ export const idempotencyRecords = sqliteTable(
2115
+ "idempotency_records",
2116
+ {
2117
+ storageKey: text("storage_key").primaryKey(),
2118
+ namespace: text("namespace").notNull(),
2119
+ idempotencyKey: text("idempotency_key").notNull(),
2120
+ scopeKey: text("scope_key").notNull(),
2121
+ fingerprint: text("fingerprint").notNull(),
2122
+ status: text("status", { enum: ["in-progress", "completed"] }).notNull(),
2123
+ resultJson: text("result_json"),
2124
+ reservedAt: text("reserved_at").notNull(),
2125
+ completedAt: text("completed_at"),
2126
+ expiresAt: text("expires_at"),
2127
+ updatedAt: text("updated_at").notNull(),
2128
+ },
2129
+ (table) => ({
2130
+ lookupIdx: index("idempotency_records_lookup_idx").on(
2131
+ table.namespace,
2132
+ table.scopeKey,
2133
+ table.idempotencyKey,
2134
+ ),
2135
+ expiresIdx: index("idempotency_records_expires_idx").on(table.expiresAt),
2136
+ }),
2137
+ );
1692
2138
  `,
1693
- productionDrizzleTodoRepository: `import { offsetPageResult } from "@beignet/core/pagination";
2139
+ productionDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
2140
+ import { offsetPageResult } from "@beignet/core/pagination";
1694
2141
  import { count, desc, eq } from "drizzle-orm";
1695
2142
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1696
2143
  import type {
@@ -1700,7 +2147,7 @@ import type {
1700
2147
  import type {
1701
2148
  CreateTodoInput,
1702
2149
  Todo,
1703
- } from "@/features/todos/use-cases/schemas";
2150
+ } from "@/features/todos/schemas";
1704
2151
  import * as schema from "@/infra/db/schema";
1705
2152
 
1706
2153
  type TodoRow = typeof schema.todos.$inferSelect;
@@ -1814,7 +2261,10 @@ import * as schema from "./schema";
1814
2261
 
1815
2262
  export function createRepositories(
1816
2263
  db: DrizzleTursoDatabase<typeof schema>,
1817
- ): Omit<AppTransactionPorts, "audit" | "events" | "jobs" | "outbox"> {
2264
+ ): Omit<
2265
+ AppTransactionPorts,
2266
+ "audit" | "events" | "idempotency" | "jobs" | "outbox"
2267
+ > {
1818
2268
  return {
1819
2269
  todoAttachments: createDrizzleTodoAttachmentRepository(db),
1820
2270
  todos: createDrizzleTodoRepository(db),
@@ -1867,7 +2317,7 @@ export function createDrizzleAuditLog(
1867
2317
  };
1868
2318
  }
1869
2319
  `,
1870
- productionDbBootstrap: `import { createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
2320
+ productionDbBootstrap: `import { createDrizzleTursoIdempotencySetupStatements, createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
1871
2321
  import type { Client } from "@libsql/client";
1872
2322
 
1873
2323
  type BootstrapOptions = {
@@ -1961,6 +2411,7 @@ const setupStatements = [
1961
2411
  "CREATE INDEX IF NOT EXISTS audit_log_request_idx ON audit_log (request_id)",
1962
2412
  "CREATE INDEX IF NOT EXISTS audit_log_resource_idx ON audit_log (resource_type, resource_id)",
1963
2413
  "CREATE INDEX IF NOT EXISTS audit_log_tenant_idx ON audit_log (tenant_id)",
2414
+ ...createDrizzleTursoIdempotencySetupStatements(),
1964
2415
  ...createDrizzleTursoOutboxSetupStatements(),
1965
2416
  ];
1966
2417
 
@@ -1987,6 +2438,7 @@ const resetTables = [
1987
2438
  "todo_attachments",
1988
2439
  "todos",
1989
2440
  "audit_log",
2441
+ "idempotency_records",
1990
2442
  "outbox_messages",
1991
2443
  ] as const;
1992
2444
 
@@ -2025,7 +2477,6 @@ export async function resetStarterDatabase(client: Client): Promise<void> {
2025
2477
  }
2026
2478
  `,
2027
2479
  productionDbProvider: `import { registerListeners } from "@beignet/core/events";
2028
- import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
2029
2480
  import { createInlineJobDispatcher } from "@beignet/core/jobs";
2030
2481
  import { createMemoryMailer } from "@beignet/core/mail";
2031
2482
  import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
@@ -2034,19 +2485,15 @@ import {
2034
2485
  createOutboxJobDispatcher,
2035
2486
  } from "@beignet/core/outbox";
2036
2487
  import {
2488
+ createInstrumentedAuditLog,
2037
2489
  createSystemActor,
2038
- createTenant,
2039
2490
  } from "@beignet/core/ports";
2040
2491
  import {
2041
2492
  createProvider,
2042
2493
  createProviderInstrumentation,
2043
2494
  } from "@beignet/core/providers";
2044
2495
  import {
2045
- createDevtoolsAuditLog,
2046
- createDevtoolsTraceContext,
2047
- type DevtoolsPort,
2048
- } from "@beignet/devtools";
2049
- import {
2496
+ createDrizzleTursoIdempotencyPort,
2050
2497
  createDrizzleTursoOutboxPort,
2051
2498
  createDrizzleTursoUnitOfWork,
2052
2499
  type DbPort,
@@ -2056,42 +2503,30 @@ import type { AppContext } from "@/app-context";
2056
2503
  import { todoListeners } from "@/features/todos/listeners";
2057
2504
  import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
2058
2505
  import type { AppPorts } from "@/ports";
2506
+ import type { AppServiceContextInput } from "@/server/context";
2059
2507
  import { ensureStarterDatabase } from "./bootstrap";
2060
2508
  import { createRepositories } from "./repositories";
2061
2509
  import type * as schema from "./schema";
2062
2510
 
2063
- export const starterDatabaseProvider = createProvider({
2511
+ export const starterDatabaseProvider = createProvider<
2512
+ { db: DbPort<typeof schema> },
2513
+ AppContext,
2514
+ AppServiceContextInput
2515
+ >()({
2064
2516
  name: "starter-database",
2065
2517
 
2066
- async setup({ ports }) {
2067
- const dbPort = (ports as { db: DbPort<typeof schema> }).db;
2068
- const devtools = (ports as { devtools?: DevtoolsPort }).devtools;
2518
+ async setup({ ports, createServiceContext }) {
2519
+ const dbPort = ports.db;
2069
2520
  if (!dbPort) {
2070
2521
  throw new Error(
2071
2522
  "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
2072
2523
  );
2073
2524
  }
2074
2525
 
2075
- let currentPorts: AppContext["ports"] | undefined;
2076
- const createBackgroundContext = (): AppContext => {
2077
- if (!currentPorts) {
2078
- throw new Error("Starter background context is not ready.");
2079
- }
2080
-
2081
- const actor = createSystemActor("starter-background");
2082
- const tenant = createTenant("tenant_default");
2083
- const traceContext = createDevtoolsTraceContext();
2084
-
2085
- return {
2086
- actor,
2087
- auth: null,
2088
- gate: currentPorts.gate.bind({ actor, auth: null }),
2089
- requestId: crypto.randomUUID(),
2090
- ...traceContext,
2091
- ports: currentPorts,
2092
- tenant,
2093
- };
2094
- };
2526
+ const createBackgroundContext = () =>
2527
+ createServiceContext({
2528
+ actor: createSystemActor("starter-background"),
2529
+ });
2095
2530
 
2096
2531
  const mailInstrumentation = createProviderInstrumentation(ports, {
2097
2532
  providerName: "memory-mailer",
@@ -2125,12 +2560,12 @@ export const starterDatabaseProvider = createProvider({
2125
2560
  });
2126
2561
  },
2127
2562
  });
2128
- const audit = createDevtoolsAuditLog({
2563
+ const audit = createInstrumentedAuditLog({
2129
2564
  audit: createDrizzleAuditLog(dbPort.db),
2130
- devtools,
2565
+ instrumentation: ports,
2131
2566
  });
2132
2567
  const repositories = createRepositories(dbPort.db);
2133
- const idempotency = createMemoryIdempotencyStore();
2568
+ const idempotency = createDrizzleTursoIdempotencyPort(dbPort.db);
2134
2569
  const outbox = createDrizzleTursoOutboxPort(dbPort.db);
2135
2570
  const unregisterListeners = registerListeners(eventBus, todoListeners, {
2136
2571
  ctx: createBackgroundContext,
@@ -2142,34 +2577,52 @@ export const starterDatabaseProvider = createProvider({
2142
2577
  },
2143
2578
  });
2144
2579
 
2145
- return {
2146
- ports: {
2147
- audit,
2148
- ...repositories,
2149
- eventBus,
2150
- idempotency,
2151
- jobs,
2152
- mailer,
2153
- notifications,
2154
- outbox,
2155
- uow: createDrizzleTursoUnitOfWork({
2156
- db: dbPort.db,
2157
- createTransactionPorts: (tx) => {
2158
- const txOutbox = createDrizzleTursoOutboxPort(tx);
2159
-
2160
- return {
2580
+ const providedPorts: Pick<
2581
+ AppPorts,
2582
+ | "audit"
2583
+ | "eventBus"
2584
+ | "idempotency"
2585
+ | "jobs"
2586
+ | "mailer"
2587
+ | "notifications"
2588
+ | "outbox"
2589
+ | "todoAttachments"
2590
+ | "todos"
2591
+ | "uow"
2592
+ > = {
2593
+ audit,
2594
+ ...repositories,
2595
+ eventBus,
2596
+ idempotency,
2597
+ jobs,
2598
+ mailer,
2599
+ notifications,
2600
+ outbox,
2601
+ uow: createDrizzleTursoUnitOfWork({
2602
+ db: dbPort.db,
2603
+ createTransactionPorts: (tx) => {
2604
+ const txIdempotency = createDrizzleTursoIdempotencyPort(tx);
2605
+ const txOutbox = createDrizzleTursoOutboxPort(tx);
2606
+
2607
+ return {
2608
+ audit: createInstrumentedAuditLog({
2161
2609
  audit: createDrizzleAuditLog(tx),
2162
- ...createRepositories(tx),
2163
- events: createOutboxEventRecorder(txOutbox),
2164
- jobs: createOutboxJobDispatcher(txOutbox),
2165
- outbox: txOutbox,
2166
- };
2167
- },
2168
- }),
2169
- } satisfies Partial<AppPorts>,
2170
- async start(ctx) {
2610
+ instrumentation: ports,
2611
+ }),
2612
+ ...createRepositories(tx),
2613
+ events: createOutboxEventRecorder(txOutbox),
2614
+ idempotency: txIdempotency,
2615
+ jobs: createOutboxJobDispatcher(txOutbox),
2616
+ outbox: txOutbox,
2617
+ };
2618
+ },
2619
+ }),
2620
+ };
2621
+
2622
+ return {
2623
+ ports: providedPorts,
2624
+ async start() {
2171
2625
  await ensureStarterDatabase(dbPort.client);
2172
- currentPorts = ctx.ports as AppContext["ports"];
2173
2626
  },
2174
2627
  stop() {
2175
2628
  unregisterListeners();
@@ -2178,14 +2631,16 @@ export const starterDatabaseProvider = createProvider({
2178
2631
  },
2179
2632
  });
2180
2633
  `,
2181
- productionContractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
2634
+ productionContractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
2182
2635
  import { z } from "zod";
2183
2636
  import { errors } from "@/features/shared/errors";
2184
2637
  import {
2185
- createTodoUseCase,
2186
- getTodoUseCase,
2187
- listTodosUseCase,
2188
- } from "@/features/todos/use-cases";
2638
+ CreateTodoInputSchema,
2639
+ GetTodoInputSchema,
2640
+ ListTodosInputSchema,
2641
+ ListTodosOutputSchema,
2642
+ TodoSchema,
2643
+ } from "@/features/todos/schemas";
2189
2644
 
2190
2645
  const ErrorResponseSchema = z.object({
2191
2646
  code: z.string(),
@@ -2193,7 +2648,7 @@ const ErrorResponseSchema = z.object({
2193
2648
  requestId: z.string().optional(),
2194
2649
  });
2195
2650
 
2196
- const todos = createContractGroup()
2651
+ const todos = defineContractGroup()
2197
2652
  .namespace("todos")
2198
2653
  .errors({ Unauthorized: errors.Unauthorized })
2199
2654
  .responses({
@@ -2202,28 +2657,29 @@ const todos = createContractGroup()
2202
2657
 
2203
2658
  export const listTodos = todos
2204
2659
  .get("/api/todos")
2205
- .query(listTodosUseCase.inputSchema)
2660
+ .query(ListTodosInputSchema)
2206
2661
  .responses({
2207
- 200: listTodosUseCase.outputSchema,
2662
+ 200: ListTodosOutputSchema,
2208
2663
  });
2209
2664
 
2210
2665
  export const createTodo = todos
2211
2666
  .post("/api/todos")
2212
- .body(createTodoUseCase.inputSchema)
2667
+ .body(CreateTodoInputSchema)
2213
2668
  .errors({ Forbidden: errors.Forbidden })
2214
2669
  .responses({
2215
- 201: createTodoUseCase.outputSchema,
2670
+ 201: TodoSchema,
2216
2671
  });
2217
2672
 
2218
2673
  export const getTodo = todos
2219
2674
  .get("/api/todos/:id")
2220
- .pathParams(getTodoUseCase.inputSchema)
2675
+ .pathParams(GetTodoInputSchema)
2221
2676
  .errors({ TodoNotFound: errors.TodoNotFound })
2222
2677
  .responses({
2223
- 200: getTodoUseCase.outputSchema,
2678
+ 200: TodoSchema,
2224
2679
  });
2225
2680
  `,
2226
- productionTodoRoutes: `import { defineRouteGroup } from "@beignet/next";
2681
+ productionTodoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
2682
+ import { defineRouteGroup } from "@beignet/next";
2227
2683
  import type { AppContext } from "@/app-context";
2228
2684
  import { createTodo, getTodo, listTodos } from "@/features/todos/contracts";
2229
2685
  import {
@@ -2235,31 +2691,14 @@ import {
2235
2691
  export const todoRoutes = defineRouteGroup<AppContext>({
2236
2692
  name: "todos",
2237
2693
  routes: [
2238
- {
2239
- contract: listTodos,
2240
- handle: async ({ ctx, query }) => ({
2241
- status: 200,
2242
- body: await listTodosUseCase.run({ ctx, input: query }),
2243
- }),
2244
- },
2245
- {
2246
- contract: createTodo,
2247
- handle: async ({ ctx, body }) => ({
2248
- status: 201,
2249
- body: await createTodoUseCase.run({ ctx, input: body }),
2250
- }),
2251
- },
2252
- {
2253
- contract: getTodo,
2254
- handle: async ({ ctx, path }) => ({
2255
- status: 200,
2256
- body: await getTodoUseCase.run({ ctx, input: path }),
2257
- }),
2258
- },
2694
+ { contract: listTodos, useCase: listTodosUseCase },
2695
+ { contract: createTodo, useCase: createTodoUseCase },
2696
+ { contract: getTodo, useCase: getTodoUseCase },
2259
2697
  ],
2260
2698
  });
2261
2699
  `,
2262
- productionServerRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
2700
+ productionServerRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
2701
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
2263
2702
  import type { AppContext } from "@/app-context";
2264
2703
  import { todoRoutes } from "@/features/todos/routes";
2265
2704
 
@@ -2268,16 +2707,72 @@ export const routes = defineRoutes<AppContext>([
2268
2707
  ]);
2269
2708
  export const contracts = contractsFromRoutes(routes);
2270
2709
  `,
2271
- productionServer: `import { createDevtoolsHooks } from "@beignet/devtools";
2272
- import { createNextServer } from "@beignet/next";
2710
+ productionServerContext: `import type { beignetServerOnly } from "@beignet/core/server-only";
2273
2711
  import {
2712
+ type ActivityActor,
2274
2713
  createAnonymousActor,
2714
+ createServiceActor,
2275
2715
  createTenant,
2276
2716
  createUserActor,
2277
2717
  } from "@beignet/core/ports";
2718
+ import { defineServerContext } from "@beignet/core/server";
2719
+ import type { TraceContext } from "@beignet/core/tracing";
2720
+ import type { AppContext, AppRuntimePorts } from "@/app-context";
2721
+
2722
+ export type AppServiceContextInput =
2723
+ | {
2724
+ actor?: ActivityActor;
2725
+ tenantId?: string;
2726
+ }
2727
+ | undefined;
2728
+
2729
+ /**
2730
+ * Context blueprint shared by the runtime server and route tests.
2731
+ */
2732
+ export const appContext = defineServerContext<AppContext, AppRuntimePorts>()({
2733
+ gate: (ports) => ports.gate,
2734
+ request: async ({ req, ports, requestId, trace }) => {
2735
+ const auth = await ports.auth.getSession(req);
2736
+ const tenantId = req.headers.get("x-tenant-id") || undefined;
2737
+
2738
+ return {
2739
+ requestId,
2740
+ actor: auth
2741
+ ? createUserActor(auth.user.id, { displayName: auth.user.name })
2742
+ : createAnonymousActor(),
2743
+ auth,
2744
+ ...trace,
2745
+ ports,
2746
+ tenant: tenantId ? createTenant(tenantId) : undefined,
2747
+ };
2748
+ },
2749
+ service: ({
2750
+ ports,
2751
+ input,
2752
+ requestId,
2753
+ trace,
2754
+ }: {
2755
+ ports: AppRuntimePorts;
2756
+ input: AppServiceContextInput;
2757
+ requestId: string;
2758
+ trace: TraceContext;
2759
+ }) => ({
2760
+ requestId,
2761
+ actor: input?.actor ?? createServiceActor("beignet-service"),
2762
+ auth: null,
2763
+ ...trace,
2764
+ ports,
2765
+ tenant: createTenant(input?.tenantId ?? "tenant_default"),
2766
+ }),
2767
+ });
2768
+ `,
2769
+ productionServer: `import type { beignetServerOnly } from "@beignet/core/server-only";
2770
+ import { createNextServer } from "@beignet/next";
2771
+ import { createIdempotencyHooks } from "@beignet/core/server";
2278
2772
  import type { AppContext } from "@/app-context";
2279
2773
  import { appPorts } from "@/infra/app-ports";
2280
2774
  import { env } from "@/lib/env";
2775
+ import { appContext } from "./context";
2281
2776
  import { providers } from "./providers";
2282
2777
  import { routes } from "./routes";
2283
2778
 
@@ -2290,29 +2785,8 @@ export const server = await createNextServer({
2290
2785
  DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2291
2786
  },
2292
2787
  },
2293
- hooks: [
2294
- createDevtoolsHooks<AppContext>({
2295
- requestIdHeader: "x-request-id",
2296
- }),
2297
- ],
2298
- createContext: async ({ req, ports }) => {
2299
- const auth = await ports.auth.getSession(req);
2300
- const tenantId = req.headers.get("x-tenant-id") || undefined;
2301
- const context = {
2302
- requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
2303
- actor: auth
2304
- ? createUserActor(auth.user.id, { displayName: auth.user.name })
2305
- : createAnonymousActor(),
2306
- auth,
2307
- ports,
2308
- tenant: tenantId ? createTenant(tenantId) : undefined,
2309
- };
2310
-
2311
- return {
2312
- ...context,
2313
- gate: ports.gate.bind(context),
2314
- };
2315
- },
2788
+ hooks: [createIdempotencyHooks<AppContext>()],
2789
+ context: appContext,
2316
2790
  routes,
2317
2791
  mapUnhandledError: ({ err, ctx }) => {
2318
2792
  ctx?.ports.logger.error("Unhandled API error", {
@@ -2332,16 +2806,13 @@ export const server = await createNextServer({
2332
2806
  },
2333
2807
  });
2334
2808
  `,
2335
- productionServerWithDrizzleTurso: `import { createDevtoolsHooks } from "@beignet/devtools";
2809
+ productionServerWithDrizzleTurso: `import type { beignetServerOnly } from "@beignet/core/server-only";
2336
2810
  import { createNextServer } from "@beignet/next";
2337
- import {
2338
- createAnonymousActor,
2339
- createTenant,
2340
- createUserActor,
2341
- } from "@beignet/core/ports";
2811
+ import { createIdempotencyHooks } from "@beignet/core/server";
2342
2812
  import type { AppContext } from "@/app-context";
2343
2813
  import { appPorts } from "@/infra/app-ports";
2344
2814
  import { env } from "@/lib/env";
2815
+ import { appContext } from "./context";
2345
2816
  import { providers } from "./providers";
2346
2817
  import { routes } from "./routes";
2347
2818
 
@@ -2354,30 +2825,8 @@ export const server = await createNextServer({
2354
2825
  DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2355
2826
  },
2356
2827
  },
2357
- hooks: [
2358
- createDevtoolsHooks<AppContext>({
2359
- requestIdHeader: "x-request-id",
2360
- }),
2361
- ],
2362
- createContext: async ({ req, ports }) => {
2363
- const auth = await ports.auth.getSession(req);
2364
- const tenantId = req.headers.get("x-tenant-id") || undefined;
2365
-
2366
- const context = {
2367
- requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
2368
- actor: auth
2369
- ? createUserActor(auth.user.id, { displayName: auth.user.name })
2370
- : createAnonymousActor(),
2371
- auth,
2372
- ports,
2373
- tenant: tenantId ? createTenant(tenantId) : undefined,
2374
- };
2375
-
2376
- return {
2377
- ...context,
2378
- gate: ports.gate.bind(context),
2379
- };
2380
- },
2828
+ hooks: [createIdempotencyHooks<AppContext>()],
2829
+ context: appContext,
2381
2830
  routes,
2382
2831
  mapUnhandledError: ({ err, ctx }) => {
2383
2832
  ctx?.ports.logger.error("Unhandled API error", {
@@ -2526,6 +2975,55 @@ try {
2526
2975
  } finally {
2527
2976
  client.close();
2528
2977
  }
2978
+ `,
2979
+ productionDbTestDatabase: `import { existsSync, unlinkSync } from "node:fs";
2980
+ import { tmpdir } from "node:os";
2981
+ import { join } from "node:path";
2982
+ import type { OutboxPort } from "@beignet/core/outbox";
2983
+ import { createDrizzleTursoOutboxPort } from "@beignet/provider-drizzle-turso";
2984
+ import { type Client, createClient } from "@libsql/client";
2985
+ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
2986
+ import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
2987
+ import { ensureStarterDatabase, resetStarterDatabase } from "./bootstrap";
2988
+ import { createRepositories } from "./repositories";
2989
+ import * as schema from "./schema";
2990
+
2991
+ export type TestDatabase = {
2992
+ \tclient: Client;
2993
+ \tdb: LibSQLDatabase<typeof schema>;
2994
+ \taudit: ReturnType<typeof createDrizzleAuditLog>;
2995
+ \toutbox: OutboxPort;
2996
+ \trepositories: ReturnType<typeof createRepositories>;
2997
+ \tpath: string;
2998
+ \treset(): Promise<void>;
2999
+ \tclose(): Promise<void>;
3000
+ };
3001
+
3002
+ export async function createTestDatabase(): Promise<TestDatabase> {
3003
+ \tconst path = join(tmpdir(), "beignet-test-" + crypto.randomUUID() + ".db");
3004
+ \tconst client = createClient({ url: "file:" + path });
3005
+ \tawait ensureStarterDatabase(client, { seed: false });
3006
+
3007
+ \tconst db = drizzle(client, { schema });
3008
+
3009
+ \treturn {
3010
+ \t\tclient,
3011
+ \t\tdb,
3012
+ \t\taudit: createDrizzleAuditLog(db),
3013
+ \t\toutbox: createDrizzleTursoOutboxPort(db),
3014
+ \t\trepositories: createRepositories(db),
3015
+ \t\tpath,
3016
+ \t\treset: async () => {
3017
+ \t\t\tawait resetStarterDatabase(client);
3018
+ \t\t},
3019
+ \t\tclose: async () => {
3020
+ \t\t\tclient.close();
3021
+ \t\t\tif (existsSync(path)) {
3022
+ \t\t\t\tunlinkSync(path);
3023
+ \t\t\t}
3024
+ \t\t},
3025
+ \t};
3026
+ }
2529
3027
  `,
2530
3028
  productionAuditHelper: `import type { AuditLogEntryInput } from "@beignet/core/ports";
2531
3029
  import type { AppContext } from "@/app-context";
@@ -2558,14 +3056,12 @@ export const TodoCreated = defineEvent("todos.created", {
2558
3056
 
2559
3057
  export const todoEvents = [TodoCreated] as const;
2560
3058
  `,
2561
- productionTodoJobs: `import { createJobHandlers, retry } from "@beignet/core/jobs";
3059
+ productionTodoJobs: `import { retry } from "@beignet/core/jobs";
2562
3060
  import { z } from "zod";
2563
- import type { AppContext } from "@/app-context";
2564
3061
  import { auditEntry } from "@/lib/audit";
3062
+ import { defineJob } from "@/lib/jobs";
2565
3063
 
2566
- const jobs = createJobHandlers<AppContext>();
2567
-
2568
- export const LogTodoCreatedJob = jobs.defineJob("todos.log-created", {
3064
+ export const LogTodoCreatedJob = defineJob("todos.log-created", {
2569
3065
  payload: z.object({
2570
3066
  todoId: z.string().uuid(),
2571
3067
  title: z.string(),
@@ -2591,16 +3087,11 @@ export const LogTodoCreatedJob = jobs.defineJob("todos.log-created", {
2591
3087
 
2592
3088
  export const todoJobs = [LogTodoCreatedJob] as const;
2593
3089
  `,
2594
- productionTodoNotifications: `import {
2595
- createNotificationHandlers,
2596
- defineMailNotificationChannel,
2597
- } from "@beignet/core/notifications";
3090
+ productionTodoNotifications: `import { defineMailNotificationChannel } from "@beignet/core/notifications";
2598
3091
  import { z } from "zod";
2599
- import type { AppContext } from "@/app-context";
3092
+ import { defineNotification } from "@/lib/notifications";
2600
3093
 
2601
- const notifications = createNotificationHandlers<AppContext>();
2602
-
2603
- export const TodoCreatedNotification = notifications.defineNotification(
3094
+ export const TodoCreatedNotification = defineNotification(
2604
3095
  "todos.created",
2605
3096
  {
2606
3097
  payload: z.object({
@@ -2617,16 +3108,13 @@ export const TodoCreatedNotification = notifications.defineNotification(
2617
3108
  },
2618
3109
  );
2619
3110
  `,
2620
- productionTodoListeners: `import { createEventHandlers } from "@beignet/core/events";
2621
- import type { AppContext } from "@/app-context";
2622
- import { TodoCreated } from "@/features/todos/domain/events";
3111
+ productionTodoListeners: `import { TodoCreated } from "@/features/todos/domain/events";
2623
3112
  import { LogTodoCreatedJob } from "@/features/todos/jobs";
2624
3113
  import { TodoCreatedNotification } from "@/features/todos/notifications";
2625
3114
  import { auditEntry } from "@/lib/audit";
3115
+ import { defineListener } from "@/lib/listeners";
2626
3116
 
2627
- const listeners = createEventHandlers<AppContext>();
2628
-
2629
- export const enqueueTodoCreatedWork = listeners.defineListener(TodoCreated, {
3117
+ export const enqueueTodoCreatedWork = defineListener(TodoCreated, {
2630
3118
  name: "todos.enqueue-created-work",
2631
3119
  async handle({ ctx, payload }) {
2632
3120
  await ctx.ports.jobs.dispatch(LogTodoCreatedJob, payload);
@@ -2648,14 +3136,11 @@ export const enqueueTodoCreatedWork = listeners.defineListener(TodoCreated, {
2648
3136
  export const todoListeners = [enqueueTodoCreatedWork] as const;
2649
3137
  `,
2650
3138
  productionTodoSchedules: `import { normalizeOffsetPage } from "@beignet/core/pagination";
2651
- import { createScheduleHandlers } from "@beignet/core/schedules";
2652
3139
  import { z } from "zod";
2653
- import type { AppContext } from "@/app-context";
2654
3140
  import { auditEntry } from "@/lib/audit";
3141
+ import { defineSchedule } from "@/lib/schedules";
2655
3142
 
2656
- const schedules = createScheduleHandlers<AppContext>();
2657
-
2658
- export const LogDailyTodoSummarySchedule = schedules.defineSchedule(
3143
+ export const LogDailyTodoSummarySchedule = defineSchedule(
2659
3144
  "todos.log-daily-summary",
2660
3145
  {
2661
3146
  cron: "0 9 * * *",
@@ -2699,6 +3184,23 @@ export const LogDailyTodoSummarySchedule = schedules.defineSchedule(
2699
3184
  );
2700
3185
 
2701
3186
  export const todoSchedules = [LogDailyTodoSummarySchedule] as const;
3187
+ `,
3188
+ productionSchedules: `import { createServiceActor } from "@beignet/core/ports";
3189
+ import type { AppContext } from "@/app-context";
3190
+ import { todoSchedules } from "@/features/todos/schedules";
3191
+ import { server } from "./index.js";
3192
+
3193
+ export const schedules = [...todoSchedules] as const;
3194
+
3195
+ export async function createScheduleContext(): Promise<AppContext> {
3196
+ return server.createServiceContext({
3197
+ actor: createServiceActor("beignet-schedule"),
3198
+ });
3199
+ }
3200
+
3201
+ export async function stopScheduleContext(): Promise<void> {
3202
+ await server.stop();
3203
+ }
2702
3204
  `,
2703
3205
  productionTodoUploads: `import { defineUpload, defineUploads } from "@beignet/core/uploads";
2704
3206
  import { z } from "zod";
@@ -2780,13 +3282,26 @@ export const todoUploads = defineUploads({
2780
3282
  });
2781
3283
  `,
2782
3284
  productionOutbox: `import { defineOutboxRegistry } from "@beignet/core/outbox";
3285
+ import { createServiceActor } from "@beignet/core/ports";
3286
+ import type { AppContext } from "@/app-context";
2783
3287
  import { todoEvents } from "@/features/todos/domain/events";
2784
3288
  import { todoJobs } from "@/features/todos/jobs";
3289
+ import { server } from "./index.js";
2785
3290
 
2786
3291
  export const outboxRegistry = defineOutboxRegistry({
2787
3292
  events: todoEvents,
2788
3293
  jobs: todoJobs,
2789
3294
  });
3295
+
3296
+ export async function createOutboxDrainContext(): Promise<AppContext> {
3297
+ return server.createServiceContext({
3298
+ actor: createServiceActor("beignet-outbox"),
3299
+ });
3300
+ }
3301
+
3302
+ export async function stopOutboxDrainContext(): Promise<void> {
3303
+ await server.stop();
3304
+ }
2790
3305
  `,
2791
3306
  productionOutboxDrainRoute: `import { createOutboxDrainRoute } from "@beignet/next";
2792
3307
  import { env } from "@/lib/env";
@@ -2801,111 +3316,23 @@ export const { GET, POST } = createOutboxDrainRoute({
2801
3316
  secret: env.CRON_SECRET,
2802
3317
  });
2803
3318
  `,
2804
- productionScheduleRoute: `import { createInlineScheduleRunner } from "@beignet/core/schedules";
2805
- import type { AppContext } from "@/app-context";
2806
- import { LogDailyTodoSummarySchedule } from "@/features/todos/schedules";
3319
+ productionScheduleRoute: `import { createScheduleRoute } from "@beignet/next";
2807
3320
  import { env } from "@/lib/env";
2808
3321
  import { server } from "@/server";
3322
+ import { schedules } from "@/server/schedules";
2809
3323
 
2810
- async function runDailyTodoSummary(request: Request) {
2811
- if (!env.CRON_SECRET) {
2812
- return Response.json(
2813
- {
2814
- ok: false,
2815
- error: "CRON_SECRET is not configured.",
2816
- scheduleName: LogDailyTodoSummarySchedule.name,
2817
- },
2818
- { status: 500 },
2819
- );
2820
- }
2821
-
2822
- if (request.headers.get("authorization") !== \`Bearer \${env.CRON_SECRET}\`) {
2823
- return Response.json({ error: "Unauthorized" }, { status: 401 });
2824
- }
2825
-
2826
- const ctx = await server.createContextFromNext();
2827
- const runner = createInlineScheduleRunner<AppContext>({
2828
- ctx,
2829
- onStart({ run, schedule }) {
2830
- ctx.ports.devtools.record({
2831
- type: "schedule",
2832
- watcher: "schedules",
2833
- requestId: ctx.requestId,
2834
- traceId: ctx.traceId,
2835
- scheduleName: schedule.name,
2836
- status: "started",
2837
- cron: schedule.cron,
2838
- timezone: schedule.timezone,
2839
- details: {
2840
- source: run.source,
2841
- scheduledAt: run.scheduledAt?.toISOString(),
2842
- },
2843
- });
2844
- },
2845
- onSuccess({ run, schedule }) {
2846
- ctx.ports.devtools.record({
2847
- type: "schedule",
2848
- watcher: "schedules",
2849
- requestId: ctx.requestId,
2850
- traceId: ctx.traceId,
2851
- scheduleName: schedule.name,
2852
- status: "completed",
2853
- cron: schedule.cron,
2854
- timezone: schedule.timezone,
2855
- details: {
2856
- source: run.source,
2857
- scheduledAt: run.scheduledAt?.toISOString(),
2858
- },
2859
- });
2860
- },
2861
- onError({ error, run, schedule }) {
2862
- ctx.ports.devtools.record({
2863
- type: "schedule",
2864
- watcher: "schedules",
2865
- requestId: ctx.requestId,
2866
- traceId: ctx.traceId,
2867
- scheduleName: schedule.name,
2868
- status: "failed",
2869
- cron: schedule.cron,
2870
- timezone: schedule.timezone,
2871
- details: {
2872
- error,
2873
- source: run.source,
2874
- scheduledAt: run.scheduledAt?.toISOString(),
2875
- },
2876
- });
2877
- ctx.ports.logger.error("Daily todo summary schedule failed", {
2878
- error,
2879
- scheduleName: schedule.name,
2880
- });
2881
- },
2882
- });
2883
-
2884
- try {
2885
- await runner.run(LogDailyTodoSummarySchedule, {
2886
- source: "starter-cron-route",
2887
- });
2888
- } catch {
2889
- return Response.json(
2890
- {
2891
- ok: false,
2892
- error: "Schedule failed",
2893
- scheduleName: LogDailyTodoSummarySchedule.name,
2894
- },
2895
- { status: 500 },
2896
- );
2897
- }
2898
-
2899
- return Response.json({
2900
- ok: true,
2901
- scheduleName: LogDailyTodoSummarySchedule.name,
2902
- });
2903
- }
3324
+ export const runtime = "nodejs";
2904
3325
 
2905
- export const GET = runDailyTodoSummary;
2906
- export const POST = runDailyTodoSummary;
3326
+ export const { GET, POST } = createScheduleRoute({
3327
+ server,
3328
+ schedules,
3329
+ schedule: "todos.log-daily-summary",
3330
+ secret: env.CRON_SECRET,
3331
+ source: "starter-cron-route",
3332
+ });
2907
3333
  `,
2908
- productionUploadsRoute: `import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
3334
+ productionUploadsRoute: `import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
3335
+ import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
2909
3336
  import { createUploadRoute } from "@beignet/next";
2910
3337
  import type { AppContext } from "@/app-context";
2911
3338
  import { todoUploads } from "@/features/todos/uploads";
@@ -2915,17 +3342,10 @@ const uploadRouter = createUploadRouter<AppContext>({
2915
3342
  uploads: uploadsFromRegistry(todoUploads),
2916
3343
  ctx: () => server.createContextFromNext(),
2917
3344
  storage: server.ports.storage,
2918
- instrumentation: server.ports.devtools,
3345
+ instrumentation: resolveProviderInstrumentationPort(server.ports),
2919
3346
  });
2920
3347
 
2921
3348
  export const { POST } = createUploadRoute(uploadRouter);
2922
- `,
2923
- productionUploadClient: `import { createUploadClient } from "@beignet/core/uploads/client";
2924
- import type { todoUploads } from "@/features/todos/uploads";
2925
-
2926
- export const uploads = createUploadClient<typeof todoUploads>({
2927
- baseUrl: "/api/uploads",
2928
- });
2929
3349
  `,
2930
3350
  };
2931
3351
  function productionProviderPortImports(ctx) {
@@ -2942,6 +3362,69 @@ function productionProviderPortFields(ctx) {
2942
3362
  }
2943
3363
  return fields;
2944
3364
  }
3365
+ function productionInfrastructurePortsWithDrizzleTurso(ctx) {
3366
+ const deferred = [
3367
+ '\t\t"audit",',
3368
+ hasIntegration(ctx, "better-auth") ? '\t\t"auth",' : undefined,
3369
+ '\t\t"eventBus",',
3370
+ '\t\t"idempotency",',
3371
+ '\t\t"jobs",',
3372
+ '\t\t"logger",',
3373
+ '\t\t"mailer",',
3374
+ '\t\t"notifications",',
3375
+ '\t\t"outbox",',
3376
+ hasIntegration(ctx, "upstash-rate-limit") ? '\t\t"rateLimit",' : undefined,
3377
+ '\t\t"storage",',
3378
+ '\t\t"todoAttachments",',
3379
+ '\t\t"todos",',
3380
+ '\t\t"uow",',
3381
+ ]
3382
+ .filter((entry) => Boolean(entry))
3383
+ .sort();
3384
+ const bound = [
3385
+ hasIntegration(ctx, "better-auth")
3386
+ ? undefined
3387
+ : "\t\tauth: createAnonymousAuth(),",
3388
+ "\t\tgate,",
3389
+ ].filter((entry) => Boolean(entry));
3390
+ const imports = [
3391
+ 'import { createGate, definePorts } from "@beignet/core/ports";',
3392
+ 'import { todoPolicy } from "@/features/todos/policy";',
3393
+ 'import { appError } from "@/features/shared/errors";',
3394
+ 'import type { AppPorts } from "@/ports";',
3395
+ hasIntegration(ctx, "better-auth")
3396
+ ? undefined
3397
+ : 'import { createAnonymousAuth } from "./auth/anonymous-auth";',
3398
+ ].filter((entry) => Boolean(entry));
3399
+ return `${imports.join("\n")}
3400
+
3401
+ const gate = createGate({
3402
+ policies: [todoPolicy],
3403
+ onDeny(decision) {
3404
+ return appError("Forbidden", {
3405
+ message: decision.reason ?? "Forbidden",
3406
+ details: decision.details,
3407
+ });
3408
+ },
3409
+ });
3410
+
3411
+ /**
3412
+ * App ports the server boots with.
3413
+ *
3414
+ * Bound ports are app-owned. Every deferred key is contributed by a provider
3415
+ * in server/providers.ts during startup; createServer(...) fails boot if any
3416
+ * of them is still unbound after providers have started.
3417
+ */
3418
+ export const appPorts = definePorts<AppPorts>()({
3419
+ bound: {
3420
+ ${bound.join("\n")}
3421
+ },
3422
+ deferred: [
3423
+ ${deferred.join("\n")}
3424
+ ],
3425
+ });
3426
+ `;
3427
+ }
2945
3428
  function productionPorts(ctx) {
2946
3429
  const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
2947
3430
  const imports = [
@@ -2969,6 +3452,7 @@ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/
2969
3452
  export type AppTransactionPorts = {
2970
3453
  audit: AuditLogPort;
2971
3454
  events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3455
+ idempotency: IdempotencyPort;
2972
3456
  jobs: JobDispatcherPort;
2973
3457
  outbox: OutboxPort;
2974
3458
  todoAttachments: TodoAttachmentRepository;
@@ -3019,14 +3503,10 @@ import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
3019
3503
  import type { AuthPort } from "./auth";
3020
3504
  import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
3021
3505
 
3022
- export type AppDatabasePort = {
3023
- db: unknown;
3024
- client: unknown;
3025
- };
3026
-
3027
3506
  export type AppTransactionPorts = {
3028
3507
  audit: AuditLogPort;
3029
3508
  events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3509
+ idempotency: IdempotencyPort;
3030
3510
  jobs: JobDispatcherPort;
3031
3511
  outbox: OutboxPort;
3032
3512
  todoAttachments: TodoAttachmentRepository;
@@ -3038,7 +3518,6 @@ export type AppGate = BoundGate<[typeof todoPolicy]>;
3038
3518
  export type AppPorts = {
3039
3519
  audit: AuditLogPort;
3040
3520
  auth: AuthPort;
3041
- db: AppDatabasePort;
3042
3521
  eventBus: EventBusPort;
3043
3522
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
3044
3523
  idempotency: IdempotencyPort;
@@ -3056,6 +3535,7 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
3056
3535
  }
3057
3536
  function productionServerProviders(ctx) {
3058
3537
  const imports = [
3538
+ 'import type { beignetServerOnly } from "@beignet/core/server-only";',
3059
3539
  'import { createDevtoolsProvider } from "@beignet/devtools";',
3060
3540
  hasIntegration(ctx, "better-auth")
3061
3541
  ? 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";'
@@ -3107,38 +3587,20 @@ function productionServerProviders(ctx) {
3107
3587
  ${declarations}
3108
3588
  export const providers = [
3109
3589
  ${entries.join("\n")}
3110
- ];
3590
+ ] as const;
3111
3591
  `;
3112
3592
  }
3113
- function productionTodosTest(ctx) {
3114
- const portImports = [
3115
- hasIntegration(ctx, "upstash-rate-limit")
3116
- ? "createMemoryRateLimiter"
3117
- : undefined,
3118
- "createMemoryStorage",
3119
- "createNoopUnitOfWork",
3120
- "createUserActor",
3121
- ].filter((item) => Boolean(item));
3122
- const extraPorts = [
3123
- hasDrizzleTurso(ctx)
3124
- ? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
3125
- : undefined,
3126
- hasIntegration(ctx, "upstash-rate-limit")
3127
- ? "\t\t\trateLimit: createMemoryRateLimiter(),"
3128
- : undefined,
3129
- ].filter((entry) => Boolean(entry));
3593
+ function productionTodosTest(_ctx) {
3130
3594
  return `import { describe, expect, it } from "bun:test";
3131
3595
  import { createUseCaseTester } from "@beignet/core/application";
3132
- import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
3133
- import { createMemoryMailer } from "@beignet/core/mail";
3134
- import { createMemoryNotificationPort } from "@beignet/core/notifications";
3135
- import { createMemoryOutbox } from "@beignet/core/outbox";
3136
3596
  import { offsetPageResult, type OffsetPage } from "@beignet/core/pagination";
3137
3597
  import { createInMemoryDevtools } from "@beignet/devtools";
3138
- import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
3139
- import { ${["createDomainEventRecorder", "createMemoryAuditLog", ...portImports].join(", ")} } from "@beignet/core/ports";
3598
+ import { createDomainEventRecorder } from "@beignet/core/ports";
3599
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
3600
+ import { createTestUserActor } from "@beignet/core/ports/testing";
3140
3601
  import type { AppContext } from "@/app-context";
3141
3602
  import { appPorts } from "@/infra/app-ports";
3603
+ import type { AppTransactionPorts } from "@/ports";
3142
3604
  import {
3143
3605
  createTodoUseCase,
3144
3606
  getTodoUseCase,
@@ -3203,14 +3665,6 @@ describe("todos resource", () => {
3203
3665
  it("creates, gets, and lists todos", async () => {
3204
3666
  const todos = createTestTodoRepository();
3205
3667
  const todoAttachments = createTestTodoAttachmentRepository();
3206
- const audit = createMemoryAuditLog();
3207
- const eventBus = createInMemoryEventBus();
3208
- const jobs = { dispatch: async () => undefined };
3209
- const mailer = createMemoryMailer({
3210
- defaultFrom: "Test <test@example.local>",
3211
- });
3212
- const notifications = createMemoryNotificationPort();
3213
- const outbox = createMemoryOutbox();
3214
3668
  const auth = {
3215
3669
  user: {
3216
3670
  id: "user_test",
@@ -3219,38 +3673,31 @@ describe("todos resource", () => {
3219
3673
  },
3220
3674
  session: { id: "session_test" },
3221
3675
  };
3222
- const actor = createUserActor(auth.user.id, {
3223
- displayName: auth.user.name,
3224
- });
3225
- const testPorts = {
3226
- ...appPorts,
3227
- audit,
3228
- eventBus,
3229
- idempotency: createMemoryIdempotencyStore(),
3230
- jobs,
3231
- mailer,
3232
- notifications,
3233
- outbox,
3234
- todoAttachments,
3235
- todos,
3236
- ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({
3237
- audit,
3238
- events: createDomainEventRecorder(),
3239
- jobs,
3240
- outbox,
3676
+ const testFixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
3677
+ base: appPorts,
3678
+ overrides: {
3679
+ gate: appPorts.gate,
3241
3680
  todoAttachments,
3242
3681
  todos,
3243
- })) as unknown as AppContext["ports"]["uow"],
3244
- devtools: createInMemoryDevtools(),
3245
- storage: createMemoryStorage(),
3246
- } as unknown as AppContext["ports"];
3247
- const tester = createUseCaseTester<AppContext>(() => ({
3248
- requestId: "test-request",
3249
- actor,
3682
+ devtools: createInMemoryDevtools(),
3683
+ },
3684
+ transaction: {
3685
+ ports: (ports) => ({
3686
+ ...ports,
3687
+ events: createDomainEventRecorder(),
3688
+ todoAttachments,
3689
+ todos,
3690
+ }),
3691
+ },
3692
+ });
3693
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
3694
+ ports: testFixture.ports,
3695
+ actor: createTestUserActor(auth.user.id, {
3696
+ displayName: auth.user.name,
3697
+ }),
3250
3698
  auth,
3251
- gate: testPorts.gate.bind({ actor, auth }),
3252
- ports: testPorts,
3253
- }));
3699
+ });
3700
+ const tester = createUseCaseTester<AppContext>(createTestContext);
3254
3701
 
3255
3702
  const ctx = await tester.ctx();
3256
3703
  const created = await tester.run(
@@ -3321,9 +3768,8 @@ function getProductionTemplateFiles(ctx) {
3321
3768
  path: "app/api/uploads/[uploadName]/[action]/route.ts",
3322
3769
  content: files.productionUploadsRoute,
3323
3770
  },
3324
- { path: "client/api-client.ts", content: files.apiClient },
3325
- { path: "client/rq.ts", content: files.rq },
3326
- { path: "client/uploads.ts", content: files.productionUploadClient },
3771
+ { path: "client/index.ts", content: clientIndex(ctx) },
3772
+ { path: "client/forms.ts", content: files.formsClient },
3327
3773
  {
3328
3774
  path: "features/todos/contracts.ts",
3329
3775
  content: files.productionContractsTodos,
@@ -3334,7 +3780,7 @@ function getProductionTemplateFiles(ctx) {
3334
3780
  {
3335
3781
  path: "infra/app-ports.ts",
3336
3782
  content: usesDrizzleTurso
3337
- ? files.productionInfrastructurePortsWithDrizzleTurso
3783
+ ? productionInfrastructurePortsWithDrizzleTurso(ctx)
3338
3784
  : files.productionInfrastructurePorts,
3339
3785
  },
3340
3786
  { path: "lib/env.ts", content: files.productionEnv },
@@ -3384,7 +3830,7 @@ function getProductionTemplateFiles(ctx) {
3384
3830
  },
3385
3831
  {
3386
3832
  path: "features/shared/errors.ts",
3387
- content: files.productimapUnhandledErrors,
3833
+ content: files.productionSharedErrors,
3388
3834
  },
3389
3835
  {
3390
3836
  path: "server/index.ts",
@@ -3392,13 +3838,22 @@ function getProductionTemplateFiles(ctx) {
3392
3838
  ? files.productionServerWithDrizzleTurso
3393
3839
  : files.productionServer,
3394
3840
  },
3841
+ { path: "server/context.ts", content: files.productionServerContext },
3395
3842
  { path: "server/routes.ts", content: files.productionServerRoutes },
3396
3843
  { path: "server/outbox.ts", content: files.productionOutbox },
3844
+ { path: "server/schedules.ts", content: files.productionSchedules },
3397
3845
  {
3398
3846
  path: "server/providers.ts",
3399
3847
  content: productionServerProviders(ctx),
3400
3848
  },
3401
3849
  { path: "lib/use-case.ts", content: files.productionUseCaseBuilder },
3850
+ { path: "lib/listeners.ts", content: files.productionListenersBuilder },
3851
+ { path: "lib/jobs.ts", content: files.productionJobsBuilder },
3852
+ { path: "lib/schedules.ts", content: files.productionSchedulesBuilder },
3853
+ {
3854
+ path: "lib/notifications.ts",
3855
+ content: files.productionNotificationsBuilder,
3856
+ },
3402
3857
  {
3403
3858
  path: "features/todos/use-cases/create-todo.ts",
3404
3859
  content: files.productionCreateTodoUseCase,
@@ -3416,7 +3871,7 @@ function getProductionTemplateFiles(ctx) {
3416
3871
  content: files.productionUseCasesIndex,
3417
3872
  },
3418
3873
  {
3419
- path: "features/todos/use-cases/schemas.ts",
3874
+ path: "features/todos/schemas.ts",
3420
3875
  content: files.productionTodoSchemas,
3421
3876
  },
3422
3877
  {
@@ -3438,6 +3893,9 @@ function getProductionTemplateFiles(ctx) {
3438
3893
  }, {
3439
3894
  path: "infra/db/reset.ts",
3440
3895
  content: files.productionDbReset,
3896
+ }, {
3897
+ path: "infra/db/test-database.ts",
3898
+ content: files.productionDbTestDatabase,
3441
3899
  }, {
3442
3900
  path: "infra/db/provider.ts",
3443
3901
  content: files.productionDbProvider,
@@ -3478,21 +3936,29 @@ export function getTemplateFiles(ctx) {
3478
3936
  { path: "app/layout.tsx", content: layout(ctx) },
3479
3937
  { path: "app/page.tsx", content: page(ctx) },
3480
3938
  { path: "app/api/[[...path]]/route.ts", content: files.apiCatchAllRoute },
3939
+ { path: "app-context.ts", content: minimalAppContext(ctx) },
3481
3940
  { path: "features/todos/contracts.ts", content: files.contractsTodos },
3482
3941
  { path: "features/todos/routes.ts", content: files.todoRoutes },
3483
- { path: "ports/index.ts", content: files.ports },
3942
+ { path: "ports/index.ts", content: minimalPorts(ctx) },
3484
3943
  { path: "server/index.ts", content: server(ctx) },
3944
+ { path: "server/context.ts", content: serverContext(ctx) },
3485
3945
  { path: "server/routes.ts", content: files.serverRoutes },
3486
- { path: "features/todos/use-cases.ts", content: files.useCasesTodos },
3946
+ { path: "features/todos/use-cases/index.ts", content: files.useCasesTodos },
3487
3947
  ];
3488
3948
  if (hasFeature(ctx, "client")) {
3489
3949
  templateFiles.push({
3490
- path: "client/api-client.ts",
3491
- content: files.apiClient,
3950
+ path: "client/index.ts",
3951
+ content: clientIndex(ctx),
3492
3952
  });
3493
3953
  }
3494
3954
  if (hasFeature(ctx, "react-query")) {
3495
- templateFiles.push({ path: "app/providers.tsx", content: files.appProviders }, { path: "client/rq.ts", content: files.rq }, { path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) });
3955
+ templateFiles.push({ path: "app/providers.tsx", content: files.appProviders }, { path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) });
3956
+ }
3957
+ if (hasFeature(ctx, "forms")) {
3958
+ templateFiles.push({
3959
+ path: "client/forms.ts",
3960
+ content: files.formsClient,
3961
+ });
3496
3962
  }
3497
3963
  if (hasFeature(ctx, "openapi")) {
3498
3964
  templateFiles.push({
@@ -3500,14 +3966,23 @@ export function getTemplateFiles(ctx) {
3500
3966
  content: files.apiOpenApiRoute,
3501
3967
  });
3502
3968
  }
3503
- if (ctx.integrations.some((integration) => ["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration))) {
3504
- templateFiles.push({
3505
- path: "server/providers.ts",
3506
- content: serverProviders(ctx),
3969
+ if (hasIntegration(ctx, "better-auth")) {
3970
+ templateFiles.push({ path: "lib/better-auth.ts", content: files.minimalBetterAuth }, {
3971
+ path: "app/api/auth/[...all]/route.ts",
3972
+ content: files.productionAuthRoute,
3973
+ });
3974
+ }
3975
+ if (hasDrizzleTurso(ctx)) {
3976
+ templateFiles.push({ path: "drizzle.config.ts", content: files.productionDrizzleConfig }, { path: "infra/db/provider.ts", content: files.minimalDbProvider }, { path: "infra/db/schema/index.ts", content: files.minimalDbSchema }, {
3977
+ path: "infra/todos/drizzle-todo-repository.ts",
3978
+ content: files.minimalDrizzleTodoRepository,
3507
3979
  });
3508
3980
  }
3509
3981
  if (ctx.integrations.length > 0) {
3510
3982
  templateFiles.push({
3983
+ path: "server/providers.ts",
3984
+ content: serverProviders(ctx),
3985
+ }, {
3511
3986
  path: ".env.example",
3512
3987
  content: envExample(ctx),
3513
3988
  }, {