@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/CHANGELOG.md +26 -0
- package/README.md +144 -55
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -0
- package/dist/config.js.map +1 -1
- package/dist/create.d.ts +9 -0
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +5 -0
- package/dist/create.js.map +1 -1
- package/dist/db.d.ts +36 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +111 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +243 -2
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts +24 -0
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +288 -4
- package/dist/inspect.js.map +1 -1
- package/dist/lint.d.ts +12 -0
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +30 -5
- package/dist/lint.js.map +1 -1
- package/dist/make.d.ts +91 -0
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +602 -72
- package/dist/make.js.map +1 -1
- package/dist/templates.d.ts +33 -0
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +1396 -112
- package/dist/templates.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +31 -0
- package/src/create.ts +11 -0
- package/src/db.ts +166 -0
- package/src/index.ts +320 -0
- package/src/inspect.ts +497 -3
- package/src/lint.ts +43 -9
- package/src/make.ts +917 -80
- package/src/templates.ts +1425 -111
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 \`${
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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("
|
|
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?.
|
|
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?.
|
|
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
|
-
|
|
936
|
-
|
|
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
|
-
|
|
993
|
-
|
|
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 {
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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 {
|
|
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
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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 {
|
|
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
|
-
.
|
|
1277
|
+
.emits([TodoCreated])
|
|
1278
|
+
.run(async ({ ctx, input, events }) => {
|
|
1225
1279
|
await ctx.gate.authorize("todos.create");
|
|
1226
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
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(
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
1348
|
-
|
|
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 {
|
|
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 {
|
|
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(
|
|
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(
|
|
1485
|
-
.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 {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
1986
|
-
|
|
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(() => ({
|
|
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.
|
|
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: "
|
|
2107
|
-
content: files.
|
|
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);
|