@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/dist/templates.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { featureChoices, integrationChoices, presetChoices, } from "./choices.js";
1
2
  const externalVersions = {
2
3
  next: "^16.2.4",
3
4
  react: "^19.2.5",
@@ -11,9 +12,9 @@ const externalVersions = {
11
12
  tanstackReactQuery: "^5.100.7",
12
13
  hookformResolvers: "^5.0.0",
13
14
  reactHookForm: "^7.74.0",
14
- betterAuth: "^1.3.26",
15
- drizzleKit: "^0.30.0",
16
- drizzleOrm: "^0.38.0",
15
+ betterAuth: "1.6.11",
16
+ drizzleKit: "^0.31.10",
17
+ drizzleOrm: "^0.45.2",
17
18
  inngest: "^3.0.0",
18
19
  libsqlClient: "^0.14.0",
19
20
  pino: "^9.7.0",
@@ -30,33 +31,15 @@ function hasDrizzleTurso(ctx) {
30
31
  function packageRunner(ctx) {
31
32
  switch (ctx.packageManager) {
32
33
  case "npm":
33
- return "npx -p @beignet/cli beignet";
34
+ return "npm run beignet --";
34
35
  case "pnpm":
35
- return "pnpm dlx @beignet/cli beignet";
36
+ return "pnpm beignet";
36
37
  case "yarn":
37
- return "yarn dlx @beignet/cli beignet";
38
+ return "yarn beignet";
38
39
  default:
39
- return "bunx -p @beignet/cli beignet";
40
+ return "bun beignet";
40
41
  }
41
42
  }
42
- export const featureChoices = [
43
- "client",
44
- "react-query",
45
- "forms",
46
- "openapi",
47
- ];
48
- export const presetChoices = [
49
- "minimal",
50
- "standard",
51
- ];
52
- export const integrationChoices = [
53
- "better-auth",
54
- "drizzle-turso",
55
- "inngest",
56
- "pino",
57
- "resend",
58
- "upstash-rate-limit",
59
- ];
60
43
  const integrationDeps = {
61
44
  "better-auth": {
62
45
  "@beignet/provider-auth-better-auth": "",
@@ -135,6 +118,7 @@ function packageJson(ctx) {
135
118
  };
136
119
  if (isStandardPreset(ctx)) {
137
120
  dependencies["@beignet/devtools"] = ctx.beignetVersion;
121
+ dependencies["@beignet/provider-event-bus-memory"] = ctx.beignetVersion;
138
122
  dependencies["@beignet/provider-storage-local"] = ctx.beignetVersion;
139
123
  }
140
124
  for (const integration of ctx.integrations) {
@@ -148,6 +132,7 @@ function packageJson(ctx) {
148
132
  }
149
133
  }
150
134
  const devDependencies = {
135
+ "@beignet/cli": ctx.beignetVersion,
151
136
  "@types/bun": externalVersions.typesBun,
152
137
  "@types/node": externalVersions.typesNode,
153
138
  "@types/react": externalVersions.typesReact,
@@ -160,6 +145,7 @@ function packageJson(ctx) {
160
145
  }
161
146
  }
162
147
  const scripts = {
148
+ beignet: "beignet",
163
149
  dev: "next dev",
164
150
  build: "next build",
165
151
  start: "next start",
@@ -169,6 +155,12 @@ function packageJson(ctx) {
169
155
  if (hasDrizzleTurso(ctx)) {
170
156
  scripts["db:generate"] = "drizzle-kit generate";
171
157
  scripts["db:migrate"] = "drizzle-kit migrate";
158
+ // The minimal preset does not scaffold infra/db/seed.ts or
159
+ // infra/db/reset.ts, so only the standard preset gets their scripts.
160
+ if (isStandardPreset(ctx)) {
161
+ scripts["db:seed"] = "bun infra/db/seed.ts";
162
+ scripts["db:reset"] = "bun infra/db/reset.ts";
163
+ }
172
164
  }
173
165
  return json({
174
166
  name: ctx.name,
@@ -188,12 +180,28 @@ function readme(ctx) {
188
180
  const run = ctx.packageManager === "npm"
189
181
  ? "npm run dev"
190
182
  : `${ctx.packageManager} run dev`;
183
+ const cli = packageRunner(ctx);
184
+ const envSetup = ctx.integrations.length > 0 ? "cp .env.example .env.local\n" : "";
191
185
  const featureList = ctx.features.length > 0
192
186
  ? ctx.features.map((feature) => `- \`${feature}\``).join("\n")
193
187
  : "- API routes only";
194
188
  const integrationList = ctx.integrations.length > 0
195
189
  ? ctx.integrations.map((integration) => `- \`${integration}\``).join("\n")
196
190
  : "- None";
191
+ const databaseSection = hasDrizzleTurso(ctx)
192
+ ? `
193
+ ## Database
194
+
195
+ The drizzle-turso integration persists todos with Drizzle on libSQL/Turso. The server creates and seeds the starter \`todos\` table at startup using \`TURSO_DB_URL\` (defaults to \`file:local.db\`).
196
+
197
+ Regenerate and apply migrations after changing \`infra/db/schema/index.ts\`:
198
+
199
+ \`\`\`bash
200
+ ${cli} db generate
201
+ ${cli} db migrate
202
+ \`\`\`
203
+ `
204
+ : "";
197
205
  return `# ${ctx.name}
198
206
 
199
207
  Beignet app scaffolded with \`@beignet/cli\`.
@@ -202,19 +210,21 @@ Beignet app scaffolded with \`@beignet/cli\`.
202
210
 
203
211
  \`\`\`bash
204
212
  ${install}
205
- ${run}
213
+ ${envSetup}${run}
206
214
  \`\`\`
207
215
 
208
- Open http://localhost:3000 and start from the todos workflow.
209
-
216
+ Open http://localhost:3000 and start from the todos workflow. The homepage links to the health check, OpenAPI document, and Beignet docs.
217
+ ${databaseSection}
210
218
  ## Files to know
211
219
 
212
220
  - \`features/todos/contracts.ts\` defines the API contract.
213
- - \`features/todos/use-cases.ts\` contains application logic with input and output validation.
221
+ - \`features/todos/use-cases/\` contains application logic with input and output validation.
214
222
  - \`ports/index.ts\` defines the app's external dependencies.
215
- - \`server/index.ts\` wires contracts to handlers.
223
+ - \`features/todos/routes.ts\` binds contracts to use cases.
224
+ - \`server/context.ts\` declares the context blueprint shared by the server and tests.
225
+ - \`server/index.ts\` composes the server from ports, context, and routes.
216
226
  - \`app/api/[[...path]]/route.ts\` exposes contract-backed API routes to Next.js.
217
- ${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
227
+ ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/index.ts` owns the Drizzle schema and `infra/db/provider.ts` wires the Drizzle todo repository into the `todos` port.\n" : ""}${hasIntegration(ctx, "better-auth") ? "- `lib/better-auth.ts` owns the Better Auth instance served at `/api/auth/*`.\n" : ""}${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
218
228
 
219
229
  ## Selected preset
220
230
 
@@ -235,6 +245,9 @@ function standardReadme(ctx) {
235
245
  const run = ctx.packageManager === "npm"
236
246
  ? "npm run dev"
237
247
  : `${ctx.packageManager} run dev`;
248
+ const test = ctx.packageManager === "npm"
249
+ ? "npm run test"
250
+ : `${ctx.packageManager} run test`;
238
251
  const typecheck = ctx.packageManager === "npm"
239
252
  ? "npm run typecheck"
240
253
  : `${ctx.packageManager} run typecheck`;
@@ -242,7 +255,8 @@ function standardReadme(ctx) {
242
255
  const beforeDeploy = hasDrizzleTurso(ctx)
243
256
  ? [
244
257
  "- Create a Turso database or keep `TURSO_DB_URL=file:local.db` for local libSQL development.",
245
- `- Run \`${ctx.packageManager} run db:generate\` and \`${ctx.packageManager} run db:migrate\` after changing the Drizzle schema.`,
258
+ `- Run \`${cli} db generate\` and \`${cli} db migrate\` after changing the Drizzle schema.`,
259
+ `- Run \`${cli} db seed\` to load starter data or \`${cli} db reset\` to rebuild a local SQLite database.`,
246
260
  ].join("\n")
247
261
  : "- Replace the in-memory todo repository with a durable adapter.";
248
262
  return `# ${ctx.name}
@@ -254,10 +268,24 @@ Beignet app scaffolded with \`@beignet/cli\`.
254
268
  \`\`\`bash
255
269
  ${install}
256
270
  cp .env.example .env.local
271
+ \`\`\`
272
+
273
+ ## Prepare the database
274
+
275
+ \`\`\`bash
276
+ ${cli} db generate
277
+ ${cli} db migrate
278
+ \`\`\`
279
+
280
+ \`db generate\` creates Drizzle migrations from \`infra/db/schema/\`. \`db migrate\` applies them to the local SQLite database configured in \`.env.local\`.
281
+
282
+ ## Start the app
283
+
284
+ \`\`\`bash
257
285
  ${run}
258
286
  \`\`\`
259
287
 
260
- Open http://localhost:3000 for the todo workflow, http://localhost:3000/api/health for health checks, and http://localhost:3000/api/devtools while developing.
288
+ Open http://localhost:3000 for the todo workflow. The homepage links to the health check, OpenAPI document, devtools, and Beignet docs.
261
289
 
262
290
  ## First checks
263
291
 
@@ -271,18 +299,39 @@ ${typecheck}
271
299
 
272
300
  \`routes\` shows the contracts Beignet can inspect. \`lint\` checks dependency direction. \`doctor\` catches route, OpenAPI, and resource drift.
273
301
 
302
+ ## Generate a feature
303
+
304
+ \`\`\`bash
305
+ ${cli} make feature projects
306
+ ${cli} db generate
307
+ ${cli} db migrate
308
+ ${test}
309
+ ${typecheck}
310
+ ${cli} lint
311
+ ${cli} doctor
312
+ \`\`\`
313
+
314
+ \`make feature\` creates a contract-to-test vertical slice. In the standard starter it also adds Drizzle schema and repository files, so regenerate and migrate the database before running the app against the new feature.
315
+
274
316
  ## App map
275
317
 
276
- - \`features/todos/contracts.ts\` owns the HTTP contract and reuses use case schemas.
318
+ - \`features/todos/contracts.ts\` owns the HTTP contract and reuses shared feature schemas.
319
+ - \`features/todos/schemas.ts\` owns shared DTO and validation schemas used by contracts, use cases, ports, tests, and clients.
277
320
  - \`features/todos/use-cases/\` owns application behavior and validation.
278
321
  - \`ports/\` defines app-owned dependencies.
279
322
  - \`infra/\` implements ports for the selected runtime.
280
323
  - \`server/routes.ts\` keeps the central route registry and OpenAPI contract list.
324
+ - \`server/context.ts\` declares the context blueprint shared by the server and route tests.
281
325
  - \`server/\` wires context, providers, routes, hooks, and error handling.
326
+ - \`server/outbox.ts\` registers durable events and jobs for the outbox drain route and CLI drain runner.
327
+ - \`server/schedules.ts\` registers schedules for cron routes and the CLI schedule runner.
282
328
  - \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
329
+ - \`features/todos/domain/events/\`, \`jobs/\`, \`listeners/\`, \`notifications/\`, \`schedules/\`, and \`uploads/\` show the feature-owned background workflow shape.
283
330
  - \`app/storage/[...key]/route.ts\` serves public local storage objects.
331
+ - \`app/api/cron/outbox/drain/route.ts\` is the bounded serverless entrypoint for durable event/job delivery.
284
332
  - \`lib/env.ts\` validates deployment configuration at startup.
285
333
  - \`lib/auth.ts\` exposes \`requireUser(ctx)\` for protected use cases.
334
+ - \`lib/better-auth.ts\` owns Better Auth setup and keeps provider-specific auth details outside use cases.
286
335
  - \`features/todos/policy.ts\` defines authorization rules registered with \`createGate(...)\`.
287
336
  ${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" : ""}
288
337
 
@@ -290,8 +339,11 @@ ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `inf
290
339
 
291
340
  ${beforeDeploy}
292
341
  - Keep \`/api/devtools\` development-only unless you add authentication and stricter redaction.
293
- - Set \`APP_URL\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
294
- - Replace anonymous auth and add use-case authorization before exposing user-owned data.
342
+ - Set \`APP_URL\`, \`BETTER_AUTH_SECRET\`, \`CRON_SECRET\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
343
+ - Configure a scheduled request to \`/api/cron/outbox/drain\` with \`Authorization: Bearer <CRON_SECRET>\`.
344
+ - Use \`${cli} outbox drain\` for bounded local, CI, or worker-hosted drain runs.
345
+ - Use \`${cli} schedule run todos.log-daily-summary\` for bounded local, CI, or worker-hosted schedule runs.
346
+ - Review the starter authorization policy before exposing user-owned data.
295
347
  ${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
296
348
  `;
297
349
  }
@@ -304,13 +356,32 @@ function integrationsDoc(ctx) {
304
356
  ];
305
357
  for (const integration of ctx.integrations) {
306
358
  if (integration === "better-auth") {
307
- lines.push("## Better Auth", "", "- Package: `@beignet/provider-auth-better-auth`", "- Peer dependency: `better-auth`", "- The starter uses an anonymous auth adapter. Replace `createAnonymousAuth()` in `infra/app-ports.ts` with an adapter backed by your Better Auth session lookup.", "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.", "- Put repeated ownership or role rules in policies registered with `createGate(...)`.", "- Add the Better Auth provider only after your app has a database adapter and session setup.", "");
359
+ lines.push("## Better Auth", "", "- Package: `@beignet/provider-auth-better-auth`", "- Peer dependency: `better-auth`", "- The starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.", ...(isStandardPreset(ctx)
360
+ ? [
361
+ "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
362
+ "- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
363
+ "- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.",
364
+ ]
365
+ : [
366
+ "- Keep auth behind the shared `AuthPort`, then read `ctx.auth` in use cases that need a signed-in user.",
367
+ "- The minimal starter uses Better Auth's in-memory adapter, so users and sessions reset on restart. Switch to a database adapter such as `better-auth/adapters/drizzle` before production.",
368
+ ]), "");
308
369
  }
309
370
  if (integration === "drizzle-turso") {
310
- lines.push("## Drizzle + Turso", "", "- Package: `@beignet/provider-drizzle-turso`", "- Peer dependencies: `@libsql/client` and `drizzle-orm`", "- Dev dependency: `drizzle-kit`", "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.", "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.", `- Run \`${ctx.packageManager} run db:generate\` and \`${ctx.packageManager} run db:migrate\` after changing \`infra/db/schema.ts\`.`, "");
371
+ lines.push("## Drizzle + Turso", "", "- Package: `@beignet/provider-drizzle-turso`", "- Peer dependencies: `@libsql/client` and `drizzle-orm`", "- Dev dependency: `drizzle-kit`", "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.", ...(isStandardPreset(ctx)
372
+ ? [
373
+ "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
374
+ ]
375
+ : [
376
+ "- The scaffold wires a typed Drizzle repository behind the `todos` port through `infra/db/provider.ts`, which also creates and seeds the starter table at startup.",
377
+ ]), `- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`, ...(isStandardPreset(ctx)
378
+ ? [
379
+ `- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`,
380
+ ]
381
+ : []), "");
311
382
  }
312
383
  if (integration === "inngest") {
313
- lines.push("## Inngest", "", "- Package: `@beignet/core` (`@beignet/core/events`, `@beignet/core/jobs`)", "- Package: `@beignet/provider-inngest`", "- Peer dependency: `inngest`", "- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.", "- Define jobs with `createJobHandlers(...).defineJob(...)` and dispatch them through `ctx.ports.jobs.dispatch(...)`.", "- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.", "");
384
+ lines.push("## Inngest", "", "- Package: `@beignet/core` (`@beignet/core/events`, `@beignet/core/jobs`)", "- Package: `@beignet/provider-inngest`", "- Peer dependency: `inngest`", "- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.", "- Define jobs with `defineJob(...)` from `lib/jobs.ts` and dispatch them through `ctx.ports.jobs.dispatch(...)`.", "- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.", "");
314
385
  }
315
386
  if (integration === "pino") {
316
387
  lines.push("## Pino logger", "", "- Package: `@beignet/provider-logger-pino`", "- Peer dependency: `pino`", "- Use this provider when you want structured request and handler logging.", "");
@@ -349,10 +420,10 @@ function envExample(ctx) {
349
420
  lines.push("RESEND_API_KEY=", "RESEND_FROM=", "");
350
421
  }
351
422
  if (hasIntegration(ctx, "upstash-rate-limit")) {
352
- lines.push("UPSTASH_REDIS_REST_URL=", "UPSTASH_REDIS_REST_TOKEN=", "UPSTASH_PREFIX=ck:ratelimit", "");
423
+ lines.push("UPSTASH_REDIS_REST_URL=", "UPSTASH_REDIS_REST_TOKEN=", "UPSTASH_PREFIX=beignet:ratelimit", "");
353
424
  }
354
425
  if (hasIntegration(ctx, "better-auth")) {
355
- lines.push("# Better Auth needs an app-owned database adapter and a shared AuthPort adapter before it can be wired.", "BETTER_AUTH_SECRET=", "BETTER_AUTH_URL=http://localhost:3000", "");
426
+ lines.push("BETTER_AUTH_SECRET=local-dev-better-auth-secret-change-me", "BETTER_AUTH_URL=http://localhost:3000", "# BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000", "");
356
427
  }
357
428
  return `${lines.join("\n")}\n`;
358
429
  }
@@ -414,6 +485,11 @@ function page(ctx) {
414
485
  <p>
415
486
  <a href="/api/todos">Open todos API</a>
416
487
  </p>
488
+ <nav className="starter-links" aria-label="Starter links">
489
+ <a href="/api/health">Health</a>
490
+ <a href="/api/openapi">OpenAPI</a>
491
+ <a href="https://beignetjs.com">Docs</a>
492
+ </nav>
417
493
  </section>
418
494
  </div>
419
495
  </main>
@@ -437,6 +513,14 @@ export default function HomePage() {
437
513
  </p>
438
514
  </header>
439
515
 
516
+ <nav className="starter-links" aria-label="Starter links">
517
+ <a href="#todos">Todo feature</a>
518
+ <a href="/api/health">Health</a>
519
+ <a href="/api/openapi">OpenAPI</a>
520
+ <a href="/api/devtools">Devtools</a>
521
+ <a href="https://beignetjs.com">Docs</a>
522
+ </nav>
523
+
440
524
  <TodoApp />
441
525
  </div>
442
526
  </main>
@@ -445,47 +529,91 @@ export default function HomePage() {
445
529
  `;
446
530
  }
447
531
  function serverProviders(ctx) {
448
- const imports = [];
532
+ const packageImports = [];
533
+ const appImports = [];
534
+ const declarations = [];
449
535
  const entries = [];
536
+ if (hasIntegration(ctx, "better-auth")) {
537
+ packageImports.push('import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";');
538
+ appImports.push('import { auth } from "@/lib/better-auth";', 'import type { AuthSessionMetadata, AuthUser } from "@/ports";');
539
+ entries.push("createAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth)");
540
+ }
450
541
  if (hasIntegration(ctx, "pino")) {
451
- imports.push('import { loggerPinoProvider } from "@beignet/provider-logger-pino";');
542
+ packageImports.push('import { loggerPinoProvider } from "@beignet/provider-logger-pino";');
452
543
  entries.push("loggerPinoProvider");
453
544
  }
545
+ if (hasDrizzleTurso(ctx)) {
546
+ packageImports.push('import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";');
547
+ appImports.unshift('import { starterDatabaseProvider } from "@/infra/db/provider";', 'import * as schema from "@/infra/db/schema";');
548
+ declarations.push("const drizzleTursoProvider = createDrizzleTursoProvider({ schema });");
549
+ entries.push("drizzleTursoProvider", "starterDatabaseProvider");
550
+ }
454
551
  if (hasIntegration(ctx, "inngest")) {
455
- imports.push('import { inngestProvider } from "@beignet/provider-inngest";');
552
+ packageImports.push('import { inngestProvider } from "@beignet/provider-inngest";');
456
553
  entries.push("inngestProvider");
457
554
  }
458
555
  if (hasIntegration(ctx, "resend")) {
459
- imports.push('import { mailResendProvider } from "@beignet/provider-mail-resend";');
556
+ packageImports.push('import { mailResendProvider } from "@beignet/provider-mail-resend";');
460
557
  entries.push("mailResendProvider");
461
558
  }
462
559
  if (hasIntegration(ctx, "upstash-rate-limit")) {
463
- imports.push('import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";');
560
+ packageImports.push('import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";');
464
561
  entries.push("upstashRateLimitProvider");
465
562
  }
466
- return `${imports.join("\n")}
563
+ const importBlock = [...packageImports.sort(), ...appImports].join("\n");
564
+ const declarationBlock = declarations.length > 0 ? `\n${declarations.join("\n")}\n` : "";
565
+ return `${importBlock}
566
+ ${declarationBlock}
467
567
  export const providers = [
468
568
  ${entries.join(",\n\t")},
469
569
  ];
470
570
  `;
471
571
  }
572
+ function serverContext(ctx) {
573
+ const blueprint = hasIntegration(ctx, "better-auth")
574
+ ? ` async ({ req, ports }) => ({
575
+ requestId: crypto.randomUUID(),
576
+ auth: await ports.auth.getSession(req),
577
+ ports,
578
+ }),`
579
+ : ` async ({ ports }) => ({
580
+ requestId: crypto.randomUUID(),
581
+ ports,
582
+ }),`;
583
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
584
+ import { defineServerContext } from "@beignet/core/server";
585
+ import type { AppContext } from "@/app-context";
586
+ import type { AppPorts } from "@/ports";
587
+
588
+ /**
589
+ * Context blueprint shared by the runtime server and route tests.
590
+ */
591
+ export const appContext = defineServerContext<AppContext, AppPorts>()(
592
+ ${blueprint}
593
+ );
594
+ `;
595
+ }
472
596
  function server(ctx) {
473
- const hasProviders = ctx.integrations.some((integration) => ["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration));
474
- return `import { createNextServer } from "@beignet/next";
597
+ const hasProviders = ctx.integrations.length > 0;
598
+ const providerConfigBlock = hasDrizzleTurso(ctx)
599
+ ? ` providerConfig: {
600
+ "drizzle-turso": {
601
+ DB_URL: process.env.TURSO_DB_URL ?? "file:local.db",
602
+ DB_AUTH_TOKEN: process.env.TURSO_DB_AUTH_TOKEN,
603
+ },
604
+ },
605
+ `
606
+ : "";
607
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
608
+ import { createNextServer } from "@beignet/next";
609
+ import { appContext } from "./context";
475
610
  ${hasProviders ? 'import { providers } from "./providers";\n' : ""}import { routes } from "./routes";
611
+ import type { AppContext } from "@/app-context";
476
612
  import { appPorts, type AppPorts } from "@/ports";
477
613
 
478
- export type AppContext = {
479
- requestId: string;
480
- ports: AppPorts;
481
- };
482
-
483
614
  export const server = await createNextServer<AppContext, AppPorts>({
484
615
  ports: appPorts,
485
- ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
486
- requestId: crypto.randomUUID(),
487
- ports,
488
- }),
616
+ ${hasProviders ? "\tproviders,\n" : ""}${providerConfigBlock} context: appContext,
489
617
  routes,
490
618
  mapUnhandledError: ({ err, ctx }) => {
491
619
  const ports = ctx?.ports as
@@ -526,17 +654,128 @@ ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
526
654
  });
527
655
  `;
528
656
  }
657
+ function minimalPorts(ctx) {
658
+ const usesDrizzleTurso = hasDrizzleTurso(ctx);
659
+ const usesBetterAuth = hasIntegration(ctx, "better-auth");
660
+ if (!usesDrizzleTurso && !usesBetterAuth) {
661
+ return files.ports;
662
+ }
663
+ const imports = [
664
+ 'import { definePorts } from "@beignet/core/ports";',
665
+ ...(usesBetterAuth
666
+ ? [
667
+ `import type {
668
+ AuthPort as BeignetAuthPort,
669
+ AuthSession as BeignetAuthSession,
670
+ } from "@beignet/core/ports";`,
671
+ ]
672
+ : []),
673
+ 'import type { z } from "zod";',
674
+ 'import type { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";',
675
+ ].join("\n");
676
+ const authTypes = usesBetterAuth
677
+ ? `
678
+
679
+ export type AuthUser = {
680
+ id: string;
681
+ email?: string;
682
+ name?: string;
683
+ };
684
+
685
+ export type AuthSessionMetadata = {
686
+ id?: string;
687
+ };
688
+
689
+ export type AuthSession = BeignetAuthSession<AuthUser, AuthSessionMetadata>;
690
+
691
+ export type AuthPort = BeignetAuthPort<AuthUser, AuthSessionMetadata>;`
692
+ : "";
693
+ const appPortsType = [
694
+ "export type AppPorts = {",
695
+ ...(usesBetterAuth ? ["\tauth: AuthPort;"] : []),
696
+ "\ttodos: TodoRepository;",
697
+ "};",
698
+ ].join("\n");
699
+ const inMemoryTodos = usesDrizzleTurso
700
+ ? ""
701
+ : `
702
+
703
+ const todos = new Map<string, Todo>();
704
+
705
+ function createTodo(input: CreateTodoInput): Todo {
706
+ const todo = {
707
+ id: crypto.randomUUID(),
708
+ title: input.title,
709
+ completed: false,
710
+ createdAt: new Date().toISOString(),
711
+ };
712
+ todos.set(todo.id, todo);
713
+ return todo;
714
+ }
715
+
716
+ createTodo({ title: "Read the generated contract" });
717
+ createTodo({ title: "Add your first use case" });`;
718
+ const bound = usesDrizzleTurso
719
+ ? "\tbound: {},"
720
+ : ` bound: {
721
+ todos: {
722
+ list: async () => Array.from(todos.values()),
723
+ create: async (input: CreateTodoInput) => createTodo(input),
724
+ },
725
+ },`;
726
+ const deferred = [
727
+ ...(usesBetterAuth ? ['"auth"'] : []),
728
+ ...(usesDrizzleTurso ? ['"todos"'] : []),
729
+ ].join(", ");
730
+ return `${imports}
731
+
732
+ export type Todo = z.infer<typeof TodoSchema>;
733
+ export type CreateTodoInput = z.infer<typeof CreateTodoSchema>;
734
+
735
+ export interface TodoRepository {
736
+ list(): Promise<Todo[]>;
737
+ create(input: CreateTodoInput): Promise<Todo>;
738
+ }${authTypes}
739
+
740
+ ${appPortsType}${inMemoryTodos}
741
+
742
+ /**
743
+ * App ports the server boots with.
744
+ *
745
+ * Bound ports are app-owned. Every deferred key is contributed by a provider
746
+ * in server/providers.ts during startup; createNextServer(...) fails boot if
747
+ * any of them is still unbound after providers have started.
748
+ */
749
+ export const appPorts = definePorts<AppPorts>()({
750
+ ${bound}
751
+ deferred: [${deferred}],
752
+ });
753
+ `;
754
+ }
755
+ function minimalAppContext(ctx) {
756
+ if (!hasIntegration(ctx, "better-auth")) {
757
+ return files.appContext;
758
+ }
759
+ return `import type { AppPorts, AuthSession } from "@/ports";
760
+
761
+ export type AppContext = {
762
+ requestId: string;
763
+ auth: AuthSession | null;
764
+ ports: AppPorts;
765
+ };
766
+ `;
767
+ }
529
768
  function todoApp(ctx) {
530
769
  const listTodosQueryOptions = ctx.preset === "standard" ? "{ query: {} }" : "";
531
770
  if (hasFeature(ctx, "forms")) {
532
771
  return `"use client";
533
772
 
534
- import { createReactHookForm } from "@beignet/react-hook-form";
773
+ import { rootFormError } from "@beignet/react-hook-form";
535
774
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
775
+ import { rq } from "@/client";
776
+ import { rhf } from "@/client/forms";
536
777
  import { createTodo, listTodos } from "@/features/todos/contracts";
537
- import { rq } from "@/client/rq";
538
778
 
539
- const rhf = createReactHookForm();
540
779
  const createTodoForm = rhf(createTodo);
541
780
 
542
781
  export function TodoApp() {
@@ -549,19 +788,21 @@ export function TodoApp() {
549
788
  rq(createTodo).mutationOptions({
550
789
  onSuccess: async () => {
551
790
  form.reset();
552
- await queryClient.invalidateQueries({
553
- queryKey: rq(listTodos).key(),
554
- });
791
+ await rq(listTodos).invalidate(queryClient);
792
+ },
793
+ onError: (error) => {
794
+ form.setError("root", rootFormError(error, "Could not create the todo."));
555
795
  },
556
796
  }),
557
797
  );
558
798
 
559
799
  const onSubmit = form.handleSubmit((body) => {
800
+ form.clearErrors("root");
560
801
  createTodoMutation.mutate({ body });
561
802
  });
562
803
 
563
804
  return (
564
- <section className="workspace">
805
+ <section id="todos" className="workspace">
565
806
  <form className="composer" onSubmit={onSubmit}>
566
807
  <label htmlFor="todo-title">New todo</label>
567
808
  <div className="composer-row">
@@ -577,19 +818,22 @@ export function TodoApp() {
577
818
  {form.formState.errors.title ? (
578
819
  <p className="error">{form.formState.errors.title.message}</p>
579
820
  ) : null}
821
+ {form.formState.errors.root ? (
822
+ <p className="error">{form.formState.errors.root.message}</p>
823
+ ) : null}
580
824
  </form>
581
825
 
582
826
  <div className="todos-panel">
583
827
  <div className="panel-heading">
584
828
  <h2>Todos</h2>
585
- <span>{todosQuery.data?.total ?? 0} total</span>
829
+ <span>{todosQuery.data?.page.total ?? 0} total</span>
586
830
  </div>
587
831
  {todosQuery.isLoading ? <p className="muted">Loading todos...</p> : null}
588
832
  {todosQuery.isError ? (
589
833
  <p className="error">Could not load todos.</p>
590
834
  ) : null}
591
835
  <ul className="todo-list">
592
- {todosQuery.data?.todos.map((todo) => (
836
+ {todosQuery.data?.items.map((todo) => (
593
837
  <li key={todo.id}>
594
838
  <span>{todo.title}</span>
595
839
  <small>{todo.completed ? "Done" : "Open"}</small>
@@ -606,8 +850,8 @@ export function TodoApp() {
606
850
 
607
851
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
608
852
  import { useState } from "react";
853
+ import { rq } from "@/client";
609
854
  import { createTodo, listTodos } from "@/features/todos/contracts";
610
- import { rq } from "@/client/rq";
611
855
 
612
856
  export function TodoApp() {
613
857
  const [title, setTitle] = useState("");
@@ -617,15 +861,13 @@ export function TodoApp() {
617
861
  rq(createTodo).mutationOptions({
618
862
  onSuccess: async () => {
619
863
  setTitle("");
620
- await queryClient.invalidateQueries({
621
- queryKey: rq(listTodos).key(),
622
- });
864
+ await rq(listTodos).invalidate(queryClient);
623
865
  },
624
866
  }),
625
867
  );
626
868
 
627
869
  return (
628
- <section className="workspace">
870
+ <section id="todos" className="workspace">
629
871
  <form
630
872
  className="composer"
631
873
  onSubmit={(event) => {
@@ -650,10 +892,10 @@ export function TodoApp() {
650
892
  <div className="todos-panel">
651
893
  <div className="panel-heading">
652
894
  <h2>Todos</h2>
653
- <span>{todosQuery.data?.total ?? 0} total</span>
895
+ <span>{todosQuery.data?.page.total ?? 0} total</span>
654
896
  </div>
655
897
  <ul className="todo-list">
656
- {todosQuery.data?.todos.map((todo) => (
898
+ {todosQuery.data?.items.map((todo) => (
657
899
  <li key={todo.id}>
658
900
  <span>{todo.title}</span>
659
901
  <small>{todo.completed ? "Done" : "Open"}</small>
@@ -666,6 +908,55 @@ export function TodoApp() {
666
908
  }
667
909
  `;
668
910
  }
911
+ function clientIndex(ctx) {
912
+ const imports = [
913
+ 'import { createClient } from "@beignet/core/client";',
914
+ ...(hasFeature(ctx, "react-query")
915
+ ? [
916
+ 'import { createReactQuery } from "@beignet/react-query";',
917
+ 'import { QueryClient } from "@tanstack/react-query";',
918
+ ]
919
+ : []),
920
+ ...(isStandardPreset(ctx)
921
+ ? [
922
+ 'import { createUploadClient } from "@beignet/core/uploads/client";',
923
+ 'import type { todoUploads } from "@/features/todos/uploads";',
924
+ ]
925
+ : []),
926
+ ].join("\n");
927
+ const exports = [
928
+ `export const apiClient = createClient({
929
+ \tvalidateInput: true,
930
+ });`,
931
+ ...(hasFeature(ctx, "react-query")
932
+ ? ["export const rq = createReactQuery(apiClient);"]
933
+ : []),
934
+ ...(isStandardPreset(ctx)
935
+ ? [
936
+ `export const uploads = createUploadClient<typeof todoUploads>({
937
+ \tbaseUrl: "/api/uploads",
938
+ });`,
939
+ ]
940
+ : []),
941
+ ...(hasFeature(ctx, "react-query")
942
+ ? [
943
+ `export function makeQueryClient() {
944
+ \treturn new QueryClient({
945
+ \t\tdefaultOptions: {
946
+ \t\t\tqueries: {
947
+ \t\t\t\tstaleTime: 60 * 1000,
948
+ \t\t\t},
949
+ \t\t},
950
+ \t});
951
+ }`,
952
+ ]
953
+ : []),
954
+ ].join("\n\n");
955
+ return `${imports}
956
+
957
+ ${exports}
958
+ `;
959
+ }
669
960
  const files = {
670
961
  gitignore: `.next
671
962
  node_modules
@@ -766,6 +1057,29 @@ input {
766
1057
  color: #475569;
767
1058
  }
768
1059
 
1060
+ .starter-links {
1061
+ display: flex;
1062
+ flex-wrap: wrap;
1063
+ gap: 10px;
1064
+ margin: 22px 0;
1065
+ }
1066
+
1067
+ .starter-links a {
1068
+ border: 1px solid #cbd5e1;
1069
+ border-radius: 8px;
1070
+ padding: 8px 10px;
1071
+ background: #ffffff;
1072
+ color: #172033;
1073
+ font-size: 14px;
1074
+ font-weight: 700;
1075
+ text-decoration: none;
1076
+ }
1077
+
1078
+ .starter-links a:hover {
1079
+ border-color: #4f46e5;
1080
+ color: #4f46e5;
1081
+ }
1082
+
769
1083
  .panel {
770
1084
  margin-top: 24px;
771
1085
  padding: 20px;
@@ -892,32 +1206,26 @@ input {
892
1206
  `,
893
1207
  appProviders: `"use client";
894
1208
 
895
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1209
+ import { QueryClientProvider } from "@tanstack/react-query";
896
1210
  import { type ReactNode, useState } from "react";
1211
+ import { makeQueryClient } from "@/client";
897
1212
 
898
1213
  export function Providers({ children }: { children: ReactNode }) {
899
- const [queryClient] = useState(() => new QueryClient());
1214
+ const [queryClient] = useState(() => makeQueryClient());
900
1215
 
901
1216
  return (
902
1217
  <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
903
1218
  );
904
1219
  }
905
1220
  `,
906
- apiClient: `import { createNextClient } from "@beignet/next";
907
-
908
- export const apiClient = createNextClient({
909
- validate: true,
910
- });
911
- `,
912
- rq: `import { createReactQuery } from "@beignet/react-query";
913
- import { apiClient } from "./api-client";
1221
+ formsClient: `import { createReactHookForm } from "@beignet/react-hook-form";
914
1222
 
915
- export const rq = createReactQuery(apiClient);
1223
+ export const rhf = createReactHookForm();
916
1224
  `,
917
- contractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
1225
+ contractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
918
1226
  import { z } from "zod";
919
1227
 
920
- const todos = createContractGroup().namespace("todos");
1228
+ const todos = defineContractGroup().namespace("todos");
921
1229
 
922
1230
  export const TodoSchema = z.object({
923
1231
  id: z.string(),
@@ -932,8 +1240,14 @@ export const CreateTodoSchema = z.object({
932
1240
 
933
1241
  export const listTodos = todos.get("/api/todos").responses({
934
1242
  200: z.object({
935
- todos: z.array(TodoSchema),
936
- total: z.number(),
1243
+ items: z.array(TodoSchema),
1244
+ page: z.object({
1245
+ kind: z.literal("offset"),
1246
+ limit: z.number(),
1247
+ offset: z.number(),
1248
+ total: z.number(),
1249
+ hasMore: z.boolean(),
1250
+ }),
937
1251
  }),
938
1252
  });
939
1253
 
@@ -976,30 +1290,175 @@ export const appPorts = definePorts({
976
1290
 
977
1291
  export type AppPorts = typeof appPorts;
978
1292
  `,
979
- useCasesTodos: `import { createUseCase } from "@beignet/core/application";
980
- import { z } from "zod";
981
- import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
982
- import type { AppPorts } from "@/ports";
1293
+ appContext: `import type { AppPorts } from "@/ports";
983
1294
 
984
- type AppContext = {
1295
+ export type AppContext = {
985
1296
  requestId: string;
986
1297
  ports: AppPorts;
987
1298
  };
1299
+ `,
1300
+ minimalBetterAuth: `import { betterAuth } from "better-auth";
1301
+ import { memoryAdapter, type MemoryDB } from "better-auth/adapters/memory";
1302
+
1303
+ // The minimal starter keeps auth storage in memory, matching its in-memory
1304
+ // ports. Users and sessions reset on restart; switch to a database adapter
1305
+ // such as better-auth/adapters/drizzle before production.
1306
+ const database: MemoryDB = {
1307
+ account: [],
1308
+ session: [],
1309
+ user: [],
1310
+ verification: [],
1311
+ };
1312
+
1313
+ export const auth = betterAuth({
1314
+ baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
1315
+ secret:
1316
+ process.env.BETTER_AUTH_SECRET ?? "local-dev-better-auth-secret-change-me",
1317
+ database: memoryAdapter(database),
1318
+ emailAndPassword: {
1319
+ enabled: true,
1320
+ },
1321
+ });
1322
+ `,
1323
+ minimalDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1324
+
1325
+ export const todos = sqliteTable("todos", {
1326
+ id: text("id").primaryKey(),
1327
+ title: text("title").notNull(),
1328
+ completed: integer("completed", { mode: "boolean" }).notNull().default(false),
1329
+ createdAt: text("created_at").notNull(),
1330
+ });
1331
+ `,
1332
+ minimalDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
1333
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1334
+ import { asc } from "drizzle-orm";
1335
+ import * as schema from "@/infra/db/schema";
1336
+ import type { CreateTodoInput, TodoRepository } from "@/ports";
1337
+
1338
+ export function createDrizzleTodoRepository(
1339
+ db: DrizzleTursoDatabase<typeof schema>,
1340
+ ): TodoRepository {
1341
+ return {
1342
+ async list() {
1343
+ return db
1344
+ .select()
1345
+ .from(schema.todos)
1346
+ .orderBy(asc(schema.todos.createdAt));
1347
+ },
1348
+ async create(input: CreateTodoInput) {
1349
+ const todo = {
1350
+ id: crypto.randomUUID(),
1351
+ title: input.title,
1352
+ completed: false,
1353
+ createdAt: new Date().toISOString(),
1354
+ };
1355
+ const [row] = await db.insert(schema.todos).values(todo).returning();
1356
+
1357
+ if (!row) {
1358
+ throw new Error("Failed to create todo");
1359
+ }
1360
+
1361
+ return row;
1362
+ },
1363
+ };
1364
+ }
1365
+ `,
1366
+ minimalDbProvider: `import type { beignetServerOnly } from "@beignet/core/server-only";
1367
+ import { createProvider } from "@beignet/core/providers";
1368
+ import type { DbPort } from "@beignet/provider-drizzle-turso";
1369
+ import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
1370
+ import type { AppPorts } from "@/ports";
1371
+ import * as schema from "./schema";
1372
+
1373
+ const setupStatements = [
1374
+ \`CREATE TABLE IF NOT EXISTS todos (
1375
+ id text PRIMARY KEY NOT NULL,
1376
+ title text NOT NULL,
1377
+ completed integer DEFAULT false NOT NULL,
1378
+ created_at text NOT NULL
1379
+ )\`,
1380
+ ];
1381
+
1382
+ const seedTitles = [
1383
+ "Read the generated contract",
1384
+ "Add your first use case",
1385
+ ] as const;
1386
+
1387
+ export const starterDatabaseProvider = createProvider<{
1388
+ db: DbPort<typeof schema>;
1389
+ }>()({
1390
+ name: "starter-database",
1391
+
1392
+ async setup({ ports }) {
1393
+ const dbPort = ports.db;
1394
+ if (!dbPort) {
1395
+ throw new Error(
1396
+ "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
1397
+ );
1398
+ }
1399
+
1400
+ const providedPorts: Pick<AppPorts, "todos"> = {
1401
+ todos: createDrizzleTodoRepository(dbPort.db),
1402
+ };
1403
+
1404
+ return {
1405
+ ports: providedPorts,
1406
+ async start() {
1407
+ for (const statement of setupStatements) {
1408
+ await dbPort.client.execute(statement);
1409
+ }
1410
+
1411
+ const result = await dbPort.client.execute(
1412
+ "SELECT count(*) as total FROM todos",
1413
+ );
1414
+ const total = Number(result.rows[0]?.total ?? 0);
1415
+ if (total > 0) return;
1416
+
1417
+ for (const title of seedTitles) {
1418
+ await dbPort.client.execute({
1419
+ sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
1420
+ args: [crypto.randomUUID(), title, 0, new Date().toISOString()],
1421
+ });
1422
+ }
1423
+ },
1424
+ };
1425
+ },
1426
+ });
1427
+ `,
1428
+ useCasesTodos: `import { createUseCase } from "@beignet/core/application";
1429
+ import { z } from "zod";
1430
+ import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
1431
+ import type { AppContext } from "@/app-context";
988
1432
 
989
1433
  const useCase = createUseCase<AppContext>();
990
1434
 
991
1435
  export const ListTodosOutputSchema = z.object({
992
- todos: z.array(TodoSchema),
993
- total: z.number(),
1436
+ items: z.array(TodoSchema),
1437
+ page: z.object({
1438
+ kind: z.literal("offset"),
1439
+ limit: z.number(),
1440
+ offset: z.number(),
1441
+ total: z.number(),
1442
+ hasMore: z.boolean(),
1443
+ }),
994
1444
  });
995
1445
 
996
1446
  export const listTodos = useCase
997
1447
  .query("listTodos")
998
- .input(z.void())
1448
+ .input(z.object({}))
999
1449
  .output(ListTodosOutputSchema)
1000
1450
  .run(async ({ ctx }) => {
1001
1451
  const todos = await ctx.ports.todos.list();
1002
- return { todos, total: todos.length };
1452
+ return {
1453
+ items: todos,
1454
+ page: {
1455
+ kind: "offset" as const,
1456
+ limit: todos.length,
1457
+ offset: 0,
1458
+ total: todos.length,
1459
+ hasMore: false,
1460
+ },
1461
+ };
1003
1462
  });
1004
1463
 
1005
1464
  export const createTodo = useCase
@@ -1008,33 +1467,23 @@ export const createTodo = useCase
1008
1467
  .output(TodoSchema)
1009
1468
  .run(async ({ ctx, input }) => ctx.ports.todos.create(input));
1010
1469
  `,
1011
- todoRoutes: `import { defineRouteGroup } from "@beignet/next";
1012
- import type { AppContext } from "@/server";
1470
+ todoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
1471
+ import { defineRouteGroup } from "@beignet/next";
1472
+ import type { AppContext } from "@/app-context";
1013
1473
  import { createTodo, listTodos } from "@/features/todos/use-cases";
1014
1474
  import * as contracts from "@/features/todos/contracts";
1015
1475
 
1016
1476
  export const todoRoutes = defineRouteGroup<AppContext>({
1017
1477
  name: "todos",
1018
1478
  routes: [
1019
- {
1020
- contract: contracts.listTodos,
1021
- handle: async ({ ctx }) => ({
1022
- status: 200,
1023
- body: await listTodos.run({ ctx, input: undefined }),
1024
- }),
1025
- },
1026
- {
1027
- contract: contracts.createTodo,
1028
- handle: async ({ ctx, body }) => ({
1029
- status: 201,
1030
- body: await createTodo.run({ ctx, input: body }),
1031
- }),
1032
- },
1479
+ { contract: contracts.listTodos, useCase: listTodos },
1480
+ { contract: contracts.createTodo, useCase: createTodo },
1033
1481
  ],
1034
1482
  });
1035
1483
  `,
1036
- serverRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1037
- import type { AppContext } from "@/server";
1484
+ serverRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
1485
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1486
+ import type { AppContext } from "@/app-context";
1038
1487
  import { todoRoutes } from "@/features/todos/routes";
1039
1488
 
1040
1489
  export const routes = defineRoutes<AppContext>([
@@ -1073,6 +1522,14 @@ export const env = createEnv({
1073
1522
  APP_URL: z.string().url().default("http://localhost:3000"),
1074
1523
  CRON_SECRET: z.string().min(1).optional(),
1075
1524
  DEVTOOLS_ENABLED: BooleanEnv.optional(),
1525
+ BETTER_AUTH_SECRET: z
1526
+ .string()
1527
+ .min(32)
1528
+ .default("local-dev-better-auth-secret-change-me"),
1529
+ BETTER_AUTH_URL: z.string().url().default("http://localhost:3000"),
1530
+ BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
1531
+ TURSO_DB_URL: z.string().default("file:local.db"),
1532
+ TURSO_DB_AUTH_TOKEN: z.string().optional(),
1076
1533
  LOG_LEVEL: z
1077
1534
  .enum(["trace", "debug", "info", "warn", "error", "fatal"])
1078
1535
  .default("info"),
@@ -1084,24 +1541,28 @@ export const env = createEnv({
1084
1541
 
1085
1542
  export const isProduction = env.NODE_ENV === "production";
1086
1543
  `,
1087
- productionAppContext: `import type { DevtoolsPort, DevtoolsTraceContext } from "@beignet/devtools";
1088
- import type { ActivityActor, ActivityTenant, StoragePort } from "@beignet/core/ports";
1544
+ productionAppContext: `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
1545
+ import type { InferProviderPorts } from "@beignet/core/providers";
1546
+ import type { TraceContext } from "@beignet/core/tracing";
1089
1547
  import type { AppGate, AppPorts } from "@/ports";
1090
1548
  import type { AuthSession } from "@/ports/auth";
1549
+ import type { providers } from "@/server/providers";
1550
+
1551
+ /**
1552
+ * App ports plus the ports contributed by the server's providers at startup.
1553
+ */
1554
+ export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
1091
1555
 
1092
1556
  export type AppContext = {
1093
1557
  requestId: string;
1094
1558
  actor: ActivityActor;
1095
1559
  auth: AuthSession | null;
1096
1560
  gate: AppGate;
1097
- ports: AppPorts & {
1098
- devtools: DevtoolsPort;
1099
- storage: StoragePort;
1100
- };
1561
+ ports: AppRuntimePorts;
1101
1562
  tenant?: ActivityTenant;
1102
- } & Partial<DevtoolsTraceContext>;
1563
+ } & Partial<TraceContext>;
1103
1564
  `,
1104
- productimapUnhandledErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
1565
+ productionSharedErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
1105
1566
 
1106
1567
  export const errors = defineErrors({
1107
1568
  Unauthorized: {
@@ -1138,10 +1599,14 @@ export const ListTodosInputSchema = z.object({
1138
1599
  });
1139
1600
 
1140
1601
  export const ListTodosOutputSchema = z.object({
1141
- todos: z.array(TodoSchema),
1142
- total: z.number().int().min(0),
1143
- limit: z.number().int().min(1),
1144
- offset: z.number().int().min(0),
1602
+ items: z.array(TodoSchema),
1603
+ page: z.object({
1604
+ kind: z.literal("offset"),
1605
+ limit: z.number().int().min(1),
1606
+ offset: z.number().int().min(0),
1607
+ total: z.number().int().min(0),
1608
+ hasMore: z.boolean(),
1609
+ }),
1145
1610
  });
1146
1611
 
1147
1612
  export const GetTodoInputSchema = z.object({
@@ -1156,13 +1621,36 @@ export type Todo = z.infer<typeof TodoSchema>;
1156
1621
  export type CreateTodoInput = z.infer<typeof CreateTodoInputSchema>;
1157
1622
  export type ListTodosInput = z.infer<typeof ListTodosInputSchema>;
1158
1623
  `,
1159
- productionUseCaseBuilder: `import { createUseCase } from "@beignet/core/application";
1160
- import { createDevtoolsUseCaseObserver } from "@beignet/devtools";
1624
+ productionUseCaseBuilder: `import type { beignetServerOnly } from "@beignet/core/server-only";
1625
+ import { createUseCase } from "@beignet/core/application";
1161
1626
  import type { AppContext } from "@/app-context";
1162
1627
 
1163
- export const useCase = createUseCase<AppContext>({
1164
- onRun: createDevtoolsUseCaseObserver<AppContext>(),
1165
- });
1628
+ export const useCase = createUseCase<AppContext>();
1629
+ `,
1630
+ productionListenersBuilder: `import { createListeners } from "@beignet/core/events";
1631
+ import type { AppContext } from "@/app-context";
1632
+
1633
+ export const { defineListener } = createListeners<AppContext>();
1634
+ `,
1635
+ productionJobsBuilder: `import { createJobs } from "@beignet/core/jobs";
1636
+ import type { AppContext } from "@/app-context";
1637
+
1638
+ export const { defineJob } = createJobs<AppContext>();
1639
+ `,
1640
+ productionSchedulesBuilder: `import { createSchedules } from "@beignet/core/schedules";
1641
+ import type { AppContext } from "@/app-context";
1642
+
1643
+ export const { defineSchedule } = createSchedules<AppContext>();
1644
+ `,
1645
+ productionNotificationsBuilder: `import { createNotifications } from "@beignet/core/notifications";
1646
+ import type { AppContext } from "@/app-context";
1647
+
1648
+ export const { defineNotification } = createNotifications<AppContext>();
1649
+ `,
1650
+ productionTasksBuilder: `import { createTasks } from "@beignet/core/tasks";
1651
+ import type { AppContext } from "@/app-context";
1652
+
1653
+ export const { defineTask } = createTasks<AppContext>();
1166
1654
  `,
1167
1655
  productionAuthHelpers: `import type { AppContext } from "@/app-context";
1168
1656
  import type { AuthSession, AuthUser } from "@/ports/auth";
@@ -1180,26 +1668,28 @@ export function requireUser(ctx: AppContext): AuthUser {
1180
1668
  return requireSession(ctx).user;
1181
1669
  }
1182
1670
  `,
1183
- productionListTodosUseCase: `import { useCase } from "@/lib/use-case";
1184
- import { ListTodosInputSchema, ListTodosOutputSchema } from "./schemas";
1671
+ productionListTodosUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1672
+ import { normalizeOffsetPage } from "@beignet/core/pagination";
1673
+ import { useCase } from "@/lib/use-case";
1674
+ import { ListTodosInputSchema, ListTodosOutputSchema } from "../schemas";
1185
1675
 
1186
1676
  export const listTodosUseCase = useCase
1187
1677
  .query("todos.list")
1188
1678
  .input(ListTodosInputSchema)
1189
1679
  .output(ListTodosOutputSchema)
1190
1680
  .run(async ({ ctx, input }) => {
1191
- const result = await ctx.ports.todos.list(input);
1192
- return {
1193
- todos: result.todos,
1194
- total: result.total,
1195
- limit: input.limit,
1196
- offset: input.offset,
1197
- };
1681
+ const page = normalizeOffsetPage(input, {
1682
+ defaultLimit: 20,
1683
+ maxLimit: 100,
1684
+ });
1685
+
1686
+ return ctx.ports.todos.list(page);
1198
1687
  });
1199
1688
  `,
1200
- productionGetTodoUseCase: `import { appError } from "@/features/shared/errors";
1689
+ productionGetTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1690
+ import { appError } from "@/features/shared/errors";
1201
1691
  import { useCase } from "@/lib/use-case";
1202
- import { GetTodoInputSchema, TodoSchema } from "./schemas";
1692
+ import { GetTodoInputSchema, TodoSchema } from "../schemas";
1203
1693
 
1204
1694
  export const getTodoUseCase = useCase
1205
1695
  .query("todos.get")
@@ -1214,16 +1704,36 @@ export const getTodoUseCase = useCase
1214
1704
  return todo;
1215
1705
  });
1216
1706
  `,
1217
- productionCreateTodoUseCase: `import { useCase } from "@/lib/use-case";
1218
- import { CreateTodoInputSchema, TodoSchema } from "./schemas";
1707
+ productionCreateTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
1708
+ import { TodoCreated } from "@/features/todos/domain/events";
1709
+ import { auditEntry } from "@/lib/audit";
1710
+ import { useCase } from "@/lib/use-case";
1711
+ import { CreateTodoInputSchema, TodoSchema } from "../schemas";
1219
1712
 
1220
1713
  export const createTodoUseCase = useCase
1221
1714
  .command("todos.create")
1222
1715
  .input(CreateTodoInputSchema)
1223
1716
  .output(TodoSchema)
1224
- .run(async ({ ctx, input }) => {
1717
+ .emits([TodoCreated])
1718
+ .run(async ({ ctx, input, events }) => {
1225
1719
  await ctx.gate.authorize("todos.create");
1226
- return ctx.ports.uow.transaction((tx) => tx.todos.create(input));
1720
+ const todo = await ctx.ports.uow.transaction(async (tx) => {
1721
+ const created = await tx.todos.create(input);
1722
+ await events.record(tx.events, TodoCreated, {
1723
+ todoId: created.id,
1724
+ title: created.title,
1725
+ });
1726
+ await tx.audit.record(
1727
+ auditEntry(ctx, {
1728
+ action: "todos.create",
1729
+ resource: { type: "todo", id: created.id, name: created.title },
1730
+ metadata: { completed: created.completed },
1731
+ }),
1732
+ );
1733
+ return created;
1734
+ });
1735
+
1736
+ return todo;
1227
1737
  });
1228
1738
  `,
1229
1739
  productionUseCasesIndex: `export { createTodoUseCase } from "./create-todo";
@@ -1238,24 +1748,40 @@ export {
1238
1748
  type CreateTodoInput,
1239
1749
  type ListTodosInput,
1240
1750
  type Todo,
1241
- } from "./schemas";
1751
+ } from "../schemas";
1242
1752
  `,
1243
1753
  productionTodoRepositoryPort: `import type {
1754
+ OffsetPage,
1755
+ OffsetPageInfo,
1756
+ PageResult,
1757
+ } from "@beignet/core/pagination";
1758
+ import type {
1244
1759
  CreateTodoInput,
1245
- ListTodosInput,
1246
1760
  Todo,
1247
- } from "@/features/todos/use-cases/schemas";
1761
+ } from "@/features/todos/schemas";
1248
1762
 
1249
- export type ListTodosResult = {
1250
- todos: Todo[];
1251
- total: number;
1763
+ export type ListTodosResult = PageResult<Todo, OffsetPageInfo>;
1764
+
1765
+ export type TodoAttachment = {
1766
+ id: string;
1767
+ todoId: string;
1768
+ key: string;
1769
+ fileName: string;
1770
+ contentType: string;
1771
+ size: number;
1772
+ createdAt: string;
1252
1773
  };
1253
1774
 
1254
1775
  export interface TodoRepository {
1255
- list(input: ListTodosInput): Promise<ListTodosResult>;
1776
+ list(page: OffsetPage): Promise<ListTodosResult>;
1256
1777
  findById(id: string): Promise<Todo | null>;
1257
1778
  create(input: CreateTodoInput): Promise<Todo>;
1258
1779
  }
1780
+
1781
+ export interface TodoAttachmentRepository {
1782
+ create(input: Omit<TodoAttachment, "createdAt">): Promise<TodoAttachment>;
1783
+ findMany(todoId: string): Promise<TodoAttachment[]>;
1784
+ }
1259
1785
  `,
1260
1786
  productionAuthPort: `import type {
1261
1787
  AuthPort as BeignetAuthPort,
@@ -1325,12 +1851,16 @@ export const todoPolicy = definePolicy({
1325
1851
  },
1326
1852
  });
1327
1853
  `,
1328
- productionInMemoryTodoRepository: `import type {
1854
+ productionInMemoryTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
1855
+ import { offsetPageResult } from "@beignet/core/pagination";
1856
+ import type {
1329
1857
  CreateTodoInput,
1330
- ListTodosInput,
1331
1858
  Todo,
1332
- } from "@/features/todos/use-cases/schemas";
1333
- import type { TodoRepository } from "@/features/todos/ports";
1859
+ } from "@/features/todos/schemas";
1860
+ import type {
1861
+ TodoAttachmentRepository,
1862
+ TodoRepository,
1863
+ } from "@/features/todos/ports";
1334
1864
 
1335
1865
  export function createInMemoryTodoRepository(
1336
1866
  seed: Todo[] = [],
@@ -1338,15 +1868,16 @@ export function createInMemoryTodoRepository(
1338
1868
  const todos = new Map(seed.map((todo) => [todo.id, todo]));
1339
1869
 
1340
1870
  return {
1341
- async list(input: ListTodosInput) {
1871
+ async list(page) {
1342
1872
  const allTodos = Array.from(todos.values()).sort((left, right) =>
1343
1873
  left.createdAt.localeCompare(right.createdAt),
1344
1874
  );
1345
1875
 
1346
- return {
1347
- todos: allTodos.slice(input.offset, input.offset + input.limit),
1348
- total: allTodos.length,
1349
- };
1876
+ return offsetPageResult(
1877
+ allTodos.slice(page.offset, page.offset + page.limit),
1878
+ page,
1879
+ allTodos.length,
1880
+ );
1350
1881
  },
1351
1882
  async findById(id: string) {
1352
1883
  return todos.get(id) ?? null;
@@ -1409,28 +1940,6 @@ export const appPorts = definePorts({
1409
1940
  todos,
1410
1941
  })),
1411
1942
  });
1412
- `,
1413
- productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
1414
- import { todoPolicy } from "@/features/todos/policy";
1415
- import { appError } from "@/features/shared/errors";
1416
- import { createAnonymousAuth } from "./auth/anonymous-auth";
1417
- import { fallbackLogger } from "./logger";
1418
-
1419
- const gate = createGate({
1420
- policies: [todoPolicy],
1421
- onDeny(decision) {
1422
- return appError("Forbidden", {
1423
- message: decision.reason ?? "Forbidden",
1424
- details: decision.details,
1425
- });
1426
- },
1427
- });
1428
-
1429
- export const appPorts = definePorts({
1430
- auth: createAnonymousAuth(),
1431
- gate,
1432
- logger: fallbackLogger,
1433
- });
1434
1943
  `,
1435
1944
  productionDrizzleConfig: `export default {
1436
1945
  schema: "./infra/db/schema/index.ts",
@@ -1442,7 +1951,63 @@ export const appPorts = definePorts({
1442
1951
  },
1443
1952
  };
1444
1953
  `,
1445
- productionDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1954
+ productionDbSchema: `import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1955
+
1956
+ export const user = sqliteTable("user", {
1957
+ id: text("id").primaryKey(),
1958
+ name: text("name").notNull(),
1959
+ email: text("email").notNull().unique(),
1960
+ emailVerified: integer("email_verified", { mode: "boolean" })
1961
+ .notNull()
1962
+ .default(false),
1963
+ image: text("image"),
1964
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1965
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
1966
+ });
1967
+
1968
+ export const session = sqliteTable("session", {
1969
+ id: text("id").primaryKey(),
1970
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
1971
+ token: text("token").notNull().unique(),
1972
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1973
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
1974
+ ipAddress: text("ip_address"),
1975
+ userAgent: text("user_agent"),
1976
+ userId: text("user_id")
1977
+ .notNull()
1978
+ .references(() => user.id, { onDelete: "cascade" }),
1979
+ });
1980
+
1981
+ export const account = sqliteTable("account", {
1982
+ id: text("id").primaryKey(),
1983
+ accountId: text("account_id").notNull(),
1984
+ providerId: text("provider_id").notNull(),
1985
+ userId: text("user_id")
1986
+ .notNull()
1987
+ .references(() => user.id, { onDelete: "cascade" }),
1988
+ accessToken: text("access_token"),
1989
+ refreshToken: text("refresh_token"),
1990
+ idToken: text("id_token"),
1991
+ accessTokenExpiresAt: integer("access_token_expires_at", {
1992
+ mode: "timestamp",
1993
+ }),
1994
+ refreshTokenExpiresAt: integer("refresh_token_expires_at", {
1995
+ mode: "timestamp",
1996
+ }),
1997
+ scope: text("scope"),
1998
+ password: text("password"),
1999
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
2000
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
2001
+ });
2002
+
2003
+ export const verification = sqliteTable("verification", {
2004
+ id: text("id").primaryKey(),
2005
+ identifier: text("identifier").notNull(),
2006
+ value: text("value").notNull(),
2007
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
2008
+ createdAt: integer("created_at", { mode: "timestamp" }),
2009
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
2010
+ });
1446
2011
 
1447
2012
  export const todos = sqliteTable("todos", {
1448
2013
  id: text("id").primaryKey(),
@@ -1450,15 +2015,139 @@ export const todos = sqliteTable("todos", {
1450
2015
  completed: integer("completed", { mode: "boolean" }).notNull().default(false),
1451
2016
  createdAt: text("created_at").notNull(),
1452
2017
  });
2018
+
2019
+ export const todoAttachments = sqliteTable(
2020
+ "todo_attachments",
2021
+ {
2022
+ id: text("id").primaryKey(),
2023
+ todoId: text("todo_id")
2024
+ .notNull()
2025
+ .references(() => todos.id, { onDelete: "cascade" }),
2026
+ key: text("key").notNull(),
2027
+ fileName: text("file_name").notNull(),
2028
+ contentType: text("content_type").notNull(),
2029
+ size: integer("size").notNull(),
2030
+ createdAt: text("created_at").notNull(),
2031
+ },
2032
+ (table) => ({
2033
+ todoIdx: index("todo_attachments_todo_idx").on(
2034
+ table.todoId,
2035
+ table.createdAt,
2036
+ ),
2037
+ keyIdx: index("todo_attachments_key_idx").on(table.key),
2038
+ }),
2039
+ );
2040
+
2041
+ export const auditLog = sqliteTable(
2042
+ "audit_log",
2043
+ {
2044
+ id: text("id").primaryKey(),
2045
+ action: text("action").notNull(),
2046
+ actorType: text("actor_type", {
2047
+ enum: ["anonymous", "service", "system", "user"],
2048
+ }).notNull(),
2049
+ actorId: text("actor_id"),
2050
+ actorDisplayName: text("actor_display_name"),
2051
+ tenantId: text("tenant_id"),
2052
+ tenantSlug: text("tenant_slug"),
2053
+ resourceType: text("resource_type"),
2054
+ resourceId: text("resource_id"),
2055
+ resourceName: text("resource_name"),
2056
+ outcome: text("outcome", { enum: ["success", "failure"] })
2057
+ .notNull()
2058
+ .default("success"),
2059
+ requestId: text("request_id"),
2060
+ traceId: text("trace_id"),
2061
+ message: text("message"),
2062
+ metadata: text("metadata"),
2063
+ actorMetadata: text("actor_metadata"),
2064
+ tenantMetadata: text("tenant_metadata"),
2065
+ resourceMetadata: text("resource_metadata"),
2066
+ occurredAt: text("occurred_at").notNull(),
2067
+ },
2068
+ (table) => ({
2069
+ actionIdx: index("audit_log_action_idx").on(table.action),
2070
+ actorIdx: index("audit_log_actor_idx").on(table.actorType, table.actorId),
2071
+ occurredAtIdx: index("audit_log_occurred_at_idx").on(table.occurredAt),
2072
+ requestIdx: index("audit_log_request_idx").on(table.requestId),
2073
+ resourceIdx: index("audit_log_resource_idx").on(
2074
+ table.resourceType,
2075
+ table.resourceId,
2076
+ ),
2077
+ tenantIdx: index("audit_log_tenant_idx").on(table.tenantId),
2078
+ }),
2079
+ );
2080
+
2081
+ export const outboxMessages = sqliteTable(
2082
+ "outbox_messages",
2083
+ {
2084
+ id: text("id").primaryKey(),
2085
+ kind: text("kind", { enum: ["event", "job"] }).notNull(),
2086
+ name: text("name").notNull(),
2087
+ payloadJson: text("payload_json").notNull(),
2088
+ status: text("status", {
2089
+ enum: ["pending", "claimed", "delivered", "deadLettered"],
2090
+ }).notNull(),
2091
+ attempts: integer("attempts").notNull().default(0),
2092
+ maxAttempts: integer("max_attempts").notNull().default(3),
2093
+ availableAt: text("available_at").notNull(),
2094
+ claimedAt: text("claimed_at"),
2095
+ lockedUntil: text("locked_until"),
2096
+ claimToken: text("claim_token"),
2097
+ deliveredAt: text("delivered_at"),
2098
+ lastErrorJson: text("last_error_json"),
2099
+ createdAt: text("created_at").notNull(),
2100
+ updatedAt: text("updated_at").notNull(),
2101
+ },
2102
+ (table) => ({
2103
+ availableIdx: index("outbox_messages_available_idx").on(
2104
+ table.status,
2105
+ table.availableAt,
2106
+ ),
2107
+ lockedIdx: index("outbox_messages_locked_idx").on(
2108
+ table.status,
2109
+ table.lockedUntil,
2110
+ ),
2111
+ }),
2112
+ );
2113
+
2114
+ export const idempotencyRecords = sqliteTable(
2115
+ "idempotency_records",
2116
+ {
2117
+ storageKey: text("storage_key").primaryKey(),
2118
+ namespace: text("namespace").notNull(),
2119
+ idempotencyKey: text("idempotency_key").notNull(),
2120
+ scopeKey: text("scope_key").notNull(),
2121
+ fingerprint: text("fingerprint").notNull(),
2122
+ status: text("status", { enum: ["in-progress", "completed"] }).notNull(),
2123
+ resultJson: text("result_json"),
2124
+ reservedAt: text("reserved_at").notNull(),
2125
+ completedAt: text("completed_at"),
2126
+ expiresAt: text("expires_at"),
2127
+ updatedAt: text("updated_at").notNull(),
2128
+ },
2129
+ (table) => ({
2130
+ lookupIdx: index("idempotency_records_lookup_idx").on(
2131
+ table.namespace,
2132
+ table.scopeKey,
2133
+ table.idempotencyKey,
2134
+ ),
2135
+ expiresIdx: index("idempotency_records_expires_idx").on(table.expiresAt),
2136
+ }),
2137
+ );
1453
2138
  `,
1454
- productionDrizzleTodoRepository: `import { count, desc, eq } from "drizzle-orm";
2139
+ productionDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
2140
+ import { offsetPageResult } from "@beignet/core/pagination";
2141
+ import { count, desc, eq } from "drizzle-orm";
1455
2142
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1456
- import type { TodoRepository } from "@/features/todos/ports";
2143
+ import type {
2144
+ TodoAttachmentRepository,
2145
+ TodoRepository,
2146
+ } from "@/features/todos/ports";
1457
2147
  import type {
1458
2148
  CreateTodoInput,
1459
- ListTodosInput,
1460
2149
  Todo,
1461
- } from "@/features/todos/use-cases/schemas";
2150
+ } from "@/features/todos/schemas";
1462
2151
  import * as schema from "@/infra/db/schema";
1463
2152
 
1464
2153
  type TodoRow = typeof schema.todos.$inferSelect;
@@ -1476,19 +2165,16 @@ export function createDrizzleTodoRepository(
1476
2165
  db: DrizzleTursoDatabase<typeof schema>,
1477
2166
  ): TodoRepository {
1478
2167
  return {
1479
- async list(input: ListTodosInput) {
2168
+ async list(page) {
1480
2169
  const rows = await db
1481
2170
  .select()
1482
2171
  .from(schema.todos)
1483
2172
  .orderBy(desc(schema.todos.createdAt))
1484
- .limit(input.limit)
1485
- .offset(input.offset);
2173
+ .limit(page.limit)
2174
+ .offset(page.offset);
1486
2175
  const [{ total }] = await db.select({ total: count() }).from(schema.todos);
1487
2176
 
1488
- return {
1489
- todos: rows.map(toTodo),
1490
- total,
1491
- };
2177
+ return offsetPageResult(rows.map(toTodo), page, total);
1492
2178
  },
1493
2179
  async findById(id: string) {
1494
2180
  const [row] = await db
@@ -1516,28 +2202,445 @@ export function createDrizzleTodoRepository(
1516
2202
  },
1517
2203
  };
1518
2204
  }
2205
+
2206
+ export function createDrizzleTodoAttachmentRepository(
2207
+ db: DrizzleTursoDatabase<typeof schema>,
2208
+ ): TodoAttachmentRepository {
2209
+ return {
2210
+ async create(input) {
2211
+ const attachment = {
2212
+ ...input,
2213
+ createdAt: new Date().toISOString(),
2214
+ };
2215
+ const [row] = await db
2216
+ .insert(schema.todoAttachments)
2217
+ .values(attachment)
2218
+ .returning();
2219
+
2220
+ if (!row) {
2221
+ throw new Error("Failed to create todo attachment");
2222
+ }
2223
+
2224
+ return {
2225
+ id: row.id,
2226
+ todoId: row.todoId,
2227
+ key: row.key,
2228
+ fileName: row.fileName,
2229
+ contentType: row.contentType,
2230
+ size: row.size,
2231
+ createdAt: row.createdAt,
2232
+ };
2233
+ },
2234
+ async findMany(todoId) {
2235
+ const rows = await db
2236
+ .select()
2237
+ .from(schema.todoAttachments)
2238
+ .where(eq(schema.todoAttachments.todoId, todoId))
2239
+ .orderBy(desc(schema.todoAttachments.createdAt));
2240
+
2241
+ return rows.map((row) => ({
2242
+ id: row.id,
2243
+ todoId: row.todoId,
2244
+ key: row.key,
2245
+ fileName: row.fileName,
2246
+ contentType: row.contentType,
2247
+ size: row.size,
2248
+ createdAt: row.createdAt,
2249
+ }));
2250
+ },
2251
+ };
2252
+ }
1519
2253
  `,
1520
2254
  productionDbRepositories: `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1521
- import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
2255
+ import {
2256
+ createDrizzleTodoAttachmentRepository,
2257
+ createDrizzleTodoRepository,
2258
+ } from "@/infra/todos/drizzle-todo-repository";
1522
2259
  import type { AppTransactionPorts } from "@/ports";
1523
2260
  import * as schema from "./schema";
1524
2261
 
1525
2262
  export function createRepositories(
1526
2263
  db: DrizzleTursoDatabase<typeof schema>,
1527
- ): Omit<AppTransactionPorts, "events"> {
2264
+ ): Omit<
2265
+ AppTransactionPorts,
2266
+ "audit" | "events" | "idempotency" | "jobs" | "outbox"
2267
+ > {
1528
2268
  return {
2269
+ todoAttachments: createDrizzleTodoAttachmentRepository(db),
1529
2270
  todos: createDrizzleTodoRepository(db),
1530
2271
  };
1531
2272
  }
1532
2273
  `,
1533
- productionContractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
2274
+ productionDrizzleAuditLog: `import type { AuditLogEntry, AuditLogPort } from "@beignet/core/ports";
2275
+ import {
2276
+ normalizeAuditLogEntry,
2277
+ redactAuditLogEntry,
2278
+ } from "@beignet/core/ports";
2279
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
2280
+ import * as schema from "@/infra/db/schema";
2281
+
2282
+ function serialize(value: unknown): string | null {
2283
+ return value === undefined ? null : JSON.stringify(value);
2284
+ }
2285
+
2286
+ export function createDrizzleAuditLog(
2287
+ db: DrizzleTursoDatabase<typeof schema>,
2288
+ ): AuditLogPort {
2289
+ return {
2290
+ async record(input) {
2291
+ const entry: AuditLogEntry = redactAuditLogEntry(
2292
+ normalizeAuditLogEntry(input),
2293
+ );
2294
+
2295
+ await db.insert(schema.auditLog).values({
2296
+ id: crypto.randomUUID(),
2297
+ action: entry.action,
2298
+ actorType: entry.actor.type,
2299
+ actorId: entry.actor.id ?? null,
2300
+ actorDisplayName: entry.actor.displayName ?? null,
2301
+ tenantId: entry.tenant?.id ?? null,
2302
+ tenantSlug: entry.tenant?.slug ?? null,
2303
+ resourceType: entry.resource?.type ?? null,
2304
+ resourceId: entry.resource?.id ?? null,
2305
+ resourceName: entry.resource?.name ?? null,
2306
+ outcome: entry.outcome,
2307
+ requestId: entry.requestId ?? null,
2308
+ traceId: entry.traceId ?? null,
2309
+ message: entry.message ?? null,
2310
+ metadata: serialize(entry.metadata),
2311
+ actorMetadata: serialize(entry.actor.metadata),
2312
+ tenantMetadata: serialize(entry.tenant?.metadata),
2313
+ resourceMetadata: serialize(entry.resource?.metadata),
2314
+ occurredAt: entry.occurredAt.toISOString(),
2315
+ });
2316
+ },
2317
+ };
2318
+ }
2319
+ `,
2320
+ productionDbBootstrap: `import { createDrizzleTursoIdempotencySetupStatements, createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
2321
+ import type { Client } from "@libsql/client";
2322
+
2323
+ type BootstrapOptions = {
2324
+ seed?: boolean;
2325
+ };
2326
+
2327
+ const setupStatements = [
2328
+ \`CREATE TABLE IF NOT EXISTS user (
2329
+ id text PRIMARY KEY NOT NULL,
2330
+ name text NOT NULL,
2331
+ email text NOT NULL UNIQUE,
2332
+ email_verified integer DEFAULT false NOT NULL,
2333
+ image text,
2334
+ created_at integer NOT NULL,
2335
+ updated_at integer NOT NULL
2336
+ )\`,
2337
+ \`CREATE TABLE IF NOT EXISTS session (
2338
+ id text PRIMARY KEY NOT NULL,
2339
+ expires_at integer NOT NULL,
2340
+ token text NOT NULL UNIQUE,
2341
+ created_at integer NOT NULL,
2342
+ updated_at integer NOT NULL,
2343
+ ip_address text,
2344
+ user_agent text,
2345
+ user_id text NOT NULL REFERENCES user(id) ON DELETE cascade
2346
+ )\`,
2347
+ \`CREATE TABLE IF NOT EXISTS account (
2348
+ id text PRIMARY KEY NOT NULL,
2349
+ account_id text NOT NULL,
2350
+ provider_id text NOT NULL,
2351
+ user_id text NOT NULL REFERENCES user(id) ON DELETE cascade,
2352
+ access_token text,
2353
+ refresh_token text,
2354
+ id_token text,
2355
+ access_token_expires_at integer,
2356
+ refresh_token_expires_at integer,
2357
+ scope text,
2358
+ password text,
2359
+ created_at integer NOT NULL,
2360
+ updated_at integer NOT NULL
2361
+ )\`,
2362
+ \`CREATE TABLE IF NOT EXISTS verification (
2363
+ id text PRIMARY KEY NOT NULL,
2364
+ identifier text NOT NULL,
2365
+ value text NOT NULL,
2366
+ expires_at integer NOT NULL,
2367
+ created_at integer,
2368
+ updated_at integer
2369
+ )\`,
2370
+ \`CREATE TABLE IF NOT EXISTS todos (
2371
+ id text PRIMARY KEY NOT NULL,
2372
+ title text NOT NULL,
2373
+ completed integer DEFAULT false NOT NULL,
2374
+ created_at text NOT NULL
2375
+ )\`,
2376
+ \`CREATE TABLE IF NOT EXISTS todo_attachments (
2377
+ id text PRIMARY KEY NOT NULL,
2378
+ todo_id text NOT NULL REFERENCES todos(id) ON DELETE cascade,
2379
+ key text NOT NULL,
2380
+ file_name text NOT NULL,
2381
+ content_type text NOT NULL,
2382
+ size integer NOT NULL,
2383
+ created_at text NOT NULL
2384
+ )\`,
2385
+ "CREATE INDEX IF NOT EXISTS todo_attachments_todo_idx ON todo_attachments (todo_id, created_at)",
2386
+ "CREATE INDEX IF NOT EXISTS todo_attachments_key_idx ON todo_attachments (key)",
2387
+ \`CREATE TABLE IF NOT EXISTS audit_log (
2388
+ id text PRIMARY KEY NOT NULL,
2389
+ action text NOT NULL,
2390
+ actor_type text NOT NULL,
2391
+ actor_id text,
2392
+ actor_display_name text,
2393
+ tenant_id text,
2394
+ tenant_slug text,
2395
+ resource_type text,
2396
+ resource_id text,
2397
+ resource_name text,
2398
+ outcome text DEFAULT 'success' NOT NULL,
2399
+ request_id text,
2400
+ trace_id text,
2401
+ message text,
2402
+ metadata text,
2403
+ actor_metadata text,
2404
+ tenant_metadata text,
2405
+ resource_metadata text,
2406
+ occurred_at text NOT NULL
2407
+ )\`,
2408
+ "CREATE INDEX IF NOT EXISTS audit_log_action_idx ON audit_log (action)",
2409
+ "CREATE INDEX IF NOT EXISTS audit_log_actor_idx ON audit_log (actor_type, actor_id)",
2410
+ "CREATE INDEX IF NOT EXISTS audit_log_occurred_at_idx ON audit_log (occurred_at)",
2411
+ "CREATE INDEX IF NOT EXISTS audit_log_request_idx ON audit_log (request_id)",
2412
+ "CREATE INDEX IF NOT EXISTS audit_log_resource_idx ON audit_log (resource_type, resource_id)",
2413
+ "CREATE INDEX IF NOT EXISTS audit_log_tenant_idx ON audit_log (tenant_id)",
2414
+ ...createDrizzleTursoIdempotencySetupStatements(),
2415
+ ...createDrizzleTursoOutboxSetupStatements(),
2416
+ ];
2417
+
2418
+ const seedTodos = [
2419
+ {
2420
+ id: "00000000-0000-4000-8000-000000000001",
2421
+ title: "Review the starter boundaries",
2422
+ completed: 0,
2423
+ createdAt: "2026-01-01T00:00:00.000Z",
2424
+ },
2425
+ {
2426
+ id: "00000000-0000-4000-8000-000000000002",
2427
+ title: "Create your first feature",
2428
+ completed: 0,
2429
+ createdAt: "2026-01-01T00:05:00.000Z",
2430
+ },
2431
+ ] as const;
2432
+
2433
+ const resetTables = [
2434
+ "verification",
2435
+ "account",
2436
+ "session",
2437
+ "user",
2438
+ "todo_attachments",
2439
+ "todos",
2440
+ "audit_log",
2441
+ "idempotency_records",
2442
+ "outbox_messages",
2443
+ ] as const;
2444
+
2445
+ export async function ensureStarterDatabase(
2446
+ client: Client,
2447
+ options: BootstrapOptions = {},
2448
+ ): Promise<void> {
2449
+ for (const statement of setupStatements) {
2450
+ await client.execute(statement);
2451
+ }
2452
+
2453
+ if (options.seed === false) return;
2454
+
2455
+ const result = await client.execute("SELECT count(*) as total FROM todos");
2456
+ const total = Number(result.rows[0]?.total ?? 0);
2457
+ if (total > 0) return;
2458
+
2459
+ for (const todo of seedTodos) {
2460
+ await client.execute({
2461
+ sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
2462
+ args: [todo.id, todo.title, todo.completed, todo.createdAt],
2463
+ });
2464
+ }
2465
+ }
2466
+
2467
+ export async function resetStarterDatabase(client: Client): Promise<void> {
2468
+ await client.execute("PRAGMA foreign_keys = OFF");
2469
+ try {
2470
+ for (const table of resetTables) {
2471
+ await client.execute(\`DROP TABLE IF EXISTS \${table}\`);
2472
+ }
2473
+ } finally {
2474
+ await client.execute("PRAGMA foreign_keys = ON");
2475
+ }
2476
+ await ensureStarterDatabase(client, { seed: false });
2477
+ }
2478
+ `,
2479
+ productionDbProvider: `import { registerListeners } from "@beignet/core/events";
2480
+ import { createInlineJobDispatcher } from "@beignet/core/jobs";
2481
+ import { createMemoryMailer } from "@beignet/core/mail";
2482
+ import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
2483
+ import {
2484
+ createOutboxEventRecorder,
2485
+ createOutboxJobDispatcher,
2486
+ } from "@beignet/core/outbox";
2487
+ import {
2488
+ createInstrumentedAuditLog,
2489
+ createSystemActor,
2490
+ } from "@beignet/core/ports";
2491
+ import {
2492
+ createProvider,
2493
+ createProviderInstrumentation,
2494
+ } from "@beignet/core/providers";
2495
+ import {
2496
+ createDrizzleTursoIdempotencyPort,
2497
+ createDrizzleTursoOutboxPort,
2498
+ createDrizzleTursoUnitOfWork,
2499
+ type DbPort,
2500
+ } from "@beignet/provider-drizzle-turso";
2501
+ import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
2502
+ import type { AppContext } from "@/app-context";
2503
+ import { todoListeners } from "@/features/todos/listeners";
2504
+ import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
2505
+ import type { AppPorts } from "@/ports";
2506
+ import type { AppServiceContextInput } from "@/server/context";
2507
+ import { ensureStarterDatabase } from "./bootstrap";
2508
+ import { createRepositories } from "./repositories";
2509
+ import type * as schema from "./schema";
2510
+
2511
+ export const starterDatabaseProvider = createProvider<
2512
+ { db: DbPort<typeof schema> },
2513
+ AppContext,
2514
+ AppServiceContextInput
2515
+ >()({
2516
+ name: "starter-database",
2517
+
2518
+ async setup({ ports, createServiceContext }) {
2519
+ const dbPort = ports.db;
2520
+ if (!dbPort) {
2521
+ throw new Error(
2522
+ "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
2523
+ );
2524
+ }
2525
+
2526
+ const createBackgroundContext = () =>
2527
+ createServiceContext({
2528
+ actor: createSystemActor("starter-background"),
2529
+ });
2530
+
2531
+ const mailInstrumentation = createProviderInstrumentation(ports, {
2532
+ providerName: "memory-mailer",
2533
+ watcher: "mail",
2534
+ });
2535
+ const eventBus = createInMemoryEventBus({
2536
+ instrumentation: ports,
2537
+ onHandlerError(error: unknown, eventName: string) {
2538
+ console.error("Event handler failed", { error, eventName });
2539
+ },
2540
+ });
2541
+ const jobs = createInlineJobDispatcher<AppContext>({
2542
+ ctx: createBackgroundContext,
2543
+ });
2544
+ const notifications = createInlineNotificationDispatcher<AppContext>({
2545
+ ctx: createBackgroundContext,
2546
+ instrumentation: ports,
2547
+ });
2548
+ const mailer = createMemoryMailer({
2549
+ defaultFrom: "Beignet Starter <noreply@example.local>",
2550
+ onSend(delivery) {
2551
+ mailInstrumentation.custom({
2552
+ name: "mail.sent",
2553
+ label: "Mail sent",
2554
+ summary: delivery.message.subject,
2555
+ details: {
2556
+ to: delivery.message.to,
2557
+ subject: delivery.message.subject,
2558
+ id: delivery.id,
2559
+ },
2560
+ });
2561
+ },
2562
+ });
2563
+ const audit = createInstrumentedAuditLog({
2564
+ audit: createDrizzleAuditLog(dbPort.db),
2565
+ instrumentation: ports,
2566
+ });
2567
+ const repositories = createRepositories(dbPort.db);
2568
+ const idempotency = createDrizzleTursoIdempotencyPort(dbPort.db);
2569
+ const outbox = createDrizzleTursoOutboxPort(dbPort.db);
2570
+ const unregisterListeners = registerListeners(eventBus, todoListeners, {
2571
+ ctx: createBackgroundContext,
2572
+ onError(error, listener) {
2573
+ console.error("Event listener failed", {
2574
+ error,
2575
+ listenerName: listener.name,
2576
+ });
2577
+ },
2578
+ });
2579
+
2580
+ const providedPorts: Pick<
2581
+ AppPorts,
2582
+ | "audit"
2583
+ | "eventBus"
2584
+ | "idempotency"
2585
+ | "jobs"
2586
+ | "mailer"
2587
+ | "notifications"
2588
+ | "outbox"
2589
+ | "todoAttachments"
2590
+ | "todos"
2591
+ | "uow"
2592
+ > = {
2593
+ audit,
2594
+ ...repositories,
2595
+ eventBus,
2596
+ idempotency,
2597
+ jobs,
2598
+ mailer,
2599
+ notifications,
2600
+ outbox,
2601
+ uow: createDrizzleTursoUnitOfWork({
2602
+ db: dbPort.db,
2603
+ createTransactionPorts: (tx) => {
2604
+ const txIdempotency = createDrizzleTursoIdempotencyPort(tx);
2605
+ const txOutbox = createDrizzleTursoOutboxPort(tx);
2606
+
2607
+ return {
2608
+ audit: createInstrumentedAuditLog({
2609
+ audit: createDrizzleAuditLog(tx),
2610
+ instrumentation: ports,
2611
+ }),
2612
+ ...createRepositories(tx),
2613
+ events: createOutboxEventRecorder(txOutbox),
2614
+ idempotency: txIdempotency,
2615
+ jobs: createOutboxJobDispatcher(txOutbox),
2616
+ outbox: txOutbox,
2617
+ };
2618
+ },
2619
+ }),
2620
+ };
2621
+
2622
+ return {
2623
+ ports: providedPorts,
2624
+ async start() {
2625
+ await ensureStarterDatabase(dbPort.client);
2626
+ },
2627
+ stop() {
2628
+ unregisterListeners();
2629
+ },
2630
+ };
2631
+ },
2632
+ });
2633
+ `,
2634
+ productionContractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
1534
2635
  import { z } from "zod";
1535
2636
  import { errors } from "@/features/shared/errors";
1536
2637
  import {
1537
- createTodoUseCase,
1538
- getTodoUseCase,
1539
- listTodosUseCase,
1540
- } from "@/features/todos/use-cases";
2638
+ CreateTodoInputSchema,
2639
+ GetTodoInputSchema,
2640
+ ListTodosInputSchema,
2641
+ ListTodosOutputSchema,
2642
+ TodoSchema,
2643
+ } from "@/features/todos/schemas";
1541
2644
 
1542
2645
  const ErrorResponseSchema = z.object({
1543
2646
  code: z.string(),
@@ -1545,7 +2648,7 @@ const ErrorResponseSchema = z.object({
1545
2648
  requestId: z.string().optional(),
1546
2649
  });
1547
2650
 
1548
- const todos = createContractGroup()
2651
+ const todos = defineContractGroup()
1549
2652
  .namespace("todos")
1550
2653
  .errors({ Unauthorized: errors.Unauthorized })
1551
2654
  .responses({
@@ -1554,28 +2657,29 @@ const todos = createContractGroup()
1554
2657
 
1555
2658
  export const listTodos = todos
1556
2659
  .get("/api/todos")
1557
- .query(listTodosUseCase.inputSchema)
2660
+ .query(ListTodosInputSchema)
1558
2661
  .responses({
1559
- 200: listTodosUseCase.outputSchema,
2662
+ 200: ListTodosOutputSchema,
1560
2663
  });
1561
2664
 
1562
2665
  export const createTodo = todos
1563
2666
  .post("/api/todos")
1564
- .body(createTodoUseCase.inputSchema)
2667
+ .body(CreateTodoInputSchema)
1565
2668
  .errors({ Forbidden: errors.Forbidden })
1566
2669
  .responses({
1567
- 201: createTodoUseCase.outputSchema,
2670
+ 201: TodoSchema,
1568
2671
  });
1569
2672
 
1570
2673
  export const getTodo = todos
1571
2674
  .get("/api/todos/:id")
1572
- .pathParams(getTodoUseCase.inputSchema)
2675
+ .pathParams(GetTodoInputSchema)
1573
2676
  .errors({ TodoNotFound: errors.TodoNotFound })
1574
2677
  .responses({
1575
- 200: getTodoUseCase.outputSchema,
2678
+ 200: TodoSchema,
1576
2679
  });
1577
2680
  `,
1578
- productionTodoRoutes: `import { defineRouteGroup } from "@beignet/next";
2681
+ productionTodoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
2682
+ import { defineRouteGroup } from "@beignet/next";
1579
2683
  import type { AppContext } from "@/app-context";
1580
2684
  import { createTodo, getTodo, listTodos } from "@/features/todos/contracts";
1581
2685
  import {
@@ -1587,31 +2691,14 @@ import {
1587
2691
  export const todoRoutes = defineRouteGroup<AppContext>({
1588
2692
  name: "todos",
1589
2693
  routes: [
1590
- {
1591
- contract: listTodos,
1592
- handle: async ({ ctx, query }) => ({
1593
- status: 200,
1594
- body: await listTodosUseCase.run({ ctx, input: query }),
1595
- }),
1596
- },
1597
- {
1598
- contract: createTodo,
1599
- handle: async ({ ctx, body }) => ({
1600
- status: 201,
1601
- body: await createTodoUseCase.run({ ctx, input: body }),
1602
- }),
1603
- },
1604
- {
1605
- contract: getTodo,
1606
- handle: async ({ ctx, path }) => ({
1607
- status: 200,
1608
- body: await getTodoUseCase.run({ ctx, input: path }),
1609
- }),
1610
- },
2694
+ { contract: listTodos, useCase: listTodosUseCase },
2695
+ { contract: createTodo, useCase: createTodoUseCase },
2696
+ { contract: getTodo, useCase: getTodoUseCase },
1611
2697
  ],
1612
2698
  });
1613
2699
  `,
1614
- productionServerRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
2700
+ productionServerRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
2701
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
1615
2702
  import type { AppContext } from "@/app-context";
1616
2703
  import { todoRoutes } from "@/features/todos/routes";
1617
2704
 
@@ -1620,45 +2707,86 @@ export const routes = defineRoutes<AppContext>([
1620
2707
  ]);
1621
2708
  export const contracts = contractsFromRoutes(routes);
1622
2709
  `,
1623
- productionServer: `import { createDevtoolsHooks } from "@beignet/devtools";
1624
- import { createNextServer } from "@beignet/next";
2710
+ productionServerContext: `import type { beignetServerOnly } from "@beignet/core/server-only";
1625
2711
  import {
2712
+ type ActivityActor,
1626
2713
  createAnonymousActor,
2714
+ createServiceActor,
1627
2715
  createTenant,
1628
2716
  createUserActor,
1629
2717
  } from "@beignet/core/ports";
1630
- import type { AppContext } from "@/app-context";
1631
- import { appPorts } from "@/infra/app-ports";
1632
- import { env } from "@/lib/env";
1633
- import { providers } from "./providers";
1634
- import { routes } from "./routes";
1635
-
1636
- export const server = await createNextServer({
1637
- ports: appPorts,
1638
- providers,
1639
- hooks: [
1640
- createDevtoolsHooks<AppContext>({
1641
- requestIdHeader: "x-request-id",
1642
- }),
1643
- ],
1644
- createContext: async ({ req, ports }) => {
2718
+ import { defineServerContext } from "@beignet/core/server";
2719
+ import type { TraceContext } from "@beignet/core/tracing";
2720
+ import type { AppContext, AppRuntimePorts } from "@/app-context";
2721
+
2722
+ export type AppServiceContextInput =
2723
+ | {
2724
+ actor?: ActivityActor;
2725
+ tenantId?: string;
2726
+ }
2727
+ | undefined;
2728
+
2729
+ /**
2730
+ * Context blueprint shared by the runtime server and route tests.
2731
+ */
2732
+ export const appContext = defineServerContext<AppContext, AppRuntimePorts>()({
2733
+ gate: (ports) => ports.gate,
2734
+ request: async ({ req, ports, requestId, trace }) => {
1645
2735
  const auth = await ports.auth.getSession(req);
1646
2736
  const tenantId = req.headers.get("x-tenant-id") || undefined;
1647
- const context = {
1648
- requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
2737
+
2738
+ return {
2739
+ requestId,
1649
2740
  actor: auth
1650
2741
  ? createUserActor(auth.user.id, { displayName: auth.user.name })
1651
2742
  : createAnonymousActor(),
1652
2743
  auth,
2744
+ ...trace,
1653
2745
  ports,
1654
2746
  tenant: tenantId ? createTenant(tenantId) : undefined,
1655
2747
  };
2748
+ },
2749
+ service: ({
2750
+ ports,
2751
+ input,
2752
+ requestId,
2753
+ trace,
2754
+ }: {
2755
+ ports: AppRuntimePorts;
2756
+ input: AppServiceContextInput;
2757
+ requestId: string;
2758
+ trace: TraceContext;
2759
+ }) => ({
2760
+ requestId,
2761
+ actor: input?.actor ?? createServiceActor("beignet-service"),
2762
+ auth: null,
2763
+ ...trace,
2764
+ ports,
2765
+ tenant: createTenant(input?.tenantId ?? "tenant_default"),
2766
+ }),
2767
+ });
2768
+ `,
2769
+ productionServer: `import type { beignetServerOnly } from "@beignet/core/server-only";
2770
+ import { createNextServer } from "@beignet/next";
2771
+ import { createIdempotencyHooks } from "@beignet/core/server";
2772
+ import type { AppContext } from "@/app-context";
2773
+ import { appPorts } from "@/infra/app-ports";
2774
+ import { env } from "@/lib/env";
2775
+ import { appContext } from "./context";
2776
+ import { providers } from "./providers";
2777
+ import { routes } from "./routes";
1656
2778
 
1657
- return {
1658
- ...context,
1659
- gate: ports.gate.bind(context),
1660
- };
2779
+ export const server = await createNextServer({
2780
+ ports: appPorts,
2781
+ providers,
2782
+ providerConfig: {
2783
+ "drizzle-turso": {
2784
+ DB_URL: env.TURSO_DB_URL,
2785
+ DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2786
+ },
1661
2787
  },
2788
+ hooks: [createIdempotencyHooks<AppContext>()],
2789
+ context: appContext,
1662
2790
  routes,
1663
2791
  mapUnhandledError: ({ err, ctx }) => {
1664
2792
  ctx?.ports.logger.error("Unhandled API error", {
@@ -1678,60 +2806,27 @@ export const server = await createNextServer({
1678
2806
  },
1679
2807
  });
1680
2808
  `,
1681
- productionServerWithDrizzleTurso: `import { createDevtoolsHooks } from "@beignet/devtools";
2809
+ productionServerWithDrizzleTurso: `import type { beignetServerOnly } from "@beignet/core/server-only";
1682
2810
  import { createNextServer } from "@beignet/next";
1683
- import {
1684
- createAnonymousActor,
1685
- createTenant,
1686
- createUserActor,
1687
- } from "@beignet/core/ports";
1688
- import { createDrizzleTursoUnitOfWork } from "@beignet/provider-drizzle-turso";
2811
+ import { createIdempotencyHooks } from "@beignet/core/server";
1689
2812
  import type { AppContext } from "@/app-context";
1690
2813
  import { appPorts } from "@/infra/app-ports";
1691
- import * as schema from "@/infra/db/schema";
1692
- import { createRepositories } from "@/infra/db/repositories";
1693
2814
  import { env } from "@/lib/env";
1694
- import type { AppTransactionPorts } from "@/ports";
2815
+ import { appContext } from "./context";
1695
2816
  import { providers } from "./providers";
1696
2817
  import { routes } from "./routes";
1697
2818
 
1698
2819
  export const server = await createNextServer({
1699
2820
  ports: appPorts,
1700
2821
  providers,
1701
- hooks: [
1702
- createDevtoolsHooks<AppContext>({
1703
- requestIdHeader: "x-request-id",
1704
- }),
1705
- ],
1706
- createContext: async ({ req, ports }) => {
1707
- const auth = await ports.auth.getSession(req);
1708
- const tenantId = req.headers.get("x-tenant-id") || undefined;
1709
- const repositories = createRepositories(ports.db.db);
1710
-
1711
- const context = {
1712
- requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
1713
- actor: auth
1714
- ? createUserActor(auth.user.id, { displayName: auth.user.name })
1715
- : createAnonymousActor(),
1716
- auth,
1717
- ports: {
1718
- ...ports,
1719
- ...repositories,
1720
- uow: createDrizzleTursoUnitOfWork<typeof schema, AppTransactionPorts>({
1721
- db: ports.db.db,
1722
- createTransactionPorts: (tx) => ({
1723
- ...createRepositories(tx),
1724
- }),
1725
- }),
1726
- },
1727
- tenant: tenantId ? createTenant(tenantId) : undefined,
1728
- };
1729
-
1730
- return {
1731
- ...context,
1732
- gate: ports.gate.bind(context),
1733
- };
2822
+ providerConfig: {
2823
+ "drizzle-turso": {
2824
+ DB_URL: env.TURSO_DB_URL,
2825
+ DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2826
+ },
1734
2827
  },
2828
+ hooks: [createIdempotencyHooks<AppContext>()],
2829
+ context: appContext,
1735
2830
  routes,
1736
2831
  mapUnhandledError: ({ err, ctx }) => {
1737
2832
  ctx?.ports.logger.error("Unhandled API error", {
@@ -1799,12 +2894,462 @@ export const GET = createOpenAPIHandler(server.contracts, {
1799
2894
  title: "Beignet starter API",
1800
2895
  version: "0.1.0",
1801
2896
  });
2897
+ `,
2898
+ productionAuthRoute: `import { toNextJsHandler } from "better-auth/next-js";
2899
+ import { auth } from "@/lib/better-auth";
2900
+
2901
+ export const { GET, POST } = toNextJsHandler(auth);
2902
+ `,
2903
+ productionBetterAuth: `import { createClient } from "@libsql/client";
2904
+ import { betterAuth } from "better-auth";
2905
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
2906
+ import { drizzle } from "drizzle-orm/libsql";
2907
+ import * as schema from "@/infra/db/schema";
2908
+ import { ensureStarterDatabase } from "@/infra/db/bootstrap";
2909
+ import { env } from "@/lib/env";
2910
+
2911
+ const client = createClient({
2912
+ url: env.TURSO_DB_URL,
2913
+ authToken: env.TURSO_DB_AUTH_TOKEN,
2914
+ });
2915
+
2916
+ await ensureStarterDatabase(client);
2917
+
2918
+ const db = drizzle(client, { schema });
2919
+
2920
+ const trustedOrigins = [
2921
+ env.APP_URL,
2922
+ env.BETTER_AUTH_URL,
2923
+ ...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",")
2924
+ .map((origin) => origin.trim())
2925
+ .filter(Boolean) ?? []),
2926
+ ];
2927
+
2928
+ export const auth = betterAuth({
2929
+ baseURL: env.BETTER_AUTH_URL,
2930
+ secret: env.BETTER_AUTH_SECRET,
2931
+ trustedOrigins: [...new Set(trustedOrigins)],
2932
+ database: drizzleAdapter(db, {
2933
+ provider: "sqlite",
2934
+ schema,
2935
+ }),
2936
+ emailAndPassword: {
2937
+ enabled: true,
2938
+ },
2939
+ });
2940
+ `,
2941
+ productionDbSeed: `import { createClient } from "@libsql/client";
2942
+ import { ensureStarterDatabase } from "@/infra/db/bootstrap";
2943
+ import { env } from "@/lib/env";
2944
+
2945
+ const client = createClient({
2946
+ url: env.TURSO_DB_URL,
2947
+ authToken: env.TURSO_DB_AUTH_TOKEN,
2948
+ });
2949
+
2950
+ try {
2951
+ await ensureStarterDatabase(client, { seed: true });
2952
+ console.log("Database seeded.");
2953
+ } finally {
2954
+ client.close();
2955
+ }
2956
+ `,
2957
+ productionDbReset: `import { createClient } from "@libsql/client";
2958
+ import { resetStarterDatabase } from "@/infra/db/bootstrap";
2959
+ import { env } from "@/lib/env";
2960
+
2961
+ if (!env.TURSO_DB_URL.startsWith("file:") && process.env.BEIGNET_ALLOW_DATABASE_RESET !== "true") {
2962
+ throw new Error(
2963
+ "Refusing to reset a non-local database. Set BEIGNET_ALLOW_DATABASE_RESET=true if this is intentional.",
2964
+ );
2965
+ }
2966
+
2967
+ const client = createClient({
2968
+ url: env.TURSO_DB_URL,
2969
+ authToken: env.TURSO_DB_AUTH_TOKEN,
2970
+ });
2971
+
2972
+ try {
2973
+ await resetStarterDatabase(client);
2974
+ console.log("Database reset.");
2975
+ } finally {
2976
+ client.close();
2977
+ }
2978
+ `,
2979
+ productionDbTestDatabase: `import { existsSync, unlinkSync } from "node:fs";
2980
+ import { tmpdir } from "node:os";
2981
+ import { join } from "node:path";
2982
+ import type { OutboxPort } from "@beignet/core/outbox";
2983
+ import { createDrizzleTursoOutboxPort } from "@beignet/provider-drizzle-turso";
2984
+ import { type Client, createClient } from "@libsql/client";
2985
+ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
2986
+ import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
2987
+ import { ensureStarterDatabase, resetStarterDatabase } from "./bootstrap";
2988
+ import { createRepositories } from "./repositories";
2989
+ import * as schema from "./schema";
2990
+
2991
+ export type TestDatabase = {
2992
+ \tclient: Client;
2993
+ \tdb: LibSQLDatabase<typeof schema>;
2994
+ \taudit: ReturnType<typeof createDrizzleAuditLog>;
2995
+ \toutbox: OutboxPort;
2996
+ \trepositories: ReturnType<typeof createRepositories>;
2997
+ \tpath: string;
2998
+ \treset(): Promise<void>;
2999
+ \tclose(): Promise<void>;
3000
+ };
3001
+
3002
+ export async function createTestDatabase(): Promise<TestDatabase> {
3003
+ \tconst path = join(tmpdir(), "beignet-test-" + crypto.randomUUID() + ".db");
3004
+ \tconst client = createClient({ url: "file:" + path });
3005
+ \tawait ensureStarterDatabase(client, { seed: false });
3006
+
3007
+ \tconst db = drizzle(client, { schema });
3008
+
3009
+ \treturn {
3010
+ \t\tclient,
3011
+ \t\tdb,
3012
+ \t\taudit: createDrizzleAuditLog(db),
3013
+ \t\toutbox: createDrizzleTursoOutboxPort(db),
3014
+ \t\trepositories: createRepositories(db),
3015
+ \t\tpath,
3016
+ \t\treset: async () => {
3017
+ \t\t\tawait resetStarterDatabase(client);
3018
+ \t\t},
3019
+ \t\tclose: async () => {
3020
+ \t\t\tclient.close();
3021
+ \t\t\tif (existsSync(path)) {
3022
+ \t\t\t\tunlinkSync(path);
3023
+ \t\t\t}
3024
+ \t\t},
3025
+ \t};
3026
+ }
3027
+ `,
3028
+ productionAuditHelper: `import type { AuditLogEntryInput } from "@beignet/core/ports";
3029
+ import type { AppContext } from "@/app-context";
3030
+
3031
+ export function auditEntry(
3032
+ ctx: AppContext,
3033
+ entry: Omit<
3034
+ AuditLogEntryInput,
3035
+ "actor" | "tenant" | "requestId" | "traceId"
3036
+ >,
3037
+ ): AuditLogEntryInput {
3038
+ return {
3039
+ ...entry,
3040
+ actor: ctx.actor,
3041
+ tenant: ctx.tenant,
3042
+ requestId: ctx.requestId,
3043
+ traceId: ctx.traceId,
3044
+ };
3045
+ }
3046
+ `,
3047
+ productionTodoEvents: `import { defineEvent } from "@beignet/core/events";
3048
+ import { z } from "zod";
3049
+
3050
+ export const TodoCreated = defineEvent("todos.created", {
3051
+ payload: z.object({
3052
+ todoId: z.string().uuid(),
3053
+ title: z.string(),
3054
+ }),
3055
+ });
3056
+
3057
+ export const todoEvents = [TodoCreated] as const;
3058
+ `,
3059
+ productionTodoJobs: `import { retry } from "@beignet/core/jobs";
3060
+ import { z } from "zod";
3061
+ import { auditEntry } from "@/lib/audit";
3062
+ import { defineJob } from "@/lib/jobs";
3063
+
3064
+ export const LogTodoCreatedJob = defineJob("todos.log-created", {
3065
+ payload: z.object({
3066
+ todoId: z.string().uuid(),
3067
+ title: z.string(),
3068
+ }),
3069
+ retry: retry.exponential({
3070
+ attempts: 3,
3071
+ initialDelay: "1s",
3072
+ maxDelay: "1m",
3073
+ }),
3074
+ async handle({ ctx, payload }) {
3075
+ ctx.ports.logger.info("Todo created job handled", {
3076
+ todoId: payload.todoId,
3077
+ });
3078
+ await ctx.ports.audit.record(
3079
+ auditEntry(ctx, {
3080
+ action: "jobs.todos.log-created",
3081
+ resource: { type: "todo", id: payload.todoId, name: payload.title },
3082
+ metadata: { jobName: "todos.log-created" },
3083
+ }),
3084
+ );
3085
+ },
3086
+ });
3087
+
3088
+ export const todoJobs = [LogTodoCreatedJob] as const;
3089
+ `,
3090
+ productionTodoNotifications: `import { defineMailNotificationChannel } from "@beignet/core/notifications";
3091
+ import { z } from "zod";
3092
+ import { defineNotification } from "@/lib/notifications";
3093
+
3094
+ export const TodoCreatedNotification = defineNotification(
3095
+ "todos.created",
3096
+ {
3097
+ payload: z.object({
3098
+ todoId: z.string().uuid(),
3099
+ title: z.string(),
3100
+ }),
3101
+ channels: {
3102
+ email: defineMailNotificationChannel(({ payload }) => ({
3103
+ to: "ops@example.local",
3104
+ subject: \`Todo created: \${payload.title}\`,
3105
+ text: \`Todo \${payload.todoId} was created.\`,
3106
+ })),
3107
+ },
3108
+ },
3109
+ );
3110
+ `,
3111
+ productionTodoListeners: `import { TodoCreated } from "@/features/todos/domain/events";
3112
+ import { LogTodoCreatedJob } from "@/features/todos/jobs";
3113
+ import { TodoCreatedNotification } from "@/features/todos/notifications";
3114
+ import { auditEntry } from "@/lib/audit";
3115
+ import { defineListener } from "@/lib/listeners";
3116
+
3117
+ export const enqueueTodoCreatedWork = defineListener(TodoCreated, {
3118
+ name: "todos.enqueue-created-work",
3119
+ async handle({ ctx, payload }) {
3120
+ await ctx.ports.jobs.dispatch(LogTodoCreatedJob, payload);
3121
+ await ctx.ports.notifications.send(TodoCreatedNotification, payload);
3122
+ await ctx.ports.audit.record(
3123
+ auditEntry(ctx, {
3124
+ action: "listeners.todos.enqueue-created-work",
3125
+ resource: { type: "todo", id: payload.todoId, name: payload.title },
3126
+ metadata: {
3127
+ eventName: TodoCreated.name,
3128
+ jobName: LogTodoCreatedJob.name,
3129
+ notificationName: TodoCreatedNotification.name,
3130
+ },
3131
+ }),
3132
+ );
3133
+ },
3134
+ });
3135
+
3136
+ export const todoListeners = [enqueueTodoCreatedWork] as const;
3137
+ `,
3138
+ productionTodoSchedules: `import { normalizeOffsetPage } from "@beignet/core/pagination";
3139
+ import { z } from "zod";
3140
+ import { auditEntry } from "@/lib/audit";
3141
+ import { defineSchedule } from "@/lib/schedules";
3142
+
3143
+ export const LogDailyTodoSummarySchedule = defineSchedule(
3144
+ "todos.log-daily-summary",
3145
+ {
3146
+ cron: "0 9 * * *",
3147
+ timezone: "America/Chicago",
3148
+ payload: z.object({
3149
+ date: z.string(),
3150
+ }),
3151
+ createPayload({ run }) {
3152
+ const date = run.scheduledAt ?? run.triggeredAt;
3153
+ return { date: date.toISOString().slice(0, 10) };
3154
+ },
3155
+ async handle({ ctx, payload, run }) {
3156
+ const { page } = await ctx.ports.todos.list(
3157
+ normalizeOffsetPage(
3158
+ { limit: 1, offset: 0 },
3159
+ { defaultLimit: 1, maxLimit: 1 },
3160
+ ),
3161
+ );
3162
+ ctx.ports.logger.info("Daily todo summary schedule handled", {
3163
+ date: payload.date,
3164
+ todoCount: page.total,
3165
+ });
3166
+ await ctx.ports.audit.record(
3167
+ auditEntry(ctx, {
3168
+ action: "schedules.todos.daily-summary",
3169
+ resource: {
3170
+ type: "schedule",
3171
+ id: "todos.log-daily-summary",
3172
+ name: "Daily todo summary",
3173
+ },
3174
+ metadata: {
3175
+ date: payload.date,
3176
+ todoCount: page.total,
3177
+ source: run.source ?? "inline",
3178
+ triggeredAt: run.triggeredAt.toISOString(),
3179
+ },
3180
+ }),
3181
+ );
3182
+ },
3183
+ },
3184
+ );
3185
+
3186
+ export const todoSchedules = [LogDailyTodoSummarySchedule] as const;
3187
+ `,
3188
+ productionSchedules: `import { createServiceActor } from "@beignet/core/ports";
3189
+ import type { AppContext } from "@/app-context";
3190
+ import { todoSchedules } from "@/features/todos/schedules";
3191
+ import { server } from "./index.js";
3192
+
3193
+ export const schedules = [...todoSchedules] as const;
3194
+
3195
+ export async function createScheduleContext(): Promise<AppContext> {
3196
+ return server.createServiceContext({
3197
+ actor: createServiceActor("beignet-schedule"),
3198
+ });
3199
+ }
3200
+
3201
+ export async function stopScheduleContext(): Promise<void> {
3202
+ await server.stop();
3203
+ }
3204
+ `,
3205
+ productionTodoUploads: `import { defineUpload, defineUploads } from "@beignet/core/uploads";
3206
+ import { z } from "zod";
3207
+ import type { AppContext } from "@/app-context";
3208
+ import { auditEntry } from "@/lib/audit";
3209
+
3210
+ const todoAttachmentMetadataSchema = z.object({
3211
+ todoId: z.string().uuid(),
3212
+ });
3213
+
3214
+ export const TodoAttachmentUpload = defineUpload<
3215
+ "todos.attachment",
3216
+ typeof todoAttachmentMetadataSchema,
3217
+ AppContext,
3218
+ { attachmentIds: string[] }
3219
+ >("todos.attachment", {
3220
+ metadata: todoAttachmentMetadataSchema,
3221
+ file: {
3222
+ contentTypes: ["application/pdf", "text/plain", "text/plain;charset=utf-8"],
3223
+ maxSizeBytes: 5 * 1024 * 1024,
3224
+ maxFiles: 3,
3225
+ visibility: "private",
3226
+ cacheControl: "private, max-age=0",
3227
+ },
3228
+ authorize({ ctx }) {
3229
+ return ctx.actor.type === "user"
3230
+ ? true
3231
+ : {
3232
+ allowed: false,
3233
+ reason: "You must be signed in to upload todo attachments.",
3234
+ };
3235
+ },
3236
+ key({ ctx, metadata, file, uploadId }) {
3237
+ const tenantId = ctx.tenant?.id ?? "tenant_default";
3238
+ const extension = file.name.includes(".")
3239
+ ? file.name.split(".").pop()
3240
+ : undefined;
3241
+ const suffix = extension ? \`.\${extension}\` : "";
3242
+ return \`todos/\${tenantId}/\${metadata.todoId}/attachments/\${uploadId}\${suffix}\`;
3243
+ },
3244
+ storageMetadata({ ctx, metadata }) {
3245
+ return {
3246
+ tenantId: ctx.tenant?.id ?? "tenant_default",
3247
+ todoId: metadata.todoId,
3248
+ };
3249
+ },
3250
+ async onComplete({ ctx, metadata, files }) {
3251
+ const attachments = await Promise.all(
3252
+ files.map((file) =>
3253
+ ctx.ports.todoAttachments.create({
3254
+ id: file.uploadId,
3255
+ todoId: metadata.todoId,
3256
+ key: file.key,
3257
+ fileName: file.name,
3258
+ contentType: file.contentType,
3259
+ size: file.object.size,
3260
+ }),
3261
+ ),
3262
+ );
3263
+ await ctx.ports.audit.record(
3264
+ auditEntry(ctx, {
3265
+ action: "todos.attachment.upload",
3266
+ resource: { type: "todo", id: metadata.todoId },
3267
+ metadata: {
3268
+ attachmentCount: attachments.length,
3269
+ keys: attachments.map((attachment) => attachment.key),
3270
+ },
3271
+ }),
3272
+ );
3273
+
3274
+ return {
3275
+ attachmentIds: attachments.map((attachment) => attachment.id),
3276
+ };
3277
+ },
3278
+ });
3279
+
3280
+ export const todoUploads = defineUploads({
3281
+ todoAttachment: TodoAttachmentUpload,
3282
+ });
3283
+ `,
3284
+ productionOutbox: `import { defineOutboxRegistry } from "@beignet/core/outbox";
3285
+ import { createServiceActor } from "@beignet/core/ports";
3286
+ import type { AppContext } from "@/app-context";
3287
+ import { todoEvents } from "@/features/todos/domain/events";
3288
+ import { todoJobs } from "@/features/todos/jobs";
3289
+ import { server } from "./index.js";
3290
+
3291
+ export const outboxRegistry = defineOutboxRegistry({
3292
+ events: todoEvents,
3293
+ jobs: todoJobs,
3294
+ });
3295
+
3296
+ export async function createOutboxDrainContext(): Promise<AppContext> {
3297
+ return server.createServiceContext({
3298
+ actor: createServiceActor("beignet-outbox"),
3299
+ });
3300
+ }
3301
+
3302
+ export async function stopOutboxDrainContext(): Promise<void> {
3303
+ await server.stop();
3304
+ }
3305
+ `,
3306
+ productionOutboxDrainRoute: `import { createOutboxDrainRoute } from "@beignet/next";
3307
+ import { env } from "@/lib/env";
3308
+ import { server } from "@/server";
3309
+ import { outboxRegistry } from "@/server/outbox";
3310
+
3311
+ export const runtime = "nodejs";
3312
+
3313
+ export const { GET, POST } = createOutboxDrainRoute({
3314
+ server,
3315
+ registry: outboxRegistry,
3316
+ secret: env.CRON_SECRET,
3317
+ });
3318
+ `,
3319
+ productionScheduleRoute: `import { createScheduleRoute } from "@beignet/next";
3320
+ import { env } from "@/lib/env";
3321
+ import { server } from "@/server";
3322
+ import { schedules } from "@/server/schedules";
3323
+
3324
+ export const runtime = "nodejs";
3325
+
3326
+ export const { GET, POST } = createScheduleRoute({
3327
+ server,
3328
+ schedules,
3329
+ schedule: "todos.log-daily-summary",
3330
+ secret: env.CRON_SECRET,
3331
+ source: "starter-cron-route",
3332
+ });
3333
+ `,
3334
+ productionUploadsRoute: `import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
3335
+ import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
3336
+ import { createUploadRoute } from "@beignet/next";
3337
+ import type { AppContext } from "@/app-context";
3338
+ import { todoUploads } from "@/features/todos/uploads";
3339
+ import { server } from "@/server";
3340
+
3341
+ const uploadRouter = createUploadRouter<AppContext>({
3342
+ uploads: uploadsFromRegistry(todoUploads),
3343
+ ctx: () => server.createContextFromNext(),
3344
+ storage: server.ports.storage,
3345
+ instrumentation: resolveProviderInstrumentationPort(server.ports),
3346
+ });
3347
+
3348
+ export const { POST } = createUploadRoute(uploadRouter);
1802
3349
  `,
1803
3350
  };
1804
3351
  function productionProviderPortImports(ctx) {
1805
3352
  const imports = [];
1806
- if (hasIntegration(ctx, "inngest"))
1807
- imports.push("\tJobDispatcherPort,");
1808
3353
  if (hasIntegration(ctx, "upstash-rate-limit")) {
1809
3354
  imports.push("\tRateLimitPort,");
1810
3355
  }
@@ -1812,43 +3357,122 @@ function productionProviderPortImports(ctx) {
1812
3357
  }
1813
3358
  function productionProviderPortFields(ctx) {
1814
3359
  const fields = [];
1815
- if (hasIntegration(ctx, "inngest"))
1816
- fields.push("\tjobs: JobDispatcherPort;");
1817
- if (hasIntegration(ctx, "resend"))
1818
- fields.push("\tmailer: MailerPort;");
1819
3360
  if (hasIntegration(ctx, "upstash-rate-limit")) {
1820
3361
  fields.push("\trateLimit: RateLimitPort;");
1821
3362
  }
1822
3363
  return fields;
1823
3364
  }
3365
+ function productionInfrastructurePortsWithDrizzleTurso(ctx) {
3366
+ const deferred = [
3367
+ '\t\t"audit",',
3368
+ hasIntegration(ctx, "better-auth") ? '\t\t"auth",' : undefined,
3369
+ '\t\t"eventBus",',
3370
+ '\t\t"idempotency",',
3371
+ '\t\t"jobs",',
3372
+ '\t\t"logger",',
3373
+ '\t\t"mailer",',
3374
+ '\t\t"notifications",',
3375
+ '\t\t"outbox",',
3376
+ hasIntegration(ctx, "upstash-rate-limit") ? '\t\t"rateLimit",' : undefined,
3377
+ '\t\t"storage",',
3378
+ '\t\t"todoAttachments",',
3379
+ '\t\t"todos",',
3380
+ '\t\t"uow",',
3381
+ ]
3382
+ .filter((entry) => Boolean(entry))
3383
+ .sort();
3384
+ const bound = [
3385
+ hasIntegration(ctx, "better-auth")
3386
+ ? undefined
3387
+ : "\t\tauth: createAnonymousAuth(),",
3388
+ "\t\tgate,",
3389
+ ].filter((entry) => Boolean(entry));
3390
+ const imports = [
3391
+ 'import { createGate, definePorts } from "@beignet/core/ports";',
3392
+ 'import { todoPolicy } from "@/features/todos/policy";',
3393
+ 'import { appError } from "@/features/shared/errors";',
3394
+ 'import type { AppPorts } from "@/ports";',
3395
+ hasIntegration(ctx, "better-auth")
3396
+ ? undefined
3397
+ : 'import { createAnonymousAuth } from "./auth/anonymous-auth";',
3398
+ ].filter((entry) => Boolean(entry));
3399
+ return `${imports.join("\n")}
3400
+
3401
+ const gate = createGate({
3402
+ policies: [todoPolicy],
3403
+ onDeny(decision) {
3404
+ return appError("Forbidden", {
3405
+ message: decision.reason ?? "Forbidden",
3406
+ details: decision.details,
3407
+ });
3408
+ },
3409
+ });
3410
+
3411
+ /**
3412
+ * App ports the server boots with.
3413
+ *
3414
+ * Bound ports are app-owned. Every deferred key is contributed by a provider
3415
+ * in server/providers.ts during startup; createServer(...) fails boot if any
3416
+ * of them is still unbound after providers have started.
3417
+ */
3418
+ export const appPorts = definePorts<AppPorts>()({
3419
+ bound: {
3420
+ ${bound.join("\n")}
3421
+ },
3422
+ deferred: [
3423
+ ${deferred.join("\n")}
3424
+ ],
3425
+ });
3426
+ `;
3427
+ }
1824
3428
  function productionPorts(ctx) {
1825
- const mailImport = hasIntegration(ctx, "resend")
1826
- ? 'import type { MailerPort } from "@beignet/core/mail";\n'
1827
- : "";
3429
+ const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
1828
3430
  const imports = [
3431
+ "\tAuditLogPort,",
1829
3432
  "\tBoundGate,",
3433
+ "\tEventBusPort,",
1830
3434
  "\tGatePort,",
3435
+ "\tJobDispatcherPort,",
1831
3436
  "\tLoggerPort,",
1832
3437
  ...productionProviderPortImports(ctx),
3438
+ "\tStoragePort,",
1833
3439
  "\tUnitOfWorkPort,",
1834
3440
  ];
1835
3441
  const providerFields = productionProviderPortFields(ctx);
1836
- return `import type {
3442
+ return `import type { IdempotencyPort } from "@beignet/core/idempotency";
3443
+ ${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
3444
+ import type { OutboxPort } from "@beignet/core/outbox";
3445
+ import type {
1837
3446
  ${imports.join("\n")}
1838
3447
  } from "@beignet/core/ports";
1839
- ${mailImport}import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
3448
+ import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
1840
3449
  import type { AuthPort } from "./auth";
1841
- import type { TodoRepository } from "@/features/todos/ports";
3450
+ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
1842
3451
 
1843
3452
  export type AppTransactionPorts = {
3453
+ audit: AuditLogPort;
3454
+ events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3455
+ idempotency: IdempotencyPort;
3456
+ jobs: JobDispatcherPort;
3457
+ outbox: OutboxPort;
3458
+ todoAttachments: TodoAttachmentRepository;
1844
3459
  todos: TodoRepository;
1845
3460
  };
1846
3461
 
1847
3462
  export type AppGate = BoundGate<[typeof todoPolicy]>;
1848
3463
 
1849
3464
  export type AppPorts = {
3465
+ audit: AuditLogPort;
1850
3466
  auth: AuthPort;
3467
+ eventBus: EventBusPort;
1851
3468
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
3469
+ idempotency: IdempotencyPort;
3470
+ jobs: JobDispatcherPort;
3471
+ mailer: MailerPort;
3472
+ notifications: NotificationPort;
3473
+ outbox: OutboxPort;
3474
+ storage: StoragePort;
3475
+ todoAttachments: TodoAttachmentRepository;
1852
3476
  todos: TodoRepository;
1853
3477
  logger: LoggerPort;
1854
3478
  ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
@@ -1856,36 +3480,53 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
1856
3480
  `;
1857
3481
  }
1858
3482
  function productionPortsWithDrizzleTurso(ctx) {
1859
- const mailImport = hasIntegration(ctx, "resend")
1860
- ? 'import type { MailerPort } from "@beignet/core/mail";\n'
1861
- : "";
3483
+ const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
1862
3484
  const imports = [
3485
+ "\tAuditLogPort,",
1863
3486
  "\tBoundGate,",
3487
+ "\tEventBusPort,",
1864
3488
  "\tGatePort,",
3489
+ "\tJobDispatcherPort,",
1865
3490
  "\tLoggerPort,",
1866
3491
  ...productionProviderPortImports(ctx),
3492
+ "\tStoragePort,",
1867
3493
  "\tUnitOfWorkPort,",
1868
3494
  ];
1869
3495
  const providerFields = productionProviderPortFields(ctx);
1870
- return `import type {
3496
+ return `import type { IdempotencyPort } from "@beignet/core/idempotency";
3497
+ ${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
3498
+ import type { OutboxPort } from "@beignet/core/outbox";
3499
+ import type {
1871
3500
  ${imports.join("\n")}
1872
3501
  } from "@beignet/core/ports";
1873
- ${mailImport}import type { DbPort } from "@beignet/provider-drizzle-turso";
1874
- import * as schema from "@/infra/db/schema";
1875
3502
  import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
1876
3503
  import type { AuthPort } from "./auth";
1877
- import type { TodoRepository } from "@/features/todos/ports";
3504
+ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
1878
3505
 
1879
3506
  export type AppTransactionPorts = {
3507
+ audit: AuditLogPort;
3508
+ events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3509
+ idempotency: IdempotencyPort;
3510
+ jobs: JobDispatcherPort;
3511
+ outbox: OutboxPort;
3512
+ todoAttachments: TodoAttachmentRepository;
1880
3513
  todos: TodoRepository;
1881
3514
  };
1882
3515
 
1883
3516
  export type AppGate = BoundGate<[typeof todoPolicy]>;
1884
3517
 
1885
3518
  export type AppPorts = {
3519
+ audit: AuditLogPort;
1886
3520
  auth: AuthPort;
1887
- db: DbPort<typeof schema>;
3521
+ eventBus: EventBusPort;
1888
3522
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
3523
+ idempotency: IdempotencyPort;
3524
+ jobs: JobDispatcherPort;
3525
+ mailer: MailerPort;
3526
+ notifications: NotificationPort;
3527
+ outbox: OutboxPort;
3528
+ storage: StoragePort;
3529
+ todoAttachments: TodoAttachmentRepository;
1889
3530
  todos: TodoRepository;
1890
3531
  logger: LoggerPort;
1891
3532
  ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
@@ -1894,7 +3535,11 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
1894
3535
  }
1895
3536
  function productionServerProviders(ctx) {
1896
3537
  const imports = [
3538
+ 'import type { beignetServerOnly } from "@beignet/core/server-only";',
1897
3539
  'import { createDevtoolsProvider } from "@beignet/devtools";',
3540
+ hasIntegration(ctx, "better-auth")
3541
+ ? 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";'
3542
+ : undefined,
1898
3543
  hasDrizzleTurso(ctx)
1899
3544
  ? 'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";'
1900
3545
  : undefined,
@@ -1912,15 +3557,26 @@ function productionServerProviders(ctx) {
1912
3557
  hasDrizzleTurso(ctx)
1913
3558
  ? 'import * as schema from "@/infra/db/schema";'
1914
3559
  : undefined,
3560
+ hasIntegration(ctx, "better-auth")
3561
+ ? 'import { auth } from "@/lib/better-auth";'
3562
+ : undefined,
3563
+ 'import { starterDatabaseProvider } from "@/infra/db/provider";',
3564
+ hasIntegration(ctx, "better-auth")
3565
+ ? 'import type { AuthSessionMetadata, AuthUser } from "@/ports/auth";'
3566
+ : undefined,
1915
3567
  ].filter((line) => Boolean(line));
1916
3568
  const declarations = hasDrizzleTurso(ctx)
1917
3569
  ? "\nconst drizzleTursoProvider = createDrizzleTursoProvider({ schema });\n"
1918
3570
  : "";
1919
3571
  const entries = [
1920
3572
  "\tcreateDevtoolsProvider(),",
3573
+ hasIntegration(ctx, "better-auth")
3574
+ ? "\tcreateAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth),"
3575
+ : undefined,
1921
3576
  "\tlocalStorageProvider,",
1922
3577
  "\tloggerPinoProvider,",
1923
3578
  hasDrizzleTurso(ctx) ? "\tdrizzleTursoProvider," : undefined,
3579
+ "\tstarterDatabaseProvider,",
1924
3580
  hasIntegration(ctx, "inngest") ? "\tinngestProvider," : undefined,
1925
3581
  hasIntegration(ctx, "resend") ? "\tmailResendProvider," : undefined,
1926
3582
  hasIntegration(ctx, "upstash-rate-limit")
@@ -1931,44 +3587,25 @@ function productionServerProviders(ctx) {
1931
3587
  ${declarations}
1932
3588
  export const providers = [
1933
3589
  ${entries.join("\n")}
1934
- ];
3590
+ ] as const;
1935
3591
  `;
1936
3592
  }
1937
- function productionTodosTest(ctx) {
1938
- const portImports = [
1939
- hasIntegration(ctx, "upstash-rate-limit")
1940
- ? "createMemoryRateLimiter"
1941
- : undefined,
1942
- "createMemoryStorage",
1943
- "createNoopUnitOfWork",
1944
- "createUserActor",
1945
- ].filter((item) => Boolean(item));
1946
- const extraPorts = [
1947
- hasDrizzleTurso(ctx)
1948
- ? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
1949
- : undefined,
1950
- hasIntegration(ctx, "inngest")
1951
- ? "\t\t\tjobs: { dispatch: async () => undefined },"
1952
- : undefined,
1953
- hasIntegration(ctx, "resend")
1954
- ? '\t\t\tmailer: { send: async () => ({ provider: "test" }) },'
1955
- : undefined,
1956
- hasIntegration(ctx, "upstash-rate-limit")
1957
- ? "\t\t\trateLimit: createMemoryRateLimiter(),"
1958
- : undefined,
1959
- ].filter((entry) => Boolean(entry));
3593
+ function productionTodosTest(_ctx) {
1960
3594
  return `import { describe, expect, it } from "bun:test";
1961
3595
  import { createUseCaseTester } from "@beignet/core/application";
3596
+ import { offsetPageResult, type OffsetPage } from "@beignet/core/pagination";
1962
3597
  import { createInMemoryDevtools } from "@beignet/devtools";
1963
- import { ${portImports.join(", ")} } from "@beignet/core/ports";
3598
+ import { createDomainEventRecorder } from "@beignet/core/ports";
3599
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
3600
+ import { createTestUserActor } from "@beignet/core/ports/testing";
1964
3601
  import type { AppContext } from "@/app-context";
1965
3602
  import { appPorts } from "@/infra/app-ports";
3603
+ import type { AppTransactionPorts } from "@/ports";
1966
3604
  import {
1967
3605
  createTodoUseCase,
1968
3606
  getTodoUseCase,
1969
3607
  listTodosUseCase,
1970
3608
  type CreateTodoInput,
1971
- type ListTodosInput,
1972
3609
  type Todo,
1973
3610
  } from "../use-cases";
1974
3611
 
@@ -1976,15 +3613,16 @@ function createTestTodoRepository() {
1976
3613
  const todos = new Map<string, Todo>();
1977
3614
 
1978
3615
  return {
1979
- async list(input: ListTodosInput) {
3616
+ async list(page: OffsetPage) {
1980
3617
  const allTodos = Array.from(todos.values()).sort((left, right) =>
1981
3618
  left.createdAt.localeCompare(right.createdAt),
1982
3619
  );
1983
3620
 
1984
- return {
1985
- todos: allTodos.slice(input.offset, input.offset + input.limit),
1986
- total: allTodos.length,
1987
- };
3621
+ return offsetPageResult(
3622
+ allTodos.slice(page.offset, page.offset + page.limit),
3623
+ page,
3624
+ allTodos.length,
3625
+ );
1988
3626
  },
1989
3627
  async findById(id: string) {
1990
3628
  return todos.get(id) ?? null;
@@ -2002,9 +3640,31 @@ function createTestTodoRepository() {
2002
3640
  };
2003
3641
  }
2004
3642
 
3643
+ function createTestTodoAttachmentRepository() {
3644
+ return {
3645
+ async create(input: {
3646
+ id: string;
3647
+ todoId: string;
3648
+ key: string;
3649
+ fileName: string;
3650
+ contentType: string;
3651
+ size: number;
3652
+ }) {
3653
+ return {
3654
+ ...input,
3655
+ createdAt: new Date().toISOString(),
3656
+ };
3657
+ },
3658
+ async findMany() {
3659
+ return [];
3660
+ },
3661
+ };
3662
+ }
3663
+
2005
3664
  describe("todos resource", () => {
2006
3665
  it("creates, gets, and lists todos", async () => {
2007
3666
  const todos = createTestTodoRepository();
3667
+ const todoAttachments = createTestTodoAttachmentRepository();
2008
3668
  const auth = {
2009
3669
  user: {
2010
3670
  id: "user_test",
@@ -2013,23 +3673,31 @@ describe("todos resource", () => {
2013
3673
  },
2014
3674
  session: { id: "session_test" },
2015
3675
  };
2016
- const actor = createUserActor(auth.user.id, {
2017
- displayName: auth.user.name,
3676
+ const testFixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
3677
+ base: appPorts,
3678
+ overrides: {
3679
+ gate: appPorts.gate,
3680
+ todoAttachments,
3681
+ todos,
3682
+ devtools: createInMemoryDevtools(),
3683
+ },
3684
+ transaction: {
3685
+ ports: (ports) => ({
3686
+ ...ports,
3687
+ events: createDomainEventRecorder(),
3688
+ todoAttachments,
3689
+ todos,
3690
+ }),
3691
+ },
2018
3692
  });
2019
- const testPorts = {
2020
- ...appPorts,
2021
- todos,
2022
- ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({ todos })) as AppContext["ports"]["uow"],
2023
- devtools: createInMemoryDevtools(),
2024
- storage: createMemoryStorage(),
2025
- };
2026
- const tester = createUseCaseTester<AppContext>(() => ({
2027
- requestId: "test-request",
2028
- actor,
3693
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
3694
+ ports: testFixture.ports,
3695
+ actor: createTestUserActor(auth.user.id, {
3696
+ displayName: auth.user.name,
3697
+ }),
2029
3698
  auth,
2030
- gate: testPorts.gate.bind({ actor, auth }),
2031
- ports: testPorts,
2032
- }));
3699
+ });
3700
+ const tester = createUseCaseTester<AppContext>(createTestContext);
2033
3701
 
2034
3702
  const ctx = await tester.ctx();
2035
3703
  const created = await tester.run(
@@ -2046,8 +3714,8 @@ ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUn
2046
3714
 
2047
3715
  expect(created.title).toBe("First todo");
2048
3716
  expect(found.id).toBe(created.id);
2049
- expect(result.total).toBe(1);
2050
- expect(result.todos).toEqual([created]);
3717
+ expect(result.page.total).toBe(1);
3718
+ expect(result.items).toEqual([created]);
2051
3719
  });
2052
3720
  });
2053
3721
  `;
@@ -2084,8 +3752,24 @@ function getProductionTemplateFiles(ctx) {
2084
3752
  path: "app/api/openapi/route.ts",
2085
3753
  content: files.productionOpenApiRoute,
2086
3754
  },
2087
- { path: "client/api-client.ts", content: files.apiClient },
2088
- { path: "client/rq.ts", content: files.rq },
3755
+ {
3756
+ path: "app/api/auth/[...all]/route.ts",
3757
+ content: files.productionAuthRoute,
3758
+ },
3759
+ {
3760
+ path: "app/api/cron/outbox/drain/route.ts",
3761
+ content: files.productionOutboxDrainRoute,
3762
+ },
3763
+ {
3764
+ path: "app/api/cron/todos/daily-summary/route.ts",
3765
+ content: files.productionScheduleRoute,
3766
+ },
3767
+ {
3768
+ path: "app/api/uploads/[uploadName]/[action]/route.ts",
3769
+ content: files.productionUploadsRoute,
3770
+ },
3771
+ { path: "client/index.ts", content: clientIndex(ctx) },
3772
+ { path: "client/forms.ts", content: files.formsClient },
2089
3773
  {
2090
3774
  path: "features/todos/contracts.ts",
2091
3775
  content: files.productionContractsTodos,
@@ -2096,16 +3780,43 @@ function getProductionTemplateFiles(ctx) {
2096
3780
  {
2097
3781
  path: "infra/app-ports.ts",
2098
3782
  content: usesDrizzleTurso
2099
- ? files.productionInfrastructurePortsWithDrizzleTurso
3783
+ ? productionInfrastructurePortsWithDrizzleTurso(ctx)
2100
3784
  : files.productionInfrastructurePorts,
2101
3785
  },
2102
3786
  { path: "lib/env.ts", content: files.productionEnv },
3787
+ { path: "lib/audit.ts", content: files.productionAuditHelper },
2103
3788
  { path: "lib/auth.ts", content: files.productionAuthHelpers },
3789
+ { path: "lib/better-auth.ts", content: files.productionBetterAuth },
2104
3790
  { path: "features/todos/policy.ts", content: files.productionTodoPolicy },
2105
3791
  {
2106
- path: "infra/auth/anonymous-auth.ts",
2107
- content: files.productionAnonymousAuth,
3792
+ path: "features/todos/domain/events/index.ts",
3793
+ content: files.productionTodoEvents,
3794
+ },
3795
+ { path: "features/todos/jobs/index.ts", content: files.productionTodoJobs },
3796
+ {
3797
+ path: "features/todos/listeners/index.ts",
3798
+ content: files.productionTodoListeners,
3799
+ },
3800
+ {
3801
+ path: "features/todos/notifications/index.ts",
3802
+ content: files.productionTodoNotifications,
3803
+ },
3804
+ {
3805
+ path: "features/todos/schedules/index.ts",
3806
+ content: files.productionTodoSchedules,
3807
+ },
3808
+ {
3809
+ path: "features/todos/uploads/index.ts",
3810
+ content: files.productionTodoUploads,
2108
3811
  },
3812
+ ...(!hasIntegration(ctx, "better-auth")
3813
+ ? [
3814
+ {
3815
+ path: "infra/auth/anonymous-auth.ts",
3816
+ content: files.productionAnonymousAuth,
3817
+ },
3818
+ ]
3819
+ : []),
2109
3820
  { path: "ports/auth.ts", content: files.productionAuthPort },
2110
3821
  {
2111
3822
  path: "ports/index.ts",
@@ -2119,7 +3830,7 @@ function getProductionTemplateFiles(ctx) {
2119
3830
  },
2120
3831
  {
2121
3832
  path: "features/shared/errors.ts",
2122
- content: files.productimapUnhandledErrors,
3833
+ content: files.productionSharedErrors,
2123
3834
  },
2124
3835
  {
2125
3836
  path: "server/index.ts",
@@ -2127,12 +3838,22 @@ function getProductionTemplateFiles(ctx) {
2127
3838
  ? files.productionServerWithDrizzleTurso
2128
3839
  : files.productionServer,
2129
3840
  },
3841
+ { path: "server/context.ts", content: files.productionServerContext },
2130
3842
  { path: "server/routes.ts", content: files.productionServerRoutes },
3843
+ { path: "server/outbox.ts", content: files.productionOutbox },
3844
+ { path: "server/schedules.ts", content: files.productionSchedules },
2131
3845
  {
2132
3846
  path: "server/providers.ts",
2133
3847
  content: productionServerProviders(ctx),
2134
3848
  },
2135
3849
  { path: "lib/use-case.ts", content: files.productionUseCaseBuilder },
3850
+ { path: "lib/listeners.ts", content: files.productionListenersBuilder },
3851
+ { path: "lib/jobs.ts", content: files.productionJobsBuilder },
3852
+ { path: "lib/schedules.ts", content: files.productionSchedulesBuilder },
3853
+ {
3854
+ path: "lib/notifications.ts",
3855
+ content: files.productionNotificationsBuilder,
3856
+ },
2136
3857
  {
2137
3858
  path: "features/todos/use-cases/create-todo.ts",
2138
3859
  content: files.productionCreateTodoUseCase,
@@ -2150,7 +3871,7 @@ function getProductionTemplateFiles(ctx) {
2150
3871
  content: files.productionUseCasesIndex,
2151
3872
  },
2152
3873
  {
2153
- path: "features/todos/use-cases/schemas.ts",
3874
+ path: "features/todos/schemas.ts",
2154
3875
  content: files.productionTodoSchemas,
2155
3876
  },
2156
3877
  {
@@ -2161,6 +3882,24 @@ function getProductionTemplateFiles(ctx) {
2161
3882
  ];
2162
3883
  if (usesDrizzleTurso) {
2163
3884
  templateFiles.push({ path: "drizzle.config.ts", content: files.productionDrizzleConfig }, {
3885
+ path: "infra/audit/drizzle-audit-log.ts",
3886
+ content: files.productionDrizzleAuditLog,
3887
+ }, {
3888
+ path: "infra/db/bootstrap.ts",
3889
+ content: files.productionDbBootstrap,
3890
+ }, {
3891
+ path: "infra/db/seed.ts",
3892
+ content: files.productionDbSeed,
3893
+ }, {
3894
+ path: "infra/db/reset.ts",
3895
+ content: files.productionDbReset,
3896
+ }, {
3897
+ path: "infra/db/test-database.ts",
3898
+ content: files.productionDbTestDatabase,
3899
+ }, {
3900
+ path: "infra/db/provider.ts",
3901
+ content: files.productionDbProvider,
3902
+ }, {
2164
3903
  path: "infra/db/schema/index.ts",
2165
3904
  content: files.productionDbSchema,
2166
3905
  }, {
@@ -2179,6 +3918,9 @@ function getProductionTemplateFiles(ctx) {
2179
3918
  }
2180
3919
  return templateFiles;
2181
3920
  }
3921
+ /**
3922
+ * Render all template files for a new Beignet app.
3923
+ */
2182
3924
  export function getTemplateFiles(ctx) {
2183
3925
  if (isStandardPreset(ctx)) {
2184
3926
  return getProductionTemplateFiles(ctx);
@@ -2194,21 +3936,29 @@ export function getTemplateFiles(ctx) {
2194
3936
  { path: "app/layout.tsx", content: layout(ctx) },
2195
3937
  { path: "app/page.tsx", content: page(ctx) },
2196
3938
  { path: "app/api/[[...path]]/route.ts", content: files.apiCatchAllRoute },
3939
+ { path: "app-context.ts", content: minimalAppContext(ctx) },
2197
3940
  { path: "features/todos/contracts.ts", content: files.contractsTodos },
2198
3941
  { path: "features/todos/routes.ts", content: files.todoRoutes },
2199
- { path: "ports/index.ts", content: files.ports },
3942
+ { path: "ports/index.ts", content: minimalPorts(ctx) },
2200
3943
  { path: "server/index.ts", content: server(ctx) },
3944
+ { path: "server/context.ts", content: serverContext(ctx) },
2201
3945
  { path: "server/routes.ts", content: files.serverRoutes },
2202
- { path: "features/todos/use-cases.ts", content: files.useCasesTodos },
3946
+ { path: "features/todos/use-cases/index.ts", content: files.useCasesTodos },
2203
3947
  ];
2204
3948
  if (hasFeature(ctx, "client")) {
2205
3949
  templateFiles.push({
2206
- path: "client/api-client.ts",
2207
- content: files.apiClient,
3950
+ path: "client/index.ts",
3951
+ content: clientIndex(ctx),
2208
3952
  });
2209
3953
  }
2210
3954
  if (hasFeature(ctx, "react-query")) {
2211
- templateFiles.push({ path: "app/providers.tsx", content: files.appProviders }, { path: "client/rq.ts", content: files.rq }, { path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) });
3955
+ templateFiles.push({ path: "app/providers.tsx", content: files.appProviders }, { path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) });
3956
+ }
3957
+ if (hasFeature(ctx, "forms")) {
3958
+ templateFiles.push({
3959
+ path: "client/forms.ts",
3960
+ content: files.formsClient,
3961
+ });
2212
3962
  }
2213
3963
  if (hasFeature(ctx, "openapi")) {
2214
3964
  templateFiles.push({
@@ -2216,14 +3966,23 @@ export function getTemplateFiles(ctx) {
2216
3966
  content: files.apiOpenApiRoute,
2217
3967
  });
2218
3968
  }
2219
- if (ctx.integrations.some((integration) => ["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration))) {
2220
- templateFiles.push({
2221
- path: "server/providers.ts",
2222
- content: serverProviders(ctx),
3969
+ if (hasIntegration(ctx, "better-auth")) {
3970
+ templateFiles.push({ path: "lib/better-auth.ts", content: files.minimalBetterAuth }, {
3971
+ path: "app/api/auth/[...all]/route.ts",
3972
+ content: files.productionAuthRoute,
3973
+ });
3974
+ }
3975
+ if (hasDrizzleTurso(ctx)) {
3976
+ templateFiles.push({ path: "drizzle.config.ts", content: files.productionDrizzleConfig }, { path: "infra/db/provider.ts", content: files.minimalDbProvider }, { path: "infra/db/schema/index.ts", content: files.minimalDbSchema }, {
3977
+ path: "infra/todos/drizzle-todo-repository.ts",
3978
+ content: files.minimalDrizzleTodoRepository,
2223
3979
  });
2224
3980
  }
2225
3981
  if (ctx.integrations.length > 0) {
2226
3982
  templateFiles.push({
3983
+ path: "server/providers.ts",
3984
+ content: serverProviders(ctx),
3985
+ }, {
2227
3986
  path: ".env.example",
2228
3987
  content: envExample(ctx),
2229
3988
  }, {