@beignet/cli 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/templates.ts CHANGED
@@ -1,7 +1,22 @@
1
+ /**
2
+ * Package managers supported by the app template generator.
3
+ */
1
4
  export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
5
+ /**
6
+ * Starter presets supported by the app template generator.
7
+ */
2
8
  export type PresetName = "minimal" | "standard";
9
+ /**
10
+ * Application templates supported by the app template generator.
11
+ */
3
12
  export type TemplateName = "next";
13
+ /**
14
+ * Optional first-party features that can be scaffolded into a new app.
15
+ */
4
16
  export type FeatureName = "client" | "react-query" | "forms" | "openapi";
17
+ /**
18
+ * Optional provider integrations that can be scaffolded into a new app.
19
+ */
5
20
  export type IntegrationName =
6
21
  | "better-auth"
7
22
  | "drizzle-turso"
@@ -10,11 +25,17 @@ export type IntegrationName =
10
25
  | "resend"
11
26
  | "upstash-rate-limit";
12
27
 
28
+ /**
29
+ * One generated file emitted by the template generator.
30
+ */
13
31
  export type TemplateFile = {
14
32
  path: string;
15
33
  content: string;
16
34
  };
17
35
 
36
+ /**
37
+ * Inputs used to render app template files.
38
+ */
18
39
  export type TemplateContext = {
19
40
  name: string;
20
41
  packageManager: PackageManager;
@@ -69,6 +90,9 @@ function packageRunner(ctx: TemplateContext): string {
69
90
  }
70
91
  }
71
92
 
