@beignet/cli 0.0.1 → 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 +231 -0
  2. package/README.md +497 -90
  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 +39 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +26 -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 +12 -0
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +19 -24
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +37 -0
  28. package/dist/db.d.ts.map +1 -0
  29. package/dist/db.js +146 -0
  30. package/dist/db.js.map +1 -0
  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 -7
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +710 -400
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +45 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +2191 -100
  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 +22 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +370 -38
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +109 -1
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +2225 -333
  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 +12 -8
  68. package/dist/templates.d.ts.map +1 -1
  69. package/dist/templates.js +2144 -385
  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 +47 -0
  80. package/src/create-prompts.ts +182 -0
  81. package/src/create.ts +32 -28
  82. package/src/db.ts +222 -0
  83. package/src/github-annotations.ts +37 -0
  84. package/src/index.ts +1119 -535
  85. package/src/inspect.ts +3372 -134
  86. package/src/lib.ts +45 -0
  87. package/src/lint.ts +533 -45
  88. package/src/make.ts +3010 -397
  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 +2282 -462
  93. package/src/version.ts +20 -0
  94. package/dist/create-bin.d.ts +0 -3
  95. package/dist/create-bin.d.ts.map +0 -1
  96. package/dist/create-bin.js +0 -9
  97. package/dist/create-bin.js.map +0 -1
  98. package/src/create-bin.ts +0 -11
package/src/templates.ts CHANGED
@@ -1,20 +1,34 @@
1
- export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
2
- export type PresetName = "minimal" | "standard";
3
- export type TemplateName = "next";
4
- export type FeatureName = "client" | "react-query" | "forms" | "openapi";
5
- export type IntegrationName =
6
- | "better-auth"
7
- | "drizzle-turso"
8
- | "inngest"
9
- | "pino"
10
- | "resend"
11
- | "upstash-rate-limit";
12
-
1
+ import type {
2
+ FeatureName,
3
+ IntegrationName,
4
+ PackageManager,
5
+ PresetName,
6
+ } from "./choices.js";
7
+
8
+ export type {
9
+ FeatureName,
10
+ IntegrationName,
11
+ PackageManager,
12
+ PresetName,
13
+ TemplateName,
14
+ } from "./choices.js";
15
+ export {
16
+ featureChoices,
17
+ integrationChoices,
18
+ presetChoices,
19
+ } from "./choices.js";
20
+
21
+ /**
22
+ * One generated file emitted by the template generator.
23
+ */
13
24
  export type TemplateFile = {
14
25
  path: string;
15
26
  content: string;
16
27
  };
17
28
 
