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