@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/dist/templates.js CHANGED
@@ -39,16 +39,25 @@ function packageRunner(ctx) {
39
39
  return "bunx -p @beignet/cli beignet";
40
40
  }
41
41
  }
42
+ /**
43
+ * Supported scaffold feature choices.
44
+ */
42
45
  export const featureChoices = [
43
46
  "client",
44
47
  "react-query",
45
48
  "forms",
46
49
  "openapi",
47
50
  ];
51
+ /**
52
+ * Supported scaffold preset choices.
53
+ */
48
54
  export const presetChoices = [
49
55
  "minimal",
50
56
  "standard",
51
57
  ];
58
+ /**
59
+ * Supported scaffold integration choices.
60
+ */
52
61
  export const integrationChoices = [
53
62
  "better-auth",
54
63
  "drizzle-turso",
@@ -135,6 +144,7 @@ function packageJson(ctx) {
135
144
  };
136
145
  if (isStandardPreset(ctx)) {
137
146
  dependencies["@beignet/devtools"] = ctx.beignetVersion;
147
+ dependencies["@beignet/provider-event-bus-memory"] = ctx.beignetVersion;
138
148
  dependencies["@beignet/provider-storage-local"] = ctx.beignetVersion;
139
149
  }
140
150
  for (const integration of ctx.integrations) {
@@ -169,6 +179,8 @@ function packageJson(ctx) {
169
179
  if (hasDrizzleTurso(ctx)) {
170
180
  scripts["db:generate"] = "drizzle-kit generate";
171
181
  scripts["db:migrate"] = "drizzle-kit migrate";
182
+ scripts["db:seed"] = "bun infra/db/seed.ts";
183
+ scripts["db:reset"] = "bun infra/db/reset.ts";
172
184
  }
173
185
  return json({
174
186
  name: ctx.name,
@@ -242,7 +254,8 @@ function standardReadme(ctx) {
242
254
  const beforeDeploy = hasDrizzleTurso(ctx)
243
255
  ? [
244
256
  "- 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.`,
257
+ `- Run \`${cli} db generate\` and \`${cli} db migrate\` after changing the Drizzle schema.`,
258
+ `- Run \`${cli} db seed\` to load starter data or \`${cli} db reset\` to rebuild a local SQLite database.`,
246
259
  ].join("\n")
247
260
  : "- Replace the in-memory todo repository with a durable adapter.";
248
261
  return `# ${ctx.name}
@@ -279,10 +292,14 @@ ${typecheck}
279
292
  - \`infra/\` implements ports for the selected runtime.
280
293
  - \`server/routes.ts\` keeps the central route registry and OpenAPI contract list.
281
294
  - \`server/\` wires context, providers, routes, hooks, and error handling.
295
+ - \`server/outbox.ts\` registers durable events and jobs for the outbox drain route.
282
296
  - \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
297
+ - \`features/todos/domain/events/\`, \`jobs/\`, \`listeners/\`, \`notifications/\`, \`schedules/\`, and \`uploads/\` show the feature-owned background workflow shape.
283
298
  - \`app/storage/[...key]/route.ts\` serves public local storage objects.
299
+ - \`app/api/cron/outbox/drain/route.ts\` is the bounded serverless entrypoint for durable event/job delivery.
284
300
  - \`lib/env.ts\` validates deployment configuration at startup.
285
301
  - \`lib/auth.ts\` exposes \`requireUser(ctx)\` for protected use cases.
302
+ - \`lib/better-auth.ts\` owns Better Auth setup and keeps provider-specific auth details outside use cases.
286
303
  - \`features/todos/policy.ts\` defines authorization rules registered with \`createGate(...)\`.
287
304
  ${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
305
 
@@ -290,8 +307,9 @@ ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `inf
290
307
 
291
308
  ${beforeDeploy}
292
309
  - 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.
310
+ - Set \`APP_URL\`, \`BETTER_AUTH_SECRET\`, \`CRON_SECRET\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
311
+ - Configure a scheduled request to \`/api/cron/outbox/drain\` with \`Authorization: Bearer <CRON_SECRET>\`.
312
+ - Review the starter authorization policy before exposing user-owned data.
295
313
  ${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
296
314
  `;
297
315
  }
@@ -304,10 +322,10 @@ function integrationsDoc(ctx) {
304
322
  ];
305
323
  for (const integration of ctx.integrations) {
306
324
  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.", "");
325
+ lines.push("## Better Auth", "", "- Package: `@beignet/provider-auth-better-auth`", "- Peer dependency: `better-auth`", "- The standard starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.", "- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.", "- Put repeated ownership or role rules in policies registered with `createGate(...)`.", "- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.", "");
308
326
  }
309
327
  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\`.`, "");
328
+ lines.push("## Drizzle + Turso", "", "- Package: `@beignet/provider-drizzle-turso`", "- Peer dependencies: `@libsql/client` and `drizzle-orm`", "- Dev dependency: `drizzle-kit`", "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.", "- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.", `- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`, `- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`, "");
311
329
  }
312
330
  if (integration === "inngest") {
313
331
  lines.push("## Inngest", "", "- Package: `@beignet/core` (`@beignet/core/events`, `@beignet/core/jobs`)", "- Package: `@beignet/provider-inngest`", "- Peer dependency: `inngest`", "- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.", "- Define jobs with `createJobHandlers(...).defineJob(...)` and dispatch them through `ctx.ports.jobs.dispatch(...)`.", "- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.", "");
@@ -352,7 +370,7 @@ function envExample(ctx) {
352
370
  lines.push("UPSTASH_REDIS_REST_URL=", "UPSTASH_REDIS_REST_TOKEN=", "UPSTASH_PREFIX=ck:ratelimit", "");
353
371
  }
354
372
  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", "");
373
+ 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
374
  }
357
375
  return `${lines.join("\n")}\n`;
358
376
  }
@@ -582,14 +600,14 @@ export function TodoApp() {
582
600
  <div className="todos-panel">
583
601
  <div className="panel-heading">
584
602
  <h2>Todos</h2>
585
- <span>{todosQuery.data?.total ?? 0} total</span>
603
+ <span>{todosQuery.data?.page.total ?? 0} total</span>
586
604
  </div>
587
605
  {todosQuery.isLoading ? <p className="muted">Loading todos...</p> : null}
588
606
  {todosQuery.isError ? (
589
607
  <p className="error">Could not load todos.</p>
590
608
  ) : null}
591
609
  <ul className="todo-list">
592
- {todosQuery.data?.todos.map((todo) => (
610
+ {todosQuery.data?.items.map((todo) => (
593
611
  <li key={todo.id}>
594
612
  <span>{todo.title}</span>
595
613
  <small>{todo.completed ? "Done" : "Open"}</small>
@@ -650,10 +668,10 @@ export function TodoApp() {
650
668
  <div className="todos-panel">
651
669
  <div className="panel-heading">
652
670
  <h2>Todos</h2>
653
- <span>{todosQuery.data?.total ?? 0} total</span>
671
+ <span>{todosQuery.data?.page.total ?? 0} total</span>
654
672
  </div>
655
673
  <ul className="todo-list">
656
- {todosQuery.data?.todos.map((todo) => (
674
+ {todosQuery.data?.items.map((todo) => (
657
675
  <li key={todo.id}>
658
676
  <span>{todo.title}</span>
659
677
  <small>{todo.completed ? "Done" : "Open"}</small>
@@ -932,8 +950,14 @@ export const CreateTodoSchema = z.object({
932
950
 
933
951
  export const listTodos = todos.get("/api/todos").responses({
934
952
  200: z.object({
935
- todos: z.array(TodoSchema),
936
- total: z.number(),
953
+ items: z.array(TodoSchema),
954
+ page: z.object({
955
+ kind: z.literal("offset"),
956
+ limit: z.number(),
957
+ offset: z.number(),
958
+ total: z.number(),
959
+ hasMore: z.boolean(),
960
+ }),
937
961
  }),
938
962
  });
939
963
 
@@ -989,8 +1013,14 @@ type AppContext = {
989
1013
  const useCase = createUseCase<AppContext>();
990
1014
 
991
1015
  export const ListTodosOutputSchema = z.object({
992
- todos: z.array(TodoSchema),
993
- total: z.number(),
1016
+ items: z.array(TodoSchema),
1017
+ page: z.object({
1018
+ kind: z.literal("offset"),
1019
+ limit: z.number(),
1020
+ offset: z.number(),
1021
+ total: z.number(),
1022
+ hasMore: z.boolean(),
1023
+ }),
994
1024
  });
995
1025
 
996
1026
  export const listTodos = useCase
@@ -999,7 +1029,16 @@ export const listTodos = useCase
999
1029
  .output(ListTodosOutputSchema)
1000
1030
  .run(async ({ ctx }) => {
1001
1031
  const todos = await ctx.ports.todos.list();
1002
- return { todos, total: todos.length };
1032
+ return {
1033
+ items: todos,
1034
+ page: {
1035
+ kind: "offset" as const,
1036
+ limit: todos.length,
1037
+ offset: 0,
1038
+ total: todos.length,
1039
+ hasMore: false,
1040
+ },
1041
+ };
1003
1042
  });
1004
1043
 
1005
1044
  export const createTodo = useCase
@@ -1073,6 +1112,14 @@ export const env = createEnv({
1073
1112
  APP_URL: z.string().url().default("http://localhost:3000"),
1074
1113
  CRON_SECRET: z.string().min(1).optional(),
1075
1114
  DEVTOOLS_ENABLED: BooleanEnv.optional(),
1115
+ BETTER_AUTH_SECRET: z
1116
+ .string()
1117
+ .min(32)
1118
+ .default("local-dev-better-auth-secret-change-me"),
1119
+ BETTER_AUTH_URL: z.string().url().default("http://localhost:3000"),
1120
+ BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
1121
+ TURSO_DB_URL: z.string().default("file:local.db"),
1122
+ TURSO_DB_AUTH_TOKEN: z.string().optional(),
1076
1123
  LOG_LEVEL: z
1077
1124
  .enum(["trace", "debug", "info", "warn", "error", "fatal"])
1078
1125
  .default("info"),
@@ -1138,10 +1185,14 @@ export const ListTodosInputSchema = z.object({
1138
1185
  });
1139
1186
 
1140
1187
  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),
1188
+ items: z.array(TodoSchema),
1189
+ page: z.object({
1190
+ kind: z.literal("offset"),
1191
+ limit: z.number().int().min(1),
1192
+ offset: z.number().int().min(0),
1193
+ total: z.number().int().min(0),
1194
+ hasMore: z.boolean(),
1195
+ }),
1145
1196
  });
1146
1197
 
1147
1198
  export const GetTodoInputSchema = z.object({
@@ -1180,7 +1231,8 @@ export function requireUser(ctx: AppContext): AuthUser {
1180
1231
  return requireSession(ctx).user;
1181
1232
  }
1182
1233
  `,
1183
- productionListTodosUseCase: `import { useCase } from "@/lib/use-case";
1234
+ productionListTodosUseCase: `import { normalizeOffsetPage } from "@beignet/core/pagination";
1235
+ import { useCase } from "@/lib/use-case";
1184
1236
  import { ListTodosInputSchema, ListTodosOutputSchema } from "./schemas";
1185
1237
 
1186
1238
  export const listTodosUseCase = useCase
@@ -1188,13 +1240,12 @@ export const listTodosUseCase = useCase
1188
1240
  .input(ListTodosInputSchema)
1189
1241
  .output(ListTodosOutputSchema)
1190
1242
  .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
- };
1243
+ const page = normalizeOffsetPage(input, {
1244
+ defaultLimit: 20,
1245
+ maxLimit: 100,
1246
+ });
1247
+
1248
+ return ctx.ports.todos.list(page);
1198
1249
  });
1199
1250
  `,
1200
1251
  productionGetTodoUseCase: `import { appError } from "@/features/shared/errors";
@@ -1214,16 +1265,35 @@ export const getTodoUseCase = useCase
1214
1265
  return todo;
1215
1266
  });
1216
1267
  `,
1217
- productionCreateTodoUseCase: `import { useCase } from "@/lib/use-case";
1268
+ productionCreateTodoUseCase: `import { TodoCreated } from "@/features/todos/domain/events";
1269
+ import { auditEntry } from "@/lib/audit";
1270
+ import { useCase } from "@/lib/use-case";
1218
1271
  import { CreateTodoInputSchema, TodoSchema } from "./schemas";
1219
1272
 
1220
1273
  export const createTodoUseCase = useCase
1221
1274
  .command("todos.create")
1222
1275
  .input(CreateTodoInputSchema)
1223
1276
  .output(TodoSchema)
1224
- .run(async ({ ctx, input }) => {
1277
+ .emits([TodoCreated])
1278
+ .run(async ({ ctx, input, events }) => {
1225
1279
  await ctx.gate.authorize("todos.create");
1226
- return ctx.ports.uow.transaction((tx) => tx.todos.create(input));
1280
+ const todo = await ctx.ports.uow.transaction(async (tx) => {
1281
+ const created = await tx.todos.create(input);
1282
+ await events.record(tx.events, TodoCreated, {
1283
+ todoId: created.id,
1284
+ title: created.title,
1285
+ });
1286
+ await tx.audit.record(
1287
+ auditEntry(ctx, {
1288
+ action: "todos.create",
1289
+ resource: { type: "todo", id: created.id, name: created.title },
1290
+ metadata: { completed: created.completed },
1291
+ }),
1292
+ );
1293
+ return created;
1294
+ });
1295
+
1296
+ return todo;
1227
1297
  });
1228
1298
  `,
1229
1299
  productionUseCasesIndex: `export { createTodoUseCase } from "./create-todo";
@@ -1241,21 +1311,37 @@ export {
1241
1311
  } from "./schemas";
1242
1312
  `,
1243
1313
  productionTodoRepositoryPort: `import type {
1314
+ OffsetPage,
1315
+ OffsetPageInfo,
1316
+ PageResult,
1317
+ } from "@beignet/core/pagination";
1318
+ import type {
1244
1319
  CreateTodoInput,
1245
- ListTodosInput,
1246
1320
  Todo,
1247
1321
  } from "@/features/todos/use-cases/schemas";
1248
1322
 
1249
- export type ListTodosResult = {
1250
- todos: Todo[];
1251
- total: number;
1323
+ export type ListTodosResult = PageResult<Todo, OffsetPageInfo>;
1324
+
1325
+ export type TodoAttachment = {
1326
+ id: string;
1327
+ todoId: string;
1328
+ key: string;
1329
+ fileName: string;
1330
+ contentType: string;
1331
+ size: number;
1332
+ createdAt: string;
1252
1333
  };
1253
1334
 
1254
1335
  export interface TodoRepository {
1255
- list(input: ListTodosInput): Promise<ListTodosResult>;
1336
+ list(page: OffsetPage): Promise<ListTodosResult>;
1256
1337
  findById(id: string): Promise<Todo | null>;
1257
1338
  create(input: CreateTodoInput): Promise<Todo>;
1258
1339
  }
1340
+
1341
+ export interface TodoAttachmentRepository {
1342
+ create(input: Omit<TodoAttachment, "createdAt">): Promise<TodoAttachment>;
1343
+ findMany(todoId: string): Promise<TodoAttachment[]>;
1344
+ }
1259
1345
  `,
1260
1346
  productionAuthPort: `import type {
1261
1347
  AuthPort as BeignetAuthPort,
@@ -1325,12 +1411,15 @@ export const todoPolicy = definePolicy({
1325
1411
  },
1326
1412
  });
1327
1413
  `,
1328
- productionInMemoryTodoRepository: `import type {
1414
+ productionInMemoryTodoRepository: `import { offsetPageResult } from "@beignet/core/pagination";
1415
+ import type {
1329
1416
  CreateTodoInput,
1330
- ListTodosInput,
1331
1417
  Todo,
1332
1418
  } from "@/features/todos/use-cases/schemas";
1333
- import type { TodoRepository } from "@/features/todos/ports";
1419
+ import type {
1420
+ TodoAttachmentRepository,
1421
+ TodoRepository,
1422
+ } from "@/features/todos/ports";
1334
1423
 
1335
1424
  export function createInMemoryTodoRepository(
1336
1425
  seed: Todo[] = [],
@@ -1338,15 +1427,16 @@ export function createInMemoryTodoRepository(
1338
1427
  const todos = new Map(seed.map((todo) => [todo.id, todo]));
1339
1428
 
1340
1429
  return {
1341
- async list(input: ListTodosInput) {
1430
+ async list(page) {
1342
1431
  const allTodos = Array.from(todos.values()).sort((left, right) =>
1343
1432
  left.createdAt.localeCompare(right.createdAt),
1344
1433
  );
1345
1434
 
1346
- return {
1347
- todos: allTodos.slice(input.offset, input.offset + input.limit),
1348
- total: allTodos.length,
1349
- };
1435
+ return offsetPageResult(
1436
+ allTodos.slice(page.offset, page.offset + page.limit),
1437
+ page,
1438
+ allTodos.length,
1439
+ );
1350
1440
  },
1351
1441
  async findById(id: string) {
1352
1442
  return todos.get(id) ?? null;
@@ -1413,7 +1503,6 @@ export const appPorts = definePorts({
1413
1503
  productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
1414
1504
  import { todoPolicy } from "@/features/todos/policy";
1415
1505
  import { appError } from "@/features/shared/errors";
1416
- import { createAnonymousAuth } from "./auth/anonymous-auth";
1417
1506
  import { fallbackLogger } from "./logger";
1418
1507
 
1419
1508
  const gate = createGate({
@@ -1427,7 +1516,6 @@ const gate = createGate({
1427
1516
  });
1428
1517
 
1429
1518
  export const appPorts = definePorts({
1430
- auth: createAnonymousAuth(),
1431
1519
  gate,
1432
1520
  logger: fallbackLogger,
1433
1521
  });
@@ -1442,7 +1530,63 @@ export const appPorts = definePorts({
1442
1530
  },
1443
1531
  };
1444
1532
  `,
1445
- productionDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1533
+ productionDbSchema: `import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1534
+
1535
+ export const user = sqliteTable("user", {
1536
+ id: text("id").primaryKey(),
1537
+ name: text("name").notNull(),
1538
+ email: text("email").notNull().unique(),
1539
+ emailVerified: integer("email_verified", { mode: "boolean" })
1540
+ .notNull()
1541
+ .default(false),
1542
+ image: text("image"),
1543
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1544
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
1545
+ });
1546
+
1547
+ export const session = sqliteTable("session", {
1548
+ id: text("id").primaryKey(),
1549
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
1550
+ token: text("token").notNull().unique(),
1551
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1552
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
1553
+ ipAddress: text("ip_address"),
1554
+ userAgent: text("user_agent"),
1555
+ userId: text("user_id")
1556
+ .notNull()
1557
+ .references(() => user.id, { onDelete: "cascade" }),
1558
+ });
1559
+
1560
+ export const account = sqliteTable("account", {
1561
+ id: text("id").primaryKey(),
1562
+ accountId: text("account_id").notNull(),
1563
+ providerId: text("provider_id").notNull(),
1564
+ userId: text("user_id")
1565
+ .notNull()
1566
+ .references(() => user.id, { onDelete: "cascade" }),
1567
+ accessToken: text("access_token"),
1568
+ refreshToken: text("refresh_token"),
1569
+ idToken: text("id_token"),
1570
+ accessTokenExpiresAt: integer("access_token_expires_at", {
1571
+ mode: "timestamp",
1572
+ }),
1573
+ refreshTokenExpiresAt: integer("refresh_token_expires_at", {
1574
+ mode: "timestamp",
1575
+ }),
1576
+ scope: text("scope"),
1577
+ password: text("password"),
1578
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1579
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
1580
+ });
1581
+
1582
+ export const verification = sqliteTable("verification", {
1583
+ id: text("id").primaryKey(),
1584
+ identifier: text("identifier").notNull(),
1585
+ value: text("value").notNull(),
1586
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
1587
+ createdAt: integer("created_at", { mode: "timestamp" }),
1588
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
1589
+ });
1446
1590
 
1447
1591
  export const todos = sqliteTable("todos", {
1448
1592
  id: text("id").primaryKey(),
@@ -1450,13 +1594,111 @@ export const todos = sqliteTable("todos", {
1450
1594
  completed: integer("completed", { mode: "boolean" }).notNull().default(false),
1451
1595
  createdAt: text("created_at").notNull(),
1452
1596
  });
1597
+
1598
+ export const todoAttachments = sqliteTable(
1599
+ "todo_attachments",
1600
+ {
1601
+ id: text("id").primaryKey(),
1602
+ todoId: text("todo_id")
1603
+ .notNull()
1604
+ .references(() => todos.id, { onDelete: "cascade" }),
1605
+ key: text("key").notNull(),
1606
+ fileName: text("file_name").notNull(),
1607
+ contentType: text("content_type").notNull(),
1608
+ size: integer("size").notNull(),
1609
+ createdAt: text("created_at").notNull(),
1610
+ },
1611
+ (table) => ({
1612
+ todoIdx: index("todo_attachments_todo_idx").on(
1613
+ table.todoId,
1614
+ table.createdAt,
1615
+ ),
1616
+ keyIdx: index("todo_attachments_key_idx").on(table.key),
1617
+ }),
1618
+ );
1619
+
1620
+ export const auditLog = sqliteTable(
1621
+ "audit_log",
1622
+ {
1623
+ id: text("id").primaryKey(),
1624
+ action: text("action").notNull(),
1625
+ actorType: text("actor_type", {
1626
+ enum: ["anonymous", "service", "system", "user"],
1627
+ }).notNull(),
1628
+ actorId: text("actor_id"),
1629
+ actorDisplayName: text("actor_display_name"),
1630
+ tenantId: text("tenant_id"),
1631
+ tenantSlug: text("tenant_slug"),
1632
+ resourceType: text("resource_type"),
1633
+ resourceId: text("resource_id"),
1634
+ resourceName: text("resource_name"),
1635
+ outcome: text("outcome", { enum: ["success", "failure"] })
1636
+ .notNull()
1637
+ .default("success"),
1638
+ requestId: text("request_id"),
1639
+ traceId: text("trace_id"),
1640
+ message: text("message"),
1641
+ metadata: text("metadata"),
1642
+ actorMetadata: text("actor_metadata"),
1643
+ tenantMetadata: text("tenant_metadata"),
1644
+ resourceMetadata: text("resource_metadata"),
1645
+ occurredAt: text("occurred_at").notNull(),
1646
+ },
1647
+ (table) => ({
1648
+ actionIdx: index("audit_log_action_idx").on(table.action),
1649
+ actorIdx: index("audit_log_actor_idx").on(table.actorType, table.actorId),
1650
+ occurredAtIdx: index("audit_log_occurred_at_idx").on(table.occurredAt),
1651
+ requestIdx: index("audit_log_request_idx").on(table.requestId),
1652
+ resourceIdx: index("audit_log_resource_idx").on(
1653
+ table.resourceType,
1654
+ table.resourceId,
1655
+ ),
1656
+ tenantIdx: index("audit_log_tenant_idx").on(table.tenantId),
1657
+ }),
1658
+ );
1659
+
1660
+ export const outboxMessages = sqliteTable(
1661
+ "outbox_messages",
1662
+ {
1663
+ id: text("id").primaryKey(),
1664
+ kind: text("kind", { enum: ["event", "job"] }).notNull(),
1665
+ name: text("name").notNull(),
1666
+ payloadJson: text("payload_json").notNull(),
1667
+ status: text("status", {
1668
+ enum: ["pending", "claimed", "delivered", "deadLettered"],
1669
+ }).notNull(),
1670
+ attempts: integer("attempts").notNull().default(0),
1671
+ maxAttempts: integer("max_attempts").notNull().default(3),
1672
+ availableAt: text("available_at").notNull(),
1673
+ claimedAt: text("claimed_at"),
1674
+ lockedUntil: text("locked_until"),
1675
+ claimToken: text("claim_token"),
1676
+ deliveredAt: text("delivered_at"),
1677
+ lastErrorJson: text("last_error_json"),
1678
+ createdAt: text("created_at").notNull(),
1679
+ updatedAt: text("updated_at").notNull(),
1680
+ },
1681
+ (table) => ({
1682
+ availableIdx: index("outbox_messages_available_idx").on(
1683
+ table.status,
1684
+ table.availableAt,
1685
+ ),
1686
+ lockedIdx: index("outbox_messages_locked_idx").on(
1687
+ table.status,
1688
+ table.lockedUntil,
1689
+ ),
1690
+ }),
1691
+ );
1453
1692
  `,
1454
- productionDrizzleTodoRepository: `import { count, desc, eq } from "drizzle-orm";
1693
+ productionDrizzleTodoRepository: `import { offsetPageResult } from "@beignet/core/pagination";
1694
+ import { count, desc, eq } from "drizzle-orm";
1455
1695
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1456
- import type { TodoRepository } from "@/features/todos/ports";
1696
+ import type {
1697
+ TodoAttachmentRepository,
1698
+ TodoRepository,
1699
+ } from "@/features/todos/ports";
1457
1700
  import type {
1458
1701
  CreateTodoInput,
1459
- ListTodosInput,
1460
1702
  Todo,
1461
1703
  } from "@/features/todos/use-cases/schemas";
1462
1704
  import * as schema from "@/infra/db/schema";
@@ -1476,19 +1718,16 @@ export function createDrizzleTodoRepository(
1476
1718
  db: DrizzleTursoDatabase<typeof schema>,
1477
1719
  ): TodoRepository {
1478
1720
  return {
1479
- async list(input: ListTodosInput) {
1721
+ async list(page) {
1480
1722
  const rows = await db
1481
1723
  .select()
1482
1724
  .from(schema.todos)
1483
1725
  .orderBy(desc(schema.todos.createdAt))
1484
- .limit(input.limit)
1485
- .offset(input.offset);
1726
+ .limit(page.limit)
1727
+ .offset(page.offset);
1486
1728
  const [{ total }] = await db.select({ total: count() }).from(schema.todos);
1487
1729
 
1488
- return {
1489
- todos: rows.map(toTodo),
1490
- total,
1491
- };
1730
+ return offsetPageResult(rows.map(toTodo), page, total);
1492
1731
  },
1493
1732
  async findById(id: string) {
1494
1733
  const [row] = await db
@@ -1516,19 +1755,428 @@ export function createDrizzleTodoRepository(
1516
1755
  },
1517
1756
  };
1518
1757
  }
1758
+
1759
+ export function createDrizzleTodoAttachmentRepository(
1760
+ db: DrizzleTursoDatabase<typeof schema>,
1761
+ ): TodoAttachmentRepository {
1762
+ return {
1763
+ async create(input) {
1764
+ const attachment = {
1765
+ ...input,
1766
+ createdAt: new Date().toISOString(),
1767
+ };
1768
+ const [row] = await db
1769
+ .insert(schema.todoAttachments)
1770
+ .values(attachment)
1771
+ .returning();
1772
+
1773
+ if (!row) {
1774
+ throw new Error("Failed to create todo attachment");
1775
+ }
1776
+
1777
+ return {
1778
+ id: row.id,
1779
+ todoId: row.todoId,
1780
+ key: row.key,
1781
+ fileName: row.fileName,
1782
+ contentType: row.contentType,
1783
+ size: row.size,
1784
+ createdAt: row.createdAt,
1785
+ };
1786
+ },
1787
+ async findMany(todoId) {
1788
+ const rows = await db
1789
+ .select()
1790
+ .from(schema.todoAttachments)
1791
+ .where(eq(schema.todoAttachments.todoId, todoId))
1792
+ .orderBy(desc(schema.todoAttachments.createdAt));
1793
+
1794
+ return rows.map((row) => ({
1795
+ id: row.id,
1796
+ todoId: row.todoId,
1797
+ key: row.key,
1798
+ fileName: row.fileName,
1799
+ contentType: row.contentType,
1800
+ size: row.size,
1801
+ createdAt: row.createdAt,
1802
+ }));
1803
+ },
1804
+ };
1805
+ }
1519
1806
  `,
1520
1807
  productionDbRepositories: `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1521
- import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
1808
+ import {
1809
+ createDrizzleTodoAttachmentRepository,
1810
+ createDrizzleTodoRepository,
1811
+ } from "@/infra/todos/drizzle-todo-repository";
1522
1812
  import type { AppTransactionPorts } from "@/ports";
1523
1813
  import * as schema from "./schema";
1524
1814
 
1525
1815
  export function createRepositories(
1526
1816
  db: DrizzleTursoDatabase<typeof schema>,
1527
- ): Omit<AppTransactionPorts, "events"> {
1817
+ ): Omit<AppTransactionPorts, "audit" | "events" | "jobs" | "outbox"> {
1528
1818
  return {
1819
+ todoAttachments: createDrizzleTodoAttachmentRepository(db),
1529
1820
  todos: createDrizzleTodoRepository(db),
1530
1821
  };
1531
1822
  }
1823
+ `,
1824
+ productionDrizzleAuditLog: `import type { AuditLogEntry, AuditLogPort } from "@beignet/core/ports";
1825
+ import {
1826
+ normalizeAuditLogEntry,
1827
+ redactAuditLogEntry,
1828
+ } from "@beignet/core/ports";
1829
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
1830
+ import * as schema from "@/infra/db/schema";
1831
+
1832
+ function serialize(value: unknown): string | null {
1833
+ return value === undefined ? null : JSON.stringify(value);
1834
+ }
1835
+
1836
+ export function createDrizzleAuditLog(
1837
+ db: DrizzleTursoDatabase<typeof schema>,
1838
+ ): AuditLogPort {
1839
+ return {
1840
+ async record(input) {
1841
+ const entry: AuditLogEntry = redactAuditLogEntry(
1842
+ normalizeAuditLogEntry(input),
1843
+ );
1844
+
1845
+ await db.insert(schema.auditLog).values({
1846
+ id: crypto.randomUUID(),
1847
+ action: entry.action,
1848
+ actorType: entry.actor.type,
1849
+ actorId: entry.actor.id ?? null,
1850
+ actorDisplayName: entry.actor.displayName ?? null,
1851
+ tenantId: entry.tenant?.id ?? null,
1852
+ tenantSlug: entry.tenant?.slug ?? null,
1853
+ resourceType: entry.resource?.type ?? null,
1854
+ resourceId: entry.resource?.id ?? null,
1855
+ resourceName: entry.resource?.name ?? null,
1856
+ outcome: entry.outcome,
1857
+ requestId: entry.requestId ?? null,
1858
+ traceId: entry.traceId ?? null,
1859
+ message: entry.message ?? null,
1860
+ metadata: serialize(entry.metadata),
1861
+ actorMetadata: serialize(entry.actor.metadata),
1862
+ tenantMetadata: serialize(entry.tenant?.metadata),
1863
+ resourceMetadata: serialize(entry.resource?.metadata),
1864
+ occurredAt: entry.occurredAt.toISOString(),
1865
+ });
1866
+ },
1867
+ };
1868
+ }
1869
+ `,
1870
+ productionDbBootstrap: `import { createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
1871
+ import type { Client } from "@libsql/client";
1872
+
1873
+ type BootstrapOptions = {
1874
+ seed?: boolean;
1875
+ };
1876
+
1877
+ const setupStatements = [
1878
+ \`CREATE TABLE IF NOT EXISTS user (
1879
+ id text PRIMARY KEY NOT NULL,
1880
+ name text NOT NULL,
1881
+ email text NOT NULL UNIQUE,
1882
+ email_verified integer DEFAULT false NOT NULL,
1883
+ image text,
1884
+ created_at integer NOT NULL,
1885
+ updated_at integer NOT NULL
1886
+ )\`,
1887
+ \`CREATE TABLE IF NOT EXISTS session (
1888
+ id text PRIMARY KEY NOT NULL,
1889
+ expires_at integer NOT NULL,
1890
+ token text NOT NULL UNIQUE,
1891
+ created_at integer NOT NULL,
1892
+ updated_at integer NOT NULL,
1893
+ ip_address text,
1894
+ user_agent text,
1895
+ user_id text NOT NULL REFERENCES user(id) ON DELETE cascade
1896
+ )\`,
1897
+ \`CREATE TABLE IF NOT EXISTS account (
1898
+ id text PRIMARY KEY NOT NULL,
1899
+ account_id text NOT NULL,
1900
+ provider_id text NOT NULL,
1901
+ user_id text NOT NULL REFERENCES user(id) ON DELETE cascade,
1902
+ access_token text,
1903
+ refresh_token text,
1904
+ id_token text,
1905
+ access_token_expires_at integer,
1906
+ refresh_token_expires_at integer,
1907
+ scope text,
1908
+ password text,
1909
+ created_at integer NOT NULL,
1910
+ updated_at integer NOT NULL
1911
+ )\`,
1912
+ \`CREATE TABLE IF NOT EXISTS verification (
1913
+ id text PRIMARY KEY NOT NULL,
1914
+ identifier text NOT NULL,
1915
+ value text NOT NULL,
1916
+ expires_at integer NOT NULL,
1917
+ created_at integer,
1918
+ updated_at integer
1919
+ )\`,
1920
+ \`CREATE TABLE IF NOT EXISTS todos (
1921
+ id text PRIMARY KEY NOT NULL,
1922
+ title text NOT NULL,
1923
+ completed integer DEFAULT false NOT NULL,
1924
+ created_at text NOT NULL
1925
+ )\`,
1926
+ \`CREATE TABLE IF NOT EXISTS todo_attachments (
1927
+ id text PRIMARY KEY NOT NULL,
1928
+ todo_id text NOT NULL REFERENCES todos(id) ON DELETE cascade,
1929
+ key text NOT NULL,
1930
+ file_name text NOT NULL,
1931
+ content_type text NOT NULL,
1932
+ size integer NOT NULL,
1933
+ created_at text NOT NULL
1934
+ )\`,
1935
+ "CREATE INDEX IF NOT EXISTS todo_attachments_todo_idx ON todo_attachments (todo_id, created_at)",
1936
+ "CREATE INDEX IF NOT EXISTS todo_attachments_key_idx ON todo_attachments (key)",
1937
+ \`CREATE TABLE IF NOT EXISTS audit_log (
1938
+ id text PRIMARY KEY NOT NULL,
1939
+ action text NOT NULL,
1940
+ actor_type text NOT NULL,
1941
+ actor_id text,
1942
+ actor_display_name text,
1943
+ tenant_id text,
1944
+ tenant_slug text,
1945
+ resource_type text,
1946
+ resource_id text,
1947
+ resource_name text,
1948
+ outcome text DEFAULT 'success' NOT NULL,
1949
+ request_id text,
1950
+ trace_id text,
1951
+ message text,
1952
+ metadata text,
1953
+ actor_metadata text,
1954
+ tenant_metadata text,
1955
+ resource_metadata text,
1956
+ occurred_at text NOT NULL
1957
+ )\`,
1958
+ "CREATE INDEX IF NOT EXISTS audit_log_action_idx ON audit_log (action)",
1959
+ "CREATE INDEX IF NOT EXISTS audit_log_actor_idx ON audit_log (actor_type, actor_id)",
1960
+ "CREATE INDEX IF NOT EXISTS audit_log_occurred_at_idx ON audit_log (occurred_at)",
1961
+ "CREATE INDEX IF NOT EXISTS audit_log_request_idx ON audit_log (request_id)",
1962
+ "CREATE INDEX IF NOT EXISTS audit_log_resource_idx ON audit_log (resource_type, resource_id)",
1963
+ "CREATE INDEX IF NOT EXISTS audit_log_tenant_idx ON audit_log (tenant_id)",
1964
+ ...createDrizzleTursoOutboxSetupStatements(),
1965
+ ];
1966
+
1967
+ const seedTodos = [
1968
+ {
1969
+ id: "00000000-0000-4000-8000-000000000001",
1970
+ title: "Review the starter boundaries",
1971
+ completed: 0,
1972
+ createdAt: "2026-01-01T00:00:00.000Z",
1973
+ },
1974
+ {
1975
+ id: "00000000-0000-4000-8000-000000000002",
1976
+ title: "Create your first feature",
1977
+ completed: 0,
1978
+ createdAt: "2026-01-01T00:05:00.000Z",
1979
+ },
1980
+ ] as const;
1981
+
1982
+ const resetTables = [
1983
+ "verification",
1984
+ "account",
1985
+ "session",
1986
+ "user",
1987
+ "todo_attachments",
1988
+ "todos",
1989
+ "audit_log",
1990
+ "outbox_messages",
1991
+ ] as const;
1992
+
1993
+ export async function ensureStarterDatabase(
1994
+ client: Client,
1995
+ options: BootstrapOptions = {},
1996
+ ): Promise<void> {
1997
+ for (const statement of setupStatements) {
1998
+ await client.execute(statement);
1999
+ }
2000
+
2001
+ if (options.seed === false) return;
2002
+
2003
+ const result = await client.execute("SELECT count(*) as total FROM todos");
2004
+ const total = Number(result.rows[0]?.total ?? 0);
2005
+ if (total > 0) return;
2006
+
2007
+ for (const todo of seedTodos) {
2008
+ await client.execute({
2009
+ sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
2010
+ args: [todo.id, todo.title, todo.completed, todo.createdAt],
2011
+ });
2012
+ }
2013
+ }
2014
+
2015
+ export async function resetStarterDatabase(client: Client): Promise<void> {
2016
+ await client.execute("PRAGMA foreign_keys = OFF");
2017
+ try {
2018
+ for (const table of resetTables) {
2019
+ await client.execute(\`DROP TABLE IF EXISTS \${table}\`);
2020
+ }
2021
+ } finally {
2022
+ await client.execute("PRAGMA foreign_keys = ON");
2023
+ }
2024
+ await ensureStarterDatabase(client, { seed: false });
2025
+ }
2026
+ `,
2027
+ productionDbProvider: `import { registerListeners } from "@beignet/core/events";
2028
+ import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
2029
+ import { createInlineJobDispatcher } from "@beignet/core/jobs";
2030
+ import { createMemoryMailer } from "@beignet/core/mail";
2031
+ import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
2032
+ import {
2033
+ createOutboxEventRecorder,
2034
+ createOutboxJobDispatcher,
2035
+ } from "@beignet/core/outbox";
2036
+ import {
2037
+ createSystemActor,
2038
+ createTenant,
2039
+ } from "@beignet/core/ports";
2040
+ import {
2041
+ createProvider,
2042
+ createProviderInstrumentation,
2043
+ } from "@beignet/core/providers";
2044
+ import {
2045
+ createDevtoolsAuditLog,
2046
+ createDevtoolsTraceContext,
2047
+ type DevtoolsPort,
2048
+ } from "@beignet/devtools";
2049
+ import {
2050
+ createDrizzleTursoOutboxPort,
2051
+ createDrizzleTursoUnitOfWork,
2052
+ type DbPort,
2053
+ } from "@beignet/provider-drizzle-turso";
2054
+ import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
2055
+ import type { AppContext } from "@/app-context";
2056
+ import { todoListeners } from "@/features/todos/listeners";
2057
+ import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
2058
+ import type { AppPorts } from "@/ports";
2059
+ import { ensureStarterDatabase } from "./bootstrap";
2060
+ import { createRepositories } from "./repositories";
2061
+ import type * as schema from "./schema";
2062
+
2063
+ export const starterDatabaseProvider = createProvider({
2064
+ name: "starter-database",
2065
+
2066
+ async setup({ ports }) {
2067
+ const dbPort = (ports as { db: DbPort<typeof schema> }).db;
2068
+ const devtools = (ports as { devtools?: DevtoolsPort }).devtools;
2069
+ if (!dbPort) {
2070
+ throw new Error(
2071
+ "starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
2072
+ );
2073
+ }
2074
+
2075
+ let currentPorts: AppContext["ports"] | undefined;
2076
+ const createBackgroundContext = (): AppContext => {
2077
+ if (!currentPorts) {
2078
+ throw new Error("Starter background context is not ready.");
2079
+ }
2080
+
2081
+ const actor = createSystemActor("starter-background");
2082
+ const tenant = createTenant("tenant_default");
2083
+ const traceContext = createDevtoolsTraceContext();
2084
+
2085
+ return {
2086
+ actor,
2087
+ auth: null,
2088
+ gate: currentPorts.gate.bind({ actor, auth: null }),
2089
+ requestId: crypto.randomUUID(),
2090
+ ...traceContext,
2091
+ ports: currentPorts,
2092
+ tenant,
2093
+ };
2094
+ };
2095
+
2096
+ const mailInstrumentation = createProviderInstrumentation(ports, {
2097
+ providerName: "memory-mailer",
2098
+ watcher: "mail",
2099
+ });
2100
+ const eventBus = createInMemoryEventBus({
2101
+ instrumentation: ports,
2102
+ onHandlerError(error: unknown, eventName: string) {
2103
+ console.error("Event handler failed", { error, eventName });
2104
+ },
2105
+ });
2106
+ const jobs = createInlineJobDispatcher<AppContext>({
2107
+ ctx: createBackgroundContext,
2108
+ });
2109
+ const notifications = createInlineNotificationDispatcher<AppContext>({
2110
+ ctx: createBackgroundContext,
2111
+ instrumentation: ports,
2112
+ });
2113
+ const mailer = createMemoryMailer({
2114
+ defaultFrom: "Beignet Starter <noreply@example.local>",
2115
+ onSend(delivery) {
2116
+ mailInstrumentation.custom({
2117
+ name: "mail.sent",
2118
+ label: "Mail sent",
2119
+ summary: delivery.message.subject,
2120
+ details: {
2121
+ to: delivery.message.to,
2122
+ subject: delivery.message.subject,
2123
+ id: delivery.id,
2124
+ },
2125
+ });
2126
+ },
2127
+ });
2128
+ const audit = createDevtoolsAuditLog({
2129
+ audit: createDrizzleAuditLog(dbPort.db),
2130
+ devtools,
2131
+ });
2132
+ const repositories = createRepositories(dbPort.db);
2133
+ const idempotency = createMemoryIdempotencyStore();
2134
+ const outbox = createDrizzleTursoOutboxPort(dbPort.db);
2135
+ const unregisterListeners = registerListeners(eventBus, todoListeners, {
2136
+ ctx: createBackgroundContext,
2137
+ onError(error, listener) {
2138
+ console.error("Event listener failed", {
2139
+ error,
2140
+ listenerName: listener.name,
2141
+ });
2142
+ },
2143
+ });
2144
+
2145
+ return {
2146
+ ports: {
2147
+ audit,
2148
+ ...repositories,
2149
+ eventBus,
2150
+ idempotency,
2151
+ jobs,
2152
+ mailer,
2153
+ notifications,
2154
+ outbox,
2155
+ uow: createDrizzleTursoUnitOfWork({
2156
+ db: dbPort.db,
2157
+ createTransactionPorts: (tx) => {
2158
+ const txOutbox = createDrizzleTursoOutboxPort(tx);
2159
+
2160
+ return {
2161
+ audit: createDrizzleAuditLog(tx),
2162
+ ...createRepositories(tx),
2163
+ events: createOutboxEventRecorder(txOutbox),
2164
+ jobs: createOutboxJobDispatcher(txOutbox),
2165
+ outbox: txOutbox,
2166
+ };
2167
+ },
2168
+ }),
2169
+ } satisfies Partial<AppPorts>,
2170
+ async start(ctx) {
2171
+ await ensureStarterDatabase(dbPort.client);
2172
+ currentPorts = ctx.ports as AppContext["ports"];
2173
+ },
2174
+ stop() {
2175
+ unregisterListeners();
2176
+ },
2177
+ };
2178
+ },
2179
+ });
1532
2180
  `,
1533
2181
  productionContractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
1534
2182
  import { z } from "zod";
@@ -1636,6 +2284,12 @@ import { routes } from "./routes";
1636
2284
  export const server = await createNextServer({
1637
2285
  ports: appPorts,
1638
2286
  providers,
2287
+ providerConfig: {
2288
+ "drizzle-turso": {
2289
+ DB_URL: env.TURSO_DB_URL,
2290
+ DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2291
+ },
2292
+ },
1639
2293
  hooks: [
1640
2294
  createDevtoolsHooks<AppContext>({
1641
2295
  requestIdHeader: "x-request-id",
@@ -1685,19 +2339,21 @@ import {
1685
2339
  createTenant,
1686
2340
  createUserActor,
1687
2341
  } from "@beignet/core/ports";
1688
- import { createDrizzleTursoUnitOfWork } from "@beignet/provider-drizzle-turso";
1689
2342
  import type { AppContext } from "@/app-context";
1690
2343
  import { appPorts } from "@/infra/app-ports";
1691
- import * as schema from "@/infra/db/schema";
1692
- import { createRepositories } from "@/infra/db/repositories";
1693
2344
  import { env } from "@/lib/env";
1694
- import type { AppTransactionPorts } from "@/ports";
1695
2345
  import { providers } from "./providers";
1696
2346
  import { routes } from "./routes";
1697
2347
 
1698
2348
  export const server = await createNextServer({
1699
2349
  ports: appPorts,
1700
2350
  providers,
2351
+ providerConfig: {
2352
+ "drizzle-turso": {
2353
+ DB_URL: env.TURSO_DB_URL,
2354
+ DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
2355
+ },
2356
+ },
1701
2357
  hooks: [
1702
2358
  createDevtoolsHooks<AppContext>({
1703
2359
  requestIdHeader: "x-request-id",
@@ -1706,7 +2362,6 @@ export const server = await createNextServer({
1706
2362
  createContext: async ({ req, ports }) => {
1707
2363
  const auth = await ports.auth.getSession(req);
1708
2364
  const tenantId = req.headers.get("x-tenant-id") || undefined;
1709
- const repositories = createRepositories(ports.db.db);
1710
2365
 
1711
2366
  const context = {
1712
2367
  requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
@@ -1714,16 +2369,7 @@ export const server = await createNextServer({
1714
2369
  ? createUserActor(auth.user.id, { displayName: auth.user.name })
1715
2370
  : createAnonymousActor(),
1716
2371
  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
- },
2372
+ ports,
1727
2373
  tenant: tenantId ? createTenant(tenantId) : undefined,
1728
2374
  };
1729
2375
 
@@ -1799,12 +2445,491 @@ export const GET = createOpenAPIHandler(server.contracts, {
1799
2445
  title: "Beignet starter API",
1800
2446
  version: "0.1.0",
1801
2447
  });
2448
+ `,
2449
+ productionAuthRoute: `import { toNextJsHandler } from "better-auth/next-js";
2450
+ import { auth } from "@/lib/better-auth";
2451
+
2452
+ export const { GET, POST } = toNextJsHandler(auth);
2453
+ `,
2454
+ productionBetterAuth: `import { createClient } from "@libsql/client";
2455
+ import { betterAuth } from "better-auth";
2456
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
2457
+ import { drizzle } from "drizzle-orm/libsql";
2458
+ import * as schema from "@/infra/db/schema";
2459
+ import { ensureStarterDatabase } from "@/infra/db/bootstrap";
2460
+ import { env } from "@/lib/env";
2461
+
2462
+ const client = createClient({
2463
+ url: env.TURSO_DB_URL,
2464
+ authToken: env.TURSO_DB_AUTH_TOKEN,
2465
+ });
2466
+
2467
+ await ensureStarterDatabase(client);
2468
+
2469
+ const db = drizzle(client, { schema });
2470
+
2471
+ const trustedOrigins = [
2472
+ env.APP_URL,
2473
+ env.BETTER_AUTH_URL,
2474
+ ...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",")
2475
+ .map((origin) => origin.trim())
2476
+ .filter(Boolean) ?? []),
2477
+ ];
2478
+
2479
+ export const auth = betterAuth({
2480
+ baseURL: env.BETTER_AUTH_URL,
2481
+ secret: env.BETTER_AUTH_SECRET,
2482
+ trustedOrigins: [...new Set(trustedOrigins)],
2483
+ database: drizzleAdapter(db, {
2484
+ provider: "sqlite",
2485
+ schema,
2486
+ }),
2487
+ emailAndPassword: {
2488
+ enabled: true,
2489
+ },
2490
+ });
2491
+ `,
2492
+ productionDbSeed: `import { createClient } from "@libsql/client";
2493
+ import { ensureStarterDatabase } from "@/infra/db/bootstrap";
2494
+ import { env } from "@/lib/env";
2495
+
2496
+ const client = createClient({
2497
+ url: env.TURSO_DB_URL,
2498
+ authToken: env.TURSO_DB_AUTH_TOKEN,
2499
+ });
2500
+
2501
+ try {
2502
+ await ensureStarterDatabase(client, { seed: true });
2503
+ console.log("Database seeded.");
2504
+ } finally {
2505
+ client.close();
2506
+ }
2507
+ `,
2508
+ productionDbReset: `import { createClient } from "@libsql/client";
2509
+ import { resetStarterDatabase } from "@/infra/db/bootstrap";
2510
+ import { env } from "@/lib/env";
2511
+
2512
+ if (!env.TURSO_DB_URL.startsWith("file:") && process.env.BEIGNET_ALLOW_DATABASE_RESET !== "true") {
2513
+ throw new Error(
2514
+ "Refusing to reset a non-local database. Set BEIGNET_ALLOW_DATABASE_RESET=true if this is intentional.",
2515
+ );
2516
+ }
2517
+
2518
+ const client = createClient({
2519
+ url: env.TURSO_DB_URL,
2520
+ authToken: env.TURSO_DB_AUTH_TOKEN,
2521
+ });
2522
+
2523
+ try {
2524
+ await resetStarterDatabase(client);
2525
+ console.log("Database reset.");
2526
+ } finally {
2527
+ client.close();
2528
+ }
2529
+ `,
2530
+ productionAuditHelper: `import type { AuditLogEntryInput } from "@beignet/core/ports";
2531
+ import type { AppContext } from "@/app-context";
2532
+
2533
+ export function auditEntry(
2534
+ ctx: AppContext,
2535
+ entry: Omit<
2536
+ AuditLogEntryInput,
2537
+ "actor" | "tenant" | "requestId" | "traceId"
2538
+ >,
2539
+ ): AuditLogEntryInput {
2540
+ return {
2541
+ ...entry,
2542
+ actor: ctx.actor,
2543
+ tenant: ctx.tenant,
2544
+ requestId: ctx.requestId,
2545
+ traceId: ctx.traceId,
2546
+ };
2547
+ }
2548
+ `,
2549
+ productionTodoEvents: `import { defineEvent } from "@beignet/core/events";
2550
+ import { z } from "zod";
2551
+
2552
+ export const TodoCreated = defineEvent("todos.created", {
2553
+ payload: z.object({
2554
+ todoId: z.string().uuid(),
2555
+ title: z.string(),
2556
+ }),
2557
+ });
2558
+
2559
+ export const todoEvents = [TodoCreated] as const;
2560
+ `,
2561
+ productionTodoJobs: `import { createJobHandlers, retry } from "@beignet/core/jobs";
2562
+ import { z } from "zod";
2563
+ import type { AppContext } from "@/app-context";
2564
+ import { auditEntry } from "@/lib/audit";
2565
+
2566
+ const jobs = createJobHandlers<AppContext>();
2567
+
2568
+ export const LogTodoCreatedJob = jobs.defineJob("todos.log-created", {
2569
+ payload: z.object({
2570
+ todoId: z.string().uuid(),
2571
+ title: z.string(),
2572
+ }),
2573
+ retry: retry.exponential({
2574
+ attempts: 3,
2575
+ initialDelay: "1s",
2576
+ maxDelay: "1m",
2577
+ }),
2578
+ async handle({ ctx, payload }) {
2579
+ ctx.ports.logger.info("Todo created job handled", {
2580
+ todoId: payload.todoId,
2581
+ });
2582
+ await ctx.ports.audit.record(
2583
+ auditEntry(ctx, {
2584
+ action: "jobs.todos.log-created",
2585
+ resource: { type: "todo", id: payload.todoId, name: payload.title },
2586
+ metadata: { jobName: "todos.log-created" },
2587
+ }),
2588
+ );
2589
+ },
2590
+ });
2591
+
2592
+ export const todoJobs = [LogTodoCreatedJob] as const;
2593
+ `,
2594
+ productionTodoNotifications: `import {
2595
+ createNotificationHandlers,
2596
+ defineMailNotificationChannel,
2597
+ } from "@beignet/core/notifications";
2598
+ import { z } from "zod";
2599
+ import type { AppContext } from "@/app-context";
2600
+
2601
+ const notifications = createNotificationHandlers<AppContext>();
2602
+
2603
+ export const TodoCreatedNotification = notifications.defineNotification(
2604
+ "todos.created",
2605
+ {
2606
+ payload: z.object({
2607
+ todoId: z.string().uuid(),
2608
+ title: z.string(),
2609
+ }),
2610
+ channels: {
2611
+ email: defineMailNotificationChannel(({ payload }) => ({
2612
+ to: "ops@example.local",
2613
+ subject: \`Todo created: \${payload.title}\`,
2614
+ text: \`Todo \${payload.todoId} was created.\`,
2615
+ })),
2616
+ },
2617
+ },
2618
+ );
2619
+ `,
2620
+ productionTodoListeners: `import { createEventHandlers } from "@beignet/core/events";
2621
+ import type { AppContext } from "@/app-context";
2622
+ import { TodoCreated } from "@/features/todos/domain/events";
2623
+ import { LogTodoCreatedJob } from "@/features/todos/jobs";
2624
+ import { TodoCreatedNotification } from "@/features/todos/notifications";
2625
+ import { auditEntry } from "@/lib/audit";
2626
+
2627
+ const listeners = createEventHandlers<AppContext>();
2628
+
2629
+ export const enqueueTodoCreatedWork = listeners.defineListener(TodoCreated, {
2630
+ name: "todos.enqueue-created-work",
2631
+ async handle({ ctx, payload }) {
2632
+ await ctx.ports.jobs.dispatch(LogTodoCreatedJob, payload);
2633
+ await ctx.ports.notifications.send(TodoCreatedNotification, payload);
2634
+ await ctx.ports.audit.record(
2635
+ auditEntry(ctx, {
2636
+ action: "listeners.todos.enqueue-created-work",
2637
+ resource: { type: "todo", id: payload.todoId, name: payload.title },
2638
+ metadata: {
2639
+ eventName: TodoCreated.name,
2640
+ jobName: LogTodoCreatedJob.name,
2641
+ notificationName: TodoCreatedNotification.name,
2642
+ },
2643
+ }),
2644
+ );
2645
+ },
2646
+ });
2647
+
2648
+ export const todoListeners = [enqueueTodoCreatedWork] as const;
2649
+ `,
2650
+ productionTodoSchedules: `import { normalizeOffsetPage } from "@beignet/core/pagination";
2651
+ import { createScheduleHandlers } from "@beignet/core/schedules";
2652
+ import { z } from "zod";
2653
+ import type { AppContext } from "@/app-context";
2654
+ import { auditEntry } from "@/lib/audit";
2655
+
2656
+ const schedules = createScheduleHandlers<AppContext>();
2657
+
2658
+ export const LogDailyTodoSummarySchedule = schedules.defineSchedule(
2659
+ "todos.log-daily-summary",
2660
+ {
2661
+ cron: "0 9 * * *",
2662
+ timezone: "America/Chicago",
2663
+ payload: z.object({
2664
+ date: z.string(),
2665
+ }),
2666
+ createPayload({ run }) {
2667
+ const date = run.scheduledAt ?? run.triggeredAt;
2668
+ return { date: date.toISOString().slice(0, 10) };
2669
+ },
2670
+ async handle({ ctx, payload, run }) {
2671
+ const { page } = await ctx.ports.todos.list(
2672
+ normalizeOffsetPage(
2673
+ { limit: 1, offset: 0 },
2674
+ { defaultLimit: 1, maxLimit: 1 },
2675
+ ),
2676
+ );
2677
+ ctx.ports.logger.info("Daily todo summary schedule handled", {
2678
+ date: payload.date,
2679
+ todoCount: page.total,
2680
+ });
2681
+ await ctx.ports.audit.record(
2682
+ auditEntry(ctx, {
2683
+ action: "schedules.todos.daily-summary",
2684
+ resource: {
2685
+ type: "schedule",
2686
+ id: "todos.log-daily-summary",
2687
+ name: "Daily todo summary",
2688
+ },
2689
+ metadata: {
2690
+ date: payload.date,
2691
+ todoCount: page.total,
2692
+ source: run.source ?? "inline",
2693
+ triggeredAt: run.triggeredAt.toISOString(),
2694
+ },
2695
+ }),
2696
+ );
2697
+ },
2698
+ },
2699
+ );
2700
+
2701
+ export const todoSchedules = [LogDailyTodoSummarySchedule] as const;
2702
+ `,
2703
+ productionTodoUploads: `import { defineUpload, defineUploads } from "@beignet/core/uploads";
2704
+ import { z } from "zod";
2705
+ import type { AppContext } from "@/app-context";
2706
+ import { auditEntry } from "@/lib/audit";
2707
+
2708
+ const todoAttachmentMetadataSchema = z.object({
2709
+ todoId: z.string().uuid(),
2710
+ });
2711
+
2712
+ export const TodoAttachmentUpload = defineUpload<
2713
+ "todos.attachment",
2714
+ typeof todoAttachmentMetadataSchema,
2715
+ AppContext,
2716
+ { attachmentIds: string[] }
2717
+ >("todos.attachment", {
2718
+ metadata: todoAttachmentMetadataSchema,
2719
+ file: {
2720
+ contentTypes: ["application/pdf", "text/plain", "text/plain;charset=utf-8"],
2721
+ maxSizeBytes: 5 * 1024 * 1024,
2722
+ maxFiles: 3,
2723
+ visibility: "private",
2724
+ cacheControl: "private, max-age=0",
2725
+ },
2726
+ authorize({ ctx }) {
2727
+ return ctx.actor.type === "user"
2728
+ ? true
2729
+ : {
2730
+ allowed: false,
2731
+ reason: "You must be signed in to upload todo attachments.",
2732
+ };
2733
+ },
2734
+ key({ ctx, metadata, file, uploadId }) {
2735
+ const tenantId = ctx.tenant?.id ?? "tenant_default";
2736
+ const extension = file.name.includes(".")
2737
+ ? file.name.split(".").pop()
2738
+ : undefined;
2739
+ const suffix = extension ? \`.\${extension}\` : "";
2740
+ return \`todos/\${tenantId}/\${metadata.todoId}/attachments/\${uploadId}\${suffix}\`;
2741
+ },
2742
+ storageMetadata({ ctx, metadata }) {
2743
+ return {
2744
+ tenantId: ctx.tenant?.id ?? "tenant_default",
2745
+ todoId: metadata.todoId,
2746
+ };
2747
+ },
2748
+ async onComplete({ ctx, metadata, files }) {
2749
+ const attachments = await Promise.all(
2750
+ files.map((file) =>
2751
+ ctx.ports.todoAttachments.create({
2752
+ id: file.uploadId,
2753
+ todoId: metadata.todoId,
2754
+ key: file.key,
2755
+ fileName: file.name,
2756
+ contentType: file.contentType,
2757
+ size: file.object.size,
2758
+ }),
2759
+ ),
2760
+ );
2761
+ await ctx.ports.audit.record(
2762
+ auditEntry(ctx, {
2763
+ action: "todos.attachment.upload",
2764
+ resource: { type: "todo", id: metadata.todoId },
2765
+ metadata: {
2766
+ attachmentCount: attachments.length,
2767
+ keys: attachments.map((attachment) => attachment.key),
2768
+ },
2769
+ }),
2770
+ );
2771
+
2772
+ return {
2773
+ attachmentIds: attachments.map((attachment) => attachment.id),
2774
+ };
2775
+ },
2776
+ });
2777
+
2778
+ export const todoUploads = defineUploads({
2779
+ todoAttachment: TodoAttachmentUpload,
2780
+ });
2781
+ `,
2782
+ productionOutbox: `import { defineOutboxRegistry } from "@beignet/core/outbox";
2783
+ import { todoEvents } from "@/features/todos/domain/events";
2784
+ import { todoJobs } from "@/features/todos/jobs";
2785
+
2786
+ export const outboxRegistry = defineOutboxRegistry({
2787
+ events: todoEvents,
2788
+ jobs: todoJobs,
2789
+ });
2790
+ `,
2791
+ productionOutboxDrainRoute: `import { createOutboxDrainRoute } from "@beignet/next";
2792
+ import { env } from "@/lib/env";
2793
+ import { server } from "@/server";
2794
+ import { outboxRegistry } from "@/server/outbox";
2795
+
2796
+ export const runtime = "nodejs";
2797
+
2798
+ export const { GET, POST } = createOutboxDrainRoute({
2799
+ server,
2800
+ registry: outboxRegistry,
2801
+ secret: env.CRON_SECRET,
2802
+ });
2803
+ `,
2804
+ productionScheduleRoute: `import { createInlineScheduleRunner } from "@beignet/core/schedules";
2805
+ import type { AppContext } from "@/app-context";
2806
+ import { LogDailyTodoSummarySchedule } from "@/features/todos/schedules";
2807
+ import { env } from "@/lib/env";
2808
+ import { server } from "@/server";
2809
+
2810
+ async function runDailyTodoSummary(request: Request) {
2811
+ if (!env.CRON_SECRET) {
2812
+ return Response.json(
2813
+ {
2814
+ ok: false,
2815
+ error: "CRON_SECRET is not configured.",
2816
+ scheduleName: LogDailyTodoSummarySchedule.name,
2817
+ },
2818
+ { status: 500 },
2819
+ );
2820
+ }
2821
+
2822
+ if (request.headers.get("authorization") !== \`Bearer \${env.CRON_SECRET}\`) {
2823
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
2824
+ }
2825
+
2826
+ const ctx = await server.createContextFromNext();
2827
+ const runner = createInlineScheduleRunner<AppContext>({
2828
+ ctx,
2829
+ onStart({ run, schedule }) {
2830
+ ctx.ports.devtools.record({
2831
+ type: "schedule",
2832
+ watcher: "schedules",
2833
+ requestId: ctx.requestId,
2834
+ traceId: ctx.traceId,
2835
+ scheduleName: schedule.name,
2836
+ status: "started",
2837
+ cron: schedule.cron,
2838
+ timezone: schedule.timezone,
2839
+ details: {
2840
+ source: run.source,
2841
+ scheduledAt: run.scheduledAt?.toISOString(),
2842
+ },
2843
+ });
2844
+ },
2845
+ onSuccess({ run, schedule }) {
2846
+ ctx.ports.devtools.record({
2847
+ type: "schedule",
2848
+ watcher: "schedules",
2849
+ requestId: ctx.requestId,
2850
+ traceId: ctx.traceId,
2851
+ scheduleName: schedule.name,
2852
+ status: "completed",
2853
+ cron: schedule.cron,
2854
+ timezone: schedule.timezone,
2855
+ details: {
2856
+ source: run.source,
2857
+ scheduledAt: run.scheduledAt?.toISOString(),
2858
+ },
2859
+ });
2860
+ },
2861
+ onError({ error, run, schedule }) {
2862
+ ctx.ports.devtools.record({
2863
+ type: "schedule",
2864
+ watcher: "schedules",
2865
+ requestId: ctx.requestId,
2866
+ traceId: ctx.traceId,
2867
+ scheduleName: schedule.name,
2868
+ status: "failed",
2869
+ cron: schedule.cron,
2870
+ timezone: schedule.timezone,
2871
+ details: {
2872
+ error,
2873
+ source: run.source,
2874
+ scheduledAt: run.scheduledAt?.toISOString(),
2875
+ },
2876
+ });
2877
+ ctx.ports.logger.error("Daily todo summary schedule failed", {
2878
+ error,
2879
+ scheduleName: schedule.name,
2880
+ });
2881
+ },
2882
+ });
2883
+
2884
+ try {
2885
+ await runner.run(LogDailyTodoSummarySchedule, {
2886
+ source: "starter-cron-route",
2887
+ });
2888
+ } catch {
2889
+ return Response.json(
2890
+ {
2891
+ ok: false,
2892
+ error: "Schedule failed",
2893
+ scheduleName: LogDailyTodoSummarySchedule.name,
2894
+ },
2895
+ { status: 500 },
2896
+ );
2897
+ }
2898
+
2899
+ return Response.json({
2900
+ ok: true,
2901
+ scheduleName: LogDailyTodoSummarySchedule.name,
2902
+ });
2903
+ }
2904
+
2905
+ export const GET = runDailyTodoSummary;
2906
+ export const POST = runDailyTodoSummary;
2907
+ `,
2908
+ productionUploadsRoute: `import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
2909
+ import { createUploadRoute } from "@beignet/next";
2910
+ import type { AppContext } from "@/app-context";
2911
+ import { todoUploads } from "@/features/todos/uploads";
2912
+ import { server } from "@/server";
2913
+
2914
+ const uploadRouter = createUploadRouter<AppContext>({
2915
+ uploads: uploadsFromRegistry(todoUploads),
2916
+ ctx: () => server.createContextFromNext(),
2917
+ storage: server.ports.storage,
2918
+ instrumentation: server.ports.devtools,
2919
+ });
2920
+
2921
+ export const { POST } = createUploadRoute(uploadRouter);
2922
+ `,
2923
+ productionUploadClient: `import { createUploadClient } from "@beignet/core/uploads/client";
2924
+ import type { todoUploads } from "@/features/todos/uploads";
2925
+
2926
+ export const uploads = createUploadClient<typeof todoUploads>({
2927
+ baseUrl: "/api/uploads",
2928
+ });
1802
2929
  `,
1803
2930
  };
1804
2931
  function productionProviderPortImports(ctx) {
1805
2932
  const imports = [];
1806
- if (hasIntegration(ctx, "inngest"))
1807
- imports.push("\tJobDispatcherPort,");
1808
2933
  if (hasIntegration(ctx, "upstash-rate-limit")) {
1809
2934
  imports.push("\tRateLimitPort,");
1810
2935
  }
@@ -1812,43 +2937,58 @@ function productionProviderPortImports(ctx) {
1812
2937
  }
1813
2938
  function productionProviderPortFields(ctx) {
1814
2939
  const fields = [];
1815
- if (hasIntegration(ctx, "inngest"))
1816
- fields.push("\tjobs: JobDispatcherPort;");
1817
- if (hasIntegration(ctx, "resend"))
1818
- fields.push("\tmailer: MailerPort;");
1819
2940
  if (hasIntegration(ctx, "upstash-rate-limit")) {
1820
2941
  fields.push("\trateLimit: RateLimitPort;");
1821
2942
  }
1822
2943
  return fields;
1823
2944
  }
1824
2945
  function productionPorts(ctx) {
1825
- const mailImport = hasIntegration(ctx, "resend")
1826
- ? 'import type { MailerPort } from "@beignet/core/mail";\n'
1827
- : "";
2946
+ const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
1828
2947
  const imports = [
2948
+ "\tAuditLogPort,",
1829
2949
  "\tBoundGate,",
2950
+ "\tEventBusPort,",
1830
2951
  "\tGatePort,",
2952
+ "\tJobDispatcherPort,",
1831
2953
  "\tLoggerPort,",
1832
2954
  ...productionProviderPortImports(ctx),
2955
+ "\tStoragePort,",
1833
2956
  "\tUnitOfWorkPort,",
1834
2957
  ];
1835
2958
  const providerFields = productionProviderPortFields(ctx);
1836
- return `import type {
2959
+ return `import type { IdempotencyPort } from "@beignet/core/idempotency";
2960
+ ${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
2961
+ import type { OutboxPort } from "@beignet/core/outbox";
2962
+ import type {
1837
2963
  ${imports.join("\n")}
1838
2964
  } from "@beignet/core/ports";
1839
- ${mailImport}import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
2965
+ import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
1840
2966
  import type { AuthPort } from "./auth";
1841
- import type { TodoRepository } from "@/features/todos/ports";
2967
+ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
1842
2968
 
1843
2969
  export type AppTransactionPorts = {
2970
+ audit: AuditLogPort;
2971
+ events: import("@beignet/core/ports").BufferedDomainEventRecorder;
2972
+ jobs: JobDispatcherPort;
2973
+ outbox: OutboxPort;
2974
+ todoAttachments: TodoAttachmentRepository;
1844
2975
  todos: TodoRepository;
1845
2976
  };
1846
2977
 
1847
2978
  export type AppGate = BoundGate<[typeof todoPolicy]>;
1848
2979
 
1849
2980
  export type AppPorts = {
2981
+ audit: AuditLogPort;
1850
2982
  auth: AuthPort;
2983
+ eventBus: EventBusPort;
1851
2984
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
2985
+ idempotency: IdempotencyPort;
2986
+ jobs: JobDispatcherPort;
2987
+ mailer: MailerPort;
2988
+ notifications: NotificationPort;
2989
+ outbox: OutboxPort;
2990
+ storage: StoragePort;
2991
+ todoAttachments: TodoAttachmentRepository;
1852
2992
  todos: TodoRepository;
1853
2993
  logger: LoggerPort;
1854
2994
  ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
@@ -1856,36 +2996,58 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
1856
2996
  `;
1857
2997
  }
1858
2998
  function productionPortsWithDrizzleTurso(ctx) {
1859
- const mailImport = hasIntegration(ctx, "resend")
1860
- ? 'import type { MailerPort } from "@beignet/core/mail";\n'
1861
- : "";
2999
+ const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
1862
3000
  const imports = [
3001
+ "\tAuditLogPort,",
1863
3002
  "\tBoundGate,",
3003
+ "\tEventBusPort,",
1864
3004
  "\tGatePort,",
3005
+ "\tJobDispatcherPort,",
1865
3006
  "\tLoggerPort,",
1866
3007
  ...productionProviderPortImports(ctx),
3008
+ "\tStoragePort,",
1867
3009
  "\tUnitOfWorkPort,",
1868
3010
  ];
1869
3011
  const providerFields = productionProviderPortFields(ctx);
1870
- return `import type {
3012
+ return `import type { IdempotencyPort } from "@beignet/core/idempotency";
3013
+ ${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
3014
+ import type { OutboxPort } from "@beignet/core/outbox";
3015
+ import type {
1871
3016
  ${imports.join("\n")}
1872
3017
  } from "@beignet/core/ports";
1873
- ${mailImport}import type { DbPort } from "@beignet/provider-drizzle-turso";
1874
- import * as schema from "@/infra/db/schema";
1875
3018
  import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
1876
3019
  import type { AuthPort } from "./auth";
1877
- import type { TodoRepository } from "@/features/todos/ports";
3020
+ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
3021
+
3022
+ export type AppDatabasePort = {
3023
+ db: unknown;
3024
+ client: unknown;
3025
+ };
1878
3026
 
1879
3027
  export type AppTransactionPorts = {
3028
+ audit: AuditLogPort;
3029
+ events: import("@beignet/core/ports").BufferedDomainEventRecorder;
3030
+ jobs: JobDispatcherPort;
3031
+ outbox: OutboxPort;
3032
+ todoAttachments: TodoAttachmentRepository;
1880
3033
  todos: TodoRepository;
1881
3034
  };
1882
3035
 
1883
3036
  export type AppGate = BoundGate<[typeof todoPolicy]>;
1884
3037
 
1885
3038
  export type AppPorts = {
3039
+ audit: AuditLogPort;
1886
3040
  auth: AuthPort;
1887
- db: DbPort<typeof schema>;
3041
+ db: AppDatabasePort;
3042
+ eventBus: EventBusPort;
1888
3043
  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
3044
+ idempotency: IdempotencyPort;
3045
+ jobs: JobDispatcherPort;
3046
+ mailer: MailerPort;
3047
+ notifications: NotificationPort;
3048
+ outbox: OutboxPort;
3049
+ storage: StoragePort;
3050
+ todoAttachments: TodoAttachmentRepository;
1889
3051
  todos: TodoRepository;
1890
3052
  logger: LoggerPort;
1891
3053
  ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
@@ -1895,6 +3057,9 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
1895
3057
  function productionServerProviders(ctx) {
1896
3058
  const imports = [
1897
3059
  'import { createDevtoolsProvider } from "@beignet/devtools";',
3060
+ hasIntegration(ctx, "better-auth")
3061
+ ? 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";'
3062
+ : undefined,
1898
3063
  hasDrizzleTurso(ctx)
1899
3064
  ? 'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";'
1900
3065
  : undefined,
@@ -1912,15 +3077,26 @@ function productionServerProviders(ctx) {
1912
3077
  hasDrizzleTurso(ctx)
1913
3078
  ? 'import * as schema from "@/infra/db/schema";'
1914
3079
  : undefined,
3080
+ hasIntegration(ctx, "better-auth")
3081
+ ? 'import { auth } from "@/lib/better-auth";'
3082
+ : undefined,
3083
+ 'import { starterDatabaseProvider } from "@/infra/db/provider";',
3084
+ hasIntegration(ctx, "better-auth")
3085
+ ? 'import type { AuthSessionMetadata, AuthUser } from "@/ports/auth";'
3086
+ : undefined,
1915
3087
  ].filter((line) => Boolean(line));
1916
3088
  const declarations = hasDrizzleTurso(ctx)
1917
3089
  ? "\nconst drizzleTursoProvider = createDrizzleTursoProvider({ schema });\n"
1918
3090
  : "";
1919
3091
  const entries = [
1920
3092
  "\tcreateDevtoolsProvider(),",
3093
+ hasIntegration(ctx, "better-auth")
3094
+ ? "\tcreateAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth),"
3095
+ : undefined,
1921
3096
  "\tlocalStorageProvider,",
1922
3097
  "\tloggerPinoProvider,",
1923
3098
  hasDrizzleTurso(ctx) ? "\tdrizzleTursoProvider," : undefined,
3099
+ "\tstarterDatabaseProvider,",
1924
3100
  hasIntegration(ctx, "inngest") ? "\tinngestProvider," : undefined,
1925
3101
  hasIntegration(ctx, "resend") ? "\tmailResendProvider," : undefined,
1926
3102
  hasIntegration(ctx, "upstash-rate-limit")
@@ -1947,20 +3123,20 @@ function productionTodosTest(ctx) {
1947
3123
  hasDrizzleTurso(ctx)
1948
3124
  ? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
1949
3125
  : 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
3126
  hasIntegration(ctx, "upstash-rate-limit")
1957
3127
  ? "\t\t\trateLimit: createMemoryRateLimiter(),"
1958
3128
  : undefined,
1959
3129
  ].filter((entry) => Boolean(entry));
1960
3130
  return `import { describe, expect, it } from "bun:test";
1961
3131
  import { createUseCaseTester } from "@beignet/core/application";
3132
+ import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
3133
+ import { createMemoryMailer } from "@beignet/core/mail";
3134
+ import { createMemoryNotificationPort } from "@beignet/core/notifications";
3135
+ import { createMemoryOutbox } from "@beignet/core/outbox";
3136
+ import { offsetPageResult, type OffsetPage } from "@beignet/core/pagination";
1962
3137
  import { createInMemoryDevtools } from "@beignet/devtools";
1963
- import { ${portImports.join(", ")} } from "@beignet/core/ports";
3138
+ import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
3139
+ import { ${["createDomainEventRecorder", "createMemoryAuditLog", ...portImports].join(", ")} } from "@beignet/core/ports";
1964
3140
  import type { AppContext } from "@/app-context";
1965
3141
  import { appPorts } from "@/infra/app-ports";
1966
3142
  import {
@@ -1968,7 +3144,6 @@ import {
1968
3144
  getTodoUseCase,
1969
3145
  listTodosUseCase,
1970
3146
  type CreateTodoInput,
1971
- type ListTodosInput,
1972
3147
  type Todo,
1973
3148
  } from "../use-cases";
1974
3149
 
@@ -1976,15 +3151,16 @@ function createTestTodoRepository() {
1976
3151
  const todos = new Map<string, Todo>();
1977
3152
 
1978
3153
  return {
1979
- async list(input: ListTodosInput) {
3154
+ async list(page: OffsetPage) {
1980
3155
  const allTodos = Array.from(todos.values()).sort((left, right) =>
1981
3156
  left.createdAt.localeCompare(right.createdAt),
1982
3157
  );
1983
3158
 
1984
- return {
1985
- todos: allTodos.slice(input.offset, input.offset + input.limit),
1986
- total: allTodos.length,
1987
- };
3159
+ return offsetPageResult(
3160
+ allTodos.slice(page.offset, page.offset + page.limit),
3161
+ page,
3162
+ allTodos.length,
3163
+ );
1988
3164
  },
1989
3165
  async findById(id: string) {
1990
3166
  return todos.get(id) ?? null;
@@ -2002,9 +3178,39 @@ function createTestTodoRepository() {
2002
3178
  };
2003
3179
  }
2004
3180
 
3181
+ function createTestTodoAttachmentRepository() {
3182
+ return {
3183
+ async create(input: {
3184
+ id: string;
3185
+ todoId: string;
3186
+ key: string;
3187
+ fileName: string;
3188
+ contentType: string;
3189
+ size: number;
3190
+ }) {
3191
+ return {
3192
+ ...input,
3193
+ createdAt: new Date().toISOString(),
3194
+ };
3195
+ },
3196
+ async findMany() {
3197
+ return [];
3198
+ },
3199
+ };
3200
+ }
3201
+
2005
3202
  describe("todos resource", () => {
2006
3203
  it("creates, gets, and lists todos", async () => {
2007
3204
  const todos = createTestTodoRepository();
3205
+ const todoAttachments = createTestTodoAttachmentRepository();
3206
+ const audit = createMemoryAuditLog();
3207
+ const eventBus = createInMemoryEventBus();
3208
+ const jobs = { dispatch: async () => undefined };
3209
+ const mailer = createMemoryMailer({
3210
+ defaultFrom: "Test <test@example.local>",
3211
+ });
3212
+ const notifications = createMemoryNotificationPort();
3213
+ const outbox = createMemoryOutbox();
2008
3214
  const auth = {
2009
3215
  user: {
2010
3216
  id: "user_test",
@@ -2018,11 +3224,26 @@ describe("todos resource", () => {
2018
3224
  });
2019
3225
  const testPorts = {
2020
3226
  ...appPorts,
3227
+ audit,
3228
+ eventBus,
3229
+ idempotency: createMemoryIdempotencyStore(),
3230
+ jobs,
3231
+ mailer,
3232
+ notifications,
3233
+ outbox,
3234
+ todoAttachments,
2021
3235
  todos,
2022
- ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({ todos })) as AppContext["ports"]["uow"],
3236
+ ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({
3237
+ audit,
3238
+ events: createDomainEventRecorder(),
3239
+ jobs,
3240
+ outbox,
3241
+ todoAttachments,
3242
+ todos,
3243
+ })) as unknown as AppContext["ports"]["uow"],
2023
3244
  devtools: createInMemoryDevtools(),
2024
3245
  storage: createMemoryStorage(),
2025
- };
3246
+ } as unknown as AppContext["ports"];
2026
3247
  const tester = createUseCaseTester<AppContext>(() => ({
2027
3248
  requestId: "test-request",
2028
3249
  actor,
@@ -2046,8 +3267,8 @@ ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUn
2046
3267
 
2047
3268
  expect(created.title).toBe("First todo");
2048
3269
  expect(found.id).toBe(created.id);
2049
- expect(result.total).toBe(1);
2050
- expect(result.todos).toEqual([created]);
3270
+ expect(result.page.total).toBe(1);
3271
+ expect(result.items).toEqual([created]);
2051
3272
  });
2052
3273
  });
2053
3274
  `;
@@ -2084,8 +3305,25 @@ function getProductionTemplateFiles(ctx) {
2084
3305
  path: "app/api/openapi/route.ts",
2085
3306
  content: files.productionOpenApiRoute,
2086
3307
  },
3308
+ {
3309
+ path: "app/api/auth/[...all]/route.ts",
3310
+ content: files.productionAuthRoute,
3311
+ },
3312
+ {
3313
+ path: "app/api/cron/outbox/drain/route.ts",
3314
+ content: files.productionOutboxDrainRoute,
3315
+ },
3316
+ {
3317
+ path: "app/api/cron/todos/daily-summary/route.ts",
3318
+ content: files.productionScheduleRoute,
3319
+ },
3320
+ {
3321
+ path: "app/api/uploads/[uploadName]/[action]/route.ts",
3322
+ content: files.productionUploadsRoute,
3323
+ },
2087
3324
  { path: "client/api-client.ts", content: files.apiClient },
2088
3325
  { path: "client/rq.ts", content: files.rq },
3326
+ { path: "client/uploads.ts", content: files.productionUploadClient },
2089
3327
  {
2090
3328
  path: "features/todos/contracts.ts",
2091
3329
  content: files.productionContractsTodos,
@@ -2100,12 +3338,39 @@ function getProductionTemplateFiles(ctx) {
2100
3338
  : files.productionInfrastructurePorts,
2101
3339
  },
2102
3340
  { path: "lib/env.ts", content: files.productionEnv },
3341
+ { path: "lib/audit.ts", content: files.productionAuditHelper },
2103
3342
  { path: "lib/auth.ts", content: files.productionAuthHelpers },
3343
+ { path: "lib/better-auth.ts", content: files.productionBetterAuth },
2104
3344
  { path: "features/todos/policy.ts", content: files.productionTodoPolicy },
2105
3345
  {
2106
- path: "infra/auth/anonymous-auth.ts",
2107
- content: files.productionAnonymousAuth,
3346
+ path: "features/todos/domain/events/index.ts",
3347
+ content: files.productionTodoEvents,
3348
+ },
3349
+ { path: "features/todos/jobs/index.ts", content: files.productionTodoJobs },
3350
+ {
3351
+ path: "features/todos/listeners/index.ts",
3352
+ content: files.productionTodoListeners,
2108
3353
  },
3354
+ {
3355
+ path: "features/todos/notifications/index.ts",
3356
+ content: files.productionTodoNotifications,
3357
+ },
3358
+ {
3359
+ path: "features/todos/schedules/index.ts",
3360
+ content: files.productionTodoSchedules,
3361
+ },
3362
+ {
3363
+ path: "features/todos/uploads/index.ts",
3364
+ content: files.productionTodoUploads,
3365
+ },
3366
+ ...(!hasIntegration(ctx, "better-auth")
3367
+ ? [
3368
+ {
3369
+ path: "infra/auth/anonymous-auth.ts",
3370
+ content: files.productionAnonymousAuth,
3371
+ },
3372
+ ]
3373
+ : []),
2109
3374
  { path: "ports/auth.ts", content: files.productionAuthPort },
2110
3375
  {
2111
3376
  path: "ports/index.ts",
@@ -2128,6 +3393,7 @@ function getProductionTemplateFiles(ctx) {
2128
3393
  : files.productionServer,
2129
3394
  },
2130
3395
  { path: "server/routes.ts", content: files.productionServerRoutes },
3396
+ { path: "server/outbox.ts", content: files.productionOutbox },
2131
3397
  {
2132
3398
  path: "server/providers.ts",
2133
3399
  content: productionServerProviders(ctx),
@@ -2161,6 +3427,21 @@ function getProductionTemplateFiles(ctx) {
2161
3427
  ];
2162
3428
  if (usesDrizzleTurso) {
2163
3429
  templateFiles.push({ path: "drizzle.config.ts", content: files.productionDrizzleConfig }, {
3430
+ path: "infra/audit/drizzle-audit-log.ts",
3431
+ content: files.productionDrizzleAuditLog,
3432
+ }, {
3433
+ path: "infra/db/bootstrap.ts",
3434
+ content: files.productionDbBootstrap,
3435
+ }, {
3436
+ path: "infra/db/seed.ts",
3437
+ content: files.productionDbSeed,
3438
+ }, {
3439
+ path: "infra/db/reset.ts",
3440
+ content: files.productionDbReset,
3441
+ }, {
3442
+ path: "infra/db/provider.ts",
3443
+ content: files.productionDbProvider,
3444
+ }, {
2164
3445
  path: "infra/db/schema/index.ts",
2165
3446
  content: files.productionDbSchema,
2166
3447
  }, {
@@ -2179,6 +3460,9 @@ function getProductionTemplateFiles(ctx) {
2179
3460
  }
2180
3461
  return templateFiles;
2181
3462
  }
3463
+ /**
3464
+ * Render all template files for a new Beignet app.
3465
+ */
2182
3466
  export function getTemplateFiles(ctx) {
2183
3467
  if (isStandardPreset(ctx)) {
2184
3468
  return getProductionTemplateFiles(ctx);