29
+ /**
30
+ * Inputs used to render app template files.
31
+ */
18
32
  export type TemplateContext = {
19
33
  name: string;
20
34
  packageManager: PackageManager;
@@ -37,9 +51,9 @@ const externalVersions = {
37
51
  tanstackReactQuery: "^5.100.7",
38
52
  hookformResolvers: "^5.0.0",
39
53
  reactHookForm: "^7.74.0",
40
- betterAuth: "^1.3.26",
41
- drizzleKit: "^0.30.0",
42
- drizzleOrm: "^0.38.0",
54
+ betterAuth: "1.6.11",
55
+ drizzleKit: "^0.31.10",
56
+ drizzleOrm: "^0.45.2",
43
57
  inngest: "^3.0.0",
44
58
  libsqlClient: "^0.14.0",
45
59
  pino: "^9.7.0",
@@ -59,37 +73,16 @@ function hasDrizzleTurso(ctx: TemplateContext): boolean {
59
73
  function packageRunner(ctx: TemplateContext): string {
60
74
  switch (ctx.packageManager) {
61
75
  case "npm":
62
- return "npx -p @beignet/cli beignet";
76
+ return "npm run beignet --";
63
77
  case "pnpm":
64
- return "pnpm dlx @beignet/cli beignet";
78
+ return "pnpm beignet";
65
79
  case "yarn":
66
- return "yarn dlx @beignet/cli beignet";
80
+ return "yarn beignet";
67
81
  default:
68
- return "bunx -p @beignet/cli beignet";
82
+ return "bun beignet";
69
83
  }
70
84
  }
71
85
 
72
- export const featureChoices = [
73
- "client",
74
- "react-query",
75
- "forms",
76
- "openapi",
77
- ] as const satisfies readonly FeatureName[];
78
-
79
- export const presetChoices = [
80
- "minimal",
81
- "standard",
82
- ] as const satisfies readonly PresetName[];
83
-
84
- export const integrationChoices = [
85
- "better-auth",
86
- "drizzle-turso",
87
- "inngest",
88
- "pino",
89
- "resend",
90
- "upstash-rate-limit",
91
- ] as const satisfies readonly IntegrationName[];
92
-
93
86
  const integrationDeps: Record<IntegrationName, Record<string, string>> = {
94
87
  "better-auth": {
95
88
  "@beignet/provider-auth-better-auth": "",
@@ -184,6 +177,7 @@ function packageJson(ctx: TemplateContext): string {
184
177
 
185
178
  if (isStandardPreset(ctx)) {
186
179
  dependencies["@beignet/devtools"] = ctx.beignetVersion;
180
+ dependencies["@beignet/provider-event-bus-memory"] = ctx.beignetVersion;
187
181
  dependencies["@beignet/provider-storage-local"] = ctx.beignetVersion;
188
182
  }
189
183
 
@@ -202,6 +196,7 @@ function packageJson(ctx: TemplateContext): string {
202
196
  }
203
197
 
204
198
  const devDependencies: Record<string, string> = {
199
+ "@beignet/cli": ctx.beignetVersion,
205
200
  "@types/bun": externalVersions.typesBun,
206
201
  "@types/node": externalVersions.typesNode,
207
202
  "@types/react": externalVersions.typesReact,
@@ -218,6 +213,7 @@ function packageJson(ctx: TemplateContext): string {
218
213
  }
219
214
 
220
215
  const scripts: Record<string, string> = {
216
+ beignet: "beignet",
221
217
  dev: "next dev",
222
218
  build: "next build",
223
219
  start: "next start",
@@ -228,6 +224,13 @@ function packageJson(ctx: TemplateContext): string {
228
224
  if (hasDrizzleTurso(ctx)) {
229
225
  scripts["db:generate"] = "drizzle-kit generate";
230
226
  scripts["db:migrate"] = "drizzle-kit migrate";
227
+
228
+ // The minimal preset does not scaffold infra/db/seed.ts or
229
+ // infra/db/reset.ts, so only the standard preset gets their scripts.
230
+ if (isStandardPreset(ctx)) {
231
+ scripts["db:seed"] = "bun infra/db/seed.ts";
232
+ scripts["db:reset"] = "bun infra/db/reset.ts";
233
+ }
231
234
  }
232
235
 
233
236
  return json({
@@ -251,6 +254,9 @@ function readme(ctx: TemplateContext): string {
251
254
  ctx.packageManager === "npm"
252
255
  ? "npm run dev"
253
256
  : `${ctx.packageManager} run dev`;
257
+ const cli = packageRunner(ctx);
258
+ const envSetup =
259
+ ctx.integrations.length > 0 ? "cp .env.example .env.local\n" : "";
254
260
  const featureList =
255
261
  ctx.features.length > 0
256
262
  ? ctx.features.map((feature) => `- \`${feature}\``).join("\n")
@@ -259,6 +265,20 @@ function readme(ctx: TemplateContext): string {
259
265
  ctx.integrations.length > 0
260
266
  ? ctx.integrations.map((integration) => `- \`${integration}\``).join("\n")
261
267
  : "- None";
268
+ const databaseSection = hasDrizzleTurso(ctx)
269
+ ? `
270
+ ## Database
271
+
272
+ The drizzle-turso integration persists todos with Drizzle on libSQL/Turso. The server creates and seeds the starter \`todos\` table at startup using \`TURSO_DB_URL\` (defaults to \`file:local.db\`).
273
+
274
+ Regenerate and apply migrations after changing \`infra/db/schema/index.ts\`:
275
+
276
+ \`\`\`bash
277
+ ${cli} db generate
278
+ ${cli} db migrate
279
+ \`\`\`
280
+ `
281
+ : "";
262
282
 
263
283
  return `# ${ctx.name}
264
284
 
@@ -268,19 +288,21 @@ Beignet app scaffolded with \`@beignet/cli\`.
268
288
 
269
289
  \`\`\`bash
270
290
  ${install}
271
- ${run}
291
+ ${envSetup}${run}
272
292
  \`\`\`
273
293
 
274
- Open http://localhost:3000 and start from the todos workflow.
275
-
294
+ Open http://localhost:3000 and start from the todos workflow. The homepage links to the health check, OpenAPI document, and Beignet docs.
295
+ ${databaseSection}
276
296
  ## Files to know
277
297
 
278
298
  - \`features/todos/contracts.ts\` defines the API contract.
279
- - \`features/todos/use-cases.ts\` contains application logic with input and output validation.
299
+ - \`features/todos/use-cases/\` contains application logic with input and output validation.
280
300
  - \`ports/index.ts\` defines the app's external dependencies.
281
- - \`server/index.ts\` wires contracts to handlers.
301
+ - \`features/todos/routes.ts\` binds contracts to use cases.
302
+ - \`server/context.ts\` declares the context blueprint shared by the server and tests.
303
+ - \`server/index.ts\` composes the server from ports, context, and routes.
282
304
  - \`app/api/[[...path]]/route.ts\` exposes contract-backed API routes to Next.js.
283
- ${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
305
+ ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/index.ts` owns the Drizzle schema and `infra/db/provider.ts` wires the Drizzle todo repository into the `todos` port.\n" : ""}${hasIntegration(ctx, "better-auth") ? "- `lib/better-auth.ts` owns the Better Auth instance served at `/api/auth/*`.\n" : ""}${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
284
306
 
285
307
  ## Selected preset
286
308
 
@@ -303,6 +325,10 @@ function standardReadme(ctx: TemplateContext): string {
303
325
  ctx.packageManager === "npm"
304
326
  ? "npm run dev"
305
327
  : `${ctx.packageManager} run dev`;
328
+ const test =
329
+ ctx.packageManager === "npm"
330
+ ? "npm run test"
331
+ : `${ctx.packageManager} run test`;
306
332
  const typecheck =
307
333
  ctx.packageManager === "npm"
308
334
  ? "npm run typecheck"
@@ -311,7 +337,8 @@ function standardReadme(ctx: TemplateContext): string {
311
337
  const beforeDeploy = hasDrizzleTurso(ctx)
312
338
  ? [
313
339
  "- Create a Turso database or keep `TURSO_DB_URL=file:local.db` for local libSQL development.",
314
- `- Run \`${ctx.packageManager} run db:generate\` and \`${ctx.packageManager} run db:migrate\` after changing the Drizzle schema.`,
340
+ `- Run \`${cli} db generate\` and \`${cli} db migrate\` after changing the Drizzle schema.`,
341
+ `- Run \`${cli} db seed\` to load starter data or \`${cli} db reset\` to rebuild a local SQLite database.`,
315
342
  ].join("\n")
316
343
  : "- Replace the in-memory todo repository with a durable adapter.";
317
344
 
@@ -324,10 +351,24 @@ Beignet app scaffolded with \`@beignet/cli\`.
324
351
  \`\`\`bash
325
352
  ${install}
326
353
  cp .env.example .env.local
354
+ \`\`\`
355
+
356
+ ## Prepare the database
357
+
358
+ \`\`\`bash
359
+ ${cli} db generate
360
+ ${cli} db migrate
361
+ \`\`\`
362
+
363
+ \`db generate\` creates Drizzle migrations from \`infra/db/schema/\`. \`db migrate\` applies them to the local SQLite database configured in \`.env.local\`.
364
+
365
+ ## Start the app
366
+
367
+ \`\`\`bash
327
368
  ${run}
328
369
  \`\`\`
329
370
 
330
- Open http://localhost:3000 for the todo workflow, http://localhost:3000/api/health for health checks, and http://localhost:3000/api/devtools while developing.
371
+ Open http://localhost:3000 for the todo workflow. The homepage links to the health check, OpenAPI document, devtools, and Beignet docs.
331
372
 
332
373
  ## First checks
333
374
 
@@ -341,18 +382,39 @@ ${typecheck}
341
382
 
342
383
  \`routes\` shows the contracts Beignet can inspect. \`lint\` checks dependency direction. \`doctor\` catches route, OpenAPI, and resource drift.
343
384
 
385
+ ## Generate a feature
386
+
387
+ \`\`\`bash
388
+ ${cli} make feature projects
389
+ ${cli} db generate
390
+ ${cli} db migrate
391
+ ${test}
392
+ ${typecheck}
393
+ ${cli} lint
394
+ ${cli} doctor
395
+ \`\`\`
396
+
397
+ \`make feature\` creates a contract-to-test vertical slice. In the standard starter it also adds Drizzle schema and repository files, so regenerate and migrate the database before running the app against the new feature.
398
+
344
399
  ## App map
345
400
 
346
- - \`features/todos/contracts.ts\` owns the HTTP contract and reuses use case schemas.
401
+ - \`features/todos/contracts.ts\` owns the HTTP contract and reuses shared feature schemas.
402
+ - \`features/todos/schemas.ts\` owns shared DTO and validation schemas used by contracts, use cases, ports, tests, and clients.
347
403
  - \`features/todos/use-cases/\` owns application behavior and validation.
348
404
  - \`ports/\` defines app-owned dependencies.
349
405
  - \`infra/\` implements ports for the selected runtime.
350
406
  - \`server/routes.ts\` keeps the central route registry and OpenAPI contract list.
407
+ - \`server/context.ts\` declares the context blueprint shared by the server and route tests.
351
408
  - \`server/\` wires context, providers, routes, hooks, and error handling.
409
+ - \`server/outbox.ts\` registers durable events and jobs for the outbox drain route and CLI drain runner.
410
+ - \`server/schedules.ts\` registers schedules for cron routes and the CLI schedule runner.
352
411
  - \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
412
+ - \`features/todos/domain/events/\`, \`jobs/\`, \`listeners/\`, \`notifications/\`, \`schedules/\`, and \`uploads/\` show the feature-owned background workflow shape.
353
413
  - \`app/storage/[...key]/route.ts\` serves public local storage objects.
414
+ - \`app/api/cron/outbox/drain/route.ts\` is the bounded serverless entrypoint for durable event/job delivery.
354
415
  - \`lib/env.ts\` validates deployment configuration at startup.
355
416
  - \`lib/auth.ts\` exposes \`requireUser(ctx)\` for protected use cases.
417
+ - \`lib/better-auth.ts\` owns Better Auth setup and keeps provider-specific auth details outside use cases.
356
418
  - \`features/todos/policy.ts\` defines authorization rules registered with \`createGate(...)\`.
357
419
  ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `infra/db/repositories.ts` creates app repositories, and `infra/todos/` contains the durable todo repository adapter.\n" : ""}
358
420
 
@@ -360,8 +422,11 @@ ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `inf
360
422
 
361
423
  ${beforeDeploy}
362
424
  - Keep \`/api/devtools\` development-only unless you add authentication and stricter redaction.
363
- - Set \`APP_URL\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
364
- - Replace anonymous auth and add use-case authorization before exposing user-owned data.
425
+ - Set \`APP_URL\`, \`BETTER_AUTH_SECRET\`, \`CRON_SECRET\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
426
+ - Configure a scheduled request to \`/api/cron/outbox/drain\` with \`Authorization: Bearer <CRON_SECRET>\`.
427
+ - Use \`${cli} outbox drain\` for bounded local, CI, or worker-hosted drain runs.
428
+ - Use \`${cli} schedule run todos.log-daily-summary\` for bounded local, CI, or worker-hosted schedule runs.
429
+ - Review the starter authorization policy before exposing user-owned data.
365
430
  ${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
366
431
  `;
367
432
  }
@@ -381,10 +446,17 @@ function integrationsDoc(ctx: TemplateContext): string {
381
446
  "",
382
447
  "- Package: `@beignet/provider-auth-better-auth`",
383
448
  "- Peer dependency: `better-auth`",
384
- "- The starter uses an anonymous auth adapter. Replace `createAnonymousAuth()` in `infra/app-ports.ts` with an adapter backed by your Better Auth session lookup.",
385
- "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
386
- "- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
387
- "- Add the Better Auth provider only after your app has a database adapter and session setup.",
449
+ "- The starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.",
450
+ ...(isStandardPreset(ctx)
451
+ ? [
452
+ "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
453
+ "- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
454
+ "- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.",
455
+ ]
456
+ : [
457
+ "- Keep auth behind the shared `AuthPort`, then read `ctx.auth` in use cases that need a signed-in user.",
458
+ "- The minimal starter uses Better Auth's in-memory adapter, so users and sessions reset on restart. Switch to a database adapter such as `better-auth/adapters/drizzle` before production.",
459
+ ]),
388
460
  "",
389
461
  );
390
462
  }
@@ -397,8 +469,19 @@ function integrationsDoc(ctx: TemplateContext): string {
397
469
  "- Peer dependencies: `@libsql/client` and `drizzle-orm`",
398
470
  "- Dev dependency: `drizzle-kit`",
399
471
  "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.",
400
- "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
401
- `- Run \`${ctx.packageManager} run db:generate\` and \`${ctx.packageManager} run db:migrate\` after changing \`infra/db/schema.ts\`.`,
472
+ ...(isStandardPreset(ctx)
473
+ ? [
474
+ "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
475
+ ]
476
+ : [
477
+ "- The scaffold wires a typed Drizzle repository behind the `todos` port through `infra/db/provider.ts`, which also creates and seeds the starter table at startup.",
478
+ ]),
479
+ `- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`,
480
+ ...(isStandardPreset(ctx)
481
+ ? [
482
+ `- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`,
483
+ ]
484
+ : []),
402
485
  "",
403
486
  );
404
487
  }
@@ -411,7 +494,7 @@ function integrationsDoc(ctx: TemplateContext): string {
411
494
  "- Package: `@beignet/provider-inngest`",
412
495
  "- Peer dependency: `inngest`",
413
496
  "- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.",
414
- "- Define jobs with `createJobHandlers(...).defineJob(...)` and dispatch them through `ctx.ports.jobs.dispatch(...)`.",
497
+ "- Define jobs with `defineJob(...)` from `lib/jobs.ts` and dispatch them through `ctx.ports.jobs.dispatch(...)`.",
415
498
  "- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.",
416
499
  "",
417
500
  );
@@ -504,16 +587,16 @@ function envExample(ctx: TemplateContext): string {
504
587
  lines.push(
505
588
  "UPSTASH_REDIS_REST_URL=",
506
589
  "UPSTASH_REDIS_REST_TOKEN=",
507
- "UPSTASH_PREFIX=ck:ratelimit",
590
+ "UPSTASH_PREFIX=beignet:ratelimit",
508
591
  "",
509
592
  );
510
593
  }
511
594
 
512
595
  if (hasIntegration(ctx, "better-auth")) {
513
596
  lines.push(
514
- "# Better Auth needs an app-owned database adapter and a shared AuthPort adapter before it can be wired.",
515
- "BETTER_AUTH_SECRET=",
597
+ "BETTER_AUTH_SECRET=local-dev-better-auth-secret-change-me",
516
598
  "BETTER_AUTH_URL=http://localhost:3000",
599
+ "# BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000",
517
600
  "",
518
601
  );
519
602
  }
@@ -581,6 +664,11 @@ function page(ctx: TemplateContext): string {
581
664
  <p>
582
665
  <a href="/api/todos">Open todos API</a>
583
666
  </p>
667
+ <nav className="starter-links" aria-label="Starter links">
668
+ <a href="/api/health">Health</a>
669
+ <a href="/api/openapi">OpenAPI</a>
670
+ <a href="https://beignetjs.com">Docs</a>
671
+ </nav>
584
672
  </section>
585
673
  </div>
586
674
  </main>
@@ -605,6 +693,14 @@ export default function HomePage() {
605
693
  </p>
606
694
  </header>
607
695
 
696
+ <nav className="starter-links" aria-label="Starter links">
697
+ <a href="#todos">Todo feature</a>
698
+ <a href="/api/health">Health</a>
699
+ <a href="/api/openapi">OpenAPI</a>
700
+ <a href="/api/devtools">Devtools</a>
701
+ <a href="https://beignetjs.com">Docs</a>
702
+ </nav>
703
+
608
704
  <TodoApp />
609
705
  </div>
610
706
  </main>
@@ -614,64 +710,126 @@ export default function HomePage() {
614
710
  }
615
711
 
616
712
  function serverProviders(ctx: TemplateContext): string {
617
- const imports: string[] = [];
713
+ const packageImports: string[] = [];
714
+ const appImports: string[] = [];
715
+ const declarations: string[] = [];
618
716
  const entries: string[] = [];
619
717
 
718
+ if (hasIntegration(ctx, "better-auth")) {
719
+ packageImports.push(
720
+ 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";',
721
+ );
722
+ appImports.push(
723
+ 'import { auth } from "@/lib/better-auth";',
724
+ 'import type { AuthSessionMetadata, AuthUser } from "@/ports";',
725
+ );
726
+ entries.push(
727
+ "createAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth)",
728
+ );
729
+ }
730
+
620
731
  if (hasIntegration(ctx, "pino")) {
621
- imports.push(
732
+ packageImports.push(
622
733
  'import { loggerPinoProvider } from "@beignet/provider-logger-pino";',
623
734
  );
624
735
  entries.push("loggerPinoProvider");
625
736
  }
626
737
 
738
+ if (hasDrizzleTurso(ctx)) {
739
+ packageImports.push(
740
+ 'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";',
741
+ );
742
+ appImports.unshift(
743
+ 'import { starterDatabaseProvider } from "@/infra/db/provider";',
744
+ 'import * as schema from "@/infra/db/schema";',
745
+ );
746
+ declarations.push(
747
+ "const drizzleTursoProvider = createDrizzleTursoProvider({ schema });",
748
+ );
749
+ entries.push("drizzleTursoProvider", "starterDatabaseProvider");
750
+ }
751
+
627
752
  if (hasIntegration(ctx, "inngest")) {
628
- imports.push(
753
+ packageImports.push(
629
754
  'import { inngestProvider } from "@beignet/provider-inngest";',
630
755
  );
631
756
  entries.push("inngestProvider");
632
757
  }
633
758
 
634
759
  if (hasIntegration(ctx, "resend")) {
635
- imports.push(
760
+ packageImports.push(
636
761
  'import { mailResendProvider } from "@beignet/provider-mail-resend";',
637
762
  );
638
763
  entries.push("mailResendProvider");
639
764
  }
640
765
 
641
766
  if (hasIntegration(ctx, "upstash-rate-limit")) {
642
- imports.push(
767
+ packageImports.push(
643
768
  'import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";',
644
769
  );
645
770
  entries.push("upstashRateLimitProvider");
646
771
  }
647
772
 
648
- return `${imports.join("\n")}
773
+ const importBlock = [...packageImports.sort(), ...appImports].join("\n");
774
+ const declarationBlock =
775
+ declarations.length > 0 ? `\n${declarations.join("\n")}\n` : "";
776
+
777
+ return `${importBlock}
778
+ ${declarationBlock}
649
779
  export const providers = [
650
780
  ${entries.join(",\n\t")},
651
781
  ];
652
782
  `;
653
783
  }
654
784
 
785
+ function serverContext(ctx: TemplateContext): string {
786
+ const blueprint = hasIntegration(ctx, "better-auth")
787
+ ? ` async ({ req, ports }) => ({
788
+ requestId: crypto.randomUUID(),
789
+ auth: await ports.auth.getSession(req),
790
+ ports,
791
+ }),`
792
+ : ` async ({ ports }) => ({
793
+ requestId: crypto.randomUUID(),
794
+ ports,
795
+ }),`;
796
+
797
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
798
+ import { defineServerContext } from "@beignet/core/server";
799
+ import type { AppContext } from "@/app-context";
800
+ import type { AppPorts } from "@/ports";
801
+
802
+ /**
803
+ * Context blueprint shared by the runtime server and route tests.
804
+ */
805
+ export const appContext = defineServerContext<AppContext, AppPorts>()(
806
+ ${blueprint}
807
+ );
808
+ `;
809
+ }
810
+
655
811
  function server(ctx: TemplateContext): string {
656
- const hasProviders = ctx.integrations.some((integration) =>
657
- ["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration),
658
- );
812
+ const hasProviders = ctx.integrations.length > 0;
813
+ const providerConfigBlock = hasDrizzleTurso(ctx)
814
+ ? ` providerConfig: {
815
+ "drizzle-turso": {
816
+ DB_URL: process.env.TURSO_DB_URL ?? "file:local.db",
817
+ DB_AUTH_TOKEN: process.env.TURSO_DB_AUTH_TOKEN,
818
+ },
819
+ },
820
+ `
821
+ : "";
659
822
 
660
- return `import { createNextServer } from "@beignet/next";
823
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
824
+ import { createNextServer } from "@beignet/next";
825
+ import { appContext } from "./context";
661
826
  ${hasProviders ? 'import { providers } from "./providers";\n' : ""}import { routes } from "./routes";
827
+ import type { AppContext } from "@/app-context";
662
828
  import { appPorts, type AppPorts } from "@/ports";
663
829
 
664
- export type AppContext = {
665
- requestId: string;
666
- ports: AppPorts;
667
- };
668
-
669
830
  export const server = await createNextServer<AppContext, AppPorts>({
670
831
  ports: appPorts,
671
- ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
672
- requestId: crypto.randomUUID(),
673
- ports,
674
- }),
832
+ ${hasProviders ? "\tproviders,\n" : ""}${providerConfigBlock} context: appContext,
675
833
  routes,
676
834
  mapUnhandledError: ({ err, ctx }) => {
677
835
  const ports = ctx?.ports as
@@ -713,6 +871,127 @@ ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
713
871
  `;
714
872
  }
715
873
 
874
+ function minimalPorts(ctx: TemplateContext): string {
875
+ const usesDrizzleTurso = hasDrizzleTurso(ctx);
876
+ const usesBetterAuth = hasIntegration(ctx, "better-auth");
877
+
878
+ if (!usesDrizzleTurso && !usesBetterAuth) {
879
+ return files.ports;
880
+ }
881
+
882
+ const imports = [
883
+ 'import { definePorts } from "@beignet/core/ports";',
884
+ ...(usesBetterAuth
885
+ ? [
886
+ `import type {
887
+ AuthPort as BeignetAuthPort,
888
+ AuthSession as BeignetAuthSession,
889
+ } from "@beignet/core/ports";`,
890
+ ]
891
+ : []),
892
+ 'import type { z } from "zod";',
893
+ 'import type { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";',
894
+ ].join("\n");
895
+
896
+ const authTypes = usesBetterAuth
897
+ ? `
898
+
899
+ export type AuthUser = {
900
+ id: string;
901
+ email?: string;
902
+ name?: string;
903
+ };
904
+
905
+ export type AuthSessionMetadata = {
906
+ id?: string;
907
+ };
908
+
909
+ export type AuthSession = BeignetAuthSession<AuthUser, AuthSessionMetadata>;
910
+
911
+ export type AuthPort = BeignetAuthPort<AuthUser, AuthSessionMetadata>;`
912
+ : "";
913
+
914
+ const appPortsType = [
915
+ "export type AppPorts = {",
916
+ ...(usesBetterAuth ? ["\tauth: AuthPort;"] : []),
917
+ "\ttodos: TodoRepository;",
918
+ "};",
919
+ ].join("\n");
920
+
921
+ const inMemoryTodos = usesDrizzleTurso
922
+ ? ""
923
+ : `
924
+
925
+ const todos = new Map<string, Todo>();
926
+
927
+ function createTodo(input: CreateTodoInput): Todo {
928
+ const todo = {
929
+ id: crypto.randomUUID(),
930
+ title: input.title,
931
+ completed: false,
932
+ createdAt: new Date().toISOString(),
933
+ };
934
+ todos.set(todo.id, todo);
935
+ return todo;
936
+ }
937
+
938
+ createTodo({ title: "Read the generated contract" });
939
+ createTodo({ title: "Add your first use case" });`;
940
+
941
+ const bound = usesDrizzleTurso
942
+ ? "\tbound: {},"
943
+ : ` bound: {
944
+ todos: {
945
+ list: async () => Array.from(todos.values()),
946
+ create: async (input: CreateTodoInput) => createTodo(input),
947
+ },
948
+ },`;
949
+ const deferred = [
950
+ ...(usesBetterAuth ? ['"auth"'] : []),
951
+ ...(usesDrizzleTurso ? ['"todos"'] : []),
952
+ ].join(", ");
953
+
954
+ return `${imports}
955
+
956
+ export type Todo = z.infer<typeof TodoSchema>;
957
+ export type CreateTodoInput = z.infer<typeof CreateTodoSchema>;
958
+
959
+ export interface TodoRepository {
960
+ list(): Promise<Todo[]>;
961
+ create(input: CreateTodoInput): Promise<Todo>;
962
+ }${authTypes}
963
+
964
+ ${appPortsType}${inMemoryTodos}
965
+
966
+ /**
967
+ * App ports the server boots with.
968
+ *
969
+ * Bound ports are app-owned. Every deferred key is contributed by a provider
970
+ * in server/providers.ts during startup; createNextServer(...) fails boot if
971
+ * any of them is still unbound after providers have started.
972
+ */
973
+ export const appPorts = definePorts<AppPorts>()({
974
+ ${bound}
975
+ deferred: [${deferred}],
976
+ });
977
+ `;
978
+ }
979
+
980
+ function minimalAppContext(ctx: TemplateContext): string {
981
+ if (!hasIntegration(ctx, "better-auth")) {
982
+ return files.appContext;
983
+ }
984
+
985
+ return `import type { AppPorts, AuthSession } from "@/ports";
986
+
987
+ export type AppContext = {
988
+ requestId: string;
989
+ auth: AuthSession | null;
990
+ ports: AppPorts;
991
+ };
992
+ `;
993
+ }
994
+
716
995
  function todoApp(ctx: TemplateContext): string {
717
996
  const listTodosQueryOptions =
718
997
  ctx.preset === "standard" ? "{ query: {} }" : "";
@@ -720,12 +999,12 @@ function todoApp(ctx: TemplateContext): string {
720
999
  if (hasFeature(ctx, "forms")) {
721
1000
  return `"use client";
722
1001
 
723
- import { createReactHookForm } from "@beignet/react-hook-form";
1002
+ import { rootFormError } from "@beignet/react-hook-form";
724
1003
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1004
+ import { rq } from "@/client";
1005
+ import { rhf } from "@/client/forms";
725
1006
  import { createTodo, listTodos } from "@/features/todos/contracts";
726
- import { rq } from "@/client/rq";
727
1007
 
728
- const rhf = createReactHookForm();
729
1008
  const createTodoForm = rhf(createTodo);
730
1009
 
731
1010
  export function TodoApp() {
@@ -738,19 +1017,21 @@ export function TodoApp() {
738
1017
  rq(createTodo).mutationOptions({
739
1018
  onSuccess: async () => {
740
1019
  form.reset();
741
- await queryClient.invalidateQueries({
742
- queryKey: rq(listTodos).key(),
743
- });
1020
+ await rq(listTodos).invalidate(queryClient);
1021
+ },
1022
+ onError: (error) => {
1023
+ form.setError("root", rootFormError(error, "Could not create the todo."));
744
1024
  },
745
1025
  }),
746
1026
  );
747
1027
 
748
1028
  const onSubmit = form.handleSubmit((body) => {
1029
+ form.clearErrors("root");
749
1030
  createTodoMutation.mutate({ body });
750
1031
  });
751
1032
 
752
1033
  return (
753
- <section className="workspace">
1034
+ <section id="todos" className="workspace">
754
1035
  <form className="composer" onSubmit={onSubmit}>
755
1036
  <label htmlFor="todo-title">New todo</label>
756
1037
  <div className="composer-row">
@@ -766,19 +1047,22 @@ export function TodoApp() {
766
1047
  {form.formState.errors.title ? (
767
1048
  <p className="error">{form.formState.errors.title.message}</p>
768
1049
  ) : null}
1050
+ {form.formState.errors.root ? (
1051
+ <p className="error">{form.formState.errors.root.message}</p>
1052
+ ) : null}
769
1053
  </form>
770
1054
 
771
1055
  <div className="todos-panel">
772
1056
  <div className="panel-heading">
773
1057
  <h2>Todos</h2>
774
- <span>{todosQuery.data?.total ?? 0} total</span>
1058
+ <span>{todosQuery.data?.page.total ?? 0} total</span>
775
1059
  </div>
776
1060
  {todosQuery.isLoading ? <p className="muted">Loading todos...</p> : null}
777
1061
  {todosQuery.isError ? (
778
1062
  <p className="error">Could not load todos.</p>
779
1063
  ) : null}
780
1064
  <ul className="todo-list">
781
- {todosQuery.data?.todos.map((todo) => (
1065
+ {todosQuery.data?.items.map((todo) => (
782
1066
  <li key={todo.id}>
783
1067
  <span>{todo.title}</span>
784
1068
  <small>{todo.completed ? "Done" : "Open"}</small>
@@ -796,8 +1080,8 @@ export function TodoApp() {
796
1080
 
797
1081
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
798
1082
  import { useState } from "react";
1083
+ import { rq } from "@/client";
799
1084
  import { createTodo, listTodos } from "@/features/todos/contracts";
800
- import { rq } from "@/client/rq";
801
1085
 
802
1086
  export function TodoApp() {
803
1087
  const [title, setTitle] = useState("");
@@ -807,15 +1091,13 @@ export function TodoApp() {
807
1091
  rq(createTodo).mutationOptions({
808
1092
  onSuccess: async () => {
809
1093
  setTitle("");
810
- await queryClient.invalidateQueries({
811
- queryKey: rq(listTodos).key(),
812
- });
1094
+ await rq(listTodos).invalidate(queryClient);
813
1095
  },
814
1096
  }),
815
1097
  );
816
1098
 
817
1099
  return (
818
- <section className="workspace">
1100
+ <section id="todos" className="workspace">
819
1101
  <form
820
1102
  className="composer"
821
1103
  onSubmit={(event) => {
@@ -840,10 +1122,10 @@ export function TodoApp() {
840
1122
  <div className="todos-panel">
841
1123
  <div className="panel-heading">
842
1124
  <h2>Todos</h2>
843
- <span>{todosQuery.data?.total ?? 0} total</span>
1125
+ <span>{todosQuery.data?.page.total ?? 0} total</span>
844
1126
  </div>
845
1127
  <ul className="todo-list">
846
- {todosQuery.data?.todos.map((todo) => (
1128
+ {todosQuery.data?.items.map((todo) => (
847
1129
  <li key={todo.id}>
848
1130
  <span>{todo.title}</span>
849
1131
  <small>{todo.completed ? "Done" : "Open"}</small>
@@ -857,6 +1139,58 @@ export function TodoApp() {
857
1139
  `;
858
1140
  }
859
1141
 
1142
+ function clientIndex(ctx: TemplateContext): string {
1143
+ const imports = [
1144
+ 'import { createClient } from "@beignet/core/client";',
1145
+ ...(hasFeature(ctx, "react-query")
1146
+ ? [
1147
+ 'import { createReactQuery } from "@beignet/react-query";',
1148
+ 'import { QueryClient } from "@tanstack/react-query";',
1149
+ ]
1150
+ : []),
1151
+ ...(isStandardPreset(ctx)
1152
+ ? [
1153
+ 'import { createUploadClient } from "@beignet/core/uploads/client";',
1154
+ 'import type { todoUploads } from "@/features/todos/uploads";',
1155
+ ]
1156
+ : []),
1157
+ ].join("\n");
1158
+
1159
+ const exports = [
1160
+ `export const apiClient = createClient({
1161
+ \tvalidateInput: true,
1162
+ });`,
1163
+ ...(hasFeature(ctx, "react-query")
1164
+ ? ["export const rq = createReactQuery(apiClient);"]
1165
+ : []),
1166
+ ...(isStandardPreset(ctx)
1167
+ ? [
1168
+ `export const uploads = createUploadClient<typeof todoUploads>({
1169
+ \tbaseUrl: "/api/uploads",
1170
+ });`,
1171
+ ]
1172
+ : []),
1173
+ ...(hasFeature(ctx, "react-query")
1174
+ ? [
1175
+ `export function makeQueryClient() {
1176
+ \treturn new QueryClient({
1177
+ \t\tdefaultOptions: {
1178
+ \t\t\tqueries: {
1179
+ \t\t\t\tstaleTime: 60 * 1000,
1180
+ \t\t\t},
1181
+ \t\t},
1182
+ \t});
1183
+ }`,
1184
+ ]
1185
+ : []),
1186
+ ].join("\n\n");
1187
+
1188
+ return `${imports}
1189
+
1190
+ ${exports}
1191
+ `;
1192
+ }
1193
+
860
1194
  const files = {
861
1195
  gitignore: `.next
862
1196
  node_modules
@@ -957,6 +1291,29 @@ input {
957
1291
  color: #475569;
958
1292
  }
959
1293
 
1294
+ .starter-links {
1295
+ display: flex;
1296
+ flex-wrap: wrap;
1297
+ gap: 10px;
1298
+ margin: 22px 0;
1299
+ }
1300
+
1301
+ .starter-links a {
1302
+ border: 1px solid #cbd5e1;
1303
+ border-radius: 8px;
1304
+ padding: 8px 10px;
1305
+ background: #ffffff;
1306
+ color: #172033;
1307
+ font-size: 14px;
1308
+ font-weight: 700;
1309
+ text-decoration: none;
1310
+ }
1311
+
1312
+ .starter-links a:hover {
1313
+ border-color: #4f46e5;
1314
+ color: #4f46e5;
1315
+ }
1316
+
960
1317
  .panel {
961
1318
  margin-top: 24px;
962
1319
  padding: 20px;
@@ -1083,32 +1440,26 @@ input {
1083
1440
  `,
1084
1441
  appProviders: `"use client";
1085
1442
 
1086
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1443
+ import { QueryClientProvider } from "@tanstack/react-query";
1087
1444
  import { type ReactNode, useState } from "react";
1445
+ import { makeQueryClient } from "@/client";
1088
1446
 
1089
1447
  export function Providers({ children }: { children: ReactNode }) {
1090
- const [queryClient] = useState(() => new QueryClient());
1448
+ const [queryClient] = useState(() => makeQueryClient());
1091
1449
 
1092
1450
  return (
1093
1451
  <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
1094
1452
  );
1095
1453
  }
1096
1454
  `,
1097
- apiClient: `import { createNextClient } from "@beignet/next";
1098
-
1099
- export const apiClient = createNextClient({
1100
- validate: true,
1101
- });
1102
- `,
1103
- rq: `import { createReactQuery } from "@beignet/react-query";
1104
- import { apiClient } from "./api-client";
1455
+ formsClient: `import { createReactHookForm } from "@beignet/react-hook-form";
1105
1456
 
1106
- export const rq = createReactQuery(apiClient);
1457
+ export const rhf = createReactHookForm();
1107
1458
  `,
1108
- contractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
1459
+ contractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
1109
1460
  import { z } from "zod";
1110
1461
 
1111
- const todos = createContractGroup().namespace("todos");
1462
+ const todos = defineContractGroup().namespace("todos");
1112
1463
 
1113
1464
  export const TodoSchema = z.object({
1114
1465
  id: z.string(),
@@ -1123,8 +1474,14 @@ export const CreateTodoSchema = z.object({
1123
1474
 
1124
1475
  export const listTodos = todos.get("/api/todos").responses({
1125
1476
  200: z.object({
1126
- todos: z.array(TodoSchema),
1127
- total: z.number(),
1477
+ items: z.array(TodoSchema),
1478
+ page: z.object({
1479
+ kind: z.literal("offset"),
1480
+ limit: z.number(),
1481
+ offset: z.number(),
1482
+ total: z.number(),
1483
+ hasMore: z.boolean(),
1484
+ }),
1128
1485
  }),
1129
1486
  });
1130
1487
 
@@ -1167,30 +1524,175 @@ export const appPorts = definePorts({
1167
1524
 
1168
1525
  export type AppPorts = typeof appPorts;
1169
1526
  `,
1170
- useCasesTodos: `import { createUseCase } from "@beignet/core/application";
1171
- import { z } from "zod";
1172
- import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
1173
- import type { AppPorts } from "@/ports";
1527
+ appContext: `import type { AppPorts } from "@/ports";
1174
1528
 
1175
- type AppContext = {
1529
+ export type AppContext = {
1176
1530
  requestId: string;
1177
1531
  ports: AppPorts;
1178
1532
  };
1533
+ `,
1534
+ minimalBetterAuth: `import { betterAuth } from "better-auth";
1535
+ import { memoryAdapter, type MemoryDB } from "better-auth/adapters/memory";
1536
+
1537
+ // The minimal starter keeps auth storage in memory, matching its in-memory
1538
+ // ports. Users and sessions reset on restart; switch to a database adapter
1539
+ // such as better-auth/adapters/drizzle before production.
1540
+ const database: MemoryDB = {
1541
+ account: [],
1542
+ session: [],
1543
+ user: [],
1544
+ verification: [],
1545
+ };
1546
+
1547
+ export const auth = betterAuth({
1548
+ baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
1549
+ secret:
1550
+ process.env.BETTER_AUTH_SECRET ?? "local-dev-better-auth-secret-change-me",
1551
+ database: memoryAdapter(database),
1552
+ emailAndPassword: {
1553
+ enabled: true,
1554
+ },
1555
+ });
1556
+ `,
1557
+ minimalDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1558
+
1559
+ export const todos = sqliteTable("todos", {
1560
+ id: text("id").primaryKey(),
1561
+ title: text("title").notNull(),
1562
+ completed: integer("completed", { mode: "boolean" }).notNull().default(false),
1563
+ createdAt: text("created_at").notNull(),
1564
+ });
1565
+ `,
1566
+ minimalDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
1567
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1568
+ import { asc } from "drizzle-orm";
1569
+ import * as schema from "@/infra/db/schema";
1570
+ import type { CreateTodoInput, TodoRepository } from "@/ports";
1571
+
1572
+ export function createDrizzleTodoRepository(
1573
+ db: DrizzleTursoDatabase<typeof schema>,
1574
+ ): TodoRepository {
1575
+ return {
1576
+ async list() {
1577
+ return db
1578
+ .select()
1579
+ .from(schema.todos)
1580
+ .orderBy(asc(schema.todos.createdAt));
1581
+ },
1582
+ async create(input: CreateTodoInput) {
1583
+ const todo = {
1584
+ id: crypto.randomUUID(),
1585
+ title: input.title,
1586
+ completed: false,
1587
+ createdAt: new Date().toISOString(),
1588
+ };
1589
+ const [row] = await db.insert(schema.todos).values(todo).returning();
1590
+
1591
+ if (!row) {
1592
+ throw new Error("Failed to create todo");
1593
+ }
1594
+
1595
+ return row;
1596
+ },
1597
+ };
1598
+ }
1599
+ `,
1600
+ minimalDbProvider: `import type { beignetServerOnly } from "@beignet/core/server-only";
1601
+ import { createProvider } from "@beignet/core/providers";
1602
+ import type { DbPort } from "@beignet/provider-drizzle-turso";
1603
+ import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
1604
+ import type { AppPorts } from "@/ports";
1605
+ import * as schema from "./schema";
1606
+
1607
+ const setupStatements = [
1608
+ \`CREATE TABLE IF NOT EXISTS todos (
1609
+ id text PRIMARY KEY NOT NULL,
1610
+ title text NOT NULL,
1611
+ completed integer DEFAULT false NOT NULL,
1612
+ created_at text NOT NULL
1613
+ )\`,
1614
+ ];
1615
+
1616
+ const seedTitles = [
1617
+ "Read the generated contract",
1618
+ "Add your first use case",
1619
+ ] as const;
1620
+
1621
+ export const starterDatabaseProvider = createProvider<{
1622
+ db: DbPort<typeof schema>;
1623
+ }>()({
1624
+ name: "starter-database",
1625
+
1626
+ async setup({ ports }) {
1627
+ const dbPort = ports.db;
1628
+ if (!dbPort) {
1629
+ throw new Error(
1630
+ "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
1631
+ );
1632
+ }
1633
+
1634
+ const providedPorts: Pick<AppPorts, "todos"> = {
1635
+ todos: createDrizzleTodoRepository(dbPort.db),
1636
+ };
1637
+
1638
+ return {
1639
+ ports: providedPorts,
1640
+ async start() {
1641
+ for (const statement of setupStatements) {
1642
+ await dbPort.client.execute(statement);
1643
+ }
1644
+
1645
+ const result = await dbPort.client.execute(
1646
+ "SELECT count(*) as total FROM todos",
1647
+ );
1648
+ const total = Number(result.rows[0]?.total ?? 0);
1649
+ if (total > 0) return;
1650
+
1651
+ for (const title of seedTitles) {
1652
+ await dbPort.client.execute({
1653
+ sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
1654
+ args: [crypto.randomUUID(), title, 0, new Date().toISOString()],
1655
+ });
1656
+ }
1657
+ },
1658
+ };
1659
+ },
1660
+ });
1661
+ `,
1662
+ useCasesTodos: `import { createUseCase } from "@beignet/core/application";
1663
+ import { z } from "zod";
1664
+ import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
1665
+ import type { AppContext } from "@/app-context";
1179
1666
 
1180
1667
  const useCase = createUseCase<AppContext>();
1181
1668
 
1182
1669
  export const ListTodosOutputSchema = z.object({
1183
- todos: z.array(TodoSchema),
1184
- total: z.number(),
1670
+ items: z.array(TodoSchema),
1671
+ page: z.object({
1672
+ kind: z.literal("offset"),
1673
+ limit: z.number(),
1674
+ offset: z.number(),
1675
+ total: z.number(),
1676
+ hasMore: z.boolean(),
1677
+ }),
1185
1678
  });
1186
1679
 
1187
1680
  export const listTodos = useCase
1188
1681
  .query("listTodos")
1189
- .input(z.void())
1682
+ .input(z.object({}))
1190
1683
  .output(ListTodosOutputSchema)
1191
1684
  .run(async ({ ctx }) => {
1192
1685
  const todos = await ctx.ports.todos.list();
1193
- return { todos, total: todos.length };
1686
+ return {
1687
+ items: todos,
1688
+ page: {
1689
+ kind: "offset" as const,
1690
+ limit: todos.length,
1691
+ offset: 0,
1692
+ total: todos.length,
1693
+ hasMore: false,
1694
+ },
1695
+ };
1194
1696
  });
1195
1697
 
1196
1698
  export const createTodo = useCase
@@ -1199,33 +1701,23 @@ export const createTodo = useCase
1199
1701
  .output(TodoSchema)
1200
1702
  .run(async ({ ctx, input }) => ctx.ports.todos.create(input));
1201
1703
  `,
1202
- todoRoutes: `import { defineRouteGroup } from "@beignet/next";
1203
- import type { AppContext } from "@/server";
1704
+ todoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
1705
+ import { defineRouteGroup } from "@beignet/next";
1706
+ import type { AppContext } from "@/app-context";
1204
1707
  import { createTodo, listTodos } from "@/features/todos/use-cases";
1205
1708
  import * as contracts from "@/features/todos/contracts";
1206
1709
 
1207
1710
  export const todoRoutes = defineRouteGroup<AppContext>({
1208
1711
  name: "todos",
1209
1712
  routes: [
1210
- {
1211
- contract: contracts.listTodos,
1212
- handle: async ({ ctx }) => ({
1213
- status: 200,
1214
- body: await listTodos.run({ ctx, input: undefined }),
1215
- }),
1216
- },
1217
- {
1218
- contract: contracts.createTodo,
1219
- handle: async ({ ctx, body }) => ({
1220
- status: 201,
1221
- body: await createTodo.run({ ctx, input: body }),
1222
- }),
1223
- },
1713
+ { contract: contracts.listTodos, useCase: listTodos },
1714
+ { contract: contracts.createTodo, useCase: createTodo },
1224
1715
  ],
1225
1716
  });
1226
1717
  `,
1227
- serverRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1228
- import type { AppContext } from "@/server";
1718
+ serverRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
1719
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1720
+ import type { AppContext } from "@/app-context";
1229
1721
  import { todoRoutes } from "@/features/todos/routes";
1230
1722
 
1231
1723
  export const routes = defineRoutes<AppContext>([
@@ -1264,6 +1756,14 @@ export const env = createEnv({
1264
1756
  APP_URL: z.string().url().default("http://localhost:3000"),
1265
1757
  CRON_SECRET: z.string().min(1).optional(),
1266
1758
  DEVTOOLS_ENABLED: BooleanEnv.optional(),
1759
+ BETTER_AUTH_SECRET: z
1760
+ .string()
1761
+ .min(32)
1762
+ .default("local-dev-better-auth-secret-change-me"),
1763
+ BETTER_AUTH_URL: z.string().url().default("http://localhost:3000"),
1764
+ BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
1765
+ TURSO_DB_URL: z.string().default("file:local.db"),
1766
+ TURSO_DB_AUTH_TOKEN: z.string().optional(),
1267
1767
  LOG_LEVEL: z
1268
1768
  .enum(["trace", "debug", "info", "warn", "error", "fatal"])
1269
1769
  .default("info"),
@@ -1275,24 +1775,28 @@ export const env = createEnv({
1275
1775
 
1276
1776
  export const isProduction = env.NODE_ENV === "production";
1277
1777
  `,
1278
- productionAppContext: `import type { DevtoolsPort, DevtoolsTraceContext } from "@beignet/devtools";
1279
- import type { ActivityActor, ActivityTenant, StoragePort } from "@beignet/core/ports";
1778
+ productionAppContext: `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
1779
+ import type { InferProviderPorts } from "@beignet/core/providers";
1780
+ import type { TraceContext } from "@beignet/core/tracing";
1280
1781
  import type { AppGate, AppPorts } from "@/ports";
1281
1782
  import type { AuthSession } from "@/ports/auth";
1783
+ import type { providers } from "@/server/providers";
1784
+
1785
+ /**
1786
+ * App ports plus the ports contributed by the server's providers at startup.
1787
+ */
1788
+ export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
1282
1789
 
1283
1790
  export type AppContext = {
1284
1791
  requestId: string;
1285
1792
  actor: ActivityActor;
1286
1793
  auth: AuthSession | null;
1287
1794
  gate: AppGate;
1288
- ports: AppPorts & {
1289
- devtools: DevtoolsPort;
1290
- storage: StoragePort;
1291
- };
1795
+ ports: AppRuntimePorts;
1292
1796
  tenant?: ActivityTenant;
1293
- } & Partial<DevtoolsTraceContext>;
1797
+ } & Partial<TraceContext>;
1294
1798
  `,
1295
- productimapUnhandledErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
1799
+ productionSharedErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
1296
1800
 
1297
1801
  export const errors = defineErrors({
1298
1802
  Unauthorized: {
@@ -1329,10 +1833,14 @@ export const ListTodosInputSchema = z.object({
1329
1833
  });
1330
1834
 
1331
1835
  export const ListTodosOutputSchema = z.object({
1332
- todos: z.array(TodoSchema),
1333
- total: z.number().int().min(0),
1334
- limit: z.number().int().min(1),
1335
- offset: z.number().int().min(0),
1836
+ items: z.array(TodoSchema),
1837
+ page: z.object({
1838
+ kind: z.literal("offset"),
1839
+ limit: z.number().int().min(1),
1840
+ offset: z.number().int().min(0),
1841
+ total: z.number().int().min(0),
1842
+ hasMore: z.boolean(),
1843
+ }),
1336
1844
  });
1337
1845
 
1338
1846
  export const GetTodoInputSchema = z.object({
@@ -1347,20 +1855,43 @@ export type Todo = z.infer<typeof TodoSchema>;
1347
1855
  export type CreateTodoInput = z.infer<typeof CreateTodoInputSchema>;
1348
1856
  export type ListTodosInput = z.infer<typeof ListTodosInputSchema>;
1349
1857
  `,
1350
- productionUseCaseBuilder: `import { createUseCase } from "@beignet/core/application";
1351
- import { createDevtoolsUseCaseObserver } from "@beignet/devtools";
1858
+ productionUseCaseBuilder: `import type { beignetServerOnly } from "@beignet/core/server-only";
1859
+ import { createUseCase } from "@beignet/core/application";
1352
1860
  import type { AppContext } from "@/app-context";
1353
1861
 
1354
- export const useCase = createUseCase<AppContext>({
1355
- onRun: createDevtoolsUseCaseObserver<AppContext>(),
1356
- });
1862
+ export const useCase = createUseCase<AppContext>();
1357
1863
  `,
1358
- productionAuthHelpers: `import type { AppContext } from "@/app-context";
1359
- import type { AuthSession, AuthUser } from "@/ports/auth";
1360
- import { appError } from "@/features/shared/errors";
1864
+ productionListenersBuilder: `import { createListeners } from "@beignet/core/events";
1865
+ import type { AppContext } from "@/app-context";
1361
1866
 
1362
- export function requireSession(ctx: AppContext): AuthSession {
1363
- if (!ctx.auth) {
1867
+ export const { defineListener } = createListeners<AppContext>();
1868
+ `,
1869
+ productionJobsBuilder: `import { createJobs } from "@beignet/core/jobs";
1870
+ import type { AppContext } from "@/app-context";
1871
+
1872
+ export const { defineJob } = createJobs<AppContext>();
1873
+ `,
1874
+ productionSchedulesBuilder: `import { createSchedules } from "@beignet/core/schedules";
1875
+ import type { AppContext } from "@/app-context";
1876
+
1877
+ export const { defineSchedule } = createSchedules<AppContext>();
1878
+ `,
1879
+ productionNotificationsBuilder: `import { createNotifications } from "@beignet/core/notifications";
1880
+ import type { AppContext } from "@/app-context";
1881
+
1882
+ export const { defineNotification } = createNotifications<AppContext>();
1883
+ `,
1884
+ productionTasksBuilder: `import { createTasks } from "@beignet/core/tasks";
1885
+ import type { AppContext } from "@/app-context";
1886
+
1887
+ export const { defineTask } = createTasks<AppContext>();
1888
+ `,
1889
+ productionAuthHelpers: `import type { AppContext } from "@/app-context";
1890
+ import type { AuthSession, AuthUser } from "@/ports/auth";
1891
+ import { appError } from "@/features/shared/errors";
1892
+
1893
+ export function requireSession(ctx: AppContext): AuthSession {
1894
+ if (!ctx.auth) {
1364
1895
  throw appError("Unauthorized");
1365
1896
  }
1366
1897
 
@@ -1371,26 +1902,28 @@ export function requireUser(ctx: AppContext): AuthUser {
1371
1902
  return requireSession(ctx).user;
1372
1903
  }
1373
1904
  `,
1374
- productionListTodosUseCase: `import { useCase } from "@/lib/use-case";
1375
- import { ListTodosInputSchema, ListTodosOutputSchema } from "./schemas";
1905
+ productionListTodosUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1906
+ import { normalizeOffsetPage } from "@beignet/core/pagination";
1907
+ import { useCase } from "@/lib/use-case";
1908
+ import { ListTodosInputSchema, ListTodosOutputSchema } from "../schemas";
1376
1909
 
1377
1910
  export const listTodosUseCase = useCase
1378
1911
  .query("todos.list")
1379
1912
  .input(ListTodosInputSchema)
1380
1913
  .output(ListTodosOutputSchema)
1381
1914
  .run(async ({ ctx, input }) => {
1382
- const result = await ctx.ports.todos.list(input);
1383
- return {
1384
- todos: result.todos,
1385
- total: result.total,
1386
- limit: input.limit,
1387
- offset: input.offset,
1388
- };
1915
+ const page = normalizeOffsetPage(input, {
1916
+ defaultLimit: 20,
1917
+ maxLimit: 100,
1918
+ });
1919
+
1920
+ return ctx.ports.todos.list(page);
1389
1921
  });
1390
1922
  `,
1391
- productionGetTodoUseCase: `import { appError } from "@/features/shared/errors";
1923
+ productionGetTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1924
+ import { appError } from "@/features/shared/errors";
1392
1925
  import { useCase } from "@/lib/use-case";
1393
- import { GetTodoInputSchema, TodoSchema } from "./schemas";
1926
+ import { GetTodoInputSchema, TodoSchema } from "../schemas";
1394
1927
 
1395
1928
  export const getTodoUseCase = useCase
1396
1929
  .query("todos.get")
@@ -1405,16 +1938,36 @@ export const getTodoUseCase = useCase
1405
1938
  return todo;
1406
1939
  });
1407
1940
  `,
1408
- productionCreateTodoUseCase: `import { useCase } from "@/lib/use-case";
1409
- import { CreateTodoInputSchema, TodoSchema } from "./schemas";
1941
+ productionCreateTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1942
+ import { TodoCreated } from "@/features/todos/domain/events";
1943
+ import { auditEntry } from "@/lib/audit";
1944
+ import { useCase } from "@/lib/use-case";
1945
+ import { CreateTodoInputSchema, TodoSchema } from "../schemas";
1410
1946
 
1411
1947
  export const createTodoUseCase = useCase
1412
1948
  .command("todos.create")
1413
1949
  .input(CreateTodoInputSchema)
1414
1950
  .output(TodoSchema)
1415
- .run(async ({ ctx, input }) => {
1951
+ .emits([TodoCreated])
1952
+ .run(async ({ ctx, input, events }) => {
1416
1953
  await ctx.gate.authorize("todos.create");
1417
- return ctx.ports.uow.transaction((tx) => tx.todos.create(input));
1954
+ const todo = await ctx.ports.uow.transaction(async (tx) => {
1955
+ const created = await tx.todos.create(input);
1956
+ await events.record(tx.events, TodoCreated, {
1957
+ todoId: created.id,
1958
+ title: created.title,
1959
+ });
1960
+ await tx.audit.record(
1961
+ auditEntry(ctx, {
1962
+ action: "todos.create",
1963
+ resource: { type: "todo", id: created.id, name: created.title },
1964
+ metadata: { completed: created.completed },
1965
+ }),
1966
+ );
1967
+ return created;
1968
+ });
1969
+
1970
+ return todo;
1418
1971
  });
1419
1972
  `,
1420
1973
  productionUseCasesIndex: `export { createTodoUseCase } from "./create-todo";
@@ -1429,24 +1982,40 @@ export {
1429
1982
  type CreateTodoInput,
1430
1983
  type ListTodosInput,
1431
1984
  type Todo,
1432
- } from "./schemas";
1985
+ } from "../schemas";
1433
1986
  `,
1434
1987
  productionTodoRepositoryPort: `import type {
1988
+ OffsetPage,
1989
+ OffsetPageInfo,
1990
+ PageResult,
1991
+ } from "@beignet/core/pagination";
1992
+ import type {
1435
1993
  CreateTodoInput,
1436
- ListTodosInput,
1437
1994
  Todo,
1438
- } from "@/features/todos/use-cases/schemas";
1995
+ } from "@/features/todos/schemas";
1996
+
1997
+ export type ListTodosResult = PageResult<Todo, OffsetPageInfo>;
1439
1998
 
1440
- export type ListTodosResult = {
1441
- todos: Todo[];
1442
- total: number;
1999
+ export type TodoAttachment = {
2000
+ id: string;
2001
+ todoId: string;
2002
+ key: string;
2003
+ fileName: string;
2004
+ contentType: string;
2005
+ size: number;
2006
+ createdAt: string;
1443
2007
  };
1444
2008
 
1445
2009
  export interface TodoRepository {
1446
- list(input: ListTodosInput): Promise<ListTodosResult>;
2010
+ list(page: OffsetPage): Promise<ListTodosResult>;
1447
2011
  findById(id: string): Promise<Todo | null>;
1448
2012
  create(input: CreateTodoInput): Promise<Todo>;
1449
2013
  }
2014
+
2015
+ export interface TodoAttachmentRepository {
2016
+ create(input: Omit<TodoAttachment, "createdAt">): Promise<TodoAttachment>;
2017
+ findMany(todoId: string): Promise<TodoAttachment[]>;
2018
+ }
1450
2019
  `,
1451
2020
  productionAuthPort: `import type {
1452
2021
  AuthPort as BeignetAuthPort,
@@ -1516,12 +2085,16 @@ export const todoPolicy = definePolicy({
1516
2085
  },
1517
2086
  });
1518
2087
  `,
1519
- productionInMemoryTodoRepository: `import type {
2088
+ productionInMemoryTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
2089
+ import { offsetPageResult } from "@beignet/core/pagination";
2090
+ import type {
1520
2091
  CreateTodoInput,
1521
- ListTodosInput,
1522
2092
  Todo,
1523
- } from "@/features/todos/use-cases/schemas";
1524
- import type { TodoRepository } from "@/features/todos/ports";
2093
+ } from "@/features/todos/schemas";
2094
+ import type {
2095
+ TodoAttachmentRepository,
2096
+ TodoRepository,
2097
+ } from "@/features/todos/ports";
1525
2098
 
1526
2099
  export function createInMemoryTodoRepository(
1527
2100
  seed: Todo[] = [],
@@ -1529,15 +2102,16 @@ export function createInMemoryTodoRepository(
1529
2102
  const todos = new Map(seed.map((todo) => [todo.id, todo]));
1530
2103
 
1531
2104
  return {
1532
- async list(input: ListTodosInput) {
2105
+ async list(page) {
1533
2106
  const allTodos = Array.from(todos.values()).sort((left, right) =>
1534
2107
  left.createdAt.localeCompare(right.createdAt),
1535
2108
  );
1536
2109
 
1537
- return {
1538
- todos: allTodos.slice(input.offset, input.offset + input.limit),
1539
- total: allTodos.length,
1540
- };
2110
+ return offsetPageResult(
2111
+ allTodos.slice(page.offset, page.offset + page.limit),
2112
+ page,
2113
+ allTodos.length,
2114
+ );
1541
2115
  },
1542
2116
  async findById(id: string) {
1543
2117
  return todos.get(id) ?? null;
@@ -1600,28 +2174,6 @@ export const appPorts = definePorts({
1600
2174
  todos,
1601
2175
  })),
1602
2176
  });
1603
- `,
1604
- productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
1605
- import { todoPolicy } from "@/features/todos/policy";
1606
- import { appError } from "@/features/shared/errors";
1607
- import { createAnonymousAuth } from "./auth/anonymous-auth";
1608
- import { fallbackLogger } from "./logger";
1609
-
1610
- const gate = createGate({
1611
- policies: [todoPolicy],
1612
- onDeny(decision) {
1613
- return appError("Forbidden", {
1614
- message: decision.reason ?? "Forbidden",
1615
- details: decision.details,
1616
- });
1617
- },
1618
- });
1619
-
1620
- export const appPorts = definePorts({
1621
- auth: createAnonymousAuth(),
1622
- gate,
1623
- logger: fallbackLogger,
1624
- });
1625
2177
  `,
1626
2178
  productionDrizzleConfig: `export default {
1627
2179
  schema: "./infra/db/schema/index.ts",
@@ -1633,7 +2185,63 @@ export const appPorts = definePorts({
1633
2185
  },
1634
2186
  };
1635
2187
  `,
1636
- productionDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2188
+ productionDbSchema: `import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2189
+
2190
+ export const user = sqliteTable("user", {
2191
+ id: text("id").primaryKey(),
2192
+ name: text("name").notNull(),
2193
+ email: text("email").notNull().unique(),
2194
+ emailVerified: integer("email_verified", { mode: "boolean" })
2195
+ .notNull()
2196
+ .default(false),
2197
+ image: text("image"),
2198
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
2199
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
2200
+ });
2201
+
2202
+ export const session = sqliteTable("session", {
2203
+ id: text("id").primaryKey(),
2204
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
2205
+ token: text("token").notNull().unique(),
2206
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
2207
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
2208
+ ipAddress: text("ip_address"),
2209
+ userAgent: text("user_agent"),
2210
+ userId: text("user_id")
2211
+ .notNull()
2212
+ .references(() => user.id, { onDelete: "cascade" }),
2213
+ });
2214
+
2215
+ export const account = sqliteTable("account", {
2216
+ id: text("id").primaryKey(),
2217
+ accountId: text("account_id").notNull(),
2218
+ providerId: text("provider_id").notNull(),
2219
+ userId: text("user_id")
2220
+ .notNull()
2221
+ .references(() => user.id, { onDelete: "cascade" }),
2222
+ accessToken: text("access_token"),
2223
+ refreshToken: text("refresh_token"),
2224
+ idToken: text("id_token"),
2225
+ accessTokenExpiresAt: integer("access_token_expires_at", {
2226
+ mode: "timestamp",
2227
+ }),
2228
+ refreshTokenExpiresAt: integer("refresh_token_expires_at", {
2229
+ mode: "timestamp",
2230
+ }),
2231
+ scope: text("scope"),
2232
+ password: text("password"),
2233
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
2234
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
2235
+ });
2236
+
2237
+ export const verification = sqliteTable("verification", {
2238
+ id: text("id").primaryKey(),
2239
+ identifier: text("identifier").notNull(),
2240
+ value: text("value").notNull(),
2241
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
2242
+ createdAt: integer("created_at", { mode: "timestamp" }),
2243
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
2244
+ });
1637
2245
 
1638
2246
  export const todos = sqliteTable("todos", {
1639
2247
  id: text("id").primaryKey(),
@@ -1641,15 +2249,139 @@ export const todos = sqliteTable("todos", {
1641
2249
  completed: integer("completed", { mode: "boolean" }).notNull().default(false),
1642
2250
  createdAt: text("created_at").notNull(),
1643
2251
  });
2252
+
2253
+ export const todoAttachments = sqliteTable(
2254
+ "todo_attachments",
2255
+ {
2256
+ id: text("id").primaryKey(),
2257
+ todoId: text("todo_id")
2258
+ .notNull()
2259
+ .references(() => todos.id, { onDelete: "cascade" }),
2260
+ key: text("key").notNull(),
2261
+ fileName: text("file_name").notNull(),
2262
+ contentType: text("content_type").notNull(),
2263
+ size: integer("size").notNull(),
2264
+ createdAt: text("created_at").notNull(),
2265
+ },
2266
+ (table) => ({
2267
+ todoIdx: index("todo_attachments_todo_idx").on(
2268
+ table.todoId,
2269
+ table.createdAt,
2270
+ ),
2271
+ keyIdx: index("todo_attachments_key_idx").on(table.key),
2272
+ }),
2273
+ );
2274
+
2275
+ export const auditLog = sqliteTable(
2276
+ "audit_log",
2277
+ {
2278
+ id: text("id").primaryKey(),
2279
+ action: text("action").notNull(),
2280
+ actorType: text("actor_type", {
2281
+ enum: ["anonymous", "service", "system", "user"],
2282
+ }).notNull(),
2283
+ actorId: text("actor_id"),
2284
+ actorDisplayName: text("actor_display_name"),
2285
+ tenantId: text("tenant_id"),
2286
+ tenantSlug: text("tenant_slug"),
2287
+ resourceType: text("resource_type"),
2288
+ resourceId: text("resource_id"),
2289
+ resourceName: text("resource_name"),
2290
+ outcome: text("outcome", { enum: ["success", "failure"] })
2291
+ .notNull()
2292
+ .default("success"),
2293
+ requestId: text("request_id"),
2294
+ traceId: text("trace_id"),
2295
+ message: text("message"),
2296
+ metadata: text("metadata"),
2297
+ actorMetadata: text("actor_metadata"),
2298
+ tenantMetadata: text("tenant_metadata"),
2299
+ resourceMetadata: text("resource_metadata"),
2300
+ occurredAt: text("occurred_at").notNull(),
2301
+ },
2302
+ (table) => ({
2303
+ actionIdx: index("audit_log_action_idx").on(table.action),
2304
+ actorIdx: index("audit_log_actor_idx").on(table.actorType, table.actorId),
2305
+ occurredAtIdx: index("audit_log_occurred_at_idx").on(table.occurredAt),
2306
+ requestIdx: index("audit_log_request_idx").on(table.requestId),
2307
+ resourceIdx: index("audit_log_resource_idx").on(
2308
+ table.resourceType,
2309
+ table.resourceId,
2310
+ ),
2311
+ tenantIdx: index("audit_log_tenant_idx").on(table.tenantId),
2312
+ }),
2313
+ );
2314
+
2315
+ export const outboxMessages = sqliteTable(
2316
+ "outbox_messages",
2317
+ {
2318
+ id: text("id").primaryKey(),
2319
+ kind: text("kind", { enum: ["event", "job"] }).notNull(),
2320
+ name: text("name").notNull(),
2321
+ payloadJson: text("payload_json").notNull(),
2322
+ status: text("status", {
2323
+ enum: ["pending", "claimed", "delivered", "deadLettered"],
2324
+ }).notNull(),
2325
+ attempts: integer("attempts").notNull().default(0),
2326
+ maxAttempts: integer("max_attempts").notNull().default(3),
2327
+ availableAt: text("available_at").notNull(),
2328
+ claimedAt: text("claimed_at"),
2329
+ lockedUntil: text("locked_until"),
2330
+ claimToken: text("claim_token"),
2331
+ deliveredAt: text("delivered_at"),
2332
+ lastErrorJson: text("last_error_json"),
2333
+ createdAt: text("created_at").notNull(),
2334
+ updatedAt: text("updated_at").notNull(),
2335
+ },
2336
+ (table) => ({
2337
+ availableIdx: index("outbox_messages_available_idx").on(
2338
+ table.status,
2339
+ table.availableAt,
2340
+ ),
2341
+ lockedIdx: index("outbox_messages_locked_idx").on(
2342
+ table.status,
2343
+ table.lockedUntil,
2344
+ ),
2345
+ }),
2346
+ );
2347
+
2348
+ export const idempotencyRecords = sqliteTable(
2349
+ "idempotency_records",
2350
+ {
2351
+ storageKey: text("storage_key").primaryKey(),
2352
+ namespace: text("namespace").notNull(),
2353
+ idempotencyKey: text("idempotency_key").notNull(),
2354
+ scopeKey: text("scope_key").notNull(),
2355
+ fingerprint: text("fingerprint").notNull(),
2356
+ status: text("status", { enum: ["in-progress", "completed"] }).notNull(),
2357
+ resultJson: text("result_json"),
2358
+ reservedAt: text("reserved_at").notNull(),
2359
+ completedAt: text("completed_at"),
2360
+ expiresAt: text("expires_at"),
2361
+ updatedAt: text("updated_at").notNull(),
2362
+ },
2363
+ (table) => ({
2364
+ lookupIdx: index("idempotency_records_lookup_idx").on(
2365
+ table.namespace,
2366
+ table.scopeKey,
2367
+ table.idempotencyKey,
2368
+ ),
2369
+ expiresIdx: index("idempotency_records_expires_idx").on(table.expiresAt),
2370
+ }),
2371
+ );
1644
2372
  `,
1645
- productionDrizzleTodoRepository: `import { count, desc, eq } from "drizzle-orm";
2373
+ productionDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
2374
+ import { offsetPageResult } from "@beignet/core/pagination";
2375
+ import { count, desc, eq } from "drizzle-orm";
1646
2376
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1647
- import type { TodoRepository } from "@/features/todos/ports";
2377
+ import type {
2378
+ TodoAttachmentRepository,
2379
+ TodoRepository,
2380
+ } from "@/features/todos/ports";
1648
2381
  import type {
1649
2382
  CreateTodoInput,
1650
- ListTodosInput,
1651
2383
  Todo,
1652
- } from "@/features/todos/use-cases/schemas";
2384
+ } from "@/features/todos/schemas";
1653
2385
  import * as schema from "@/infra/db/schema";
1654
2386
 
1655
2387
  type TodoRow = typeof schema.todos.$inferSelect;
@@ -1667,19 +2399,16 @@ export function createDrizzleTodoRepository(
1667
2399
  db: DrizzleTursoDatabase<typeof schema>,
1668
2400
  ): TodoRepository {
1669
2401
  return {
1670
- async list(input: ListTodosInput) {
2402
+ async list(page) {
1671
2403
  const rows = await db
1672
2404
  .select()
1673
2405
  .from(schema.todos)
1674
2406
  .orderBy(desc(schema.todos.createdAt))
1675
- .limit(input.limit)
1676
- .offset(input.offset);
2407
+ .limit(page.limit)
2408
+ .offset(page.offset);
1677
2409
  const [{ total }] = await db.select({ total: count() }).from(schema.todos);
1678
2410
 
1679
- return {
1680
- todos: rows.map(toTodo),
1681
- total,
1682
- };
2411
+ return offsetPageResult(rows.map(toTodo), page, total);
1683
2412
  },
1684
2413
  async findById(id: string) {
1685
2414
  const [row] = await db
@@ -1707,28 +2436,445 @@ export function createDrizzleTodoRepository(
1707
2436
  },
1708
2437
  };
1709
2438
  }
2439
+
2440
+ export function createDrizzleTodoAttachmentRepository(
2441
+ db: DrizzleTursoDatabase<typeof schema>,
2442
+ ): TodoAttachmentRepository {
2443
+ return {
2444
+ async create(input) {
2445
+ const attachment = {
2446
+ ...input,
2447
+ createdAt: new Date().toISOString(),
2448
+ };
2449
+ const [row] = await db
2450
+ .insert(schema.todoAttachments)
2451
+ .values(attachment)
2452
+ .returning();
2453
+
2454
+ if (!row) {
2455
+ throw new Error("Failed to create todo attachment");
2456
+ }
2457
+
2458
+ return {
2459
+ id: row.id,
2460
+ todoId: row.todoId,
2461
+ key: row.key,
2462
+ fileName: row.fileName,
2463
+ contentType: row.contentType,
2464
+ size: row.size,
2465
+ createdAt: row.createdAt,
2466
+ };
2467
+ },
2468
+ async findMany(todoId) {
2469
+ const rows = await db
2470
+ .select()
2471
+ .from(schema.todoAttachments)
2472
+ .where(eq(schema.todoAttachments.todoId, todoId))
2473
+ .orderBy(desc(schema.todoAttachments.createdAt));
2474
+
2475
+ return rows.map((row) => ({
2476
+ id: row.id,
2477
+ todoId: row.todoId,
2478
+ key: row.key,
2479
+ fileName: row.fileName,
2480
+ contentType: row.contentType,
2481
+ size: row.size,
2482
+ createdAt: row.createdAt,
2483
+ }));
2484
+ },
2485
+ };
2486
+ }
1710
2487
  `,
1711
2488
  productionDbRepositories: `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1712
- import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
2489
+ import {
2490
+ createDrizzleTodoAttachmentRepository,
2491
+ createDrizzleTodoRepository,
2492
+ } from "@/infra/todos/drizzle-todo-repository";
1713
2493
  import type { AppTransactionPorts } from "@/ports";
1714
2494
  import * as schema from "./schema";
1715
2495
 
1716
2496
  export function createRepositories(
1717
2497
  db: DrizzleTursoDatabase<typeof schema>,
1718
- ): Omit<AppTransactionPorts, "events"> {
2498
+ ): Omit<
2499
+ AppTransactionPorts,
2500
+ "audit" | "events" | "idempotency" | "jobs" | "outbox"
2501
+ > {
1719
2502
  return {
2503
+ todoAttachments: createDrizzleTodoAttachmentRepository(db),
1720
2504
  todos: createDrizzleTodoRepository(db),
1721
2505
  };
1722
2506
  }
1723
2507
  `,
1724
- productionContractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
2508
+ productionDrizzleAuditLog: `import type { AuditLogEntry, AuditLogPort } from "@beignet/core/ports";
2509
+ import {
2510
+ normalizeAuditLogEntry,
2511
+ redactAuditLogEntry,
2512
+ } from "@beignet/core/ports";
2513
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
2514
+ import * as schema from "@/infra/db/schema";
2515
+
2516
+ function serialize(value: unknown): string | null {
2517
+ return value === undefined ? null : JSON.stringify(value);
2518
+ }
2519
+
2520
+ export function createDrizzleAuditLog(
2521
+ db: DrizzleTursoDatabase<typeof schema>,
2522
+ ): AuditLogPort {
2523
+ return {
2524
+ async record(input) {
2525
+ const entry: AuditLogEntry = redactAuditLogEntry(
2526
+ normalizeAuditLogEntry(input),
2527
+ );
2528
+
2529
+ await db.insert(schema.auditLog).values({
2530
+ id: crypto.randomUUID(),
2531
+ action: entry.action,
2532
+ actorType: entry.actor.type,
2533
+ actorId: entry.actor.id ?? null,
2534
+ actorDisplayName: entry.actor.displayName ?? null,
2535
+ tenantId: entry.tenant?.id ?? null,
2536
+ tenantSlug: entry.tenant?.slug ?? null,
2537
+ resourceType: entry.resource?.type ?? null,
2538
+ resourceId: entry.resource?.id ?? null,
2539
+ resourceName: entry.resource?.name ?? null,
2540
+ outcome: entry.outcome,
2541
+ requestId: entry.requestId ?? null,
2542
+ traceId: entry.traceId ?? null,
2543
+ message: entry.message ?? null,
2544
+ metadata: serialize(entry.metadata),
2545
+ actorMetadata: serialize(entry.actor.metadata),
2546
+ tenantMetadata: serialize(entry.tenant?.metadata),
2547
+ resourceMetadata: serialize(entry.resource?.metadata),
2548
+ occurredAt: entry.occurredAt.toISOString(),
2549
+ });
2550
+ },
2551
+ };
2552
+ }
2553
+ `,
2554
+ productionDbBootstrap: `import { createDrizzleTursoIdempotencySetupStatements, createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
2555
+ import type { Client } from "@libsql/client";
2556
+
2557
+ type BootstrapOptions = {
2558
+ seed?: boolean;
2559
+ };
2560
+
2561
+ const setupStatements = [
2562
+ \`CREATE TABLE IF NOT EXISTS user (
2563
+ id text PRIMARY KEY NOT NULL,
2564
+ name text NOT NULL,
2565
+ email text NOT NULL UNIQUE,
2566
+ email_verified integer DEFAULT false NOT NULL,
2567
+ image text,
2568
+ created_at integer NOT NULL,
2569
+ updated_at integer NOT NULL
2570
+ )\`,
2571
+ \`CREATE TABLE IF NOT EXISTS session (
2572
+ id text PRIMARY KEY NOT NULL,
2573
+ expires_at integer NOT NULL,
2574
+ token text NOT NULL UNIQUE,
2575
+ created_at integer NOT NULL,
2576
+ updated_at integer NOT NULL,
2577
+ ip_address text,
2578
+ user_agent text,
2579
+ user_id text NOT NULL REFERENCES user(id) ON DELETE cascade
2580
+ )\`,
2581
+ \`CREATE TABLE IF NOT EXISTS account (
2582
+ id text PRIMARY KEY NOT NULL,
2583
+ account_id text NOT NULL,
2584
+ provider_id text NOT NULL,
2585
+ user_id text NOT NULL REFERENCES user(id) ON DELETE cascade,
2586
+ access_token text,
2587
+ refresh_token text,
2588
+ id_token text,
2589
+ access_token_expires_at integer,
2590
+ refresh_token_expires_at integer,
2591
+ scope text,
2592
+ password text,
2593
+ created_at integer NOT NULL,
2594
+ updated_at integer NOT NULL
2595
+ )\`,
2596
+ \`CREATE TABLE IF NOT EXISTS verification (
2597
+ id text PRIMARY KEY NOT NULL,
2598
+ identifier text NOT NULL,
2599
+ value text NOT NULL,
2600
+ expires_at integer NOT NULL,
2601
+ created_at integer,
2602
+ updated_at integer
2603
+ )\`,
2604
+ \`CREATE TABLE IF NOT EXISTS todos (
2605
+ id text PRIMARY KEY NOT NULL,
2606
+ title text NOT NULL,
2607
+ completed integer DEFAULT false NOT NULL,
2608
+ created_at text NOT NULL
2609
+ )\`,
2610
+ \`CREATE TABLE IF NOT EXISTS todo_attachments (
2611
+ id text PRIMARY KEY NOT NULL,
2612
+ todo_id text NOT NULL REFERENCES todos(id) ON DELETE cascade,
2613
+ key text NOT NULL,
2614
+ file_name text NOT NULL,
2615
+ content_type text NOT NULL,
2616
+ size integer NOT NULL,
2617
+ created_at text NOT NULL
2618
+ )\`,
2619
+ "CREATE INDEX IF NOT EXISTS todo_attachments_todo_idx ON todo_attachments (todo_id, created_at)",
2620
+ "CREATE INDEX IF NOT EXISTS todo_attachments_key_idx ON todo_attachments (key)",
2621
+ \`CREATE TABLE IF NOT EXISTS audit_log (
2622
+ id text PRIMARY KEY NOT NULL,
2623
+ action text NOT NULL,
2624
+ actor_type text NOT NULL,
2625
+ actor_id text,
2626
+ actor_display_name text,
2627
+ tenant_id text,
2628
+ tenant_slug text,
2629
+ resource_type text,
2630
+ resource_id text,
2631
+ resource_name text,
2632
+ outcome text DEFAULT 'success' NOT NULL,
2633
+ request_id text,
2634
+ trace_id text,
2635
+ message text,
2636
+ metadata text,
2637
+ actor_metadata text,
2638
+ tenant_metadata text,
2639
+ resource_metadata text,
2640
+ occurred_at text NOT NULL
2641
+ )\`,
2642
+ "CREATE INDEX IF NOT EXISTS audit_log_action_idx ON audit_log (action)",
2643
+ "CREATE INDEX IF NOT EXISTS audit_log_actor_idx ON audit_log (actor_type, actor_id)",
2644
+ "CREATE INDEX IF NOT EXISTS audit_log_occurred_at_idx ON audit_log (occurred_at)",
2645
+ "CREATE INDEX IF NOT EXISTS audit_log_request_idx ON audit_log (request_id)",
2646
+ "CREATE INDEX IF NOT EXISTS audit_log_resource_idx ON audit_log (resource_type, resource_id)",
2647
+ "CREATE INDEX IF NOT EXISTS audit_log_tenant_idx ON audit_log (tenant_id)",
2648
+ ...createDrizzleTursoIdempotencySetupStatements(),
2649
+ ...createDrizzleTursoOutboxSetupStatements(),
2650
+ ];
2651
+
2652
+ const seedTodos = [
2653
+ {
2654
+ id: "00000000-0000-4000-8000-000000000001",
2655
+ title: "Review the starter boundaries",
2656
+ completed: 0,
2657
+ createdAt: "2026-01-01T00:00:00.000Z",
2658
+ },
2659
+ {
2660
+ id: "00000000-0000-4000-8000-000000000002",
2661
+ title: "Create your first feature",
2662
+ completed: 0,
2663
+ createdAt: "2026-01-01T00:05:00.000Z",
2664
+ },
2665
+ ] as const;
2666
+
2667
+ const resetTables = [
2668
+ "verification",
2669
+ "account",
2670
+ "session",
2671
+ "user",
2672
+ "todo_attachments",
2673
+ "todos",
2674
+ "audit_log",
2675
+ "idempotency_records",
2676
+ "outbox_messages",
2677
+ ] as const;
2678
+
2679
+ export async function ensureStarterDatabase(
2680
+ client: Client,
2681
+ options: BootstrapOptions = {},
2682
+ ): Promise<void> {
2683
+ for (const statement of setupStatements) {
2684
+ await client.execute(statement);
2685
+ }
2686
+
2687
+ if (options.seed === false) return;
2688
+
2689
+ const result = await client.execute("SELECT count(*) as total FROM todos");
2690
+ const total = Number(result.rows[0]?.total ?? 0);
2691
+ if (total > 0) return;
2692
+
2693
+ for (const todo of seedTodos) {
2694
+ await client.execute({
2695
+ sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
2696
+ args: [todo.id, todo.title, todo.completed, todo.createdAt],
2697
+ });
2698
+ }
2699
+ }
2700
+
2701
+ export async function resetStarterDatabase(client: Client): Promise<void> {
2702
+ await client.execute("PRAGMA foreign_keys = OFF");
2703
+ try {
2704
+ for (const table of resetTables) {
2705
+ await client.execute(\`DROP TABLE IF EXISTS \${table}\`);
2706
+ }
2707
+ } finally {
2708
+ await client.execute("PRAGMA foreign_keys = ON");
2709
+ }
2710
+ await ensureStarterDatabase(client, { seed: false });
2711
+ }
2712
+ `,
2713
+ productionDbProvider: `import { registerListeners } from "@beignet/core/events";
2714
+ import { createInlineJobDispatcher } from "@beignet/core/jobs";
2715
+ import { createMemoryMailer } from "@beignet/core/mail";
2716
+ import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
2717
+ import {
2718
+ createOutboxEventRecorder,
2719
+ createOutboxJobDispatcher,
2720
+ } from "@beignet/core/outbox";
2721
+ import {
2722
+ createInstrumentedAuditLog,
2723
+ createSystemActor,
2724
+ } from "@beignet/core/ports";
2725
+ import {
2726
+ createProvider,
2727
+ createProviderInstrumentation,
2728
+ } from "@beignet/core/providers";
2729
+ import {
2730
+ createDrizzleTursoIdempotencyPort,
2731
+ createDrizzleTursoOutboxPort,
2732
+ createDrizzleTursoUnitOfWork,
2733
+ type DbPort,
2734
+ } from "@beignet/provider-drizzle-turso";
2735
+ import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
2736
+ import type { AppContext } from "@/app-context";
2737
+ import { todoListeners } from "@/features/todos/listeners";
2738
+ import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
2739
+ import type { AppPorts } from "@/ports";
2740
+ import type { AppServiceContextInput } from "@/server/context";
2741
+ import { ensureStarterDatabase } from "./bootstrap";
2742
+ import { createRepositories } from "./repositories";
2743
+ import type * as schema from "./schema";
2744
+
2745
+ export const starterDatabaseProvider = createProvider<
2746
+ { db: DbPort<typeof schema> },
2747
+ AppContext,
2748
+ AppServiceContextInput
2749
+ >()({
2750
+ name: "starter-database",
2751
+
2752
+ async setup({ ports, createServiceContext }) {
2753
+ const dbPort = ports.db;
2754
+ if (!dbPort) {
2755
+ throw new Error(
2756
+ "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
2757
+ );
2758
+ }
2759
+
2760
+ const createBackgroundContext = () =>
2761
+ createServiceContext({
2762
+ actor: createSystemActor("starter-background"),
2763
+ });
2764
+
2765
+ const mailInstrumentation = createProviderInstrumentation(ports, {
2766
+ providerName: "memory-mailer",
2767
+ watcher: "mail",
2768
+ });
2769
+ const eventBus = createInMemoryEventBus({
2770
+ instrumentation: ports,
2771
+ onHandlerError(error: unknown, eventName: string) {
2772
+ console.error("Event handler failed", { error, eventName });
2773
+ },
2774
+ });
2775
+ const jobs = createInlineJobDispatcher<AppContext>({
2776
+ ctx: createBackgroundContext,
2777
+ });
2778
+ const notifications = createInlineNotificationDispatcher<AppContext>({
2779
+ ctx: createBackgroundContext,
2780
+ instrumentation: ports,
2781
+ });
2782
+ const mailer = createMemoryMailer({
2783
+ defaultFrom: "Beignet Starter <noreply@example.local>",
2784
+ onSend(delivery) {
2785
+ mailInstrumentation.custom({
2786
+ name: "mail.sent",
2787
+ label: "Mail sent",
2788
+ summary: delivery.message.subject,
2789
+ details: {
2790
+ to: delivery.message.to,
2791
+ subject: delivery.message.subject,
2792
+ id: delivery.id,
2793
+ },
2794
+ });
2795
+ },
2796
+ });
2797
+ const audit = createInstrumentedAuditLog({
2798
+ audit: createDrizzleAuditLog(dbPort.db),
2799
+ instrumentation: ports,
2800
+ });
2801
+ const repositories = createRepositories(dbPort.db);
2802
+ const idempotency = createDrizzleTursoIdempotencyPort(dbPort.db);
2803
+ const outbox = createDrizzleTursoOutboxPort(dbPort.db);
2804
+ const unregisterListeners = registerListeners(eventBus, todoListeners, {
2805
+ ctx: createBackgroundContext,
2806
+ onError(error, listener) {
2807
+ console.error("Event listener failed", {
2808
+ error,
2809
+ listenerName: listener.name,
2810
+ });
2811
+ },
2812
+ });
2813
+
2814
+ const providedPorts: Pick<
2815
+ AppPorts,
2816
+ | "audit"
2817
+ | "eventBus"
2818
+ | "idempotency"
2819
+ | "jobs"
2820
+ | "mailer"
2821
+ | "notifications"
2822
+ | "outbox"
2823
+ | "todoAttachments"
2824
+ | "todos"
2825
+ | "uow"
2826
+ > = {
2827
+ audit,
2828
+ ...repositories,
2829
+ eventBus,
2830
+ idempotency,
2831
+ jobs,
2832
+ mailer,
2833
+ notifications,
2834
+ outbox,
2835
+ uow: createDrizzleTursoUnitOfWork({
2836
+ db: dbPort.db,
2837
+ createTransactionPorts: (tx) => {
2838
+ const txIdempotency = createDrizzleTursoIdempotencyPort(tx);
2839
+ const txOutbox = createDrizzleTursoOutboxPort(tx);
2840
+
2841
+ return {
2842
+ audit: createInstrumentedAuditLog({
2843
+ audit: createDrizzleAuditLog(tx),
2844
+ instrumentation: ports,
2845
+ }),
2846
+ ...createRepositories(tx),
2847
+ events: createOutboxEventRecorder(txOutbox),
2848
+ idempotency: txIdempotency,
2849
+ jobs: createOutboxJobDispatcher(txOutbox),
2850
+ outbox: txOutbox,
2851
+ };
2852
+ },
2853
+ }),
2854
+ };
2855
+
2856
+ return {
2857
+ ports: providedPorts,
2858
+ async start() {
2859
+ await ensureStarterDatabase(dbPort.client);
2860
+ },
2861
+ stop() {
2862
+ unregisterListeners();
2863
+ },
2864
+ };
2865
+ },
2866
+ });
2867
+ `,
2868
+ productionContractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
1725
2869
  import { z } from "zod";
1726
2870
  import { errors } from "@/features/shared/errors";
1727
2871
  import {
1728
- createTodoUseCase,
1729
- getTodoUseCase,
1730
- listTodosUseCase,
1731
- } from "@/features/todos/use-cases";
2872
+ CreateTodoInputSchema,
2873
+ GetTodoInputSchema,
2874
+ ListTodosInputSchema,
2875
+ ListTodosOutputSchema,
2876
+ TodoSchema,
2877
+ } from "@/features/todos/schemas";
1732
2878
 
1733
2879
  const ErrorResponseSchema = z.object({
1734
2880
  code: z.string(),
@@ -1736,7 +2882,7 @@ const ErrorResponseSchema = z.object({
1736
2882
  requestId: z.string().optional(),
1737
2883
  });
1738
2884
 
1739
- const todos = createContractGroup()
2885
+ const todos = defineContractGroup()
1740
2886
  .namespace("todos")
1741
2887
  .errors({ Unauthorized: errors.Unauthorized })
1742
2888
  .responses({
@@ -1745,28 +2891,29 @@ const todos = createContractGroup()
1745
2891
 
1746
2892
  export const listTodos = todos
1747
2893
  .get("/api/todos")
1748
- .query(listTodosUseCase.inputSchema)
2894
+ .query(ListTodosInputSchema)
1749
2895
  .responses({
1750
- 200: listTodosUseCase.outputSchema,
2896
+ 200: ListTodosOutputSchema,
1751
2897
  });
1752
2898
 
1753
2899
  export const createTodo = todos
1754
2900
  .post("/api/todos")
1755
- .body(createTodoUseCase.inputSchema)
2901
+ .body(CreateTodoInputSchema)
1756
2902
  .errors({ Forbidden: errors.Forbidden })
1757
2903
  .responses({
1758
- 201: createTodoUseCase.outputSchema,
2904
+ 201: TodoSchema,
1759
2905
  });
1760
2906
 
1761
2907
  export const getTodo = todos
1762
2908
  .get("/api/todos/:id")
1763
- .pathParams(getTodoUseCase.inputSchema)
2909
+ .pathParams(GetTodoInputSchema)
1764
2910
  .errors({ TodoNotFound: errors.TodoNotFound })
1765
2911
  .responses({
1766
- 200: getTodoUseCase.outputSchema,
2912
+ 200: TodoSchema,
1767
2913
  });
1768
2914
  `,
1769
- productionTodoRoutes: `import { defineRouteGroup } from "@beignet/next";
2915
+ productionTodoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
2916
+ import { defineRouteGroup } from "@beignet/next";
1770
2917
  import type { AppContext } from "@/app-context";
1771
2918
  import { createTodo, getTodo, listTodos } from "@/features/todos/contracts";
1772
2919
  import {
@@ -1778,31 +2925,14 @@ import {
1778
2925
  export const todoRoutes = defineRouteGroup<AppContext>({
1779
2926
  name: "todos",
1780
2927
  routes: [
1781
- {
1782
- contract: listTodos,
1783
- handle: async ({ ctx, query }) => ({
1784
- status: 200,
1785
- body: await listTodosUseCase.run({ ctx, input: query }),
1786
- }),
1787
- },
1788
- {
1789
- contract: createTodo,
1790
- handle: async ({ ctx, body }) => ({
1791
- status: 201,
1792
- body: await createTodoUseCase.run({ ctx, input: body }),
1793
- }),
1794
- },
1795
- {
1796
- contract: getTodo,
1797
- handle: async ({ ctx, path }) => ({
1798
- status: 200,
1799
- body: await getTodoUseCase.run({ ctx, input: path }),
1800
- }),
1801
- },
2928
+ { contract: listTodos, useCase: listTodosUseCase },
2929
+ { contract: createTodo, useCase: createTodoUseCase },
2930
+ { contract: getTodo, useCase: getTodoUseCase },
1802
2931
  ],
1803
2932
  });
1804
2933
  `,
1805
- productionServerRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
2934
+ productionServerRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
2935
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1806
2936
  import type { AppContext } from "@/app-context";
1807
2937
  import { todoRoutes } from "@/features/todos/routes";
1808
2938
 
@@ -1811,45 +2941,126 @@ export const routes = defineRoutes<AppContext>([
1811
2941
  ]);
1812
2942
  export const contracts = contractsFromRoutes(routes);
1813
2943
  `,
1814
- productionServer: `import { createDevtoolsHooks } from "@beignet/devtools";
1815
- import { createNextServer } from "@beignet/next";
2944
+ productionServerContext: `import type { beignetServerOnly } from "@beignet/core/server-only";
1816
2945
  import {
2946
+ type ActivityActor,
1817
2947
  createAnonymousActor,
2948
+ createServiceActor,
1818
2949
  createTenant,
1819
2950
  createUserActor,
1820
2951
  } from "@beignet/core/ports";
1821
- import type { AppContext } from "@/app-context";
1822
- import { appPorts } from "@/infra/app-ports";
1823
- import { env } from "@/lib/env";
1824
- import { providers } from "./providers";
1825
- import { routes } from "./routes";
1826
-
1827
- export const server = await createNextServer({
1828
- ports: appPorts,
1829
- providers,
1830
- hooks: [
1831
- createDevtoolsHooks<AppContext>({
1832
- requestIdHeader: "x-request-id",
1833
- }),
1834
- ],
1835
- createContext: async ({ req, ports }) => {
2952
+ import { defineServerContext } from "@beignet/core/server";
2953
+ import type { TraceContext } from "@beignet/core/tracing";
2954
+ import type { AppContext, AppRuntimePorts } from "@/app-context";
2955
+
2956
+ export type AppServiceContextInput =
2957
+ | {
2958
+ actor?: ActivityActor;
2959
+ tenantId?: string;
2960
+ }
2961
+ | undefined;
2962
+
2963
+ /**
2964
+ * Context blueprint shared by the runtime server and route tests.
2965
+ */
2966
+ export const appContext = defineServerContext<AppContext, AppRuntimePorts>()({
2967
+ gate: (ports) => ports.gate,
2968
+ request: async ({ req, ports, requestId, trace }) => {
1836
2969
  const auth = await ports.auth.getSession(req);
1837
2970
  const tenantId = req.headers.get("x-tenant-id") || undefined;
1838
- const context = {
1839
- requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
2971
+
2972
+ return {
2973
+ requestId,
1840
2974
  actor: auth
1841
2975
  ? createUserActor(auth.user.id, { displayName: auth.user.name })
1842
2976
  : createAnonymousActor(),
1843
2977
  auth,
2978
+ ...trace,
1844
2979
  ports,
1845
2980
  tenant: tenantId ? createTenant(tenantId) : undefined,
1846
2981
  };
2982
+ },
2983
+ service: ({
2984
+ ports,
2985
+ input,
2986
+ requestId,
2987
+ trace,
2988
+ }: {
2989
+ ports: AppRuntimePorts;
2990
+ input: AppServiceContextInput;
2991
+ requestId: string;
2992
+ trace: TraceContext;
2993
+ }) => ({
2994
+ requestId,
2995
+ actor: input?.actor ?? createServiceActor("beignet-service"),
2996
+ auth: null,
2997
+ ...trace,
2998
+ ports,
2999
+ tenant: createTenant(input?.tenantId ?? "tenant_default"),
3000
+ }),
3001
+ });
3002
+ `,
3003
+ productionServer: `import type { beignetServerOnly } from "@beignet/core/server-only";
3004
+ import { createNextServer } from "@beignet/next";
3005
+ import { createIdempotencyHooks } from "@beignet/core/server";
3006
+ import type { AppContext } from "@/app-context";
3007
+ import { appPorts } from "@/infra/app-ports";
3008
+ import { env } from "@/lib/env";
3009
+ import { appContext } from "./context";
3010
+ import { providers } from "./providers";
3011
+ import { routes } from "./routes";
3012
+
3013
+ export const server = await createNextServer({
3014
+ ports: appPorts,
3015
+ providers,
3016
+ providerConfig: {
3017
+ "drizzle-turso": {
3018
+ DB_URL: env.TURSO_DB_URL,
3019
+ DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
3020
+ },
3021
+ },
3022
+ hooks: [createIdempotencyHooks<AppContext>()],
3023
+ context: appContext,
3024
+ routes,
3025
+ mapUnhandledError: ({ err, ctx }) => {
3026
+ ctx?.ports.logger.error("Unhandled API error", {
3027
+ error: err,
3028
+ requestId: ctx?.requestId,
3029
+ environment: env.NODE_ENV,
3030
+ });
1847
3031
 
1848
3032
  return {
1849
- ...context,
1850
- gate: ports.gate.bind(context),
3033
+ status: 500,
3034
+ body: {
3035
+ code: "INTERNAL_SERVER_ERROR",
3036
+ message: "Internal server error",
3037
+ requestId: ctx?.requestId,
3038
+ },
1851
3039
  };
1852
3040
  },
3041
+ });
3042
+ `,
3043
+ productionServerWithDrizzleTurso: `import type { beignetServerOnly } from "@beignet/core/server-only";
3044
+ import { createNextServer } from "@beignet/next";
3045
+ import { createIdempotencyHooks } from "@beignet/core/server";
3046
+ import type { AppContext } from "@/app-context";
3047
+ import { appPorts } from "@/infra/app-ports";
3048
+ import { env } from "@/lib/env";
3049
+ import { appContext } from "./context";
3050
+ import { providers } from "./providers";
3051
+ import { routes } from "./routes";
3052
+
3053
+ export const server = await createNextServer({
3054
+ ports: appPorts,
3055
+ providers,
3056
+ providerConfig: {
3057
+ "drizzle-turso": {
3058
+ DB_URL: env.TURSO_DB_URL,
3059
+ DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
3060
+ },
3061
+ },
3062
+ hooks: [createIdempotencyHooks<AppContext>()],
3063
+ context: appContext,
1853
3064
  routes,
1854
3065
  mapUnhandledError: ({ err, ctx }) => {
1855
3066
  ctx?.ports.logger.error("Unhandled API error", {
@@ -1869,133 +3080,511 @@ export const server = await createNextServer({
1869
3080
  },
1870
3081
  });
1871
3082
  `,
1872
- productionServerWithDrizzleTurso: `import { createDevtoolsHooks } from "@beignet/devtools";
1873
- import { createNextServer } from "@beignet/next";
1874
- import {
1875
- createAnonymousActor,
1876
- createTenant,
1877
- createUserActor,
1878
- } from "@beignet/core/ports";
1879
- import { createDrizzleTursoUnitOfWork } from "@beignet/provider-drizzle-turso";
1880
- import type { AppContext } from "@/app-context";
1881
- import { appPorts } from "@/infra/app-ports";
1882
- import * as schema from "@/infra/db/schema";
1883
- import { createRepositories } from "@/infra/db/repositories";
1884
- import { env } from "@/lib/env";
1885
- import type { AppTransactionPorts } from "@/ports";
1886
- import { providers } from "./providers";
1887
- import { routes } from "./routes";
3083
+ productionApiCatchAllRoute: `import { server } from "@/server";
3084
+
3085
+ export const GET = server.api;
3086
+ export const HEAD = server.api;
3087
+ export const OPTIONS = server.api;
3088
+ export const PATCH = server.api;
3089
+ export const POST = server.api;
3090
+ export const PUT = server.api;
3091
+ export const DELETE = server.api;
3092
+ `,
3093
+ productionHealthRoute: `import { env } from "@/lib/env";
3094
+
3095
+ export function GET() {
3096
+ return Response.json(
3097
+ {
3098
+ status: "ok",
3099
+ environment: env.NODE_ENV,
3100
+ timestamp: new Date().toISOString(),
3101
+ },
3102
+ {
3103
+ headers: {
3104
+ "cache-control": "no-store",
3105
+ },
3106
+ },
3107
+ );
3108
+ }
3109
+ `,
3110
+ productionDevtoolsRoute: `import { createDevtoolsRoute } from "@beignet/devtools";
3111
+ import { server } from "@/server";
3112
+
3113
+ export const { GET, POST } = createDevtoolsRoute(server.ports.devtools, {
3114
+ basePath: "/api/devtools",
3115
+ });
3116
+ `,
3117
+ productionStorageRoute: `import { createStorageRoute } from "@beignet/next";
3118
+ import { server } from "@/server";
3119
+
3120
+ export const { GET, HEAD } = createStorageRoute(server.ports.storage, {
3121
+ basePath: "/storage",
3122
+ });
3123
+ `,
3124
+ productionOpenApiRoute: `import { createOpenAPIHandler } from "@beignet/next";
3125
+ import { server } from "@/server";
3126
+
3127
+ export const GET = createOpenAPIHandler(server.contracts, {
3128
+ title: "Beignet starter API",
3129
+ version: "0.1.0",
3130
+ });
3131
+ `,
3132
+ productionAuthRoute: `import { toNextJsHandler } from "better-auth/next-js";
3133
+ import { auth } from "@/lib/better-auth";
3134
+
3135
+ export const { GET, POST } = toNextJsHandler(auth);
3136
+ `,
3137
+ productionBetterAuth: `import { createClient } from "@libsql/client";
3138
+ import { betterAuth } from "better-auth";
3139
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
3140
+ import { drizzle } from "drizzle-orm/libsql";
3141
+ import * as schema from "@/infra/db/schema";
3142
+ import { ensureStarterDatabase } from "@/infra/db/bootstrap";
3143
+ import { env } from "@/lib/env";
3144
+
3145
+ const client = createClient({
3146
+ url: env.TURSO_DB_URL,
3147
+ authToken: env.TURSO_DB_AUTH_TOKEN,
3148
+ });
3149
+
3150
+ await ensureStarterDatabase(client);
3151
+
3152
+ const db = drizzle(client, { schema });
3153
+
3154
+ const trustedOrigins = [
3155
+ env.APP_URL,
3156
+ env.BETTER_AUTH_URL,
3157
+ ...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",")
3158
+ .map((origin) => origin.trim())
3159
+ .filter(Boolean) ?? []),
3160
+ ];
3161
+
3162
+ export const auth = betterAuth({
3163
+ baseURL: env.BETTER_AUTH_URL,
3164
+ secret: env.BETTER_AUTH_SECRET,
3165
+ trustedOrigins: [...new Set(trustedOrigins)],
3166
+ database: drizzleAdapter(db, {
3167
+ provider: "sqlite",
3168
+ schema,
3169
+ }),
3170
+ emailAndPassword: {
3171
+ enabled: true,
3172
+ },
3173
+ });
3174
+ `,
3175
+ productionDbSeed: `import { createClient } from "@libsql/client";
3176
+ import { ensureStarterDatabase } from "@/infra/db/bootstrap";
3177
+ import { env } from "@/lib/env";
3178
+
3179
+ const client = createClient({
3180
+ url: env.TURSO_DB_URL,
3181
+ authToken: env.TURSO_DB_AUTH_TOKEN,
3182
+ });
3183
+
3184
+ try {
3185
+ await ensureStarterDatabase(client, { seed: true });
3186
+ console.log("Database seeded.");
3187
+ } finally {
3188
+ client.close();
3189
+ }
3190
+ `,
3191
+ productionDbReset: `import { createClient } from "@libsql/client";
3192
+ import { resetStarterDatabase } from "@/infra/db/bootstrap";
3193
+ import { env } from "@/lib/env";
3194
+
3195
+ if (!env.TURSO_DB_URL.startsWith("file:") && process.env.BEIGNET_ALLOW_DATABASE_RESET !== "true") {
3196
+ throw new Error(
3197
+ "Refusing to reset a non-local database. Set BEIGNET_ALLOW_DATABASE_RESET=true if this is intentional.",
3198
+ );
3199
+ }
3200
+
3201
+ const client = createClient({
3202
+ url: env.TURSO_DB_URL,
3203
+ authToken: env.TURSO_DB_AUTH_TOKEN,
3204
+ });
3205
+
3206
+ try {
3207
+ await resetStarterDatabase(client);
3208
+ console.log("Database reset.");
3209
+ } finally {
3210
+ client.close();
3211
+ }
3212
+ `,
3213
+ productionDbTestDatabase: `import { existsSync, unlinkSync } from "node:fs";
3214
+ import { tmpdir } from "node:os";
3215
+ import { join } from "node:path";
3216
+ import type { OutboxPort } from "@beignet/core/outbox";
3217
+ import { createDrizzleTursoOutboxPort } from "@beignet/provider-drizzle-turso";
3218
+ import { type Client, createClient } from "@libsql/client";
3219
+ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
3220
+ import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
3221
+ import { ensureStarterDatabase, resetStarterDatabase } from "./bootstrap";
3222
+ import { createRepositories } from "./repositories";
3223
+ import * as schema from "./schema";
3224
+
3225
+ export type TestDatabase = {
3226
+ \tclient: Client;
3227
+ \tdb: LibSQLDatabase<typeof schema>;
3228
+ \taudit: ReturnType<typeof createDrizzleAuditLog>;
3229
+ \toutbox: OutboxPort;
3230
+ \trepositories: ReturnType<typeof createRepositories>;
3231
+ \tpath: string;
3232
+ \treset(): Promise<void>;
3233
+ \tclose(): Promise<void>;
3234
+ };
3235
+
3236
+ export async function createTestDatabase(): Promise<TestDatabase> {
3237
+ \tconst path = join(tmpdir(), "beignet-test-" + crypto.randomUUID() + ".db");
3238
+ \tconst client = createClient({ url: "file:" + path });
3239
+ \tawait ensureStarterDatabase(client, { seed: false });
3240
+
3241
+ \tconst db = drizzle(client, { schema });
3242
+
3243
+ \treturn {
3244
+ \t\tclient,
3245
+ \t\tdb,
3246
+ \t\taudit: createDrizzleAuditLog(db),
3247
+ \t\toutbox: createDrizzleTursoOutboxPort(db),
3248
+ \t\trepositories: createRepositories(db),
3249
+ \t\tpath,
3250
+ \t\treset: async () => {
3251
+ \t\t\tawait resetStarterDatabase(client);
3252
+ \t\t},
3253
+ \t\tclose: async () => {
3254
+ \t\t\tclient.close();
3255
+ \t\t\tif (existsSync(path)) {
3256
+ \t\t\t\tunlinkSync(path);
3257
+ \t\t\t}
3258
+ \t\t},
3259
+ \t};
3260
+ }
3261
+ `,
3262
+ productionAuditHelper: `import type { AuditLogEntryInput } from "@beignet/core/ports";
3263
+ import type { AppContext } from "@/app-context";
3264
+
3265
+ export function auditEntry(
3266
+ ctx: AppContext,
3267
+ entry: Omit<
3268
+ AuditLogEntryInput,
3269
+ "actor" | "tenant" | "requestId" | "traceId"
3270
+ >,
3271
+ ): AuditLogEntryInput {
3272
+ return {
3273
+ ...entry,
3274
+ actor: ctx.actor,
3275
+ tenant: ctx.tenant,
3276
+ requestId: ctx.requestId,
3277
+ traceId: ctx.traceId,
3278
+ };
3279
+ }
3280
+ `,
3281
+ productionTodoEvents: `import { defineEvent } from "@beignet/core/events";
3282
+ import { z } from "zod";
3283
+
3284
+ export const TodoCreated = defineEvent("todos.created", {
3285
+ payload: z.object({
3286
+ todoId: z.string().uuid(),
3287
+ title: z.string(),
3288
+ }),
3289
+ });
3290
+
3291
+ export const todoEvents = [TodoCreated] as const;
3292
+ `,
3293
+ productionTodoJobs: `import { retry } from "@beignet/core/jobs";
3294
+ import { z } from "zod";
3295
+ import { auditEntry } from "@/lib/audit";
3296
+ import { defineJob } from "@/lib/jobs";
3297
+
3298
+ export const LogTodoCreatedJob = defineJob("todos.log-created", {
3299
+ payload: z.object({
3300
+ todoId: z.string().uuid(),
3301
+ title: z.string(),
3302
+ }),
3303
+ retry: retry.exponential({
3304
+ attempts: 3,
3305
+ initialDelay: "1s",
3306
+ maxDelay: "1m",
3307
+ }),
3308
+ async handle({ ctx, payload }) {
3309
+ ctx.ports.logger.info("Todo created job handled", {
3310
+ todoId: payload.todoId,
3311
+ });
3312
+ await ctx.ports.audit.record(
3313
+ auditEntry(ctx, {
3314
+ action: "jobs.todos.log-created",
3315
+ resource: { type: "todo", id: payload.todoId, name: payload.title },
3316
+ metadata: { jobName: "todos.log-created" },
3317
+ }),
3318
+ );
3319
+ },
3320
+ });
3321
+
3322
+ export const todoJobs = [LogTodoCreatedJob] as const;
3323
+ `,
3324
+ productionTodoNotifications: `import { defineMailNotificationChannel } from "@beignet/core/notifications";
3325
+ import { z } from "zod";
3326
+ import { defineNotification } from "@/lib/notifications";
1888
3327
 
1889
- export const server = await createNextServer({
1890
- ports: appPorts,
1891
- providers,
1892
- hooks: [
1893
- createDevtoolsHooks<AppContext>({
1894
- requestIdHeader: "x-request-id",
3328
+ export const TodoCreatedNotification = defineNotification(
3329
+ "todos.created",
3330
+ {
3331
+ payload: z.object({
3332
+ todoId: z.string().uuid(),
3333
+ title: z.string(),
1895
3334
  }),
1896
- ],
1897
- createContext: async ({ req, ports }) => {
1898
- const auth = await ports.auth.getSession(req);
1899
- const tenantId = req.headers.get("x-tenant-id") || undefined;
1900
- const repositories = createRepositories(ports.db.db);
3335
+ channels: {
3336
+ email: defineMailNotificationChannel(({ payload }) => ({
3337
+ to: "ops@example.local",
3338
+ subject: \`Todo created: \${payload.title}\`,
3339
+ text: \`Todo \${payload.todoId} was created.\`,
3340
+ })),
3341
+ },
3342
+ },
3343
+ );
3344
+ `,
3345
+ productionTodoListeners: `import { TodoCreated } from "@/features/todos/domain/events";
3346
+ import { LogTodoCreatedJob } from "@/features/todos/jobs";
3347
+ import { TodoCreatedNotification } from "@/features/todos/notifications";
3348
+ import { auditEntry } from "@/lib/audit";
3349
+ import { defineListener } from "@/lib/listeners";
3350
+
3351
+ export const enqueueTodoCreatedWork = defineListener(TodoCreated, {
3352
+ name: "todos.enqueue-created-work",
3353
+ async handle({ ctx, payload }) {
3354
+ await ctx.ports.jobs.dispatch(LogTodoCreatedJob, payload);
3355
+ await ctx.ports.notifications.send(TodoCreatedNotification, payload);
3356
+ await ctx.ports.audit.record(
3357
+ auditEntry(ctx, {
3358
+ action: "listeners.todos.enqueue-created-work",
3359
+ resource: { type: "todo", id: payload.todoId, name: payload.title },
3360
+ metadata: {
3361
+ eventName: TodoCreated.name,
3362
+ jobName: LogTodoCreatedJob.name,
3363
+ notificationName: TodoCreatedNotification.name,
3364
+ },
3365
+ }),
3366
+ );
3367
+ },
3368
+ });
1901
3369
 
1902
- const context = {
1903
- requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
1904
- actor: auth
1905
- ? createUserActor(auth.user.id, { displayName: auth.user.name })
1906
- : createAnonymousActor(),
1907
- auth,
1908
- ports: {
1909
- ...ports,
1910
- ...repositories,
1911
- uow: createDrizzleTursoUnitOfWork<typeof schema, AppTransactionPorts>({
1912
- db: ports.db.db,
1913
- createTransactionPorts: (tx) => ({
1914
- ...createRepositories(tx),
1915
- }),
3370
+ export const todoListeners = [enqueueTodoCreatedWork] as const;
3371
+ `,
3372
+ productionTodoSchedules: `import { normalizeOffsetPage } from "@beignet/core/pagination";
3373
+ import { z } from "zod";
3374
+ import { auditEntry } from "@/lib/audit";
3375
+ import { defineSchedule } from "@/lib/schedules";
3376
+
3377
+ export const LogDailyTodoSummarySchedule = defineSchedule(
3378
+ "todos.log-daily-summary",
3379
+ {
3380
+ cron: "0 9 * * *",
3381
+ timezone: "America/Chicago",
3382
+ payload: z.object({
3383
+ date: z.string(),
3384
+ }),
3385
+ createPayload({ run }) {
3386
+ const date = run.scheduledAt ?? run.triggeredAt;
3387
+ return { date: date.toISOString().slice(0, 10) };
3388
+ },
3389
+ async handle({ ctx, payload, run }) {
3390
+ const { page } = await ctx.ports.todos.list(
3391
+ normalizeOffsetPage(
3392
+ { limit: 1, offset: 0 },
3393
+ { defaultLimit: 1, maxLimit: 1 },
3394
+ ),
3395
+ );
3396
+ ctx.ports.logger.info("Daily todo summary schedule handled", {
3397
+ date: payload.date,
3398
+ todoCount: page.total,
3399
+ });
3400
+ await ctx.ports.audit.record(
3401
+ auditEntry(ctx, {
3402
+ action: "schedules.todos.daily-summary",
3403
+ resource: {
3404
+ type: "schedule",
3405
+ id: "todos.log-daily-summary",
3406
+ name: "Daily todo summary",
3407
+ },
3408
+ metadata: {
3409
+ date: payload.date,
3410
+ todoCount: page.total,
3411
+ source: run.source ?? "inline",
3412
+ triggeredAt: run.triggeredAt.toISOString(),
3413
+ },
1916
3414
  }),
1917
- },
1918
- tenant: tenantId ? createTenant(tenantId) : undefined,
1919
- };
3415
+ );
3416
+ },
3417
+ },
3418
+ );
3419
+
3420
+ export const todoSchedules = [LogDailyTodoSummarySchedule] as const;
3421
+ `,
3422
+ productionSchedules: `import { createServiceActor } from "@beignet/core/ports";
3423
+ import type { AppContext } from "@/app-context";
3424
+ import { todoSchedules } from "@/features/todos/schedules";
3425
+ import { server } from "./index.js";
3426
+
3427
+ export const schedules = [...todoSchedules] as const;
3428
+
3429
+ export async function createScheduleContext(): Promise<AppContext> {
3430
+ return server.createServiceContext({
3431
+ actor: createServiceActor("beignet-schedule"),
3432
+ });
3433
+ }
3434
+
3435
+ export async function stopScheduleContext(): Promise<void> {
3436
+ await server.stop();
3437
+ }
3438
+ `,
3439
+ productionTodoUploads: `import { defineUpload, defineUploads } from "@beignet/core/uploads";
3440
+ import { z } from "zod";
3441
+ import type { AppContext } from "@/app-context";
3442
+ import { auditEntry } from "@/lib/audit";
3443
+
3444
+ const todoAttachmentMetadataSchema = z.object({
3445
+ todoId: z.string().uuid(),
3446
+ });
1920
3447
 
3448
+ export const TodoAttachmentUpload = defineUpload<
3449
+ "todos.attachment",
3450
+ typeof todoAttachmentMetadataSchema,
3451
+ AppContext,
3452
+ { attachmentIds: string[] }
3453
+ >("todos.attachment", {
3454
+ metadata: todoAttachmentMetadataSchema,
3455
+ file: {
3456
+ contentTypes: ["application/pdf", "text/plain", "text/plain;charset=utf-8"],
3457
+ maxSizeBytes: 5 * 1024 * 1024,
3458
+ maxFiles: 3,
3459
+ visibility: "private",
3460
+ cacheControl: "private, max-age=0",
3461
+ },
3462
+ authorize({ ctx }) {
3463
+ return ctx.actor.type === "user"
3464
+ ? true
3465
+ : {
3466
+ allowed: false,
3467
+ reason: "You must be signed in to upload todo attachments.",
3468
+ };
3469
+ },
3470
+ key({ ctx, metadata, file, uploadId }) {
3471
+ const tenantId = ctx.tenant?.id ?? "tenant_default";
3472
+ const extension = file.name.includes(".")
3473
+ ? file.name.split(".").pop()
3474
+ : undefined;
3475
+ const suffix = extension ? \`.\${extension}\` : "";
3476
+ return \`todos/\${tenantId}/\${metadata.todoId}/attachments/\${uploadId}\${suffix}\`;
3477
+ },
3478
+ storageMetadata({ ctx, metadata }) {
1921
3479
  return {
1922
- ...context,
1923
- gate: ports.gate.bind(context),
3480
+ tenantId: ctx.tenant?.id ?? "tenant_default",
3481
+ todoId: metadata.todoId,
1924
3482
  };
1925
3483
  },
1926
- routes,
1927
- mapUnhandledError: ({ err, ctx }) => {
1928
- ctx?.ports.logger.error("Unhandled API error", {
1929
- error: err,
1930
- requestId: ctx?.requestId,
1931
- environment: env.NODE_ENV,
1932
- });
3484
+ async onComplete({ ctx, metadata, files }) {
3485
+ const attachments = await Promise.all(
3486
+ files.map((file) =>
3487
+ ctx.ports.todoAttachments.create({
3488
+ id: file.uploadId,
3489
+ todoId: metadata.todoId,
3490
+ key: file.key,
3491
+ fileName: file.name,
3492
+ contentType: file.contentType,
3493
+ size: file.object.size,
3494
+ }),
3495
+ ),
3496
+ );
3497
+ await ctx.ports.audit.record(
3498
+ auditEntry(ctx, {
3499
+ action: "todos.attachment.upload",
3500
+ resource: { type: "todo", id: metadata.todoId },
3501
+ metadata: {
3502
+ attachmentCount: attachments.length,
3503
+ keys: attachments.map((attachment) => attachment.key),
3504
+ },
3505
+ }),
3506
+ );
1933
3507
 
1934
3508
  return {
1935
- status: 500,
1936
- body: {
1937
- code: "INTERNAL_SERVER_ERROR",
1938
- message: "Internal server error",
1939
- requestId: ctx?.requestId,
1940
- },
3509
+ attachmentIds: attachments.map((attachment) => attachment.id),
1941
3510
  };
1942
3511
  },
1943
3512
  });
1944
- `,
1945
- productionApiCatchAllRoute: `import { server } from "@/server";
1946
3513
 
1947
- export const GET = server.api;
1948
- export const HEAD = server.api;
1949
- export const OPTIONS = server.api;
1950
- export const PATCH = server.api;
1951
- export const POST = server.api;
1952
- export const PUT = server.api;
1953
- export const DELETE = server.api;
3514
+ export const todoUploads = defineUploads({
3515
+ todoAttachment: TodoAttachmentUpload,
3516
+ });
1954
3517
  `,
1955
- productionHealthRoute: `import { env } from "@/lib/env";
3518
+ productionOutbox: `import { defineOutboxRegistry } from "@beignet/core/outbox";
3519
+ import { createServiceActor } from "@beignet/core/ports";
3520
+ import type { AppContext } from "@/app-context";
3521
+ import { todoEvents } from "@/features/todos/domain/events";
3522
+ import { todoJobs } from "@/features/todos/jobs";
3523
+ import { server } from "./index.js";
1956
3524
 
1957
- export function GET() {
1958
- return Response.json(
1959
- {
1960
- status: "ok",
1961
- environment: env.NODE_ENV,
1962
- timestamp: new Date().toISOString(),
1963
- },
1964
- {
1965
- headers: {
1966
- "cache-control": "no-store",
1967
- },
1968
- },
1969
- );
3525
+ export const outboxRegistry = defineOutboxRegistry({
3526
+ events: todoEvents,
3527
+ jobs: todoJobs,
3528
+ });
3529
+
3530
+ export async function createOutboxDrainContext(): Promise<AppContext> {
3531
+ return server.createServiceContext({
3532
+ actor: createServiceActor("beignet-outbox"),
3533
+ });
3534
+ }
3535
+
3536
+ export async function stopOutboxDrainContext(): Promise<void> {
3537
+ await server.stop();
1970
3538
  }
1971
3539
  `,
1972
- productionDevtoolsRoute: `import { createDevtoolsRoute } from "@beignet/devtools";
3540
+ productionOutboxDrainRoute: `import { createOutboxDrainRoute } from "@beignet/next";
3541
+ import { env } from "@/lib/env";
1973
3542
  import { server } from "@/server";
3543
+ import { outboxRegistry } from "@/server/outbox";
1974
3544
 
1975
- export const { GET, POST } = createDevtoolsRoute(server.ports.devtools, {
1976
- basePath: "/api/devtools",
3545
+ export const runtime = "nodejs";
3546
+
3547
+ export const { GET, POST } = createOutboxDrainRoute({
3548
+ server,
3549
+ registry: outboxRegistry,
3550
+ secret: env.CRON_SECRET,
1977
3551
  });
1978
3552
  `,
1979
- productionStorageRoute: `import { createStorageRoute } from "@beignet/next";
3553
+ productionScheduleRoute: `import { createScheduleRoute } from "@beignet/next";
3554
+ import { env } from "@/lib/env";
1980
3555
  import { server } from "@/server";
3556
+ import { schedules } from "@/server/schedules";
1981
3557
 
1982
- export const { GET, HEAD } = createStorageRoute(server.ports.storage, {
1983
- basePath: "/storage",
3558
+ export const runtime = "nodejs";
3559
+
3560
+ export const { GET, POST } = createScheduleRoute({
3561
+ server,
3562
+ schedules,
3563
+ schedule: "todos.log-daily-summary",
3564
+ secret: env.CRON_SECRET,
3565
+ source: "starter-cron-route",
1984
3566
  });
1985
3567
  `,
1986
- productionOpenApiRoute: `import { createOpenAPIHandler } from "@beignet/next";
3568
+ productionUploadsRoute: `import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
3569
+ import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
3570
+ import { createUploadRoute } from "@beignet/next";
3571
+ import type { AppContext } from "@/app-context";
3572
+ import { todoUploads } from "@/features/todos/uploads";
1987
3573
  import { server } from "@/server";
1988
3574
 
1989
- export const GET = createOpenAPIHandler(server.contracts, {
1990
- title: "Beignet starter API",
1991
- version: "0.1.0",
3575
+ const uploadRouter = createUploadRouter<AppContext>({
3576
+ uploads: uploadsFromRegistry(todoUploads),
3577
+ ctx: () => server.createContextFromNext(),
3578
+ storage: server.ports.storage,
3579
+ instrumentation: resolveProviderInstrumentationPort(server.ports),
1992
3580
  });
3581
+
3582
+ export const { POST } = createUploadRoute(uploadRouter);
1993
3583
  `,
1994
3584
  };
1995
3585
 
1996
3586
  function productionProviderPortImports(ctx: TemplateContext): string[] {
1997
3587
  const imports: string[] = [];
1998
- if (hasIntegration(ctx, "inngest")) imports.push("\tJobDispatcherPort,");
1999
3588
  if (hasIntegration(ctx, "upstash-rate-limit")) {
2000
3589
  imports.push("\tRateLimitPort,");
2001
3590
  }
@@ -2004,43 +3593,128 @@ function productionProviderPortImports(ctx: TemplateContext): string[] {
2004
3593
 
2005
3594
  function productionProviderPortFields(ctx: TemplateContext): string[] {
2006
3595
  const fields: string[] = [];
2007
- if (hasIntegration(ctx, "inngest")) fields.push("\tjobs: JobDispatcherPort;");
2008
- if (hasIntegration(ctx, "resend")) fields.push("\tmailer: MailerPort;");
2009
3596
  if (hasIntegration(ctx, "upstash-rate-limit")) {
2010
3597
  fields.push("\trateLimit: RateLimitPort;");
2011
3598
  }
2012
3599
  return fields;
2013
3600
  }
2014
3601
 
3602
+ function productionInfrastructurePortsWithDrizzleTurso(
3603
+ ctx: TemplateContext,
3604
+ ): string {
3605
+ const deferred = [
3606
+ '\t\t"audit",',
3607
+ hasIntegration(ctx, "better-auth") ? '\t\t"auth",' : undefined,
3608
+ '\t\t"eventBus",',
3609
+ '\t\t"idempotency",',
3610
+ '\t\t"jobs",',
3611
+ '\t\t"logger",',
3612
+ '\t\t"mailer",',
3613
+ '\t\t"notifications",',
3614
+ '\t\t"outbox",',
3615
+ hasIntegration(ctx, "upstash-rate-limit") ? '\t\t"rateLimit",' : undefined,
3616
+ '\t\t"storage",',
3617
+ '\t\t"todoAttachments",',
3618
+ '\t\t"todos",',
3619
+ '\t\t"uow",',
3620
+ ]
3621
+ .filter((entry): entry is string => Boolean(entry))
3622
+ .sort();
3623
+ const bound = [
3624
+ hasIntegration(ctx, "better-auth")
3625
+ ? undefined
3626
+ : "\t\tauth: createAnonymousAuth(),",
3627
+ "\t\tgate,",
3628
+ ].filter((entry): entry is string => Boolean(entry));
3629
+ const imports = [
3630
+ 'import { createGate, definePorts } from "@beignet/core/ports";',
3631
+ 'import { todoPolicy } from "@/features/todos/policy";',
3632
+ 'import { appError } from "@/features/shared/errors";',
3633
+ 'import type { AppPorts } from "@/ports";',
3634
+ hasIntegration(ctx, "better-auth")
3635
+ ? undefined
3636
+ : 'import { createAnonymousAuth } from "./auth/anonymous-auth";',
3637
+ ].filter((entry): entry is string => Boolean(entry));
3638
+
3639
+ return `${imports.join("\n")}
3640
+
3641
+ const gate = createGate({
3642
+ policies: [todoPolicy],
3643
+ onDeny(decision) {
3644
+ return appError("Forbidden", {
3645
+ message: decision.reason ?? "Forbidden",
3646
+ details: decision.details,
3647
+ });
3648
+ },
3649
+ });
3650
+
3651
+ /**
3652
+ * App ports the server boots with.
3653
+ *
3654
+ * Bound ports are app-owned. Every deferred key is contributed by a provider
3655
+ * in server/providers.ts during startup; createServer(...) fails boot if any
3656
+ * of them is still unbound after providers have started.
3657
+ */
3658
+ export const appPorts = definePorts<AppPorts>()({
3659
+ bound: {
3660
+ ${bound.join("\n")}
3661
+ },
3662
+ deferred: [
3663
+ ${deferred.join("\n")}
3664
+ ],
3665
+ });
3666
+ `;
3667
+ }
3668
+
2015
3669
  function productionPorts(ctx: TemplateContext): string {
2016
- const mailImport = hasIntegration(ctx, "resend")
2017
- ? 'import type { MailerPort } from "@beignet/core/mail";\n'
2018
- : "";
3670
+ const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
2019
3671
  const imports = [
3672
+ "\tAuditLogPort,",
2020
3673
  "\tBoundGate,",
3674
+ "\tEventBusPort,",
2021
3675
  "\tGatePort,",
3676
+ "\tJobDispatcherPort,",
2022
3677
  "\tLoggerPort,",
2023
3678
  ...productionProviderPortImports(ctx),
3679
+ "\tStoragePort,",
2024
3680
  "\tUnitOfWorkPort,",
2025
3681
  ];
2026
3682
  const providerFields = productionProviderPortFields(ctx);
2027
3683
 
2028
- return `import type {
3684
+ return `import type { IdempotencyPort } from "@beignet/core/idempotency";
3685
+ ${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
3686
+ import type { OutboxPort } from "@beignet/core/outbox";
3687
+ import type {
2029
3688
  ${imports.join("\n")}
2030
3689
  } from "@beignet/core/ports";
2031
- ${mailImport}import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
3690
+ import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
2032
3691
  import type { AuthPort } from "./auth";
2033
- import type { TodoRepository } from "@/features/todos/ports";
3692
+ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
2034
3693
 
2035
3694
  export type AppTransactionPorts = {
3695
+ audit: AuditLogPort;
3696
+ events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3697
+ idempotency: IdempotencyPort;
3698
+ jobs: JobDispatcherPort;
3699
+ outbox: OutboxPort;
3700
+ todoAttachments: TodoAttachmentRepository;
2036
3701
  todos: TodoRepository;
2037
3702
  };
2038
3703
 
2039
3704
  export type AppGate = BoundGate<[typeof todoPolicy]>;
2040
3705
 
2041
3706
  export type AppPorts = {
3707
+ audit: AuditLogPort;
2042
3708
  auth: AuthPort;
3709
+ eventBus: EventBusPort;
2043
3710
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
3711
+ idempotency: IdempotencyPort;
3712
+ jobs: JobDispatcherPort;
3713
+ mailer: MailerPort;
3714
+ notifications: NotificationPort;
3715
+ outbox: OutboxPort;
3716
+ storage: StoragePort;
3717
+ todoAttachments: TodoAttachmentRepository;
2044
3718
  todos: TodoRepository;
2045
3719
  logger: LoggerPort;
2046
3720
  ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
@@ -2049,37 +3723,54 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
2049
3723
  }
2050
3724
 
2051
3725
  function productionPortsWithDrizzleTurso(ctx: TemplateContext): string {
2052
- const mailImport = hasIntegration(ctx, "resend")
2053
- ? 'import type { MailerPort } from "@beignet/core/mail";\n'
2054
- : "";
3726
+ const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
2055
3727
  const imports = [
3728
+ "\tAuditLogPort,",
2056
3729
  "\tBoundGate,",
3730
+ "\tEventBusPort,",
2057
3731
  "\tGatePort,",
3732
+ "\tJobDispatcherPort,",
2058
3733
  "\tLoggerPort,",
2059
3734
  ...productionProviderPortImports(ctx),
3735
+ "\tStoragePort,",
2060
3736
  "\tUnitOfWorkPort,",
2061
3737
  ];
2062
3738
  const providerFields = productionProviderPortFields(ctx);
2063
3739
 
2064
- return `import type {
3740
+ return `import type { IdempotencyPort } from "@beignet/core/idempotency";
3741
+ ${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
3742
+ import type { OutboxPort } from "@beignet/core/outbox";
3743
+ import type {
2065
3744
  ${imports.join("\n")}
2066
3745
  } from "@beignet/core/ports";
2067
- ${mailImport}import type { DbPort } from "@beignet/provider-drizzle-turso";
2068
- import * as schema from "@/infra/db/schema";
2069
3746
  import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
2070
3747
  import type { AuthPort } from "./auth";
2071
- import type { TodoRepository } from "@/features/todos/ports";
3748
+ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
2072
3749
 
2073
3750
  export type AppTransactionPorts = {
3751
+ audit: AuditLogPort;
3752
+ events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3753
+ idempotency: IdempotencyPort;
3754
+ jobs: JobDispatcherPort;
3755
+ outbox: OutboxPort;
3756
+ todoAttachments: TodoAttachmentRepository;
2074
3757
  todos: TodoRepository;
2075
3758
  };
2076
3759
 
2077
3760
  export type AppGate = BoundGate<[typeof todoPolicy]>;
2078
3761
 
2079
3762
  export type AppPorts = {
3763
+ audit: AuditLogPort;
2080
3764
  auth: AuthPort;
2081
- db: DbPort<typeof schema>;
3765
+ eventBus: EventBusPort;
2082
3766
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
3767
+ idempotency: IdempotencyPort;
3768
+ jobs: JobDispatcherPort;
3769
+ mailer: MailerPort;
3770
+ notifications: NotificationPort;
3771
+ outbox: OutboxPort;
3772
+ storage: StoragePort;
3773
+ todoAttachments: TodoAttachmentRepository;
2083
3774
  todos: TodoRepository;
2084
3775
  logger: LoggerPort;
2085
3776
  ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
@@ -2089,7 +3780,11 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
2089
3780
 
2090
3781
  function productionServerProviders(ctx: TemplateContext): string {
2091
3782
  const imports = [
3783
+ 'import type { beignetServerOnly } from "@beignet/core/server-only";',
2092
3784
  'import { createDevtoolsProvider } from "@beignet/devtools";',
3785
+ hasIntegration(ctx, "better-auth")
3786
+ ? 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";'
3787
+ : undefined,
2093
3788
  hasDrizzleTurso(ctx)
2094
3789
  ? 'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";'
2095
3790
  : undefined,
@@ -2107,6 +3802,13 @@ function productionServerProviders(ctx: TemplateContext): string {
2107
3802
  hasDrizzleTurso(ctx)
2108
3803
  ? 'import * as schema from "@/infra/db/schema";'
2109
3804
  : undefined,
3805
+ hasIntegration(ctx, "better-auth")
3806
+ ? 'import { auth } from "@/lib/better-auth";'
3807
+ : undefined,
3808
+ 'import { starterDatabaseProvider } from "@/infra/db/provider";',
3809
+ hasIntegration(ctx, "better-auth")
3810
+ ? 'import type { AuthSessionMetadata, AuthUser } from "@/ports/auth";'
3811
+ : undefined,
2110
3812
  ].filter((line): line is string => Boolean(line));
2111
3813
 
2112
3814
  const declarations = hasDrizzleTurso(ctx)
@@ -2114,9 +3816,13 @@ function productionServerProviders(ctx: TemplateContext): string {
2114
3816
  : "";
2115
3817
  const entries = [
2116
3818
  "\tcreateDevtoolsProvider(),",
3819
+ hasIntegration(ctx, "better-auth")
3820
+ ? "\tcreateAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth),"
3821
+ : undefined,
2117
3822
  "\tlocalStorageProvider,",
2118
3823
  "\tloggerPinoProvider,",
2119
3824
  hasDrizzleTurso(ctx) ? "\tdrizzleTursoProvider," : undefined,
3825
+ "\tstarterDatabaseProvider,",
2120
3826
  hasIntegration(ctx, "inngest") ? "\tinngestProvider," : undefined,
2121
3827
  hasIntegration(ctx, "resend") ? "\tmailResendProvider," : undefined,
2122
3828
  hasIntegration(ctx, "upstash-rate-limit")
@@ -2128,46 +3834,26 @@ function productionServerProviders(ctx: TemplateContext): string {
2128
3834
  ${declarations}
2129
3835
  export const providers = [
2130
3836
  ${entries.join("\n")}
2131
- ];
3837
+ ] as const;
2132
3838
  `;
2133
3839
  }
2134
3840
 
2135
- function productionTodosTest(ctx: TemplateContext): string {
2136
- const portImports = [
2137
- hasIntegration(ctx, "upstash-rate-limit")
2138
- ? "createMemoryRateLimiter"
2139
- : undefined,
2140
- "createMemoryStorage",
2141
- "createNoopUnitOfWork",
2142
- "createUserActor",
2143
- ].filter((item): item is string => Boolean(item));
2144
- const extraPorts = [
2145
- hasDrizzleTurso(ctx)
2146
- ? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
2147
- : undefined,
2148
- hasIntegration(ctx, "inngest")
2149
- ? "\t\t\tjobs: { dispatch: async () => undefined },"
2150
- : undefined,
2151
- hasIntegration(ctx, "resend")
2152
- ? '\t\t\tmailer: { send: async () => ({ provider: "test" }) },'
2153
- : undefined,
2154
- hasIntegration(ctx, "upstash-rate-limit")
2155
- ? "\t\t\trateLimit: createMemoryRateLimiter(),"
2156
- : undefined,
2157
- ].filter((entry): entry is string => Boolean(entry));
2158
-
3841
+ function productionTodosTest(_ctx: TemplateContext): string {
2159
3842
  return `import { describe, expect, it } from "bun:test";
2160
3843
  import { createUseCaseTester } from "@beignet/core/application";
3844
+ import { offsetPageResult, type OffsetPage } from "@beignet/core/pagination";
2161
3845
  import { createInMemoryDevtools } from "@beignet/devtools";
2162
- import { ${portImports.join(", ")} } from "@beignet/core/ports";
3846
+ import { createDomainEventRecorder } from "@beignet/core/ports";
3847
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
3848
+ import { createTestUserActor } from "@beignet/core/ports/testing";
2163
3849
  import type { AppContext } from "@/app-context";
2164
3850
  import { appPorts } from "@/infra/app-ports";
3851
+ import type { AppTransactionPorts } from "@/ports";
2165
3852
  import {
2166
3853
  createTodoUseCase,
2167
3854
  getTodoUseCase,
2168
3855
  listTodosUseCase,
2169
3856
  type CreateTodoInput,
2170
- type ListTodosInput,
2171
3857
  type Todo,
2172
3858
  } from "../use-cases";
2173
3859
 
@@ -2175,15 +3861,16 @@ function createTestTodoRepository() {
2175
3861
  const todos = new Map<string, Todo>();
2176
3862
 
2177
3863
  return {
2178
- async list(input: ListTodosInput) {
3864
+ async list(page: OffsetPage) {
2179
3865
  const allTodos = Array.from(todos.values()).sort((left, right) =>
2180
3866
  left.createdAt.localeCompare(right.createdAt),
2181
3867
  );
2182
3868
 
2183
- return {
2184
- todos: allTodos.slice(input.offset, input.offset + input.limit),
2185
- total: allTodos.length,
2186
- };
3869
+ return offsetPageResult(
3870
+ allTodos.slice(page.offset, page.offset + page.limit),
3871
+ page,
3872
+ allTodos.length,
3873
+ );
2187
3874
  },
2188
3875
  async findById(id: string) {
2189
3876
  return todos.get(id) ?? null;
@@ -2201,9 +3888,31 @@ function createTestTodoRepository() {
2201
3888
  };
2202
3889
  }
2203
3890
 
3891
+ function createTestTodoAttachmentRepository() {
3892
+ return {
3893
+ async create(input: {
3894
+ id: string;
3895
+ todoId: string;
3896
+ key: string;
3897
+ fileName: string;
3898
+ contentType: string;
3899
+ size: number;
3900
+ }) {
3901
+ return {
3902
+ ...input,
3903
+ createdAt: new Date().toISOString(),
3904
+ };
3905
+ },
3906
+ async findMany() {
3907
+ return [];
3908
+ },
3909
+ };
3910
+ }
3911
+
2204
3912
  describe("todos resource", () => {
2205
3913
  it("creates, gets, and lists todos", async () => {
2206
3914
  const todos = createTestTodoRepository();
3915
+ const todoAttachments = createTestTodoAttachmentRepository();
2207
3916
  const auth = {
2208
3917
  user: {
2209
3918
  id: "user_test",
@@ -2212,23 +3921,31 @@ describe("todos resource", () => {
2212
3921
  },
2213
3922
  session: { id: "session_test" },
2214
3923
  };
2215
- const actor = createUserActor(auth.user.id, {
2216
- displayName: auth.user.name,
3924
+ const testFixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
3925
+ base: appPorts,
3926
+ overrides: {
3927
+ gate: appPorts.gate,
3928
+ todoAttachments,
3929
+ todos,
3930
+ devtools: createInMemoryDevtools(),
3931
+ },
3932
+ transaction: {
3933
+ ports: (ports) => ({
3934
+ ...ports,
3935
+ events: createDomainEventRecorder(),
3936
+ todoAttachments,
3937
+ todos,
3938
+ }),
3939
+ },
2217
3940
  });
2218
- const testPorts = {
2219
- ...appPorts,
2220
- todos,
2221
- ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({ todos })) as AppContext["ports"]["uow"],
2222
- devtools: createInMemoryDevtools(),
2223
- storage: createMemoryStorage(),
2224
- };
2225
- const tester = createUseCaseTester<AppContext>(() => ({
2226
- requestId: "test-request",
2227
- actor,
3941
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
3942
+ ports: testFixture.ports,
3943
+ actor: createTestUserActor(auth.user.id, {
3944
+ displayName: auth.user.name,
3945
+ }),
2228
3946
  auth,
2229
- gate: testPorts.gate.bind({ actor, auth }),
2230
- ports: testPorts,
2231
- }));
3947
+ });
3948
+ const tester = createUseCaseTester<AppContext>(createTestContext);
2232
3949
 
2233
3950
  const ctx = await tester.ctx();
2234
3951
  const created = await tester.run(
@@ -2245,8 +3962,8 @@ ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUn
2245
3962
 
2246
3963
  expect(created.title).toBe("First todo");
2247
3964
  expect(found.id).toBe(created.id);
2248
- expect(result.total).toBe(1);
2249
- expect(result.todos).toEqual([created]);
3965
+ expect(result.page.total).toBe(1);
3966
+ expect(result.items).toEqual([created]);
2250
3967
  });
2251
3968
  });
2252
3969
  `;
@@ -2284,8 +4001,24 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2284
4001
  path: "app/api/openapi/route.ts",
2285
4002
  content: files.productionOpenApiRoute,
2286
4003
  },
2287
- { path: "client/api-client.ts", content: files.apiClient },
2288
- { path: "client/rq.ts", content: files.rq },
4004
+ {
4005
+ path: "app/api/auth/[...all]/route.ts",
4006
+ content: files.productionAuthRoute,
4007
+ },
4008
+ {
4009
+ path: "app/api/cron/outbox/drain/route.ts",
4010
+ content: files.productionOutboxDrainRoute,
4011
+ },
4012
+ {
4013
+ path: "app/api/cron/todos/daily-summary/route.ts",
4014
+ content: files.productionScheduleRoute,
4015
+ },
4016
+ {
4017
+ path: "app/api/uploads/[uploadName]/[action]/route.ts",
4018
+ content: files.productionUploadsRoute,
4019
+ },
4020
+ { path: "client/index.ts", content: clientIndex(ctx) },
4021
+ { path: "client/forms.ts", content: files.formsClient },
2289
4022
  {
2290
4023
  path: "features/todos/contracts.ts",
2291
4024
  content: files.productionContractsTodos,
@@ -2296,16 +4029,43 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2296
4029
  {
2297
4030
  path: "infra/app-ports.ts",
2298
4031
  content: usesDrizzleTurso
2299
- ? files.productionInfrastructurePortsWithDrizzleTurso
4032
+ ? productionInfrastructurePortsWithDrizzleTurso(ctx)
2300
4033
  : files.productionInfrastructurePorts,
2301
4034
  },
2302
4035
  { path: "lib/env.ts", content: files.productionEnv },
4036
+ { path: "lib/audit.ts", content: files.productionAuditHelper },
2303
4037
  { path: "lib/auth.ts", content: files.productionAuthHelpers },
4038
+ { path: "lib/better-auth.ts", content: files.productionBetterAuth },
2304
4039
  { path: "features/todos/policy.ts", content: files.productionTodoPolicy },
2305
4040
  {
2306
- path: "infra/auth/anonymous-auth.ts",
2307
- content: files.productionAnonymousAuth,
4041
+ path: "features/todos/domain/events/index.ts",
4042
+ content: files.productionTodoEvents,
4043
+ },
4044
+ { path: "features/todos/jobs/index.ts", content: files.productionTodoJobs },
4045
+ {
4046
+ path: "features/todos/listeners/index.ts",
4047
+ content: files.productionTodoListeners,
4048
+ },
4049
+ {
4050
+ path: "features/todos/notifications/index.ts",
4051
+ content: files.productionTodoNotifications,
4052
+ },
4053
+ {
4054
+ path: "features/todos/schedules/index.ts",
4055
+ content: files.productionTodoSchedules,
2308
4056
  },
4057
+ {
4058
+ path: "features/todos/uploads/index.ts",
4059
+ content: files.productionTodoUploads,
4060
+ },
4061
+ ...(!hasIntegration(ctx, "better-auth")
4062
+ ? [
4063
+ {
4064
+ path: "infra/auth/anonymous-auth.ts",
4065
+ content: files.productionAnonymousAuth,
4066
+ },
4067
+ ]
4068
+ : []),
2309
4069
  { path: "ports/auth.ts", content: files.productionAuthPort },
2310
4070
  {
2311
4071
  path: "ports/index.ts",
@@ -2319,7 +4079,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2319
4079
  },
2320
4080
  {
2321
4081
  path: "features/shared/errors.ts",
2322
- content: files.productimapUnhandledErrors,
4082
+ content: files.productionSharedErrors,
2323
4083
  },
2324
4084
  {
2325
4085
  path: "server/index.ts",
@@ -2327,12 +4087,22 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2327
4087
  ? files.productionServerWithDrizzleTurso
2328
4088
  : files.productionServer,
2329
4089
  },
4090
+ { path: "server/context.ts", content: files.productionServerContext },
2330
4091
  { path: "server/routes.ts", content: files.productionServerRoutes },
4092
+ { path: "server/outbox.ts", content: files.productionOutbox },
4093
+ { path: "server/schedules.ts", content: files.productionSchedules },
2331
4094
  {
2332
4095
  path: "server/providers.ts",
2333
4096
  content: productionServerProviders(ctx),
2334
4097
  },
2335
4098
  { path: "lib/use-case.ts", content: files.productionUseCaseBuilder },
4099
+ { path: "lib/listeners.ts", content: files.productionListenersBuilder },
4100
+ { path: "lib/jobs.ts", content: files.productionJobsBuilder },
4101
+ { path: "lib/schedules.ts", content: files.productionSchedulesBuilder },
4102
+ {
4103
+ path: "lib/notifications.ts",
4104
+ content: files.productionNotificationsBuilder,
4105
+ },
2336
4106
  {
2337
4107
  path: "features/todos/use-cases/create-todo.ts",
2338
4108
  content: files.productionCreateTodoUseCase,
@@ -2350,7 +4120,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2350
4120
  content: files.productionUseCasesIndex,
2351
4121
  },
2352
4122
  {
2353
- path: "features/todos/use-cases/schemas.ts",
4123
+ path: "features/todos/schemas.ts",
2354
4124
  content: files.productionTodoSchemas,
2355
4125
  },
2356
4126
  {
@@ -2363,6 +4133,30 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2363
4133
  if (usesDrizzleTurso) {
2364
4134
  templateFiles.push(
2365
4135
  { path: "drizzle.config.ts", content: files.productionDrizzleConfig },
4136
+ {
4137
+ path: "infra/audit/drizzle-audit-log.ts",
4138
+ content: files.productionDrizzleAuditLog,
4139
+ },
4140
+ {
4141
+ path: "infra/db/bootstrap.ts",
4142
+ content: files.productionDbBootstrap,
4143
+ },
4144
+ {
4145
+ path: "infra/db/seed.ts",
4146
+ content: files.productionDbSeed,
4147
+ },
4148
+ {
4149
+ path: "infra/db/reset.ts",
4150
+ content: files.productionDbReset,
4151
+ },
4152
+ {
4153
+ path: "infra/db/test-database.ts",
4154
+ content: files.productionDbTestDatabase,
4155
+ },
4156
+ {
4157
+ path: "infra/db/provider.ts",
4158
+ content: files.productionDbProvider,
4159
+ },
2366
4160
  {
2367
4161
  path: "infra/db/schema/index.ts",
2368
4162
  content: files.productionDbSchema,
@@ -2386,6 +4180,9 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2386
4180
  return templateFiles;
2387
4181
  }
2388
4182
 
4183
+ /**
4184
+ * Render all template files for a new Beignet app.
4185
+ */
2389
4186
  export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2390
4187
  if (isStandardPreset(ctx)) {
2391
4188
  return getProductionTemplateFiles(ctx);
@@ -2402,29 +4199,37 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2402
4199
  { path: "app/layout.tsx", content: layout(ctx) },
2403
4200
  { path: "app/page.tsx", content: page(ctx) },
2404
4201
  { path: "app/api/[[...path]]/route.ts", content: files.apiCatchAllRoute },
4202
+ { path: "app-context.ts", content: minimalAppContext(ctx) },
2405
4203
  { path: "features/todos/contracts.ts", content: files.contractsTodos },
2406
4204
  { path: "features/todos/routes.ts", content: files.todoRoutes },
2407
- { path: "ports/index.ts", content: files.ports },
4205
+ { path: "ports/index.ts", content: minimalPorts(ctx) },
2408
4206
  { path: "server/index.ts", content: server(ctx) },
4207
+ { path: "server/context.ts", content: serverContext(ctx) },
2409
4208
  { path: "server/routes.ts", content: files.serverRoutes },
2410
- { path: "features/todos/use-cases.ts", content: files.useCasesTodos },
4209
+ { path: "features/todos/use-cases/index.ts", content: files.useCasesTodos },
2411
4210
  ];
2412
4211
 
2413
4212
  if (hasFeature(ctx, "client")) {
2414
4213
  templateFiles.push({
2415
- path: "client/api-client.ts",
2416
- content: files.apiClient,
4214
+ path: "client/index.ts",
4215
+ content: clientIndex(ctx),
2417
4216
  });
2418
4217
  }
2419
4218
 
2420
4219
  if (hasFeature(ctx, "react-query")) {
2421
4220
  templateFiles.push(
2422
4221
  { path: "app/providers.tsx", content: files.appProviders },
2423
- { path: "client/rq.ts", content: files.rq },
2424
4222
  { path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) },
2425
4223
  );
2426
4224
  }
2427
4225
 
4226
+ if (hasFeature(ctx, "forms")) {
4227
+ templateFiles.push({
4228
+ path: "client/forms.ts",
4229
+ content: files.formsClient,
4230
+ });
4231
+ }
4232
+
2428
4233
  if (hasFeature(ctx, "openapi")) {
2429
4234
  templateFiles.push({
2430
4235
  path: "app/api/openapi/route.ts",
@@ -2432,19 +4237,34 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2432
4237
  });
2433
4238
  }
2434
4239
 
2435
- if (
2436
- ctx.integrations.some((integration) =>
2437
- ["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration),
2438
- )
2439
- ) {
2440
- templateFiles.push({
2441
- path: "server/providers.ts",
2442
- content: serverProviders(ctx),
2443
- });
4240
+ if (hasIntegration(ctx, "better-auth")) {
4241
+ templateFiles.push(
4242
+ { path: "lib/better-auth.ts", content: files.minimalBetterAuth },
4243
+ {
4244
+ path: "app/api/auth/[...all]/route.ts",
4245
+ content: files.productionAuthRoute,
4246
+ },
4247
+ );
4248
+ }
4249
+
4250
+ if (hasDrizzleTurso(ctx)) {
4251
+ templateFiles.push(
4252
+ { path: "drizzle.config.ts", content: files.productionDrizzleConfig },
4253
+ { path: "infra/db/provider.ts", content: files.minimalDbProvider },
4254
+ { path: "infra/db/schema/index.ts", content: files.minimalDbSchema },
4255
+ {
4256
+ path: "infra/todos/drizzle-todo-repository.ts",
4257
+ content: files.minimalDrizzleTodoRepository,
4258
+ },
4259
+ );
2444
4260
  }
2445
4261
 
2446
4262
  if (ctx.integrations.length > 0) {
2447
4263
  templateFiles.push(
4264
+ {
4265
+ path: "server/providers.ts",
4266
+ content: serverProviders(ctx),
4267
+ },
2448
4268
  {
2449
4269
  path: ".env.example",
2450
4270
  content: envExample(ctx),