93
+ /**
94
+ * Supported scaffold feature choices.
95
+ */
72
96
  export const featureChoices = [
73
97
  "client",
74
98
  "react-query",
@@ -76,11 +100,17 @@ export const featureChoices = [
76
100
  "openapi",
77
101
  ] as const satisfies readonly FeatureName[];
78
102
 
103
+ /**
104
+ * Supported scaffold preset choices.
105
+ */
79
106
  export const presetChoices = [
80
107
  "minimal",
81
108
  "standard",
82
109
  ] as const satisfies readonly PresetName[];
83
110
 
111
+ /**
112
+ * Supported scaffold integration choices.
113
+ */
84
114
  export const integrationChoices = [
85
115
  "better-auth",
86
116
  "drizzle-turso",
@@ -184,6 +214,7 @@ function packageJson(ctx: TemplateContext): string {
184
214
 
185
215
  if (isStandardPreset(ctx)) {
186
216
  dependencies["@beignet/devtools"] = ctx.beignetVersion;
217
+ dependencies["@beignet/provider-event-bus-memory"] = ctx.beignetVersion;
187
218
  dependencies["@beignet/provider-storage-local"] = ctx.beignetVersion;
188
219
  }
189
220
 
@@ -228,6 +259,8 @@ function packageJson(ctx: TemplateContext): string {
228
259
  if (hasDrizzleTurso(ctx)) {
229
260
  scripts["db:generate"] = "drizzle-kit generate";
230
261
  scripts["db:migrate"] = "drizzle-kit migrate";
262
+ scripts["db:seed"] = "bun infra/db/seed.ts";
263
+ scripts["db:reset"] = "bun infra/db/reset.ts";
231
264
  }
232
265
 
233
266
  return json({
@@ -311,7 +344,8 @@ function standardReadme(ctx: TemplateContext): string {
311
344
  const beforeDeploy = hasDrizzleTurso(ctx)
312
345
  ? [
313
346
  "- Create a Turso database or keep `TURSO_DB_URL=file:local.db` for local libSQL development.",
314
- `- Run \`${ctx.packageManager} run db:generate\` and \`${ctx.packageManager} run db:migrate\` after changing the Drizzle schema.`,
347
+ `- Run \`${cli} db generate\` and \`${cli} db migrate\` after changing the Drizzle schema.`,
348
+ `- Run \`${cli} db seed\` to load starter data or \`${cli} db reset\` to rebuild a local SQLite database.`,
315
349
  ].join("\n")
316
350
  : "- Replace the in-memory todo repository with a durable adapter.";
317
351
 
@@ -349,10 +383,14 @@ ${typecheck}
349
383
  - \`infra/\` implements ports for the selected runtime.
350
384
  - \`server/routes.ts\` keeps the central route registry and OpenAPI contract list.
351
385
  - \`server/\` wires context, providers, routes, hooks, and error handling.
386
+ - \`server/outbox.ts\` registers durable events and jobs for the outbox drain route.
352
387
  - \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
388
+ - \`features/todos/domain/events/\`, \`jobs/\`, \`listeners/\`, \`notifications/\`, \`schedules/\`, and \`uploads/\` show the feature-owned background workflow shape.
353
389
  - \`app/storage/[...key]/route.ts\` serves public local storage objects.
390
+ - \`app/api/cron/outbox/drain/route.ts\` is the bounded serverless entrypoint for durable event/job delivery.
354
391
  - \`lib/env.ts\` validates deployment configuration at startup.
355
392
  - \`lib/auth.ts\` exposes \`requireUser(ctx)\` for protected use cases.
393
+ - \`lib/better-auth.ts\` owns Better Auth setup and keeps provider-specific auth details outside use cases.
356
394
  - \`features/todos/policy.ts\` defines authorization rules registered with \`createGate(...)\`.
357
395
  ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `infra/db/repositories.ts` creates app repositories, and `infra/todos/` contains the durable todo repository adapter.\n" : ""}
358
396
 
@@ -360,8 +398,9 @@ ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `inf
360
398
 
361
399
  ${beforeDeploy}
362
400
  - Keep \`/api/devtools\` development-only unless you add authentication and stricter redaction.
363
- - Set \`APP_URL\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
364
- - Replace anonymous auth and add use-case authorization before exposing user-owned data.
401
+ - Set \`APP_URL\`, \`BETTER_AUTH_SECRET\`, \`CRON_SECRET\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
402
+ - Configure a scheduled request to \`/api/cron/outbox/drain\` with \`Authorization: Bearer <CRON_SECRET>\`.
403
+ - Review the starter authorization policy before exposing user-owned data.
365
404
  ${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
366
405
  `;
367
406
  }
@@ -381,10 +420,10 @@ function integrationsDoc(ctx: TemplateContext): string {
381
420
  "",
382
421
  "- Package: `@beignet/provider-auth-better-auth`",
383
422
  "- Peer dependency: `better-auth`",
384
- "- The starter uses an anonymous auth adapter. Replace `createAnonymousAuth()` in `infra/app-ports.ts` with an adapter backed by your Better Auth session lookup.",
423
+ "- The standard starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.",
385
424
  "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
386
425
  "- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
387
- "- Add the Better Auth provider only after your app has a database adapter and session setup.",
426
+ "- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.",
388
427
  "",
389
428
  );
390
429
  }
@@ -398,7 +437,8 @@ function integrationsDoc(ctx: TemplateContext): string {
398
437
  "- Dev dependency: `drizzle-kit`",
399
438
  "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.",
400
439
  "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
401
- `- Run \`${ctx.packageManager} run db:generate\` and \`${ctx.packageManager} run db:migrate\` after changing \`infra/db/schema.ts\`.`,
440
+ `- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`,
441
+ `- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`,
402
442
  "",
403
443
  );
404
444
  }
@@ -511,9 +551,9 @@ function envExample(ctx: TemplateContext): string {
511
551
 
512
552
  if (hasIntegration(ctx, "better-auth")) {
513
553
  lines.push(
514
- "# Better Auth needs an app-owned database adapter and a shared AuthPort adapter before it can be wired.",
515
- "BETTER_AUTH_SECRET=",
554
+ "BETTER_AUTH_SECRET=local-dev-better-auth-secret-change-me",
516
555
  "BETTER_AUTH_URL=http://localhost:3000",
556
+ "# BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000",
517
557
  "",
518
558
  );
519
559
  }
@@ -771,14 +811,14 @@ export function TodoApp() {
771
811
  <div className="todos-panel">
772
812
  <div className="panel-heading">
773
813
  <h2>Todos</h2>
774
- <span>{todosQuery.data?.total ?? 0} total</span>
814
+ <span>{todosQuery.data?.page.total ?? 0} total</span>
775
815
  </div>
776
816
  {todosQuery.isLoading ? <p className="muted">Loading todos...</p> : null}
777
817
  {todosQuery.isError ? (
778
818
  <p className="error">Could not load todos.</p>
779
819
  ) : null}
780
820
  <ul className="todo-list">
781
- {todosQuery.data?.todos.map((todo) => (
821
+ {todosQuery.data?.items.map((todo) => (
782
822
  <li key={todo.id}>
783
823
  <span>{todo.title}</span>
784
824
  <small>{todo.completed ? "Done" : "Open"}</small>
@@ -840,10 +880,10 @@ export function TodoApp() {
840
880
  <div className="todos-panel">
841
881
  <div className="panel-heading">
842
882
  <h2>Todos</h2>
843
- <span>{todosQuery.data?.total ?? 0} total</span>
883
+ <span>{todosQuery.data?.page.total ?? 0} total</span>
844
884
  </div>
845
885
  <ul className="todo-list">
846
- {todosQuery.data?.todos.map((todo) => (
886
+ {todosQuery.data?.items.map((todo) => (
847
887
  <li key={todo.id}>
848
888
  <span>{todo.title}</span>
849
889
  <small>{todo.completed ? "Done" : "Open"}</small>
@@ -1123,8 +1163,14 @@ export const CreateTodoSchema = z.object({
1123
1163
 
1124
1164
  export const listTodos = todos.get("/api/todos").responses({
1125
1165
  200: z.object({
1126
- todos: z.array(TodoSchema),
1127
- total: z.number(),
1166
+ items: z.array(TodoSchema),
1167
+ page: z.object({
1168
+ kind: z.literal("offset"),
1169
+ limit: z.number(),
1170
+ offset: z.number(),
1171
+ total: z.number(),
1172
+ hasMore: z.boolean(),
1173
+ }),
1128
1174
  }),
1129
1175
  });
1130
1176
 
@@ -1180,8 +1226,14 @@ type AppContext = {
1180
1226
  const useCase = createUseCase<AppContext>();
1181
1227
 
1182
1228
  export const ListTodosOutputSchema = z.object({
1183
- todos: z.array(TodoSchema),
1184
- total: z.number(),
1229
+ items: z.array(TodoSchema),
1230
+ page: z.object({
1231
+ kind: z.literal("offset"),
1232
+ limit: z.number(),
1233
+ offset: z.number(),
1234
+ total: z.number(),
1235
+ hasMore: z.boolean(),
1236
+ }),
1185
1237
  });
1186
1238
 
1187
1239
  export const listTodos = useCase
@@ -1190,7 +1242,16 @@ export const listTodos = useCase
1190
1242
  .output(ListTodosOutputSchema)
1191
1243
  .run(async ({ ctx }) => {
1192
1244
  const todos = await ctx.ports.todos.list();
1193
- return { todos, total: todos.length };
1245
+ return {
1246
+ items: todos,
1247
+ page: {
1248
+ kind: "offset" as const,
1249
+ limit: todos.length,
1250
+ offset: 0,
1251
+ total: todos.length,
1252
+ hasMore: false,
1253
+ },
1254
+ };
1194
1255
  });
1195
1256
 
1196
1257
  export const createTodo = useCase
@@ -1264,6 +1325,14 @@ export const env = createEnv({
1264
1325
  APP_URL: z.string().url().default("http://localhost:3000"),
1265
1326
  CRON_SECRET: z.string().min(1).optional(),
1266
1327
  DEVTOOLS_ENABLED: BooleanEnv.optional(),
1328
+ BETTER_AUTH_SECRET: z
1329
+ .string()
1330
+ .min(32)
1331
+ .default("local-dev-better-auth-secret-change-me"),
1332
+ BETTER_AUTH_URL: z.string().url().default("http://localhost:3000"),
1333
+ BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
1334
+ TURSO_DB_URL: z.string().default("file:local.db"),
1335
+ TURSO_DB_AUTH_TOKEN: z.string().optional(),
1267
1336
  LOG_LEVEL: z
1268
1337
  .enum(["trace", "debug", "info", "warn", "error", "fatal"])
1269
1338
  .default("info"),
@@ -1329,10 +1398,14 @@ export const ListTodosInputSchema = z.object({
1329
1398
  });
1330
1399
 
1331
1400
  export const ListTodosOutputSchema = z.object({
1332
- todos: z.array(TodoSchema),
1333
- total: z.number().int().min(0),
1334
- limit: z.number().int().min(1),
1335
- offset: z.number().int().min(0),
1401
+ items: z.array(TodoSchema),
1402
+ page: z.object({
1403
+ kind: z.literal("offset"),
1404
+ limit: z.number().int().min(1),
1405
+ offset: z.number().int().min(0),
1406
+ total: z.number().int().min(0),
1407
+ hasMore: z.boolean(),
1408
+ }),
1336
1409
  });
1337
1410
 
1338
1411
  export const GetTodoInputSchema = z.object({
@@ -1371,7 +1444,8 @@ export function requireUser(ctx: AppContext): AuthUser {
1371
1444
  return requireSession(ctx).user;
1372
1445
  }
1373
1446
  `,
1374
- productionListTodosUseCase: `import { useCase } from "@/lib/use-case";
1447
+ productionListTodosUseCase: `import { normalizeOffsetPage } from "@beignet/core/pagination";
1448
+ import { useCase } from "@/lib/use-case";
1375
1449
  import { ListTodosInputSchema, ListTodosOutputSchema } from "./schemas";
1376
1450
 
1377
1451
  export const listTodosUseCase = useCase
@@ -1379,13 +1453,12 @@ export const listTodosUseCase = useCase
1379
1453
  .input(ListTodosInputSchema)
1380
1454
  .output(ListTodosOutputSchema)
1381
1455
  .run(async ({ ctx, input }) => {
1382
- const result = await ctx.ports.todos.list(input);
1383
- return {
1384
- todos: result.todos,
1385
- total: result.total,
1386
- limit: input.limit,
1387
- offset: input.offset,
1388
- };
1456
+ const page = normalizeOffsetPage(input, {
1457
+ defaultLimit: 20,
1458
+ maxLimit: 100,
1459
+ });
1460
+
1461
+ return ctx.ports.todos.list(page);
1389
1462
  });
1390
1463
  `,
1391
1464
  productionGetTodoUseCase: `import { appError } from "@/features/shared/errors";
@@ -1405,16 +1478,35 @@ export const getTodoUseCase = useCase
1405
1478
  return todo;
1406
1479
  });
1407
1480
  `,
1408
- productionCreateTodoUseCase: `import { useCase } from "@/lib/use-case";
1481
+ productionCreateTodoUseCase: `import { TodoCreated } from "@/features/todos/domain/events";
1482
+ import { auditEntry } from "@/lib/audit";
1483
+ import { useCase } from "@/lib/use-case";
1409
1484
  import { CreateTodoInputSchema, TodoSchema } from "./schemas";
1410
1485
 
1411
1486
  export const createTodoUseCase = useCase
1412
1487
  .command("todos.create")
1413
1488
  .input(CreateTodoInputSchema)
1414
1489
  .output(TodoSchema)
1415
- .run(async ({ ctx, input }) => {
1490
+ .emits([TodoCreated])
1491
+ .run(async ({ ctx, input, events }) => {
1416
1492
  await ctx.gate.authorize("todos.create");
1417
- return ctx.ports.uow.transaction((tx) => tx.todos.create(input));
1493
+ const todo = await ctx.ports.uow.transaction(async (tx) => {
1494
+ const created = await tx.todos.create(input);
1495
+ await events.record(tx.events, TodoCreated, {
1496
+ todoId: created.id,
1497
+ title: created.title,
1498
+ });
1499
+ await tx.audit.record(
1500
+ auditEntry(ctx, {
1501
+ action: "todos.create",
1502
+ resource: { type: "todo", id: created.id, name: created.title },
1503
+ metadata: { completed: created.completed },
1504
+ }),
1505
+ );
1506
+ return created;
1507
+ });
1508
+
1509
+ return todo;
1418
1510
  });
1419
1511
  `,
1420
1512
  productionUseCasesIndex: `export { createTodoUseCase } from "./create-todo";
@@ -1432,21 +1524,37 @@ export {
1432
1524
  } from "./schemas";
1433
1525
  `,
1434
1526
  productionTodoRepositoryPort: `import type {
1527
+ OffsetPage,
1528
+ OffsetPageInfo,
1529
+ PageResult,
1530
+ } from "@beignet/core/pagination";
1531
+ import type {
1435
1532
  CreateTodoInput,
1436
- ListTodosInput,
1437
1533
  Todo,
1438
1534
  } from "@/features/todos/use-cases/schemas";
1439
1535
 
1440
- export type ListTodosResult = {
1441
- todos: Todo[];
1442
- total: number;
1536
+ export type ListTodosResult = PageResult<Todo, OffsetPageInfo>;
1537
+
1538
+ export type TodoAttachment = {
1539
+ id: string;
1540
+ todoId: string;
1541
+ key: string;
1542
+ fileName: string;
1543
+ contentType: string;
1544
+ size: number;
1545
+ createdAt: string;
1443
1546
  };
1444
1547
 
1445
1548
  export interface TodoRepository {
1446
- list(input: ListTodosInput): Promise<ListTodosResult>;
1549
+ list(page: OffsetPage): Promise<ListTodosResult>;
1447
1550
  findById(id: string): Promise<Todo | null>;
1448
1551
  create(input: CreateTodoInput): Promise<Todo>;
1449
1552
  }
1553
+
1554
+ export interface TodoAttachmentRepository {
1555
+ create(input: Omit<TodoAttachment, "createdAt">): Promise<TodoAttachment>;
1556
+ findMany(todoId: string): Promise<TodoAttachment[]>;
1557
+ }
1450
1558
  `,
1451
1559
  productionAuthPort: `import type {
1452
1560
  AuthPort as BeignetAuthPort,
@@ -1516,12 +1624,15 @@ export const todoPolicy = definePolicy({
1516
1624
  },
1517
1625
  });
1518
1626
  `,
1519
- productionInMemoryTodoRepository: `import type {
1627
+ productionInMemoryTodoRepository: `import { offsetPageResult } from "@beignet/core/pagination";
1628
+ import type {
1520
1629
  CreateTodoInput,
1521
- ListTodosInput,
1522
1630
  Todo,
1523
1631
  } from "@/features/todos/use-cases/schemas";
1524
- import type { TodoRepository } from "@/features/todos/ports";
1632
+ import type {
1633
+ TodoAttachmentRepository,
1634
+ TodoRepository,
1635
+ } from "@/features/todos/ports";
1525
1636
 
1526
1637
  export function createInMemoryTodoRepository(
1527
1638
  seed: Todo[] = [],
@@ -1529,15 +1640,16 @@ export function createInMemoryTodoRepository(
1529
1640
  const todos = new Map(seed.map((todo) => [todo.id, todo]));
1530
1641
 
1531
1642
  return {
1532
- async list(input: ListTodosInput) {
1643
+ async list(page) {
1533
1644
  const allTodos = Array.from(todos.values()).sort((left, right) =>
1534
1645
  left.createdAt.localeCompare(right.createdAt),
1535
1646
  );
1536
1647
 
1537
- return {
1538
- todos: allTodos.slice(input.offset, input.offset + input.limit),
1539
- total: allTodos.length,
1540
- };
1648
+ return offsetPageResult(
1649
+ allTodos.slice(page.offset, page.offset + page.limit),
1650
+ page,
1651
+ allTodos.length,
1652
+ );
1541
1653
  },
1542
1654
  async findById(id: string) {
1543
1655
  return todos.get(id) ?? null;
@@ -1604,7 +1716,6 @@ export const appPorts = definePorts({
1604
1716
  productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
1605
1717
  import { todoPolicy } from "@/features/todos/policy";
1606
1718
  import { appError } from "@/features/shared/errors";
1607
- import { createAnonymousAuth } from "./auth/anonymous-auth";
1608
1719
  import { fallbackLogger } from "./logger";
1609
1720
 
1610
1721
  const gate = createGate({
@@ -1618,7 +1729,6 @@ const gate = createGate({
1618
1729
  });
1619
1730
 
1620
1731
  export const appPorts = definePorts({
1621
- auth: createAnonymousAuth(),
1622
1732
  gate,
1623
1733
  logger: fallbackLogger,
1624
1734
  });
@@ -1633,7 +1743,63 @@ export const appPorts = definePorts({
1633
1743
  },
1634
1744
  };
1635
1745
  `,
1636
- productionDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1746
+ productionDbSchema: `import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1747
+
1748
+ export const user = sqliteTable("user", {
1749
+ id: text("id").primaryKey(),
1750
+ name: text("name").notNull(),
1751
+ email: text("email").notNull().unique(),
1752
+ emailVerified: integer("email_verified", { mode: "boolean" })
1753
+ .notNull()
1754
+ .default(false),
1755
+ image: text("image"),
1756
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1757
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
1758
+ });
1759
+
1760
+ export const session = sqliteTable("session", {
1761
+ id: text("id").primaryKey(),
1762
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
1763
+ token: text("token").notNull().unique(),
1764
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1765
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
1766
+ ipAddress: text("ip_address"),
1767
+ userAgent: text("user_agent"),
1768
+ userId: text("user_id")
1769
+ .notNull()
1770
+ .references(() => user.id, { onDelete: "cascade" }),
1771
+ });
1772
+
1773
+ export const account = sqliteTable("account", {
1774
+ id: text("id").primaryKey(),
1775
+ accountId: text("account_id").notNull(),
1776
+ providerId: text("provider_id").notNull(),
1777
+ userId: text("user_id")
1778
+ .notNull()
1779
+ .references(() => user.id, { onDelete: "cascade" }),
1780
+ accessToken: text("access_token"),
1781
+ refreshToken: text("refresh_token"),
1782
+ idToken: text("id_token"),
1783
+ accessTokenExpiresAt: integer("access_token_expires_at", {
1784
+ mode: "timestamp",
1785
+ }),
1786
+ refreshTokenExpiresAt: integer("refresh_token_expires_at", {
1787
+ mode: "timestamp",
1788
+ }),
1789
+ scope: text("scope"),
1790
+ password: text("password"),
1791
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1792
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
1793
+ });
1794
+
1795
+ export const verification = sqliteTable("verification", {
1796
+ id: text("id").primaryKey(),
1797
+ identifier: text("identifier").notNull(),
1798
+ value: text("value").notNull(),
1799
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
1800
+ createdAt: integer("created_at", { mode: "timestamp" }),
1801
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
1802
+ });
1637
1803
 
1638
1804
  export const todos = sqliteTable("todos", {
1639
1805
  id: text("id").primaryKey(),
@@ -1641,13 +1807,111 @@ export const todos = sqliteTable("todos", {
1641
1807
  completed: integer("completed", { mode: "boolean" }).notNull().default(false),
1642
1808
  createdAt: text("created_at").notNull(),
1643
1809
  });
1810
+
1811
+ export const todoAttachments = sqliteTable(
1812
+ "todo_attachments",
1813
+ {
1814
+ id: text("id").primaryKey(),
1815
+ todoId: text("todo_id")
1816
+ .notNull()
1817
+ .references(() => todos.id, { onDelete: "cascade" }),
1818
+ key: text("key").notNull(),
1819
+ fileName: text("file_name").notNull(),
1820
+ contentType: text("content_type").notNull(),
1821
+ size: integer("size").notNull(),
1822
+ createdAt: text("created_at").notNull(),
1823
+ },
1824
+ (table) => ({
1825
+ todoIdx: index("todo_attachments_todo_idx").on(
1826
+ table.todoId,
1827
+ table.createdAt,
1828
+ ),
1829
+ keyIdx: index("todo_attachments_key_idx").on(table.key),
1830
+ }),
1831
+ );
1832
+
1833
+ export const auditLog = sqliteTable(
1834
+ "audit_log",
1835
+ {
1836
+ id: text("id").primaryKey(),
1837
+ action: text("action").notNull(),
1838
+ actorType: text("actor_type", {
1839
+ enum: ["anonymous", "service", "system", "user"],
1840
+ }).notNull(),
1841
+ actorId: text("actor_id"),
1842
+ actorDisplayName: text("actor_display_name"),
1843
+ tenantId: text("tenant_id"),
1844
+ tenantSlug: text("tenant_slug"),
1845
+ resourceType: text("resource_type"),
1846
+ resourceId: text("resource_id"),
1847
+ resourceName: text("resource_name"),
1848
+ outcome: text("outcome", { enum: ["success", "failure"] })
1849
+ .notNull()
1850
+ .default("success"),
1851
+ requestId: text("request_id"),
1852
+ traceId: text("trace_id"),
1853
+ message: text("message"),
1854
+ metadata: text("metadata"),
1855
+ actorMetadata: text("actor_metadata"),
1856
+ tenantMetadata: text("tenant_metadata"),
1857
+ resourceMetadata: text("resource_metadata"),
1858
+ occurredAt: text("occurred_at").notNull(),
1859
+ },
1860
+ (table) => ({
1861
+ actionIdx: index("audit_log_action_idx").on(table.action),
1862
+ actorIdx: index("audit_log_actor_idx").on(table.actorType, table.actorId),
1863
+ occurredAtIdx: index("audit_log_occurred_at_idx").on(table.occurredAt),
1864
+ requestIdx: index("audit_log_request_idx").on(table.requestId),
1865
+ resourceIdx: index("audit_log_resource_idx").on(
1866
+ table.resourceType,
1867
+ table.resourceId,
1868
+ ),
1869
+ tenantIdx: index("audit_log_tenant_idx").on(table.tenantId),
1870
+ }),
1871
+ );
1872
+
1873
+ export const outboxMessages = sqliteTable(
1874
+ "outbox_messages",
1875
+ {
1876
+ id: text("id").primaryKey(),
1877
+ kind: text("kind", { enum: ["event", "job"] }).notNull(),
1878
+ name: text("name").notNull(),
1879
+ payloadJson: text("payload_json").notNull(),
1880
+ status: text("status", {
1881
+ enum: ["pending", "claimed", "delivered", "deadLettered"],
1882
+ }).notNull(),
1883
+ attempts: integer("attempts").notNull().default(0),
1884
+ maxAttempts: integer("max_attempts").notNull().default(3),
1885
+ availableAt: text("available_at").notNull(),
1886
+ claimedAt: text("claimed_at"),
1887
+ lockedUntil: text("locked_until"),
1888
+ claimToken: text("claim_token"),
1889
+ deliveredAt: text("delivered_at"),
1890
+ lastErrorJson: text("last_error_json"),
1891
+ createdAt: text("created_at").notNull(),
1892
+ updatedAt: text("updated_at").notNull(),
1893
+ },
1894
+ (table) => ({
1895
+ availableIdx: index("outbox_messages_available_idx").on(
1896
+ table.status,
1897
+ table.availableAt,
1898
+ ),
1899
+ lockedIdx: index("outbox_messages_locked_idx").on(
1900
+ table.status,
1901
+ table.lockedUntil,
1902
+ ),
1903
+ }),
1904
+ );
1644
1905
  `,
1645
- productionDrizzleTodoRepository: `import { count, desc, eq } from "drizzle-orm";
1906
+ productionDrizzleTodoRepository: `import { offsetPageResult } from "@beignet/core/pagination";
1907
+ import { count, desc, eq } from "drizzle-orm";
1646
1908
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1647
- import type { TodoRepository } from "@/features/todos/ports";
1909
+ import type {
1910
+ TodoAttachmentRepository,
1911
+ TodoRepository,
1912
+ } from "@/features/todos/ports";
1648
1913
  import type {
1649
1914
  CreateTodoInput,
1650
- ListTodosInput,
1651
1915
  Todo,
1652
1916
  } from "@/features/todos/use-cases/schemas";
1653
1917
  import * as schema from "@/infra/db/schema";
@@ -1667,19 +1931,16 @@ export function createDrizzleTodoRepository(
1667
1931
  db: DrizzleTursoDatabase<typeof schema>,
1668
1932
  ): TodoRepository {
1669
1933
  return {
1670
- async list(input: ListTodosInput) {
1934
+ async list(page) {
1671
1935
  const rows = await db
1672
1936
  .select()
1673
1937
  .from(schema.todos)
1674
1938
  .orderBy(desc(schema.todos.createdAt))
1675
- .limit(input.limit)
1676
- .offset(input.offset);
1939
+ .limit(page.limit)
1940
+ .offset(page.offset);
1677
1941
  const [{ total }] = await db.select({ total: count() }).from(schema.todos);
1678
1942
 
1679
- return {
1680
- todos: rows.map(toTodo),
1681
- total,
1682
- };
1943
+ return offsetPageResult(rows.map(toTodo), page, total);
1683
1944
  },
1684
1945
  async findById(id: string) {
1685
1946
  const [row] = await db
@@ -1707,19 +1968,428 @@ export function createDrizzleTodoRepository(
1707
1968
  },
1708
1969
  };
1709
1970
  }
1971
+
1972
+ export function createDrizzleTodoAttachmentRepository(
1973
+ db: DrizzleTursoDatabase<typeof schema>,
1974
+ ): TodoAttachmentRepository {
1975
+ return {
1976
+ async create(input) {
1977
+ const attachment = {
1978
+ ...input,
1979
+ createdAt: new Date().toISOString(),
1980
+ };
1981
+ const [row] = await db
1982
+ .insert(schema.todoAttachments)
1983
+ .values(attachment)
1984
+ .returning();
1985
+
1986
+ if (!row) {
1987
+ throw new Error("Failed to create todo attachment");
1988
+ }
1989
+
1990
+ return {
1991
+ id: row.id,
1992
+ todoId: row.todoId,
1993
+ key: row.key,
1994
+ fileName: row.fileName,
1995
+ contentType: row.contentType,
1996
+ size: row.size,
1997
+ createdAt: row.createdAt,
1998
+ };
1999
+ },
2000
+ async findMany(todoId) {
2001
+ const rows = await db
2002
+ .select()
2003
+ .from(schema.todoAttachments)
2004
+ .where(eq(schema.todoAttachments.todoId, todoId))
2005
+ .orderBy(desc(schema.todoAttachments.createdAt));
2006
+
2007
+ return rows.map((row) => ({
2008
+ id: row.id,
2009
+ todoId: row.todoId,
2010
+ key: row.key,
2011
+ fileName: row.fileName,
2012
+ contentType: row.contentType,
2013
+ size: row.size,
2014
+ createdAt: row.createdAt,
2015
+ }));
2016
+ },
2017
+ };
2018
+ }
1710
2019
  `,
1711
2020
  productionDbRepositories: `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1712
- import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
2021
+ import {
2022
+ createDrizzleTodoAttachmentRepository,
2023
+ createDrizzleTodoRepository,
2024
+ } from "@/infra/todos/drizzle-todo-repository";
1713
2025
  import type { AppTransactionPorts } from "@/ports";
1714
2026
  import * as schema from "./schema";
1715
2027
 
1716
2028
  export function createRepositories(
1717
2029
  db: DrizzleTursoDatabase<typeof schema>,
1718
- ): Omit<AppTransactionPorts, "events"> {
2030
+ ): Omit<AppTransactionPorts, "audit" | "events" | "jobs" | "outbox"> {
1719
2031
  return {
2032
+ todoAttachments: createDrizzleTodoAttachmentRepository(db),
1720
2033
  todos: createDrizzleTodoRepository(db),
1721
2034
  };
1722
2035
  }
2036
+ `,
2037
+ productionDrizzleAuditLog: `import type { AuditLogEntry, AuditLogPort } from "@beignet/core/ports";
2038
+ import {
2039
+ normalizeAuditLogEntry,
2040
+ redactAuditLogEntry,
2041
+ } from "@beignet/core/ports";
2042
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
2043
+ import * as schema from "@/infra/db/schema";
2044
+
2045
+ function serialize(value: unknown): string | null {
2046
+ return value === undefined ? null : JSON.stringify(value);
2047
+ }
2048
+
2049
+ export function createDrizzleAuditLog(
2050
+ db: DrizzleTursoDatabase<typeof schema>,
2051
+ ): AuditLogPort {
2052
+ return {
2053
+ async record(input) {
2054
+ const entry: AuditLogEntry = redactAuditLogEntry(
2055
+ normalizeAuditLogEntry(input),
2056
+ );
2057
+
2058
+ await db.insert(schema.auditLog).values({
2059
+ id: crypto.randomUUID(),
2060
+ action: entry.action,
2061
+ actorType: entry.actor.type,
2062
+ actorId: entry.actor.id ?? null,
2063
+ actorDisplayName: entry.actor.displayName ?? null,
2064
+ tenantId: entry.tenant?.id ?? null,
2065
+ tenantSlug: entry.tenant?.slug ?? null,
2066
+ resourceType: entry.resource?.type ?? null,
2067
+ resourceId: entry.resource?.id ?? null,
2068
+ resourceName: entry.resource?.name ?? null,
2069
+ outcome: entry.outcome,
2070
+ requestId: entry.requestId ?? null,
2071
+ traceId: entry.traceId ?? null,
2072
+ message: entry.message ?? null,
2073
+ metadata: serialize(entry.metadata),
2074
+ actorMetadata: serialize(entry.actor.metadata),
2075
+ tenantMetadata: serialize(entry.tenant?.metadata),
2076
+ resourceMetadata: serialize(entry.resource?.metadata),
2077
+ occurredAt: entry.occurredAt.toISOString(),
2078
+ });
2079
+ },
2080
+ };
2081
+ }
2082
+ `,
2083
+ productionDbBootstrap: `import { createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
2084
+ import type { Client } from "@libsql/client";
2085
+
2086
+ type BootstrapOptions = {
2087
+ seed?: boolean;
2088
+ };
2089
+
2090
+ const setupStatements = [
2091
+ \`CREATE TABLE IF NOT EXISTS user (
2092
+ id text PRIMARY KEY NOT NULL,
2093
+ name text NOT NULL,
2094
+ email text NOT NULL UNIQUE,
2095
+ email_verified integer DEFAULT false NOT NULL,
2096
+ image text,
2097
+ created_at integer NOT NULL,
2098
+ updated_at integer NOT NULL
2099
+ )\`,
2100
+ \`CREATE TABLE IF NOT EXISTS session (
2101
+ id text PRIMARY KEY NOT NULL,
2102
+ expires_at integer NOT NULL,
2103
+ token text NOT NULL UNIQUE,
2104
+ created_at integer NOT NULL,
2105
+ updated_at integer NOT NULL,
2106
+ ip_address text,
2107
+ user_agent text,
2108
+ user_id text NOT NULL REFERENCES user(id) ON DELETE cascade
2109
+ )\`,
2110
+ \`CREATE TABLE IF NOT EXISTS account (
2111
+ id text PRIMARY KEY NOT NULL,
2112
+ account_id text NOT NULL,
2113
+ provider_id text NOT NULL,
2114
+ user_id text NOT NULL REFERENCES user(id) ON DELETE cascade,
2115
+ access_token text,
2116
+ refresh_token text,
2117
+ id_token text,
2118
+ access_token_expires_at integer,
2119
+ refresh_token_expires_at integer,
2120
+ scope text,
2121
+ password text,
2122
+ created_at integer NOT NULL,
2123
+ updated_at integer NOT NULL
2124
+ )\`,
2125
+ \`CREATE TABLE IF NOT EXISTS verification (
2126
+ id text PRIMARY KEY NOT NULL,
2127
+ identifier text NOT NULL,
2128
+ value text NOT NULL,
2129
+ expires_at integer NOT NULL,
2130
+ created_at integer,
2131
+ updated_at integer
2132
+ )\`,
2133
+ \`CREATE TABLE IF NOT EXISTS todos (
2134
+ id text PRIMARY KEY NOT NULL,
2135
+ title text NOT NULL,
2136
+ completed integer DEFAULT false NOT NULL,
2137
+ created_at text NOT NULL
2138
+ )\`,
2139
+ \`CREATE TABLE IF NOT EXISTS todo_attachments (
2140
+ id text PRIMARY KEY NOT NULL,
2141
+ todo_id text NOT NULL REFERENCES todos(id) ON DELETE cascade,
2142
+ key text NOT NULL,
2143
+ file_name text NOT NULL,
2144
+ content_type text NOT NULL,
2145
+ size integer NOT NULL,
2146
+ created_at text NOT NULL
2147
+ )\`,
2148
+ "CREATE INDEX IF NOT EXISTS todo_attachments_todo_idx ON todo_attachments (todo_id, created_at)",
2149
+ "CREATE INDEX IF NOT EXISTS todo_attachments_key_idx ON todo_attachments (key)",
2150
+ \`CREATE TABLE IF NOT EXISTS audit_log (
2151
+ id text PRIMARY KEY NOT NULL,
2152
+ action text NOT NULL,
2153
+ actor_type text NOT NULL,
2154
+ actor_id text,
2155
+ actor_display_name text,
2156
+ tenant_id text,
2157
+ tenant_slug text,
2158
+ resource_type text,
2159
+ resource_id text,
2160
+ resource_name text,
2161
+ outcome text DEFAULT 'success' NOT NULL,
2162
+ request_id text,
2163
+ trace_id text,
2164
+ message text,
2165
+ metadata text,
2166
+ actor_metadata text,
2167
+ tenant_metadata text,
2168
+ resource_metadata text,
2169
+ occurred_at text NOT NULL
2170
+ )\`,
2171
+ "CREATE INDEX IF NOT EXISTS audit_log_action_idx ON audit_log (action)",
2172
+ "CREATE INDEX IF NOT EXISTS audit_log_actor_idx ON audit_log (actor_type, actor_id)",
2173
+ "CREATE INDEX IF NOT EXISTS audit_log_occurred_at_idx ON audit_log (occurred_at)",
2174
+ "CREATE INDEX IF NOT EXISTS audit_log_request_idx ON audit_log (request_id)",
2175
+ "CREATE INDEX IF NOT EXISTS audit_log_resource_idx ON audit_log (resource_type, resource_id)",
2176
+ "CREATE INDEX IF NOT EXISTS audit_log_tenant_idx ON audit_log (tenant_id)",
2177
+ ...createDrizzleTursoOutboxSetupStatements(),
2178
+ ];
2179
+
2180
+ const seedTodos = [
2181
+ {
2182
+ id: "00000000-0000-4000-8000-000000000001",
2183
+ title: "Review the starter boundaries",
2184
+ completed: 0,
2185
+ createdAt: "2026-01-01T00:00:00.000Z",
2186
+ },
2187
+ {
2188
+ id: "00000000-0000-4000-8000-000000000002",
2189
+ title: "Create your first feature",
2190
+ completed: 0,
2191
+ createdAt: "2026-01-01T00:05:00.000Z",
2192
+ },
2193
+ ] as const;
2194
+
2195
+ const resetTables = [
2196
+ "verification",
2197
+ "account",
2198
+ "session",
2199
+ "user",
2200
+ "todo_attachments",
2201
+ "todos",
2202
+ "audit_log",
2203
+ "outbox_messages",
2204
+ ] as const;
2205
+
2206
+ export async function ensureStarterDatabase(
2207
+ client: Client,
2208
+ options: BootstrapOptions = {},
2209
+ ): Promise<void> {
2210
+ for (const statement of setupStatements) {
2211
+ await client.execute(statement);
2212
+ }
2213
+
2214
+ if (options.seed === false) return;
2215
+
2216
+ const result = await client.execute("SELECT count(*) as total FROM todos");
2217
+ const total = Number(result.rows[0]?.total ?? 0);
2218
+ if (total > 0) return;
2219
+
2220
+ for (const todo of seedTodos) {
2221
+ await client.execute({
2222
+ sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
2223
+ args: [todo.id, todo.title, todo.completed, todo.createdAt],
2224
+ });
2225
+ }
2226
+ }
2227
+
2228
+ export async function resetStarterDatabase(client: Client): Promise<void> {
2229
+ await client.execute("PRAGMA foreign_keys = OFF");
2230
+ try {
2231
+ for (const table of resetTables) {
2232
+ await client.execute(\`DROP TABLE IF EXISTS \${table}\`);
2233
+ }
2234
+ } finally {
2235
+ await client.execute("PRAGMA foreign_keys = ON");
2236
+ }
2237
+ await ensureStarterDatabase(client, { seed: false });
2238
+ }
2239
+ `,
2240
+ productionDbProvider: `import { registerListeners } from "@beignet/core/events";
2241
+ import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
2242
+ import { createInlineJobDispatcher } from "@beignet/core/jobs";
2243
+ import { createMemoryMailer } from "@beignet/core/mail";
2244
+ import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
2245
+ import {
2246
+ createOutboxEventRecorder,
2247
+ createOutboxJobDispatcher,
2248
+ } from "@beignet/core/outbox";
2249
+ import {
2250
+ createSystemActor,
2251
+ createTenant,
2252
+ } from "@beignet/core/ports";
2253
+ import {
2254
+ createProvider,
2255
+ createProviderInstrumentation,
2256
+ } from "@beignet/core/providers";
2257
+ import {
2258
+ createDevtoolsAuditLog,
2259
+ createDevtoolsTraceContext,
2260
+ type DevtoolsPort,
2261
+ } from "@beignet/devtools";
2262
+ import {
2263
+ createDrizzleTursoOutboxPort,
2264
+ createDrizzleTursoUnitOfWork,
2265
+ type DbPort,
2266
+ } from "@beignet/provider-drizzle-turso";
2267
+ import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
2268
+ import type { AppContext } from "@/app-context";
2269
+ import { todoListeners } from "@/features/todos/listeners";
2270
+ import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
2271
+ import type { AppPorts } from "@/ports";
2272
+ import { ensureStarterDatabase } from "./bootstrap";
2273
+ import { createRepositories } from "./repositories";
2274
+ import type * as schema from "./schema";
2275
+
2276
+ export const starterDatabaseProvider = createProvider({
2277
+ name: "starter-database",
2278
+
2279
+ async setup({ ports }) {
2280
+ const dbPort = (ports as { db: DbPort<typeof schema> }).db;
2281
+ const devtools = (ports as { devtools?: DevtoolsPort }).devtools;
2282
+ if (!dbPort) {
2283
+ throw new Error(
2284
+ "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
2285
+ );
2286
+ }
2287
+
2288
+ let currentPorts: AppContext["ports"] | undefined;
2289
+ const createBackgroundContext = (): AppContext => {
2290
+ if (!currentPorts) {
2291
+ throw new Error("Starter background context is not ready.");
2292
+ }
2293
+
2294
+ const actor = createSystemActor("starter-background");
2295
+ const tenant = createTenant("tenant_default");
2296
+ const traceContext = createDevtoolsTraceContext();
2297
+
2298
+ return {
2299
+ actor,
2300
+ auth: null,
2301
+ gate: currentPorts.gate.bind({ actor, auth: null }),
2302
+ requestId: crypto.randomUUID(),
2303
+ ...traceContext,
2304
+ ports: currentPorts,
2305
+ tenant,
2306
+ };
2307
+ };
2308
+
2309
+ const mailInstrumentation = createProviderInstrumentation(ports, {
2310
+ providerName: "memory-mailer",
2311
+ watcher: "mail",
2312
+ });
2313
+ const eventBus = createInMemoryEventBus({
2314
+ instrumentation: ports,
2315
+ onHandlerError(error: unknown, eventName: string) {
2316
+ console.error("Event handler failed", { error, eventName });
2317
+ },
2318
+ });
2319
+ const jobs = createInlineJobDispatcher<AppContext>({
2320
+ ctx: createBackgroundContext,
2321
+ });
2322
+ const notifications = createInlineNotificationDispatcher<AppContext>({
2323
+ ctx: createBackgroundContext,
2324
+ instrumentation: ports,
2325
+ });
2326
+ const mailer = createMemoryMailer({
2327
+ defaultFrom: "Beignet Starter <noreply@example.local>",
2328
+ onSend(delivery) {
2329
+ mailInstrumentation.custom({
2330
+ name: "mail.sent",
2331
+ label: "Mail sent",
2332
+ summary: delivery.message.subject,
2333
+ details: {
2334
+ to: delivery.message.to,
2335
+ subject: delivery.message.subject,
2336
+ id: delivery.id,
2337
+ },
2338
+ });
2339
+ },
2340
+ });
2341
+ const audit = createDevtoolsAuditLog({
2342
+ audit: createDrizzleAuditLog(dbPort.db),
2343
+ devtools,
2344
+ });
2345
+ const repositories = createRepositories(dbPort.db);
2346
+ const idempotency = createMemoryIdempotencyStore();
2347
+ const outbox = createDrizzleTursoOutboxPort(dbPort.db);
2348
+ const unregisterListeners = registerListeners(eventBus, todoListeners, {
2349
+ ctx: createBackgroundContext,
2350
+ onError(error, listener) {
2351
+ console.error("Event listener failed", {
2352
+ error,
2353
+ listenerName: listener.name,
2354
+ });
2355
+ },
2356
+ });
2357
+
2358
+ return {
2359
+ ports: {
2360
+ audit,
2361
+ ...repositories,
2362
+ eventBus,
2363
+ idempotency,
2364
+ jobs,
2365
+ mailer,
2366
+ notifications,
2367
+ outbox,
2368
+ uow: createDrizzleTursoUnitOfWork({
2369
+ db: dbPort.db,
2370
+ createTransactionPorts: (tx) => {
2371
+ const txOutbox = createDrizzleTursoOutboxPort(tx);
2372
+
2373
+ return {
2374
+ audit: createDrizzleAuditLog(tx),
2375
+ ...createRepositories(tx),
2376
+ events: createOutboxEventRecorder(txOutbox),
2377
+ jobs: createOutboxJobDispatcher(txOutbox),
2378
+ outbox: txOutbox,
2379
+ };
2380
+ },
2381
+ }),
2382
+ } satisfies Partial<AppPorts>,
2383
+ async start(ctx) {
2384
+ await ensureStarterDatabase(dbPort.client);
2385
+ currentPorts = ctx.ports as AppContext["ports"];
2386
+ },
2387
+ stop() {
2388
+ unregisterListeners();
2389
+ },
2390
+ };
2391
+ },
2392
+ });
1723
2393
  `,
1724
2394
  productionContractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
1725
2395
  import { z } from "zod";
@@ -1827,6 +2497,12 @@ import { routes } from "./routes";
1827
2497
  export const server = await createNextServer({
1828
2498
  ports: appPorts,
1829
2499
  providers,
2500
+ providerConfig: {
2501
+ "drizzle-turso": {
2502
+ DB_URL: env.TURSO_DB_URL,
2503
+ DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2504
+ },
2505
+ },
1830
2506
  hooks: [
1831
2507
  createDevtoolsHooks<AppContext>({
1832
2508
  requestIdHeader: "x-request-id",
@@ -1876,19 +2552,21 @@ import {
1876
2552
  createTenant,
1877
2553
  createUserActor,
1878
2554
  } from "@beignet/core/ports";
1879
- import { createDrizzleTursoUnitOfWork } from "@beignet/provider-drizzle-turso";
1880
2555
  import type { AppContext } from "@/app-context";
1881
2556
  import { appPorts } from "@/infra/app-ports";
1882
- import * as schema from "@/infra/db/schema";
1883
- import { createRepositories } from "@/infra/db/repositories";
1884
2557
  import { env } from "@/lib/env";
1885
- import type { AppTransactionPorts } from "@/ports";
1886
2558
  import { providers } from "./providers";
1887
2559
  import { routes } from "./routes";
1888
2560
 
1889
2561
  export const server = await createNextServer({
1890
2562
  ports: appPorts,
1891
2563
  providers,
2564
+ providerConfig: {
2565
+ "drizzle-turso": {
2566
+ DB_URL: env.TURSO_DB_URL,
2567
+ DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2568
+ },
2569
+ },
1892
2570
  hooks: [
1893
2571
  createDevtoolsHooks<AppContext>({
1894
2572
  requestIdHeader: "x-request-id",
@@ -1897,7 +2575,6 @@ export const server = await createNextServer({
1897
2575
  createContext: async ({ req, ports }) => {
1898
2576
  const auth = await ports.auth.getSession(req);
1899
2577
  const tenantId = req.headers.get("x-tenant-id") || undefined;
1900
- const repositories = createRepositories(ports.db.db);
1901
2578
 
1902
2579
  const context = {
1903
2580
  requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
@@ -1905,16 +2582,7 @@ export const server = await createNextServer({
1905
2582
  ? createUserActor(auth.user.id, { displayName: auth.user.name })
1906
2583
  : createAnonymousActor(),
1907
2584
  auth,
1908
- ports: {
1909
- ...ports,
1910
- ...repositories,
1911
- uow: createDrizzleTursoUnitOfWork<typeof schema, AppTransactionPorts>({
1912
- db: ports.db.db,
1913
- createTransactionPorts: (tx) => ({
1914
- ...createRepositories(tx),
1915
- }),
1916
- }),
1917
- },
2585
+ ports,
1918
2586
  tenant: tenantId ? createTenant(tenantId) : undefined,
1919
2587
  };
1920
2588
 
@@ -1990,12 +2658,492 @@ export const GET = createOpenAPIHandler(server.contracts, {
1990
2658
  title: "Beignet starter API",
1991
2659
  version: "0.1.0",
1992
2660
  });
2661
+ `,
2662
+ productionAuthRoute: `import { toNextJsHandler } from "better-auth/next-js";
2663
+ import { auth } from "@/lib/better-auth";
2664
+
2665
+ export const { GET, POST } = toNextJsHandler(auth);
2666
+ `,
2667
+ productionBetterAuth: `import { createClient } from "@libsql/client";
2668
+ import { betterAuth } from "better-auth";
2669
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
2670
+ import { drizzle } from "drizzle-orm/libsql";
2671
+ import * as schema from "@/infra/db/schema";
2672
+ import { ensureStarterDatabase } from "@/infra/db/bootstrap";
2673
+ import { env } from "@/lib/env";
2674
+
2675
+ const client = createClient({
2676
+ url: env.TURSO_DB_URL,
2677
+ authToken: env.TURSO_DB_AUTH_TOKEN,
2678
+ });
2679
+
2680
+ await ensureStarterDatabase(client);
2681
+
2682
+ const db = drizzle(client, { schema });
2683
+
2684
+ const trustedOrigins = [
2685
+ env.APP_URL,
2686
+ env.BETTER_AUTH_URL,
2687
+ ...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",")
2688
+ .map((origin) => origin.trim())
2689
+ .filter(Boolean) ?? []),
2690
+ ];
2691
+
2692
+ export const auth = betterAuth({
2693
+ baseURL: env.BETTER_AUTH_URL,
2694
+ secret: env.BETTER_AUTH_SECRET,
2695
+ trustedOrigins: [...new Set(trustedOrigins)],
2696
+ database: drizzleAdapter(db, {
2697
+ provider: "sqlite",
2698
+ schema,
2699
+ }),
2700
+ emailAndPassword: {
2701
+ enabled: true,
2702
+ },
2703
+ });
2704
+ `,
2705
+ productionDbSeed: `import { createClient } from "@libsql/client";
2706
+ import { ensureStarterDatabase } from "@/infra/db/bootstrap";
2707
+ import { env } from "@/lib/env";
2708
+
2709
+ const client = createClient({
2710
+ url: env.TURSO_DB_URL,
2711
+ authToken: env.TURSO_DB_AUTH_TOKEN,
2712
+ });
2713
+
2714
+ try {
2715
+ await ensureStarterDatabase(client, { seed: true });
2716
+ console.log("Database seeded.");
2717
+ } finally {
2718
+ client.close();
2719
+ }
2720
+ `,
2721
+ productionDbReset: `import { createClient } from "@libsql/client";
2722
+ import { resetStarterDatabase } from "@/infra/db/bootstrap";
2723
+ import { env } from "@/lib/env";
2724
+
2725
+ if (!env.TURSO_DB_URL.startsWith("file:") && process.env.BEIGNET_ALLOW_DATABASE_RESET !== "true") {
2726
+ throw new Error(
2727
+ "Refusing to reset a non-local database. Set BEIGNET_ALLOW_DATABASE_RESET=true if this is intentional.",
2728
+ );
2729
+ }
2730
+
2731
+ const client = createClient({
2732
+ url: env.TURSO_DB_URL,
2733
+ authToken: env.TURSO_DB_AUTH_TOKEN,
2734
+ });
2735
+
2736
+ try {
2737
+ await resetStarterDatabase(client);
2738
+ console.log("Database reset.");
2739
+ } finally {
2740
+ client.close();
2741
+ }
2742
+ `,
2743
+ productionAuditHelper: `import type { AuditLogEntryInput } from "@beignet/core/ports";
2744
+ import type { AppContext } from "@/app-context";
2745
+
2746
+ export function auditEntry(
2747
+ ctx: AppContext,
2748
+ entry: Omit<
2749
+ AuditLogEntryInput,
2750
+ "actor" | "tenant" | "requestId" | "traceId"
2751
+ >,
2752
+ ): AuditLogEntryInput {
2753
+ return {
2754
+ ...entry,
2755
+ actor: ctx.actor,
2756
+ tenant: ctx.tenant,
2757
+ requestId: ctx.requestId,
2758
+ traceId: ctx.traceId,
2759
+ };
2760
+ }
2761
+ `,
2762
+ productionTodoEvents: `import { defineEvent } from "@beignet/core/events";
2763
+ import { z } from "zod";
2764
+
2765
+ export const TodoCreated = defineEvent("todos.created", {
2766
+ payload: z.object({
2767
+ todoId: z.string().uuid(),
2768
+ title: z.string(),
2769
+ }),
2770
+ });
2771
+
2772
+ export const todoEvents = [TodoCreated] as const;
2773
+ `,
2774
+ productionTodoJobs: `import { createJobHandlers, retry } from "@beignet/core/jobs";
2775
+ import { z } from "zod";
2776
+ import type { AppContext } from "@/app-context";
2777
+ import { auditEntry } from "@/lib/audit";
2778
+
2779
+ const jobs = createJobHandlers<AppContext>();
2780
+
2781
+ export const LogTodoCreatedJob = jobs.defineJob("todos.log-created", {
2782
+ payload: z.object({
2783
+ todoId: z.string().uuid(),
2784
+ title: z.string(),
2785
+ }),
2786
+ retry: retry.exponential({
2787
+ attempts: 3,
2788
+ initialDelay: "1s",
2789
+ maxDelay: "1m",
2790
+ }),
2791
+ async handle({ ctx, payload }) {
2792
+ ctx.ports.logger.info("Todo created job handled", {
2793
+ todoId: payload.todoId,
2794
+ });
2795
+ await ctx.ports.audit.record(
2796
+ auditEntry(ctx, {
2797
+ action: "jobs.todos.log-created",
2798
+ resource: { type: "todo", id: payload.todoId, name: payload.title },
2799
+ metadata: { jobName: "todos.log-created" },
2800
+ }),
2801
+ );
2802
+ },
2803
+ });
2804
+
2805
+ export const todoJobs = [LogTodoCreatedJob] as const;
2806
+ `,
2807
+ productionTodoNotifications: `import {
2808
+ createNotificationHandlers,
2809
+ defineMailNotificationChannel,
2810
+ } from "@beignet/core/notifications";
2811
+ import { z } from "zod";
2812
+ import type { AppContext } from "@/app-context";
2813
+
2814
+ const notifications = createNotificationHandlers<AppContext>();
2815
+
2816
+ export const TodoCreatedNotification = notifications.defineNotification(
2817
+ "todos.created",
2818
+ {
2819
+ payload: z.object({
2820
+ todoId: z.string().uuid(),
2821
+ title: z.string(),
2822
+ }),
2823
+ channels: {
2824
+ email: defineMailNotificationChannel(({ payload }) => ({
2825
+ to: "ops@example.local",
2826
+ subject: \`Todo created: \${payload.title}\`,
2827
+ text: \`Todo \${payload.todoId} was created.\`,
2828
+ })),
2829
+ },
2830
+ },
2831
+ );
2832
+ `,
2833
+ productionTodoListeners: `import { createEventHandlers } from "@beignet/core/events";
2834
+ import type { AppContext } from "@/app-context";
2835
+ import { TodoCreated } from "@/features/todos/domain/events";
2836
+ import { LogTodoCreatedJob } from "@/features/todos/jobs";
2837
+ import { TodoCreatedNotification } from "@/features/todos/notifications";
2838
+ import { auditEntry } from "@/lib/audit";
2839
+
2840
+ const listeners = createEventHandlers<AppContext>();
2841
+
2842
+ export const enqueueTodoCreatedWork = listeners.defineListener(TodoCreated, {
2843
+ name: "todos.enqueue-created-work",
2844
+ async handle({ ctx, payload }) {
2845
+ await ctx.ports.jobs.dispatch(LogTodoCreatedJob, payload);
2846
+ await ctx.ports.notifications.send(TodoCreatedNotification, payload);
2847
+ await ctx.ports.audit.record(
2848
+ auditEntry(ctx, {
2849
+ action: "listeners.todos.enqueue-created-work",
2850
+ resource: { type: "todo", id: payload.todoId, name: payload.title },
2851
+ metadata: {
2852
+ eventName: TodoCreated.name,
2853
+ jobName: LogTodoCreatedJob.name,
2854
+ notificationName: TodoCreatedNotification.name,
2855
+ },
2856
+ }),
2857
+ );
2858
+ },
2859
+ });
2860
+
2861
+ export const todoListeners = [enqueueTodoCreatedWork] as const;
2862
+ `,
2863
+ productionTodoSchedules: `import { normalizeOffsetPage } from "@beignet/core/pagination";
2864
+ import { createScheduleHandlers } from "@beignet/core/schedules";
2865
+ import { z } from "zod";
2866
+ import type { AppContext } from "@/app-context";
2867
+ import { auditEntry } from "@/lib/audit";
2868
+
2869
+ const schedules = createScheduleHandlers<AppContext>();
2870
+
2871
+ export const LogDailyTodoSummarySchedule = schedules.defineSchedule(
2872
+ "todos.log-daily-summary",
2873
+ {
2874
+ cron: "0 9 * * *",
2875
+ timezone: "America/Chicago",
2876
+ payload: z.object({
2877
+ date: z.string(),
2878
+ }),
2879
+ createPayload({ run }) {
2880
+ const date = run.scheduledAt ?? run.triggeredAt;
2881
+ return { date: date.toISOString().slice(0, 10) };
2882
+ },
2883
+ async handle({ ctx, payload, run }) {
2884
+ const { page } = await ctx.ports.todos.list(
2885
+ normalizeOffsetPage(
2886
+ { limit: 1, offset: 0 },
2887
+ { defaultLimit: 1, maxLimit: 1 },
2888
+ ),
2889
+ );
2890
+ ctx.ports.logger.info("Daily todo summary schedule handled", {
2891
+ date: payload.date,
2892
+ todoCount: page.total,
2893
+ });
2894
+ await ctx.ports.audit.record(
2895
+ auditEntry(ctx, {
2896
+ action: "schedules.todos.daily-summary",
2897
+ resource: {
2898
+ type: "schedule",
2899
+ id: "todos.log-daily-summary",
2900
+ name: "Daily todo summary",
2901
+ },
2902
+ metadata: {
2903
+ date: payload.date,
2904
+ todoCount: page.total,
2905
+ source: run.source ?? "inline",
2906
+ triggeredAt: run.triggeredAt.toISOString(),
2907
+ },
2908
+ }),
2909
+ );
2910
+ },
2911
+ },
2912
+ );
2913
+
2914
+ export const todoSchedules = [LogDailyTodoSummarySchedule] as const;
2915
+ `,
2916
+ productionTodoUploads: `import { defineUpload, defineUploads } from "@beignet/core/uploads";
2917
+ import { z } from "zod";
2918
+ import type { AppContext } from "@/app-context";
2919
+ import { auditEntry } from "@/lib/audit";
2920
+
2921
+ const todoAttachmentMetadataSchema = z.object({
2922
+ todoId: z.string().uuid(),
2923
+ });
2924
+
2925
+ export const TodoAttachmentUpload = defineUpload<
2926
+ "todos.attachment",
2927
+ typeof todoAttachmentMetadataSchema,
2928
+ AppContext,
2929
+ { attachmentIds: string[] }
2930
+ >("todos.attachment", {
2931
+ metadata: todoAttachmentMetadataSchema,
2932
+ file: {
2933
+ contentTypes: ["application/pdf", "text/plain", "text/plain;charset=utf-8"],
2934
+ maxSizeBytes: 5 * 1024 * 1024,
2935
+ maxFiles: 3,
2936
+ visibility: "private",
2937
+ cacheControl: "private, max-age=0",
2938
+ },
2939
+ authorize({ ctx }) {
2940
+ return ctx.actor.type === "user"
2941
+ ? true
2942
+ : {
2943
+ allowed: false,
2944
+ reason: "You must be signed in to upload todo attachments.",
2945
+ };
2946
+ },
2947
+ key({ ctx, metadata, file, uploadId }) {
2948
+ const tenantId = ctx.tenant?.id ?? "tenant_default";
2949
+ const extension = file.name.includes(".")
2950
+ ? file.name.split(".").pop()
2951
+ : undefined;
2952
+ const suffix = extension ? \`.\${extension}\` : "";
2953
+ return \`todos/\${tenantId}/\${metadata.todoId}/attachments/\${uploadId}\${suffix}\`;
2954
+ },
2955
+ storageMetadata({ ctx, metadata }) {
2956
+ return {
2957
+ tenantId: ctx.tenant?.id ?? "tenant_default",
2958
+ todoId: metadata.todoId,
2959
+ };
2960
+ },
2961
+ async onComplete({ ctx, metadata, files }) {
2962
+ const attachments = await Promise.all(
2963
+ files.map((file) =>
2964
+ ctx.ports.todoAttachments.create({
2965
+ id: file.uploadId,
2966
+ todoId: metadata.todoId,
2967
+ key: file.key,
2968
+ fileName: file.name,
2969
+ contentType: file.contentType,
2970
+ size: file.object.size,
2971
+ }),
2972
+ ),
2973
+ );
2974
+ await ctx.ports.audit.record(
2975
+ auditEntry(ctx, {
2976
+ action: "todos.attachment.upload",
2977
+ resource: { type: "todo", id: metadata.todoId },
2978
+ metadata: {
2979
+ attachmentCount: attachments.length,
2980
+ keys: attachments.map((attachment) => attachment.key),
2981
+ },
2982
+ }),
2983
+ );
2984
+
2985
+ return {
2986
+ attachmentIds: attachments.map((attachment) => attachment.id),
2987
+ };
2988
+ },
2989
+ });
2990
+
2991
+ export const todoUploads = defineUploads({
2992
+ todoAttachment: TodoAttachmentUpload,
2993
+ });
2994
+ `,
2995
+ productionOutbox: `import { defineOutboxRegistry } from "@beignet/core/outbox";
2996
+ import { todoEvents } from "@/features/todos/domain/events";
2997
+ import { todoJobs } from "@/features/todos/jobs";
2998
+
2999
+ export const outboxRegistry = defineOutboxRegistry({
3000
+ events: todoEvents,
3001
+ jobs: todoJobs,
3002
+ });
3003
+ `,
3004
+ productionOutboxDrainRoute: `import { createOutboxDrainRoute } from "@beignet/next";
3005
+ import { env } from "@/lib/env";
3006
+ import { server } from "@/server";
3007
+ import { outboxRegistry } from "@/server/outbox";
3008
+
3009
+ export const runtime = "nodejs";
3010
+
3011
+ export const { GET, POST } = createOutboxDrainRoute({
3012
+ server,
3013
+ registry: outboxRegistry,
3014
+ secret: env.CRON_SECRET,
3015
+ });
3016
+ `,
3017
+ productionScheduleRoute: `import { createInlineScheduleRunner } from "@beignet/core/schedules";
3018
+ import type { AppContext } from "@/app-context";
3019
+ import { LogDailyTodoSummarySchedule } from "@/features/todos/schedules";
3020
+ import { env } from "@/lib/env";
3021
+ import { server } from "@/server";
3022
+
3023
+ async function runDailyTodoSummary(request: Request) {
3024
+ if (!env.CRON_SECRET) {
3025
+ return Response.json(
3026
+ {
3027
+ ok: false,
3028
+ error: "CRON_SECRET is not configured.",
3029
+ scheduleName: LogDailyTodoSummarySchedule.name,
3030
+ },
3031
+ { status: 500 },
3032
+ );
3033
+ }
3034
+
3035
+ if (request.headers.get("authorization") !== \`Bearer \${env.CRON_SECRET}\`) {
3036
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
3037
+ }
3038
+
3039
+ const ctx = await server.createContextFromNext();
3040
+ const runner = createInlineScheduleRunner<AppContext>({
3041
+ ctx,
3042
+ onStart({ run, schedule }) {
3043
+ ctx.ports.devtools.record({
3044
+ type: "schedule",
3045
+ watcher: "schedules",
3046
+ requestId: ctx.requestId,
3047
+ traceId: ctx.traceId,
3048
+ scheduleName: schedule.name,
3049
+ status: "started",
3050
+ cron: schedule.cron,
3051
+ timezone: schedule.timezone,
3052
+ details: {
3053
+ source: run.source,
3054
+ scheduledAt: run.scheduledAt?.toISOString(),
3055
+ },
3056
+ });
3057
+ },
3058
+ onSuccess({ run, schedule }) {
3059
+ ctx.ports.devtools.record({
3060
+ type: "schedule",
3061
+ watcher: "schedules",
3062
+ requestId: ctx.requestId,
3063
+ traceId: ctx.traceId,
3064
+ scheduleName: schedule.name,
3065
+ status: "completed",
3066
+ cron: schedule.cron,
3067
+ timezone: schedule.timezone,
3068
+ details: {
3069
+ source: run.source,
3070
+ scheduledAt: run.scheduledAt?.toISOString(),
3071
+ },
3072
+ });
3073
+ },
3074
+ onError({ error, run, schedule }) {
3075
+ ctx.ports.devtools.record({
3076
+ type: "schedule",
3077
+ watcher: "schedules",
3078
+ requestId: ctx.requestId,
3079
+ traceId: ctx.traceId,
3080
+ scheduleName: schedule.name,
3081
+ status: "failed",
3082
+ cron: schedule.cron,
3083
+ timezone: schedule.timezone,
3084
+ details: {
3085
+ error,
3086
+ source: run.source,
3087
+ scheduledAt: run.scheduledAt?.toISOString(),
3088
+ },
3089
+ });
3090
+ ctx.ports.logger.error("Daily todo summary schedule failed", {
3091
+ error,
3092
+ scheduleName: schedule.name,
3093
+ });
3094
+ },
3095
+ });
3096
+
3097
+ try {
3098
+ await runner.run(LogDailyTodoSummarySchedule, {
3099
+ source: "starter-cron-route",
3100
+ });
3101
+ } catch {
3102
+ return Response.json(
3103
+ {
3104
+ ok: false,
3105
+ error: "Schedule failed",
3106
+ scheduleName: LogDailyTodoSummarySchedule.name,
3107
+ },
3108
+ { status: 500 },
3109
+ );
3110
+ }
3111
+
3112
+ return Response.json({
3113
+ ok: true,
3114
+ scheduleName: LogDailyTodoSummarySchedule.name,
3115
+ });
3116
+ }
3117
+
3118
+ export const GET = runDailyTodoSummary;
3119
+ export const POST = runDailyTodoSummary;
3120
+ `,
3121
+ productionUploadsRoute: `import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
3122
+ import { createUploadRoute } from "@beignet/next";
3123
+ import type { AppContext } from "@/app-context";
3124
+ import { todoUploads } from "@/features/todos/uploads";
3125
+ import { server } from "@/server";
3126
+
3127
+ const uploadRouter = createUploadRouter<AppContext>({
3128
+ uploads: uploadsFromRegistry(todoUploads),
3129
+ ctx: () => server.createContextFromNext(),
3130
+ storage: server.ports.storage,
3131
+ instrumentation: server.ports.devtools,
3132
+ });
3133
+
3134
+ export const { POST } = createUploadRoute(uploadRouter);
3135
+ `,
3136
+ productionUploadClient: `import { createUploadClient } from "@beignet/core/uploads/client";
3137
+ import type { todoUploads } from "@/features/todos/uploads";
3138
+
3139
+ export const uploads = createUploadClient<typeof todoUploads>({
3140
+ baseUrl: "/api/uploads",
3141
+ });
1993
3142
  `,
1994
3143
  };
1995
3144
 
1996
3145
  function productionProviderPortImports(ctx: TemplateContext): string[] {
1997
3146
  const imports: string[] = [];
1998
- if (hasIntegration(ctx, "inngest")) imports.push("\tJobDispatcherPort,");
1999
3147
  if (hasIntegration(ctx, "upstash-rate-limit")) {
2000
3148
  imports.push("\tRateLimitPort,");
2001
3149
  }
@@ -2004,8 +3152,6 @@ function productionProviderPortImports(ctx: TemplateContext): string[] {
2004
3152
 
2005
3153
  function productionProviderPortFields(ctx: TemplateContext): string[] {
2006
3154
  const fields: string[] = [];
2007
- if (hasIntegration(ctx, "inngest")) fields.push("\tjobs: JobDispatcherPort;");
2008
- if (hasIntegration(ctx, "resend")) fields.push("\tmailer: MailerPort;");
2009
3155
  if (hasIntegration(ctx, "upstash-rate-limit")) {
2010
3156
  fields.push("\trateLimit: RateLimitPort;");
2011
3157
  }
@@ -2013,34 +3159,53 @@ function productionProviderPortFields(ctx: TemplateContext): string[] {
2013
3159
  }
2014
3160
 
2015
3161
  function productionPorts(ctx: TemplateContext): string {
2016
- const mailImport = hasIntegration(ctx, "resend")
2017
- ? 'import type { MailerPort } from "@beignet/core/mail";\n'
2018
- : "";
3162
+ const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
2019
3163
  const imports = [
3164
+ "\tAuditLogPort,",
2020
3165
  "\tBoundGate,",
3166
+ "\tEventBusPort,",
2021
3167
  "\tGatePort,",
3168
+ "\tJobDispatcherPort,",
2022
3169
  "\tLoggerPort,",
2023
3170
  ...productionProviderPortImports(ctx),
3171
+ "\tStoragePort,",
2024
3172
  "\tUnitOfWorkPort,",
2025
3173
  ];
2026
3174
  const providerFields = productionProviderPortFields(ctx);
2027
3175
 
2028
- return `import type {
3176
+ return `import type { IdempotencyPort } from "@beignet/core/idempotency";
3177
+ ${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
3178
+ import type { OutboxPort } from "@beignet/core/outbox";
3179
+ import type {
2029
3180
  ${imports.join("\n")}
2030
3181
  } from "@beignet/core/ports";
2031
- ${mailImport}import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
3182
+ import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
2032
3183
  import type { AuthPort } from "./auth";
2033
- import type { TodoRepository } from "@/features/todos/ports";
3184
+ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
2034
3185
 
2035
3186
  export type AppTransactionPorts = {
3187
+ audit: AuditLogPort;
3188
+ events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3189
+ jobs: JobDispatcherPort;
3190
+ outbox: OutboxPort;
3191
+ todoAttachments: TodoAttachmentRepository;
2036
3192
  todos: TodoRepository;
2037
3193
  };
2038
3194
 
2039
3195
  export type AppGate = BoundGate<[typeof todoPolicy]>;
2040
3196
 
2041
3197
  export type AppPorts = {
3198
+ audit: AuditLogPort;
2042
3199
  auth: AuthPort;
3200
+ eventBus: EventBusPort;
2043
3201
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
3202
+ idempotency: IdempotencyPort;
3203
+ jobs: JobDispatcherPort;
3204
+ mailer: MailerPort;
3205
+ notifications: NotificationPort;
3206
+ outbox: OutboxPort;
3207
+ storage: StoragePort;
3208
+ todoAttachments: TodoAttachmentRepository;
2044
3209
  todos: TodoRepository;
2045
3210
  logger: LoggerPort;
2046
3211
  ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
@@ -2049,37 +3214,59 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
2049
3214
  }
2050
3215
 
2051
3216
  function productionPortsWithDrizzleTurso(ctx: TemplateContext): string {
2052
- const mailImport = hasIntegration(ctx, "resend")
2053
- ? 'import type { MailerPort } from "@beignet/core/mail";\n'
2054
- : "";
3217
+ const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
2055
3218
  const imports = [
3219
+ "\tAuditLogPort,",
2056
3220
  "\tBoundGate,",
3221
+ "\tEventBusPort,",
2057
3222
  "\tGatePort,",
3223
+ "\tJobDispatcherPort,",
2058
3224
  "\tLoggerPort,",
2059
3225
  ...productionProviderPortImports(ctx),
3226
+ "\tStoragePort,",
2060
3227
  "\tUnitOfWorkPort,",
2061
3228
  ];
2062
3229
  const providerFields = productionProviderPortFields(ctx);
2063
3230
 
2064
- return `import type {
3231
+ return `import type { IdempotencyPort } from "@beignet/core/idempotency";
3232
+ ${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
3233
+ import type { OutboxPort } from "@beignet/core/outbox";
3234
+ import type {
2065
3235
  ${imports.join("\n")}
2066
3236
  } from "@beignet/core/ports";
2067
- ${mailImport}import type { DbPort } from "@beignet/provider-drizzle-turso";
2068
- import * as schema from "@/infra/db/schema";
2069
3237
  import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
2070
3238
  import type { AuthPort } from "./auth";
2071
- import type { TodoRepository } from "@/features/todos/ports";
3239
+ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
3240
+
3241
+ export type AppDatabasePort = {
3242
+ db: unknown;
3243
+ client: unknown;
3244
+ };
2072
3245
 
2073
3246
  export type AppTransactionPorts = {
3247
+ audit: AuditLogPort;
3248
+ events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3249
+ jobs: JobDispatcherPort;
3250
+ outbox: OutboxPort;
3251
+ todoAttachments: TodoAttachmentRepository;
2074
3252
  todos: TodoRepository;
2075
3253
  };
2076
3254
 
2077
3255
  export type AppGate = BoundGate<[typeof todoPolicy]>;
2078
3256
 
2079
3257
  export type AppPorts = {
3258
+ audit: AuditLogPort;
2080
3259
  auth: AuthPort;
2081
- db: DbPort<typeof schema>;
3260
+ db: AppDatabasePort;
3261
+ eventBus: EventBusPort;
2082
3262
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
3263
+ idempotency: IdempotencyPort;
3264
+ jobs: JobDispatcherPort;
3265
+ mailer: MailerPort;
3266
+ notifications: NotificationPort;
3267
+ outbox: OutboxPort;
3268
+ storage: StoragePort;
3269
+ todoAttachments: TodoAttachmentRepository;
2083
3270
  todos: TodoRepository;
2084
3271
  logger: LoggerPort;
2085
3272
  ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
@@ -2090,6 +3277,9 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
2090
3277
  function productionServerProviders(ctx: TemplateContext): string {
2091
3278
  const imports = [
2092
3279
  'import { createDevtoolsProvider } from "@beignet/devtools";',
3280
+ hasIntegration(ctx, "better-auth")
3281
+ ? 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";'
3282
+ : undefined,
2093
3283
  hasDrizzleTurso(ctx)
2094
3284
  ? 'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";'
2095
3285
  : undefined,
@@ -2107,6 +3297,13 @@ function productionServerProviders(ctx: TemplateContext): string {
2107
3297
  hasDrizzleTurso(ctx)
2108
3298
  ? 'import * as schema from "@/infra/db/schema";'
2109
3299
  : undefined,
3300
+ hasIntegration(ctx, "better-auth")
3301
+ ? 'import { auth } from "@/lib/better-auth";'
3302
+ : undefined,
3303
+ 'import { starterDatabaseProvider } from "@/infra/db/provider";',
3304
+ hasIntegration(ctx, "better-auth")
3305
+ ? 'import type { AuthSessionMetadata, AuthUser } from "@/ports/auth";'
3306
+ : undefined,
2110
3307
  ].filter((line): line is string => Boolean(line));
2111
3308
 
2112
3309
  const declarations = hasDrizzleTurso(ctx)
@@ -2114,9 +3311,13 @@ function productionServerProviders(ctx: TemplateContext): string {
2114
3311
  : "";
2115
3312
  const entries = [
2116
3313
  "\tcreateDevtoolsProvider(),",
3314
+ hasIntegration(ctx, "better-auth")
3315
+ ? "\tcreateAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth),"
3316
+ : undefined,
2117
3317
  "\tlocalStorageProvider,",
2118
3318
  "\tloggerPinoProvider,",
2119
3319
  hasDrizzleTurso(ctx) ? "\tdrizzleTursoProvider," : undefined,
3320
+ "\tstarterDatabaseProvider,",
2120
3321
  hasIntegration(ctx, "inngest") ? "\tinngestProvider," : undefined,
2121
3322
  hasIntegration(ctx, "resend") ? "\tmailResendProvider," : undefined,
2122
3323
  hasIntegration(ctx, "upstash-rate-limit")
@@ -2145,12 +3346,6 @@ function productionTodosTest(ctx: TemplateContext): string {
2145
3346
  hasDrizzleTurso(ctx)
2146
3347
  ? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
2147
3348
  : undefined,
2148
- hasIntegration(ctx, "inngest")
2149
- ? "\t\t\tjobs: { dispatch: async () => undefined },"
2150
- : undefined,
2151
- hasIntegration(ctx, "resend")
2152
- ? '\t\t\tmailer: { send: async () => ({ provider: "test" }) },'
2153
- : undefined,
2154
3349
  hasIntegration(ctx, "upstash-rate-limit")
2155
3350
  ? "\t\t\trateLimit: createMemoryRateLimiter(),"
2156
3351
  : undefined,
@@ -2158,8 +3353,14 @@ function productionTodosTest(ctx: TemplateContext): string {
2158
3353
 
2159
3354
  return `import { describe, expect, it } from "bun:test";
2160
3355
  import { createUseCaseTester } from "@beignet/core/application";
3356
+ import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
3357
+ import { createMemoryMailer } from "@beignet/core/mail";
3358
+ import { createMemoryNotificationPort } from "@beignet/core/notifications";
3359
+ import { createMemoryOutbox } from "@beignet/core/outbox";
3360
+ import { offsetPageResult, type OffsetPage } from "@beignet/core/pagination";
2161
3361
  import { createInMemoryDevtools } from "@beignet/devtools";
2162
- import { ${portImports.join(", ")} } from "@beignet/core/ports";
3362
+ import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
3363
+ import { ${["createDomainEventRecorder", "createMemoryAuditLog", ...portImports].join(", ")} } from "@beignet/core/ports";
2163
3364
  import type { AppContext } from "@/app-context";
2164
3365
  import { appPorts } from "@/infra/app-ports";
2165
3366
  import {
@@ -2167,7 +3368,6 @@ import {
2167
3368
  getTodoUseCase,
2168
3369
  listTodosUseCase,
2169
3370
  type CreateTodoInput,
2170
- type ListTodosInput,
2171
3371
  type Todo,
2172
3372
  } from "../use-cases";
2173
3373
 
@@ -2175,15 +3375,16 @@ function createTestTodoRepository() {
2175
3375
  const todos = new Map<string, Todo>();
2176
3376
 
2177
3377
  return {
2178
- async list(input: ListTodosInput) {
3378
+ async list(page: OffsetPage) {
2179
3379
  const allTodos = Array.from(todos.values()).sort((left, right) =>
2180
3380
  left.createdAt.localeCompare(right.createdAt),
2181
3381
  );
2182
3382
 
2183
- return {
2184
- todos: allTodos.slice(input.offset, input.offset + input.limit),
2185
- total: allTodos.length,
2186
- };
3383
+ return offsetPageResult(
3384
+ allTodos.slice(page.offset, page.offset + page.limit),
3385
+ page,
3386
+ allTodos.length,
3387
+ );
2187
3388
  },
2188
3389
  async findById(id: string) {
2189
3390
  return todos.get(id) ?? null;
@@ -2201,9 +3402,39 @@ function createTestTodoRepository() {
2201
3402
  };
2202
3403
  }
2203
3404
 
3405
+ function createTestTodoAttachmentRepository() {
3406
+ return {
3407
+ async create(input: {
3408
+ id: string;
3409
+ todoId: string;
3410
+ key: string;
3411
+ fileName: string;
3412
+ contentType: string;
3413
+ size: number;
3414
+ }) {
3415
+ return {
3416
+ ...input,
3417
+ createdAt: new Date().toISOString(),
3418
+ };
3419
+ },
3420
+ async findMany() {
3421
+ return [];
3422
+ },
3423
+ };
3424
+ }
3425
+
2204
3426
  describe("todos resource", () => {
2205
3427
  it("creates, gets, and lists todos", async () => {
2206
3428
  const todos = createTestTodoRepository();
3429
+ const todoAttachments = createTestTodoAttachmentRepository();
3430
+ const audit = createMemoryAuditLog();
3431
+ const eventBus = createInMemoryEventBus();
3432
+ const jobs = { dispatch: async () => undefined };
3433
+ const mailer = createMemoryMailer({
3434
+ defaultFrom: "Test <test@example.local>",
3435
+ });
3436
+ const notifications = createMemoryNotificationPort();
3437
+ const outbox = createMemoryOutbox();
2207
3438
  const auth = {
2208
3439
  user: {
2209
3440
  id: "user_test",
@@ -2217,11 +3448,26 @@ describe("todos resource", () => {
2217
3448
  });
2218
3449
  const testPorts = {
2219
3450
  ...appPorts,
3451
+ audit,
3452
+ eventBus,
3453
+ idempotency: createMemoryIdempotencyStore(),
3454
+ jobs,
3455
+ mailer,
3456
+ notifications,
3457
+ outbox,
3458
+ todoAttachments,
2220
3459
  todos,
2221
- ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({ todos })) as AppContext["ports"]["uow"],
3460
+ ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({
3461
+ audit,
3462
+ events: createDomainEventRecorder(),
3463
+ jobs,
3464
+ outbox,
3465
+ todoAttachments,
3466
+ todos,
3467
+ })) as unknown as AppContext["ports"]["uow"],
2222
3468
  devtools: createInMemoryDevtools(),
2223
3469
  storage: createMemoryStorage(),
2224
- };
3470
+ } as unknown as AppContext["ports"];
2225
3471
  const tester = createUseCaseTester<AppContext>(() => ({
2226
3472
  requestId: "test-request",
2227
3473
  actor,
@@ -2245,8 +3491,8 @@ ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUn
2245
3491
 
2246
3492
  expect(created.title).toBe("First todo");
2247
3493
  expect(found.id).toBe(created.id);
2248
- expect(result.total).toBe(1);
2249
- expect(result.todos).toEqual([created]);
3494
+ expect(result.page.total).toBe(1);
3495
+ expect(result.items).toEqual([created]);
2250
3496
  });
2251
3497
  });
2252
3498
  `;
@@ -2284,8 +3530,25 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2284
3530
  path: "app/api/openapi/route.ts",
2285
3531
  content: files.productionOpenApiRoute,
2286
3532
  },
3533
+ {
3534
+ path: "app/api/auth/[...all]/route.ts",
3535
+ content: files.productionAuthRoute,
3536
+ },
3537
+ {
3538
+ path: "app/api/cron/outbox/drain/route.ts",
3539
+ content: files.productionOutboxDrainRoute,
3540
+ },
3541
+ {
3542
+ path: "app/api/cron/todos/daily-summary/route.ts",
3543
+ content: files.productionScheduleRoute,
3544
+ },
3545
+ {
3546
+ path: "app/api/uploads/[uploadName]/[action]/route.ts",
3547
+ content: files.productionUploadsRoute,
3548
+ },
2287
3549
  { path: "client/api-client.ts", content: files.apiClient },
2288
3550
  { path: "client/rq.ts", content: files.rq },
3551
+ { path: "client/uploads.ts", content: files.productionUploadClient },
2289
3552
  {
2290
3553
  path: "features/todos/contracts.ts",
2291
3554
  content: files.productionContractsTodos,
@@ -2300,12 +3563,39 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2300
3563
  : files.productionInfrastructurePorts,
2301
3564
  },
2302
3565
  { path: "lib/env.ts", content: files.productionEnv },
3566
+ { path: "lib/audit.ts", content: files.productionAuditHelper },
2303
3567
  { path: "lib/auth.ts", content: files.productionAuthHelpers },
3568
+ { path: "lib/better-auth.ts", content: files.productionBetterAuth },
2304
3569
  { path: "features/todos/policy.ts", content: files.productionTodoPolicy },
2305
3570
  {
2306
- path: "infra/auth/anonymous-auth.ts",
2307
- content: files.productionAnonymousAuth,
3571
+ path: "features/todos/domain/events/index.ts",
3572
+ content: files.productionTodoEvents,
3573
+ },
3574
+ { path: "features/todos/jobs/index.ts", content: files.productionTodoJobs },
3575
+ {
3576
+ path: "features/todos/listeners/index.ts",
3577
+ content: files.productionTodoListeners,
3578
+ },
3579
+ {
3580
+ path: "features/todos/notifications/index.ts",
3581
+ content: files.productionTodoNotifications,
3582
+ },
3583
+ {
3584
+ path: "features/todos/schedules/index.ts",
3585
+ content: files.productionTodoSchedules,
2308
3586
  },
3587
+ {
3588
+ path: "features/todos/uploads/index.ts",
3589
+ content: files.productionTodoUploads,
3590
+ },
3591
+ ...(!hasIntegration(ctx, "better-auth")
3592
+ ? [
3593
+ {
3594
+ path: "infra/auth/anonymous-auth.ts",
3595
+ content: files.productionAnonymousAuth,
3596
+ },
3597
+ ]
3598
+ : []),
2309
3599
  { path: "ports/auth.ts", content: files.productionAuthPort },
2310
3600
  {
2311
3601
  path: "ports/index.ts",
@@ -2328,6 +3618,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2328
3618
  : files.productionServer,
2329
3619
  },
2330
3620
  { path: "server/routes.ts", content: files.productionServerRoutes },
3621
+ { path: "server/outbox.ts", content: files.productionOutbox },
2331
3622
  {
2332
3623
  path: "server/providers.ts",
2333
3624
  content: productionServerProviders(ctx),
@@ -2363,6 +3654,26 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2363
3654
  if (usesDrizzleTurso) {
2364
3655
  templateFiles.push(
2365
3656
  { path: "drizzle.config.ts", content: files.productionDrizzleConfig },
3657
+ {
3658
+ path: "infra/audit/drizzle-audit-log.ts",
3659
+ content: files.productionDrizzleAuditLog,
3660
+ },
3661
+ {
3662
+ path: "infra/db/bootstrap.ts",
3663
+ content: files.productionDbBootstrap,
3664
+ },
3665
+ {
3666
+ path: "infra/db/seed.ts",
3667
+ content: files.productionDbSeed,
3668
+ },
3669
+ {
3670
+ path: "infra/db/reset.ts",
3671
+ content: files.productionDbReset,
3672
+ },
3673
+ {
3674
+ path: "infra/db/provider.ts",
3675
+ content: files.productionDbProvider,
3676
+ },
2366
3677
  {
2367
3678
  path: "infra/db/schema/index.ts",
2368
3679
  content: files.productionDbSchema,
@@ -2386,6 +3697,9 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2386
3697
  return templateFiles;
2387
3698
  }
2388
3699
 
3700
+ /**
3701
+ * Render all template files for a new Beignet app.
3702
+ */
2389
3703
  export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
2390
3704
  if (isStandardPreset(ctx)) {
2391
3705
  return getProductionTemplateFiles(ctx);