@beignet/cli 0.0.1 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +231 -0
- package/README.md +497 -90
- package/dist/ansi.d.ts +10 -0
- package/dist/ansi.d.ts.map +1 -0
- package/dist/ansi.js +20 -0
- package/dist/ansi.js.map +1 -0
- package/dist/choices.d.ts +72 -0
- package/dist/choices.d.ts.map +1 -0
- package/dist/choices.js +88 -0
- package/dist/choices.js.map +1 -0
- package/dist/completion.d.ts +47 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +123 -0
- package/dist/completion.js.map +1 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +26 -0
- package/dist/config.js.map +1 -1
- package/dist/create-prompts.d.ts +42 -0
- package/dist/create-prompts.d.ts.map +1 -0
- package/dist/create-prompts.js +136 -0
- package/dist/create-prompts.js.map +1 -0
- package/dist/create.d.ts +12 -0
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +19 -24
- package/dist/create.js.map +1 -1
- package/dist/db.d.ts +37 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +146 -0
- package/dist/db.js.map +1 -0
- package/dist/github-annotations.d.ts +18 -0
- package/dist/github-annotations.d.ts.map +1 -0
- package/dist/github-annotations.js +22 -0
- package/dist/github-annotations.js.map +1 -0
- package/dist/index.d.ts +1 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +710 -400
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts +45 -2
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +2191 -100
- package/dist/inspect.js.map +1 -1
- package/dist/lib.d.ts +20 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +17 -0
- package/dist/lib.js.map +1 -0
- package/dist/lint.d.ts +22 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +370 -38
- package/dist/lint.js.map +1 -1
- package/dist/make.d.ts +109 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +2225 -333
- package/dist/make.js.map +1 -1
- package/dist/outbox.d.ts +24 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +138 -0
- package/dist/outbox.js.map +1 -0
- package/dist/schedule.d.ts +36 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +155 -0
- package/dist/schedule.js.map +1 -0
- package/dist/task.d.ts +26 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +106 -0
- package/dist/task.js.map +1 -0
- package/dist/templates.d.ts +12 -8
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +2144 -385
- package/dist/templates.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +18 -0
- package/dist/version.js.map +1 -0
- package/package.json +9 -8
- package/src/ansi.ts +30 -0
- package/src/choices.ts +137 -0
- package/src/completion.ts +169 -0
- package/src/config.ts +47 -0
- package/src/create-prompts.ts +182 -0
- package/src/create.ts +32 -28
- package/src/db.ts +222 -0
- package/src/github-annotations.ts +37 -0
- package/src/index.ts +1119 -535
- package/src/inspect.ts +3372 -134
- package/src/lib.ts +45 -0
- package/src/lint.ts +533 -45
- package/src/make.ts +3010 -397
- package/src/outbox.ts +249 -0
- package/src/schedule.ts +272 -0
- package/src/task.ts +169 -0
- package/src/templates.ts +2282 -462
- package/src/version.ts +20 -0
- package/dist/create-bin.d.ts +0 -3
- package/dist/create-bin.d.ts.map +0 -1
- package/dist/create-bin.js +0 -9
- package/dist/create-bin.js.map +0 -1
- package/src/create-bin.ts +0 -11
package/src/templates.ts
CHANGED
|
@@ -1,20 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import type {
|
|
2
|
+
FeatureName,
|
|
3
|
+
IntegrationName,
|
|
4
|
+
PackageManager,
|
|
5
|
+
PresetName,
|
|
6
|
+
} from "./choices.js";
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
FeatureName,
|
|
10
|
+
IntegrationName,
|
|
11
|
+
PackageManager,
|
|
12
|
+
PresetName,
|
|
13
|
+
TemplateName,
|
|
14
|
+
} from "./choices.js";
|
|
15
|
+
export {
|
|
16
|
+
featureChoices,
|
|
17
|
+
integrationChoices,
|
|
18
|
+
presetChoices,
|
|
19
|
+
} from "./choices.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One generated file emitted by the template generator.
|
|
23
|
+
*/
|
|
13
24
|
export type TemplateFile = {
|
|
14
25
|
path: string;
|
|
15
26
|
content: string;
|
|
16
27
|
};
|
|
17
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Inputs used to render app template files.
|
|
31
|
+
*/
|
|
18
32
|
export type TemplateContext = {
|
|
19
33
|
name: string;
|
|
20
34
|
packageManager: PackageManager;
|
|
@@ -37,9 +51,9 @@ const externalVersions = {
|
|
|
37
51
|
tanstackReactQuery: "^5.100.7",
|
|
38
52
|
hookformResolvers: "^5.0.0",
|
|
39
53
|
reactHookForm: "^7.74.0",
|
|
40
|
-
betterAuth: "
|
|
41
|
-
drizzleKit: "^0.
|
|
42
|
-
drizzleOrm: "^0.
|
|
54
|
+
betterAuth: "1.6.11",
|
|
55
|
+
drizzleKit: "^0.31.10",
|
|
56
|
+
drizzleOrm: "^0.45.2",
|
|
43
57
|
inngest: "^3.0.0",
|
|
44
58
|
libsqlClient: "^0.14.0",
|
|
45
59
|
pino: "^9.7.0",
|
|
@@ -59,37 +73,16 @@ function hasDrizzleTurso(ctx: TemplateContext): boolean {
|
|
|
59
73
|
function packageRunner(ctx: TemplateContext): string {
|
|
60
74
|
switch (ctx.packageManager) {
|
|
61
75
|
case "npm":
|
|
62
|
-
return "
|
|
76
|
+
return "npm run beignet --";
|
|
63
77
|
case "pnpm":
|
|
64
|
-
return "pnpm
|
|
78
|
+
return "pnpm beignet";
|
|
65
79
|
case "yarn":
|
|
66
|
-
return "yarn
|
|
80
|
+
return "yarn beignet";
|
|
67
81
|
default:
|
|
68
|
-
return "
|
|
82
|
+
return "bun beignet";
|
|
69
83
|
}
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
export const featureChoices = [
|
|
73
|
-
"client",
|
|
74
|
-
"react-query",
|
|
75
|
-
"forms",
|
|
76
|
-
"openapi",
|
|
77
|
-
] as const satisfies readonly FeatureName[];
|
|
78
|
-
|
|
79
|
-
export const presetChoices = [
|
|
80
|
-
"minimal",
|
|
81
|
-
"standard",
|
|
82
|
-
] as const satisfies readonly PresetName[];
|
|
83
|
-
|
|
84
|
-
export const integrationChoices = [
|
|
85
|
-
"better-auth",
|
|
86
|
-
"drizzle-turso",
|
|
87
|
-
"inngest",
|
|
88
|
-
"pino",
|
|
89
|
-
"resend",
|
|
90
|
-
"upstash-rate-limit",
|
|
91
|
-
] as const satisfies readonly IntegrationName[];
|
|
92
|
-
|
|
93
86
|
const integrationDeps: Record<IntegrationName, Record<string, string>> = {
|
|
94
87
|
"better-auth": {
|
|
95
88
|
"@beignet/provider-auth-better-auth": "",
|
|
@@ -184,6 +177,7 @@ function packageJson(ctx: TemplateContext): string {
|
|
|
184
177
|
|
|
185
178
|
if (isStandardPreset(ctx)) {
|
|
186
179
|
dependencies["@beignet/devtools"] = ctx.beignetVersion;
|
|
180
|
+
dependencies["@beignet/provider-event-bus-memory"] = ctx.beignetVersion;
|
|
187
181
|
dependencies["@beignet/provider-storage-local"] = ctx.beignetVersion;
|
|
188
182
|
}
|
|
189
183
|
|
|
@@ -202,6 +196,7 @@ function packageJson(ctx: TemplateContext): string {
|
|
|
202
196
|
}
|
|
203
197
|
|
|
204
198
|
const devDependencies: Record<string, string> = {
|
|
199
|
+
"@beignet/cli": ctx.beignetVersion,
|
|
205
200
|
"@types/bun": externalVersions.typesBun,
|
|
206
201
|
"@types/node": externalVersions.typesNode,
|
|
207
202
|
"@types/react": externalVersions.typesReact,
|
|
@@ -218,6 +213,7 @@ function packageJson(ctx: TemplateContext): string {
|
|
|
218
213
|
}
|
|
219
214
|
|
|
220
215
|
const scripts: Record<string, string> = {
|
|
216
|
+
beignet: "beignet",
|
|
221
217
|
dev: "next dev",
|
|
222
218
|
build: "next build",
|
|
223
219
|
start: "next start",
|
|
@@ -228,6 +224,13 @@ function packageJson(ctx: TemplateContext): string {
|
|
|
228
224
|
if (hasDrizzleTurso(ctx)) {
|
|
229
225
|
scripts["db:generate"] = "drizzle-kit generate";
|
|
230
226
|
scripts["db:migrate"] = "drizzle-kit migrate";
|
|
227
|
+
|
|
228
|
+
// The minimal preset does not scaffold infra/db/seed.ts or
|
|
229
|
+
// infra/db/reset.ts, so only the standard preset gets their scripts.
|
|
230
|
+
if (isStandardPreset(ctx)) {
|
|
231
|
+
scripts["db:seed"] = "bun infra/db/seed.ts";
|
|
232
|
+
scripts["db:reset"] = "bun infra/db/reset.ts";
|
|
233
|
+
}
|
|
231
234
|
}
|
|
232
235
|
|
|
233
236
|
return json({
|
|
@@ -251,6 +254,9 @@ function readme(ctx: TemplateContext): string {
|
|
|
251
254
|
ctx.packageManager === "npm"
|
|
252
255
|
? "npm run dev"
|
|
253
256
|
: `${ctx.packageManager} run dev`;
|
|
257
|
+
const cli = packageRunner(ctx);
|
|
258
|
+
const envSetup =
|
|
259
|
+
ctx.integrations.length > 0 ? "cp .env.example .env.local\n" : "";
|
|
254
260
|
const featureList =
|
|
255
261
|
ctx.features.length > 0
|
|
256
262
|
? ctx.features.map((feature) => `- \`${feature}\``).join("\n")
|
|
@@ -259,6 +265,20 @@ function readme(ctx: TemplateContext): string {
|
|
|
259
265
|
ctx.integrations.length > 0
|
|
260
266
|
? ctx.integrations.map((integration) => `- \`${integration}\``).join("\n")
|
|
261
267
|
: "- None";
|
|
268
|
+
const databaseSection = hasDrizzleTurso(ctx)
|
|
269
|
+
? `
|
|
270
|
+
## Database
|
|
271
|
+
|
|
272
|
+
The drizzle-turso integration persists todos with Drizzle on libSQL/Turso. The server creates and seeds the starter \`todos\` table at startup using \`TURSO_DB_URL\` (defaults to \`file:local.db\`).
|
|
273
|
+
|
|
274
|
+
Regenerate and apply migrations after changing \`infra/db/schema/index.ts\`:
|
|
275
|
+
|
|
276
|
+
\`\`\`bash
|
|
277
|
+
${cli} db generate
|
|
278
|
+
${cli} db migrate
|
|
279
|
+
\`\`\`
|
|
280
|
+
`
|
|
281
|
+
: "";
|
|
262
282
|
|
|
263
283
|
return `# ${ctx.name}
|
|
264
284
|
|
|
@@ -268,19 +288,21 @@ Beignet app scaffolded with \`@beignet/cli\`.
|
|
|
268
288
|
|
|
269
289
|
\`\`\`bash
|
|
270
290
|
${install}
|
|
271
|
-
${run}
|
|
291
|
+
${envSetup}${run}
|
|
272
292
|
\`\`\`
|
|
273
293
|
|
|
274
|
-
Open http://localhost:3000 and start from the todos workflow.
|
|
275
|
-
|
|
294
|
+
Open http://localhost:3000 and start from the todos workflow. The homepage links to the health check, OpenAPI document, and Beignet docs.
|
|
295
|
+
${databaseSection}
|
|
276
296
|
## Files to know
|
|
277
297
|
|
|
278
298
|
- \`features/todos/contracts.ts\` defines the API contract.
|
|
279
|
-
- \`features/todos/use-cases
|
|
299
|
+
- \`features/todos/use-cases/\` contains application logic with input and output validation.
|
|
280
300
|
- \`ports/index.ts\` defines the app's external dependencies.
|
|
281
|
-
- \`
|
|
301
|
+
- \`features/todos/routes.ts\` binds contracts to use cases.
|
|
302
|
+
- \`server/context.ts\` declares the context blueprint shared by the server and tests.
|
|
303
|
+
- \`server/index.ts\` composes the server from ports, context, and routes.
|
|
282
304
|
- \`app/api/[[...path]]/route.ts\` exposes contract-backed API routes to Next.js.
|
|
283
|
-
${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
|
|
305
|
+
${hasDrizzleTurso(ctx) ? "- `infra/db/schema/index.ts` owns the Drizzle schema and `infra/db/provider.ts` wires the Drizzle todo repository into the `todos` port.\n" : ""}${hasIntegration(ctx, "better-auth") ? "- `lib/better-auth.ts` owns the Better Auth instance served at `/api/auth/*`.\n" : ""}${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
|
|
284
306
|
|
|
285
307
|
## Selected preset
|
|
286
308
|
|
|
@@ -303,6 +325,10 @@ function standardReadme(ctx: TemplateContext): string {
|
|
|
303
325
|
ctx.packageManager === "npm"
|
|
304
326
|
? "npm run dev"
|
|
305
327
|
: `${ctx.packageManager} run dev`;
|
|
328
|
+
const test =
|
|
329
|
+
ctx.packageManager === "npm"
|
|
330
|
+
? "npm run test"
|
|
331
|
+
: `${ctx.packageManager} run test`;
|
|
306
332
|
const typecheck =
|
|
307
333
|
ctx.packageManager === "npm"
|
|
308
334
|
? "npm run typecheck"
|
|
@@ -311,7 +337,8 @@ function standardReadme(ctx: TemplateContext): string {
|
|
|
311
337
|
const beforeDeploy = hasDrizzleTurso(ctx)
|
|
312
338
|
? [
|
|
313
339
|
"- Create a Turso database or keep `TURSO_DB_URL=file:local.db` for local libSQL development.",
|
|
314
|
-
`- Run \`${
|
|
340
|
+
`- Run \`${cli} db generate\` and \`${cli} db migrate\` after changing the Drizzle schema.`,
|
|
341
|
+
`- Run \`${cli} db seed\` to load starter data or \`${cli} db reset\` to rebuild a local SQLite database.`,
|
|
315
342
|
].join("\n")
|
|
316
343
|
: "- Replace the in-memory todo repository with a durable adapter.";
|
|
317
344
|
|
|
@@ -324,10 +351,24 @@ Beignet app scaffolded with \`@beignet/cli\`.
|
|
|
324
351
|
\`\`\`bash
|
|
325
352
|
${install}
|
|
326
353
|
cp .env.example .env.local
|
|
354
|
+
\`\`\`
|
|
355
|
+
|
|
356
|
+
## Prepare the database
|
|
357
|
+
|
|
358
|
+
\`\`\`bash
|
|
359
|
+
${cli} db generate
|
|
360
|
+
${cli} db migrate
|
|
361
|
+
\`\`\`
|
|
362
|
+
|
|
363
|
+
\`db generate\` creates Drizzle migrations from \`infra/db/schema/\`. \`db migrate\` applies them to the local SQLite database configured in \`.env.local\`.
|
|
364
|
+
|
|
365
|
+
## Start the app
|
|
366
|
+
|
|
367
|
+
\`\`\`bash
|
|
327
368
|
${run}
|
|
328
369
|
\`\`\`
|
|
329
370
|
|
|
330
|
-
Open http://localhost:3000 for the todo workflow
|
|
371
|
+
Open http://localhost:3000 for the todo workflow. The homepage links to the health check, OpenAPI document, devtools, and Beignet docs.
|
|
331
372
|
|
|
332
373
|
## First checks
|
|
333
374
|
|
|
@@ -341,18 +382,39 @@ ${typecheck}
|
|
|
341
382
|
|
|
342
383
|
\`routes\` shows the contracts Beignet can inspect. \`lint\` checks dependency direction. \`doctor\` catches route, OpenAPI, and resource drift.
|
|
343
384
|
|
|
385
|
+
## Generate a feature
|
|
386
|
+
|
|
387
|
+
\`\`\`bash
|
|
388
|
+
${cli} make feature projects
|
|
389
|
+
${cli} db generate
|
|
390
|
+
${cli} db migrate
|
|
391
|
+
${test}
|
|
392
|
+
${typecheck}
|
|
393
|
+
${cli} lint
|
|
394
|
+
${cli} doctor
|
|
395
|
+
\`\`\`
|
|
396
|
+
|
|
397
|
+
\`make feature\` creates a contract-to-test vertical slice. In the standard starter it also adds Drizzle schema and repository files, so regenerate and migrate the database before running the app against the new feature.
|
|
398
|
+
|
|
344
399
|
## App map
|
|
345
400
|
|
|
346
|
-
- \`features/todos/contracts.ts\` owns the HTTP contract and reuses
|
|
401
|
+
- \`features/todos/contracts.ts\` owns the HTTP contract and reuses shared feature schemas.
|
|
402
|
+
- \`features/todos/schemas.ts\` owns shared DTO and validation schemas used by contracts, use cases, ports, tests, and clients.
|
|
347
403
|
- \`features/todos/use-cases/\` owns application behavior and validation.
|
|
348
404
|
- \`ports/\` defines app-owned dependencies.
|
|
349
405
|
- \`infra/\` implements ports for the selected runtime.
|
|
350
406
|
- \`server/routes.ts\` keeps the central route registry and OpenAPI contract list.
|
|
407
|
+
- \`server/context.ts\` declares the context blueprint shared by the server and route tests.
|
|
351
408
|
- \`server/\` wires context, providers, routes, hooks, and error handling.
|
|
409
|
+
- \`server/outbox.ts\` registers durable events and jobs for the outbox drain route and CLI drain runner.
|
|
410
|
+
- \`server/schedules.ts\` registers schedules for cron routes and the CLI schedule runner.
|
|
352
411
|
- \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
|
|
412
|
+
- \`features/todos/domain/events/\`, \`jobs/\`, \`listeners/\`, \`notifications/\`, \`schedules/\`, and \`uploads/\` show the feature-owned background workflow shape.
|
|
353
413
|
- \`app/storage/[...key]/route.ts\` serves public local storage objects.
|
|
414
|
+
- \`app/api/cron/outbox/drain/route.ts\` is the bounded serverless entrypoint for durable event/job delivery.
|
|
354
415
|
- \`lib/env.ts\` validates deployment configuration at startup.
|
|
355
416
|
- \`lib/auth.ts\` exposes \`requireUser(ctx)\` for protected use cases.
|
|
417
|
+
- \`lib/better-auth.ts\` owns Better Auth setup and keeps provider-specific auth details outside use cases.
|
|
356
418
|
- \`features/todos/policy.ts\` defines authorization rules registered with \`createGate(...)\`.
|
|
357
419
|
${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
420
|
|
|
@@ -360,8 +422,11 @@ ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `inf
|
|
|
360
422
|
|
|
361
423
|
${beforeDeploy}
|
|
362
424
|
- 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
|
-
-
|
|
425
|
+
- Set \`APP_URL\`, \`BETTER_AUTH_SECRET\`, \`CRON_SECRET\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
|
|
426
|
+
- Configure a scheduled request to \`/api/cron/outbox/drain\` with \`Authorization: Bearer <CRON_SECRET>\`.
|
|
427
|
+
- Use \`${cli} outbox drain\` for bounded local, CI, or worker-hosted drain runs.
|
|
428
|
+
- Use \`${cli} schedule run todos.log-daily-summary\` for bounded local, CI, or worker-hosted schedule runs.
|
|
429
|
+
- Review the starter authorization policy before exposing user-owned data.
|
|
365
430
|
${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
|
|
366
431
|
`;
|
|
367
432
|
}
|
|
@@ -381,10 +446,17 @@ function integrationsDoc(ctx: TemplateContext): string {
|
|
|
381
446
|
"",
|
|
382
447
|
"- Package: `@beignet/provider-auth-better-auth`",
|
|
383
448
|
"- Peer dependency: `better-auth`",
|
|
384
|
-
"- The starter
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
449
|
+
"- The starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.",
|
|
450
|
+
...(isStandardPreset(ctx)
|
|
451
|
+
? [
|
|
452
|
+
"- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
|
|
453
|
+
"- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
|
|
454
|
+
"- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.",
|
|
455
|
+
]
|
|
456
|
+
: [
|
|
457
|
+
"- Keep auth behind the shared `AuthPort`, then read `ctx.auth` in use cases that need a signed-in user.",
|
|
458
|
+
"- The minimal starter uses Better Auth's in-memory adapter, so users and sessions reset on restart. Switch to a database adapter such as `better-auth/adapters/drizzle` before production.",
|
|
459
|
+
]),
|
|
388
460
|
"",
|
|
389
461
|
);
|
|
390
462
|
}
|
|
@@ -397,8 +469,19 @@ function integrationsDoc(ctx: TemplateContext): string {
|
|
|
397
469
|
"- Peer dependencies: `@libsql/client` and `drizzle-orm`",
|
|
398
470
|
"- Dev dependency: `drizzle-kit`",
|
|
399
471
|
"- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.",
|
|
400
|
-
|
|
401
|
-
|
|
472
|
+
...(isStandardPreset(ctx)
|
|
473
|
+
? [
|
|
474
|
+
"- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
|
|
475
|
+
]
|
|
476
|
+
: [
|
|
477
|
+
"- The scaffold wires a typed Drizzle repository behind the `todos` port through `infra/db/provider.ts`, which also creates and seeds the starter table at startup.",
|
|
478
|
+
]),
|
|
479
|
+
`- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`,
|
|
480
|
+
...(isStandardPreset(ctx)
|
|
481
|
+
? [
|
|
482
|
+
`- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`,
|
|
483
|
+
]
|
|
484
|
+
: []),
|
|
402
485
|
"",
|
|
403
486
|
);
|
|
404
487
|
}
|
|
@@ -411,7 +494,7 @@ function integrationsDoc(ctx: TemplateContext): string {
|
|
|
411
494
|
"- Package: `@beignet/provider-inngest`",
|
|
412
495
|
"- Peer dependency: `inngest`",
|
|
413
496
|
"- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.",
|
|
414
|
-
"- Define jobs with `
|
|
497
|
+
"- Define jobs with `defineJob(...)` from `lib/jobs.ts` and dispatch them through `ctx.ports.jobs.dispatch(...)`.",
|
|
415
498
|
"- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.",
|
|
416
499
|
"",
|
|
417
500
|
);
|
|
@@ -504,16 +587,16 @@ function envExample(ctx: TemplateContext): string {
|
|
|
504
587
|
lines.push(
|
|
505
588
|
"UPSTASH_REDIS_REST_URL=",
|
|
506
589
|
"UPSTASH_REDIS_REST_TOKEN=",
|
|
507
|
-
"UPSTASH_PREFIX=
|
|
590
|
+
"UPSTASH_PREFIX=beignet:ratelimit",
|
|
508
591
|
"",
|
|
509
592
|
);
|
|
510
593
|
}
|
|
511
594
|
|
|
512
595
|
if (hasIntegration(ctx, "better-auth")) {
|
|
513
596
|
lines.push(
|
|
514
|
-
"
|
|
515
|
-
"BETTER_AUTH_SECRET=",
|
|
597
|
+
"BETTER_AUTH_SECRET=local-dev-better-auth-secret-change-me",
|
|
516
598
|
"BETTER_AUTH_URL=http://localhost:3000",
|
|
599
|
+
"# BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000",
|
|
517
600
|
"",
|
|
518
601
|
);
|
|
519
602
|
}
|
|
@@ -581,6 +664,11 @@ function page(ctx: TemplateContext): string {
|
|
|
581
664
|
<p>
|
|
582
665
|
<a href="/api/todos">Open todos API</a>
|
|
583
666
|
</p>
|
|
667
|
+
<nav className="starter-links" aria-label="Starter links">
|
|
668
|
+
<a href="/api/health">Health</a>
|
|
669
|
+
<a href="/api/openapi">OpenAPI</a>
|
|
670
|
+
<a href="https://beignetjs.com">Docs</a>
|
|
671
|
+
</nav>
|
|
584
672
|
</section>
|
|
585
673
|
</div>
|
|
586
674
|
</main>
|
|
@@ -605,6 +693,14 @@ export default function HomePage() {
|
|
|
605
693
|
</p>
|
|
606
694
|
</header>
|
|
607
695
|
|
|
696
|
+
<nav className="starter-links" aria-label="Starter links">
|
|
697
|
+
<a href="#todos">Todo feature</a>
|
|
698
|
+
<a href="/api/health">Health</a>
|
|
699
|
+
<a href="/api/openapi">OpenAPI</a>
|
|
700
|
+
<a href="/api/devtools">Devtools</a>
|
|
701
|
+
<a href="https://beignetjs.com">Docs</a>
|
|
702
|
+
</nav>
|
|
703
|
+
|
|
608
704
|
<TodoApp />
|
|
609
705
|
</div>
|
|
610
706
|
</main>
|
|
@@ -614,64 +710,126 @@ export default function HomePage() {
|
|
|
614
710
|
}
|
|
615
711
|
|
|
616
712
|
function serverProviders(ctx: TemplateContext): string {
|
|
617
|
-
const
|
|
713
|
+
const packageImports: string[] = [];
|
|
714
|
+
const appImports: string[] = [];
|
|
715
|
+
const declarations: string[] = [];
|
|
618
716
|
const entries: string[] = [];
|
|
619
717
|
|
|
718
|
+
if (hasIntegration(ctx, "better-auth")) {
|
|
719
|
+
packageImports.push(
|
|
720
|
+
'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";',
|
|
721
|
+
);
|
|
722
|
+
appImports.push(
|
|
723
|
+
'import { auth } from "@/lib/better-auth";',
|
|
724
|
+
'import type { AuthSessionMetadata, AuthUser } from "@/ports";',
|
|
725
|
+
);
|
|
726
|
+
entries.push(
|
|
727
|
+
"createAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth)",
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
620
731
|
if (hasIntegration(ctx, "pino")) {
|
|
621
|
-
|
|
732
|
+
packageImports.push(
|
|
622
733
|
'import { loggerPinoProvider } from "@beignet/provider-logger-pino";',
|
|
623
734
|
);
|
|
624
735
|
entries.push("loggerPinoProvider");
|
|
625
736
|
}
|
|
626
737
|
|
|
738
|
+
if (hasDrizzleTurso(ctx)) {
|
|
739
|
+
packageImports.push(
|
|
740
|
+
'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";',
|
|
741
|
+
);
|
|
742
|
+
appImports.unshift(
|
|
743
|
+
'import { starterDatabaseProvider } from "@/infra/db/provider";',
|
|
744
|
+
'import * as schema from "@/infra/db/schema";',
|
|
745
|
+
);
|
|
746
|
+
declarations.push(
|
|
747
|
+
"const drizzleTursoProvider = createDrizzleTursoProvider({ schema });",
|
|
748
|
+
);
|
|
749
|
+
entries.push("drizzleTursoProvider", "starterDatabaseProvider");
|
|
750
|
+
}
|
|
751
|
+
|
|
627
752
|
if (hasIntegration(ctx, "inngest")) {
|
|
628
|
-
|
|
753
|
+
packageImports.push(
|
|
629
754
|
'import { inngestProvider } from "@beignet/provider-inngest";',
|
|
630
755
|
);
|
|
631
756
|
entries.push("inngestProvider");
|
|
632
757
|
}
|
|
633
758
|
|
|
634
759
|
if (hasIntegration(ctx, "resend")) {
|
|
635
|
-
|
|
760
|
+
packageImports.push(
|
|
636
761
|
'import { mailResendProvider } from "@beignet/provider-mail-resend";',
|
|
637
762
|
);
|
|
638
763
|
entries.push("mailResendProvider");
|
|
639
764
|
}
|
|
640
765
|
|
|
641
766
|
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
642
|
-
|
|
767
|
+
packageImports.push(
|
|
643
768
|
'import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";',
|
|
644
769
|
);
|
|
645
770
|
entries.push("upstashRateLimitProvider");
|
|
646
771
|
}
|
|
647
772
|
|
|
648
|
-
|
|
773
|
+
const importBlock = [...packageImports.sort(), ...appImports].join("\n");
|
|
774
|
+
const declarationBlock =
|
|
775
|
+
declarations.length > 0 ? `\n${declarations.join("\n")}\n` : "";
|
|
776
|
+
|
|
777
|
+
return `${importBlock}
|
|
778
|
+
${declarationBlock}
|
|
649
779
|
export const providers = [
|
|
650
780
|
${entries.join(",\n\t")},
|
|
651
781
|
];
|
|
652
782
|
`;
|
|
653
783
|
}
|
|
654
784
|
|
|
785
|
+
function serverContext(ctx: TemplateContext): string {
|
|
786
|
+
const blueprint = hasIntegration(ctx, "better-auth")
|
|
787
|
+
? ` async ({ req, ports }) => ({
|
|
788
|
+
requestId: crypto.randomUUID(),
|
|
789
|
+
auth: await ports.auth.getSession(req),
|
|
790
|
+
ports,
|
|
791
|
+
}),`
|
|
792
|
+
: ` async ({ ports }) => ({
|
|
793
|
+
requestId: crypto.randomUUID(),
|
|
794
|
+
ports,
|
|
795
|
+
}),`;
|
|
796
|
+
|
|
797
|
+
return `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
798
|
+
import { defineServerContext } from "@beignet/core/server";
|
|
799
|
+
import type { AppContext } from "@/app-context";
|
|
800
|
+
import type { AppPorts } from "@/ports";
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Context blueprint shared by the runtime server and route tests.
|
|
804
|
+
*/
|
|
805
|
+
export const appContext = defineServerContext<AppContext, AppPorts>()(
|
|
806
|
+
${blueprint}
|
|
807
|
+
);
|
|
808
|
+
`;
|
|
809
|
+
}
|
|
810
|
+
|
|
655
811
|
function server(ctx: TemplateContext): string {
|
|
656
|
-
const hasProviders = ctx.integrations.
|
|
657
|
-
|
|
658
|
-
|
|
812
|
+
const hasProviders = ctx.integrations.length > 0;
|
|
813
|
+
const providerConfigBlock = hasDrizzleTurso(ctx)
|
|
814
|
+
? ` providerConfig: {
|
|
815
|
+
"drizzle-turso": {
|
|
816
|
+
DB_URL: process.env.TURSO_DB_URL ?? "file:local.db",
|
|
817
|
+
DB_AUTH_TOKEN: process.env.TURSO_DB_AUTH_TOKEN,
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
`
|
|
821
|
+
: "";
|
|
659
822
|
|
|
660
|
-
return `import {
|
|
823
|
+
return `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
824
|
+
import { createNextServer } from "@beignet/next";
|
|
825
|
+
import { appContext } from "./context";
|
|
661
826
|
${hasProviders ? 'import { providers } from "./providers";\n' : ""}import { routes } from "./routes";
|
|
827
|
+
import type { AppContext } from "@/app-context";
|
|
662
828
|
import { appPorts, type AppPorts } from "@/ports";
|
|
663
829
|
|
|
664
|
-
export type AppContext = {
|
|
665
|
-
requestId: string;
|
|
666
|
-
ports: AppPorts;
|
|
667
|
-
};
|
|
668
|
-
|
|
669
830
|
export const server = await createNextServer<AppContext, AppPorts>({
|
|
670
831
|
ports: appPorts,
|
|
671
|
-
${hasProviders ? "\tproviders,\n" : ""}
|
|
672
|
-
requestId: crypto.randomUUID(),
|
|
673
|
-
ports,
|
|
674
|
-
}),
|
|
832
|
+
${hasProviders ? "\tproviders,\n" : ""}${providerConfigBlock} context: appContext,
|
|
675
833
|
routes,
|
|
676
834
|
mapUnhandledError: ({ err, ctx }) => {
|
|
677
835
|
const ports = ctx?.ports as
|
|
@@ -713,6 +871,127 @@ ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
|
|
|
713
871
|
`;
|
|
714
872
|
}
|
|
715
873
|
|
|
874
|
+
function minimalPorts(ctx: TemplateContext): string {
|
|
875
|
+
const usesDrizzleTurso = hasDrizzleTurso(ctx);
|
|
876
|
+
const usesBetterAuth = hasIntegration(ctx, "better-auth");
|
|
877
|
+
|
|
878
|
+
if (!usesDrizzleTurso && !usesBetterAuth) {
|
|
879
|
+
return files.ports;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const imports = [
|
|
883
|
+
'import { definePorts } from "@beignet/core/ports";',
|
|
884
|
+
...(usesBetterAuth
|
|
885
|
+
? [
|
|
886
|
+
`import type {
|
|
887
|
+
AuthPort as BeignetAuthPort,
|
|
888
|
+
AuthSession as BeignetAuthSession,
|
|
889
|
+
} from "@beignet/core/ports";`,
|
|
890
|
+
]
|
|
891
|
+
: []),
|
|
892
|
+
'import type { z } from "zod";',
|
|
893
|
+
'import type { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";',
|
|
894
|
+
].join("\n");
|
|
895
|
+
|
|
896
|
+
const authTypes = usesBetterAuth
|
|
897
|
+
? `
|
|
898
|
+
|
|
899
|
+
export type AuthUser = {
|
|
900
|
+
id: string;
|
|
901
|
+
email?: string;
|
|
902
|
+
name?: string;
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
export type AuthSessionMetadata = {
|
|
906
|
+
id?: string;
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
export type AuthSession = BeignetAuthSession<AuthUser, AuthSessionMetadata>;
|
|
910
|
+
|
|
911
|
+
export type AuthPort = BeignetAuthPort<AuthUser, AuthSessionMetadata>;`
|
|
912
|
+
: "";
|
|
913
|
+
|
|
914
|
+
const appPortsType = [
|
|
915
|
+
"export type AppPorts = {",
|
|
916
|
+
...(usesBetterAuth ? ["\tauth: AuthPort;"] : []),
|
|
917
|
+
"\ttodos: TodoRepository;",
|
|
918
|
+
"};",
|
|
919
|
+
].join("\n");
|
|
920
|
+
|
|
921
|
+
const inMemoryTodos = usesDrizzleTurso
|
|
922
|
+
? ""
|
|
923
|
+
: `
|
|
924
|
+
|
|
925
|
+
const todos = new Map<string, Todo>();
|
|
926
|
+
|
|
927
|
+
function createTodo(input: CreateTodoInput): Todo {
|
|
928
|
+
const todo = {
|
|
929
|
+
id: crypto.randomUUID(),
|
|
930
|
+
title: input.title,
|
|
931
|
+
completed: false,
|
|
932
|
+
createdAt: new Date().toISOString(),
|
|
933
|
+
};
|
|
934
|
+
todos.set(todo.id, todo);
|
|
935
|
+
return todo;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
createTodo({ title: "Read the generated contract" });
|
|
939
|
+
createTodo({ title: "Add your first use case" });`;
|
|
940
|
+
|
|
941
|
+
const bound = usesDrizzleTurso
|
|
942
|
+
? "\tbound: {},"
|
|
943
|
+
: ` bound: {
|
|
944
|
+
todos: {
|
|
945
|
+
list: async () => Array.from(todos.values()),
|
|
946
|
+
create: async (input: CreateTodoInput) => createTodo(input),
|
|
947
|
+
},
|
|
948
|
+
},`;
|
|
949
|
+
const deferred = [
|
|
950
|
+
...(usesBetterAuth ? ['"auth"'] : []),
|
|
951
|
+
...(usesDrizzleTurso ? ['"todos"'] : []),
|
|
952
|
+
].join(", ");
|
|
953
|
+
|
|
954
|
+
return `${imports}
|
|
955
|
+
|
|
956
|
+
export type Todo = z.infer<typeof TodoSchema>;
|
|
957
|
+
export type CreateTodoInput = z.infer<typeof CreateTodoSchema>;
|
|
958
|
+
|
|
959
|
+
export interface TodoRepository {
|
|
960
|
+
list(): Promise<Todo[]>;
|
|
961
|
+
create(input: CreateTodoInput): Promise<Todo>;
|
|
962
|
+
}${authTypes}
|
|
963
|
+
|
|
964
|
+
${appPortsType}${inMemoryTodos}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* App ports the server boots with.
|
|
968
|
+
*
|
|
969
|
+
* Bound ports are app-owned. Every deferred key is contributed by a provider
|
|
970
|
+
* in server/providers.ts during startup; createNextServer(...) fails boot if
|
|
971
|
+
* any of them is still unbound after providers have started.
|
|
972
|
+
*/
|
|
973
|
+
export const appPorts = definePorts<AppPorts>()({
|
|
974
|
+
${bound}
|
|
975
|
+
deferred: [${deferred}],
|
|
976
|
+
});
|
|
977
|
+
`;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function minimalAppContext(ctx: TemplateContext): string {
|
|
981
|
+
if (!hasIntegration(ctx, "better-auth")) {
|
|
982
|
+
return files.appContext;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
return `import type { AppPorts, AuthSession } from "@/ports";
|
|
986
|
+
|
|
987
|
+
export type AppContext = {
|
|
988
|
+
requestId: string;
|
|
989
|
+
auth: AuthSession | null;
|
|
990
|
+
ports: AppPorts;
|
|
991
|
+
};
|
|
992
|
+
`;
|
|
993
|
+
}
|
|
994
|
+
|
|
716
995
|
function todoApp(ctx: TemplateContext): string {
|
|
717
996
|
const listTodosQueryOptions =
|
|
718
997
|
ctx.preset === "standard" ? "{ query: {} }" : "";
|
|
@@ -720,12 +999,12 @@ function todoApp(ctx: TemplateContext): string {
|
|
|
720
999
|
if (hasFeature(ctx, "forms")) {
|
|
721
1000
|
return `"use client";
|
|
722
1001
|
|
|
723
|
-
import {
|
|
1002
|
+
import { rootFormError } from "@beignet/react-hook-form";
|
|
724
1003
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
1004
|
+
import { rq } from "@/client";
|
|
1005
|
+
import { rhf } from "@/client/forms";
|
|
725
1006
|
import { createTodo, listTodos } from "@/features/todos/contracts";
|
|
726
|
-
import { rq } from "@/client/rq";
|
|
727
1007
|
|
|
728
|
-
const rhf = createReactHookForm();
|
|
729
1008
|
const createTodoForm = rhf(createTodo);
|
|
730
1009
|
|
|
731
1010
|
export function TodoApp() {
|
|
@@ -738,19 +1017,21 @@ export function TodoApp() {
|
|
|
738
1017
|
rq(createTodo).mutationOptions({
|
|
739
1018
|
onSuccess: async () => {
|
|
740
1019
|
form.reset();
|
|
741
|
-
await
|
|
742
|
-
|
|
743
|
-
|
|
1020
|
+
await rq(listTodos).invalidate(queryClient);
|
|
1021
|
+
},
|
|
1022
|
+
onError: (error) => {
|
|
1023
|
+
form.setError("root", rootFormError(error, "Could not create the todo."));
|
|
744
1024
|
},
|
|
745
1025
|
}),
|
|
746
1026
|
);
|
|
747
1027
|
|
|
748
1028
|
const onSubmit = form.handleSubmit((body) => {
|
|
1029
|
+
form.clearErrors("root");
|
|
749
1030
|
createTodoMutation.mutate({ body });
|
|
750
1031
|
});
|
|
751
1032
|
|
|
752
1033
|
return (
|
|
753
|
-
<section className="workspace">
|
|
1034
|
+
<section id="todos" className="workspace">
|
|
754
1035
|
<form className="composer" onSubmit={onSubmit}>
|
|
755
1036
|
<label htmlFor="todo-title">New todo</label>
|
|
756
1037
|
<div className="composer-row">
|
|
@@ -766,19 +1047,22 @@ export function TodoApp() {
|
|
|
766
1047
|
{form.formState.errors.title ? (
|
|
767
1048
|
<p className="error">{form.formState.errors.title.message}</p>
|
|
768
1049
|
) : null}
|
|
1050
|
+
{form.formState.errors.root ? (
|
|
1051
|
+
<p className="error">{form.formState.errors.root.message}</p>
|
|
1052
|
+
) : null}
|
|
769
1053
|
</form>
|
|
770
1054
|
|
|
771
1055
|
<div className="todos-panel">
|
|
772
1056
|
<div className="panel-heading">
|
|
773
1057
|
<h2>Todos</h2>
|
|
774
|
-
<span>{todosQuery.data?.total ?? 0} total</span>
|
|
1058
|
+
<span>{todosQuery.data?.page.total ?? 0} total</span>
|
|
775
1059
|
</div>
|
|
776
1060
|
{todosQuery.isLoading ? <p className="muted">Loading todos...</p> : null}
|
|
777
1061
|
{todosQuery.isError ? (
|
|
778
1062
|
<p className="error">Could not load todos.</p>
|
|
779
1063
|
) : null}
|
|
780
1064
|
<ul className="todo-list">
|
|
781
|
-
{todosQuery.data?.
|
|
1065
|
+
{todosQuery.data?.items.map((todo) => (
|
|
782
1066
|
<li key={todo.id}>
|
|
783
1067
|
<span>{todo.title}</span>
|
|
784
1068
|
<small>{todo.completed ? "Done" : "Open"}</small>
|
|
@@ -796,8 +1080,8 @@ export function TodoApp() {
|
|
|
796
1080
|
|
|
797
1081
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
798
1082
|
import { useState } from "react";
|
|
1083
|
+
import { rq } from "@/client";
|
|
799
1084
|
import { createTodo, listTodos } from "@/features/todos/contracts";
|
|
800
|
-
import { rq } from "@/client/rq";
|
|
801
1085
|
|
|
802
1086
|
export function TodoApp() {
|
|
803
1087
|
const [title, setTitle] = useState("");
|
|
@@ -807,15 +1091,13 @@ export function TodoApp() {
|
|
|
807
1091
|
rq(createTodo).mutationOptions({
|
|
808
1092
|
onSuccess: async () => {
|
|
809
1093
|
setTitle("");
|
|
810
|
-
await
|
|
811
|
-
queryKey: rq(listTodos).key(),
|
|
812
|
-
});
|
|
1094
|
+
await rq(listTodos).invalidate(queryClient);
|
|
813
1095
|
},
|
|
814
1096
|
}),
|
|
815
1097
|
);
|
|
816
1098
|
|
|
817
1099
|
return (
|
|
818
|
-
<section className="workspace">
|
|
1100
|
+
<section id="todos" className="workspace">
|
|
819
1101
|
<form
|
|
820
1102
|
className="composer"
|
|
821
1103
|
onSubmit={(event) => {
|
|
@@ -840,10 +1122,10 @@ export function TodoApp() {
|
|
|
840
1122
|
<div className="todos-panel">
|
|
841
1123
|
<div className="panel-heading">
|
|
842
1124
|
<h2>Todos</h2>
|
|
843
|
-
<span>{todosQuery.data?.total ?? 0} total</span>
|
|
1125
|
+
<span>{todosQuery.data?.page.total ?? 0} total</span>
|
|
844
1126
|
</div>
|
|
845
1127
|
<ul className="todo-list">
|
|
846
|
-
{todosQuery.data?.
|
|
1128
|
+
{todosQuery.data?.items.map((todo) => (
|
|
847
1129
|
<li key={todo.id}>
|
|
848
1130
|
<span>{todo.title}</span>
|
|
849
1131
|
<small>{todo.completed ? "Done" : "Open"}</small>
|
|
@@ -857,6 +1139,58 @@ export function TodoApp() {
|
|
|
857
1139
|
`;
|
|
858
1140
|
}
|
|
859
1141
|
|
|
1142
|
+
function clientIndex(ctx: TemplateContext): string {
|
|
1143
|
+
const imports = [
|
|
1144
|
+
'import { createClient } from "@beignet/core/client";',
|
|
1145
|
+
...(hasFeature(ctx, "react-query")
|
|
1146
|
+
? [
|
|
1147
|
+
'import { createReactQuery } from "@beignet/react-query";',
|
|
1148
|
+
'import { QueryClient } from "@tanstack/react-query";',
|
|
1149
|
+
]
|
|
1150
|
+
: []),
|
|
1151
|
+
...(isStandardPreset(ctx)
|
|
1152
|
+
? [
|
|
1153
|
+
'import { createUploadClient } from "@beignet/core/uploads/client";',
|
|
1154
|
+
'import type { todoUploads } from "@/features/todos/uploads";',
|
|
1155
|
+
]
|
|
1156
|
+
: []),
|
|
1157
|
+
].join("\n");
|
|
1158
|
+
|
|
1159
|
+
const exports = [
|
|
1160
|
+
`export const apiClient = createClient({
|
|
1161
|
+
\tvalidateInput: true,
|
|
1162
|
+
});`,
|
|
1163
|
+
...(hasFeature(ctx, "react-query")
|
|
1164
|
+
? ["export const rq = createReactQuery(apiClient);"]
|
|
1165
|
+
: []),
|
|
1166
|
+
...(isStandardPreset(ctx)
|
|
1167
|
+
? [
|
|
1168
|
+
`export const uploads = createUploadClient<typeof todoUploads>({
|
|
1169
|
+
\tbaseUrl: "/api/uploads",
|
|
1170
|
+
});`,
|
|
1171
|
+
]
|
|
1172
|
+
: []),
|
|
1173
|
+
...(hasFeature(ctx, "react-query")
|
|
1174
|
+
? [
|
|
1175
|
+
`export function makeQueryClient() {
|
|
1176
|
+
\treturn new QueryClient({
|
|
1177
|
+
\t\tdefaultOptions: {
|
|
1178
|
+
\t\t\tqueries: {
|
|
1179
|
+
\t\t\t\tstaleTime: 60 * 1000,
|
|
1180
|
+
\t\t\t},
|
|
1181
|
+
\t\t},
|
|
1182
|
+
\t});
|
|
1183
|
+
}`,
|
|
1184
|
+
]
|
|
1185
|
+
: []),
|
|
1186
|
+
].join("\n\n");
|
|
1187
|
+
|
|
1188
|
+
return `${imports}
|
|
1189
|
+
|
|
1190
|
+
${exports}
|
|
1191
|
+
`;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
860
1194
|
const files = {
|
|
861
1195
|
gitignore: `.next
|
|
862
1196
|
node_modules
|
|
@@ -957,6 +1291,29 @@ input {
|
|
|
957
1291
|
color: #475569;
|
|
958
1292
|
}
|
|
959
1293
|
|
|
1294
|
+
.starter-links {
|
|
1295
|
+
display: flex;
|
|
1296
|
+
flex-wrap: wrap;
|
|
1297
|
+
gap: 10px;
|
|
1298
|
+
margin: 22px 0;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
.starter-links a {
|
|
1302
|
+
border: 1px solid #cbd5e1;
|
|
1303
|
+
border-radius: 8px;
|
|
1304
|
+
padding: 8px 10px;
|
|
1305
|
+
background: #ffffff;
|
|
1306
|
+
color: #172033;
|
|
1307
|
+
font-size: 14px;
|
|
1308
|
+
font-weight: 700;
|
|
1309
|
+
text-decoration: none;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
.starter-links a:hover {
|
|
1313
|
+
border-color: #4f46e5;
|
|
1314
|
+
color: #4f46e5;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
960
1317
|
.panel {
|
|
961
1318
|
margin-top: 24px;
|
|
962
1319
|
padding: 20px;
|
|
@@ -1083,32 +1440,26 @@ input {
|
|
|
1083
1440
|
`,
|
|
1084
1441
|
appProviders: `"use client";
|
|
1085
1442
|
|
|
1086
|
-
import {
|
|
1443
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
1087
1444
|
import { type ReactNode, useState } from "react";
|
|
1445
|
+
import { makeQueryClient } from "@/client";
|
|
1088
1446
|
|
|
1089
1447
|
export function Providers({ children }: { children: ReactNode }) {
|
|
1090
|
-
const [queryClient] = useState(() =>
|
|
1448
|
+
const [queryClient] = useState(() => makeQueryClient());
|
|
1091
1449
|
|
|
1092
1450
|
return (
|
|
1093
1451
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
1094
1452
|
);
|
|
1095
1453
|
}
|
|
1096
1454
|
`,
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
export const apiClient = createNextClient({
|
|
1100
|
-
validate: true,
|
|
1101
|
-
});
|
|
1102
|
-
`,
|
|
1103
|
-
rq: `import { createReactQuery } from "@beignet/react-query";
|
|
1104
|
-
import { apiClient } from "./api-client";
|
|
1455
|
+
formsClient: `import { createReactHookForm } from "@beignet/react-hook-form";
|
|
1105
1456
|
|
|
1106
|
-
export const
|
|
1457
|
+
export const rhf = createReactHookForm();
|
|
1107
1458
|
`,
|
|
1108
|
-
contractsTodos: `import {
|
|
1459
|
+
contractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
|
|
1109
1460
|
import { z } from "zod";
|
|
1110
1461
|
|
|
1111
|
-
const todos =
|
|
1462
|
+
const todos = defineContractGroup().namespace("todos");
|
|
1112
1463
|
|
|
1113
1464
|
export const TodoSchema = z.object({
|
|
1114
1465
|
id: z.string(),
|
|
@@ -1123,8 +1474,14 @@ export const CreateTodoSchema = z.object({
|
|
|
1123
1474
|
|
|
1124
1475
|
export const listTodos = todos.get("/api/todos").responses({
|
|
1125
1476
|
200: z.object({
|
|
1126
|
-
|
|
1127
|
-
|
|
1477
|
+
items: z.array(TodoSchema),
|
|
1478
|
+
page: z.object({
|
|
1479
|
+
kind: z.literal("offset"),
|
|
1480
|
+
limit: z.number(),
|
|
1481
|
+
offset: z.number(),
|
|
1482
|
+
total: z.number(),
|
|
1483
|
+
hasMore: z.boolean(),
|
|
1484
|
+
}),
|
|
1128
1485
|
}),
|
|
1129
1486
|
});
|
|
1130
1487
|
|
|
@@ -1167,30 +1524,175 @@ export const appPorts = definePorts({
|
|
|
1167
1524
|
|
|
1168
1525
|
export type AppPorts = typeof appPorts;
|
|
1169
1526
|
`,
|
|
1170
|
-
|
|
1171
|
-
import { z } from "zod";
|
|
1172
|
-
import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
|
|
1173
|
-
import type { AppPorts } from "@/ports";
|
|
1527
|
+
appContext: `import type { AppPorts } from "@/ports";
|
|
1174
1528
|
|
|
1175
|
-
type AppContext = {
|
|
1529
|
+
export type AppContext = {
|
|
1176
1530
|
requestId: string;
|
|
1177
1531
|
ports: AppPorts;
|
|
1178
1532
|
};
|
|
1533
|
+
`,
|
|
1534
|
+
minimalBetterAuth: `import { betterAuth } from "better-auth";
|
|
1535
|
+
import { memoryAdapter, type MemoryDB } from "better-auth/adapters/memory";
|
|
1536
|
+
|
|
1537
|
+
// The minimal starter keeps auth storage in memory, matching its in-memory
|
|
1538
|
+
// ports. Users and sessions reset on restart; switch to a database adapter
|
|
1539
|
+
// such as better-auth/adapters/drizzle before production.
|
|
1540
|
+
const database: MemoryDB = {
|
|
1541
|
+
account: [],
|
|
1542
|
+
session: [],
|
|
1543
|
+
user: [],
|
|
1544
|
+
verification: [],
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
export const auth = betterAuth({
|
|
1548
|
+
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
|
1549
|
+
secret:
|
|
1550
|
+
process.env.BETTER_AUTH_SECRET ?? "local-dev-better-auth-secret-change-me",
|
|
1551
|
+
database: memoryAdapter(database),
|
|
1552
|
+
emailAndPassword: {
|
|
1553
|
+
enabled: true,
|
|
1554
|
+
},
|
|
1555
|
+
});
|
|
1556
|
+
`,
|
|
1557
|
+
minimalDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
1558
|
+
|
|
1559
|
+
export const todos = sqliteTable("todos", {
|
|
1560
|
+
id: text("id").primaryKey(),
|
|
1561
|
+
title: text("title").notNull(),
|
|
1562
|
+
completed: integer("completed", { mode: "boolean" }).notNull().default(false),
|
|
1563
|
+
createdAt: text("created_at").notNull(),
|
|
1564
|
+
});
|
|
1565
|
+
`,
|
|
1566
|
+
minimalDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1567
|
+
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
1568
|
+
import { asc } from "drizzle-orm";
|
|
1569
|
+
import * as schema from "@/infra/db/schema";
|
|
1570
|
+
import type { CreateTodoInput, TodoRepository } from "@/ports";
|
|
1571
|
+
|
|
1572
|
+
export function createDrizzleTodoRepository(
|
|
1573
|
+
db: DrizzleTursoDatabase<typeof schema>,
|
|
1574
|
+
): TodoRepository {
|
|
1575
|
+
return {
|
|
1576
|
+
async list() {
|
|
1577
|
+
return db
|
|
1578
|
+
.select()
|
|
1579
|
+
.from(schema.todos)
|
|
1580
|
+
.orderBy(asc(schema.todos.createdAt));
|
|
1581
|
+
},
|
|
1582
|
+
async create(input: CreateTodoInput) {
|
|
1583
|
+
const todo = {
|
|
1584
|
+
id: crypto.randomUUID(),
|
|
1585
|
+
title: input.title,
|
|
1586
|
+
completed: false,
|
|
1587
|
+
createdAt: new Date().toISOString(),
|
|
1588
|
+
};
|
|
1589
|
+
const [row] = await db.insert(schema.todos).values(todo).returning();
|
|
1590
|
+
|
|
1591
|
+
if (!row) {
|
|
1592
|
+
throw new Error("Failed to create todo");
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
return row;
|
|
1596
|
+
},
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
`,
|
|
1600
|
+
minimalDbProvider: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1601
|
+
import { createProvider } from "@beignet/core/providers";
|
|
1602
|
+
import type { DbPort } from "@beignet/provider-drizzle-turso";
|
|
1603
|
+
import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
|
|
1604
|
+
import type { AppPorts } from "@/ports";
|
|
1605
|
+
import * as schema from "./schema";
|
|
1606
|
+
|
|
1607
|
+
const setupStatements = [
|
|
1608
|
+
\`CREATE TABLE IF NOT EXISTS todos (
|
|
1609
|
+
id text PRIMARY KEY NOT NULL,
|
|
1610
|
+
title text NOT NULL,
|
|
1611
|
+
completed integer DEFAULT false NOT NULL,
|
|
1612
|
+
created_at text NOT NULL
|
|
1613
|
+
)\`,
|
|
1614
|
+
];
|
|
1615
|
+
|
|
1616
|
+
const seedTitles = [
|
|
1617
|
+
"Read the generated contract",
|
|
1618
|
+
"Add your first use case",
|
|
1619
|
+
] as const;
|
|
1620
|
+
|
|
1621
|
+
export const starterDatabaseProvider = createProvider<{
|
|
1622
|
+
db: DbPort<typeof schema>;
|
|
1623
|
+
}>()({
|
|
1624
|
+
name: "starter-database",
|
|
1625
|
+
|
|
1626
|
+
async setup({ ports }) {
|
|
1627
|
+
const dbPort = ports.db;
|
|
1628
|
+
if (!dbPort) {
|
|
1629
|
+
throw new Error(
|
|
1630
|
+
"starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const providedPorts: Pick<AppPorts, "todos"> = {
|
|
1635
|
+
todos: createDrizzleTodoRepository(dbPort.db),
|
|
1636
|
+
};
|
|
1637
|
+
|
|
1638
|
+
return {
|
|
1639
|
+
ports: providedPorts,
|
|
1640
|
+
async start() {
|
|
1641
|
+
for (const statement of setupStatements) {
|
|
1642
|
+
await dbPort.client.execute(statement);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const result = await dbPort.client.execute(
|
|
1646
|
+
"SELECT count(*) as total FROM todos",
|
|
1647
|
+
);
|
|
1648
|
+
const total = Number(result.rows[0]?.total ?? 0);
|
|
1649
|
+
if (total > 0) return;
|
|
1650
|
+
|
|
1651
|
+
for (const title of seedTitles) {
|
|
1652
|
+
await dbPort.client.execute({
|
|
1653
|
+
sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
|
|
1654
|
+
args: [crypto.randomUUID(), title, 0, new Date().toISOString()],
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
},
|
|
1658
|
+
};
|
|
1659
|
+
},
|
|
1660
|
+
});
|
|
1661
|
+
`,
|
|
1662
|
+
useCasesTodos: `import { createUseCase } from "@beignet/core/application";
|
|
1663
|
+
import { z } from "zod";
|
|
1664
|
+
import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
|
|
1665
|
+
import type { AppContext } from "@/app-context";
|
|
1179
1666
|
|
|
1180
1667
|
const useCase = createUseCase<AppContext>();
|
|
1181
1668
|
|
|
1182
1669
|
export const ListTodosOutputSchema = z.object({
|
|
1183
|
-
|
|
1184
|
-
|
|
1670
|
+
items: z.array(TodoSchema),
|
|
1671
|
+
page: z.object({
|
|
1672
|
+
kind: z.literal("offset"),
|
|
1673
|
+
limit: z.number(),
|
|
1674
|
+
offset: z.number(),
|
|
1675
|
+
total: z.number(),
|
|
1676
|
+
hasMore: z.boolean(),
|
|
1677
|
+
}),
|
|
1185
1678
|
});
|
|
1186
1679
|
|
|
1187
1680
|
export const listTodos = useCase
|
|
1188
1681
|
.query("listTodos")
|
|
1189
|
-
.input(z.
|
|
1682
|
+
.input(z.object({}))
|
|
1190
1683
|
.output(ListTodosOutputSchema)
|
|
1191
1684
|
.run(async ({ ctx }) => {
|
|
1192
1685
|
const todos = await ctx.ports.todos.list();
|
|
1193
|
-
return {
|
|
1686
|
+
return {
|
|
1687
|
+
items: todos,
|
|
1688
|
+
page: {
|
|
1689
|
+
kind: "offset" as const,
|
|
1690
|
+
limit: todos.length,
|
|
1691
|
+
offset: 0,
|
|
1692
|
+
total: todos.length,
|
|
1693
|
+
hasMore: false,
|
|
1694
|
+
},
|
|
1695
|
+
};
|
|
1194
1696
|
});
|
|
1195
1697
|
|
|
1196
1698
|
export const createTodo = useCase
|
|
@@ -1199,33 +1701,23 @@ export const createTodo = useCase
|
|
|
1199
1701
|
.output(TodoSchema)
|
|
1200
1702
|
.run(async ({ ctx, input }) => ctx.ports.todos.create(input));
|
|
1201
1703
|
`,
|
|
1202
|
-
todoRoutes: `import {
|
|
1203
|
-
import
|
|
1704
|
+
todoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1705
|
+
import { defineRouteGroup } from "@beignet/next";
|
|
1706
|
+
import type { AppContext } from "@/app-context";
|
|
1204
1707
|
import { createTodo, listTodos } from "@/features/todos/use-cases";
|
|
1205
1708
|
import * as contracts from "@/features/todos/contracts";
|
|
1206
1709
|
|
|
1207
1710
|
export const todoRoutes = defineRouteGroup<AppContext>({
|
|
1208
1711
|
name: "todos",
|
|
1209
1712
|
routes: [
|
|
1210
|
-
{
|
|
1211
|
-
|
|
1212
|
-
handle: async ({ ctx }) => ({
|
|
1213
|
-
status: 200,
|
|
1214
|
-
body: await listTodos.run({ ctx, input: undefined }),
|
|
1215
|
-
}),
|
|
1216
|
-
},
|
|
1217
|
-
{
|
|
1218
|
-
contract: contracts.createTodo,
|
|
1219
|
-
handle: async ({ ctx, body }) => ({
|
|
1220
|
-
status: 201,
|
|
1221
|
-
body: await createTodo.run({ ctx, input: body }),
|
|
1222
|
-
}),
|
|
1223
|
-
},
|
|
1713
|
+
{ contract: contracts.listTodos, useCase: listTodos },
|
|
1714
|
+
{ contract: contracts.createTodo, useCase: createTodo },
|
|
1224
1715
|
],
|
|
1225
1716
|
});
|
|
1226
1717
|
`,
|
|
1227
|
-
serverRoutes: `import {
|
|
1228
|
-
import
|
|
1718
|
+
serverRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1719
|
+
import { contractsFromRoutes, defineRoutes } from "@beignet/next";
|
|
1720
|
+
import type { AppContext } from "@/app-context";
|
|
1229
1721
|
import { todoRoutes } from "@/features/todos/routes";
|
|
1230
1722
|
|
|
1231
1723
|
export const routes = defineRoutes<AppContext>([
|
|
@@ -1264,6 +1756,14 @@ export const env = createEnv({
|
|
|
1264
1756
|
APP_URL: z.string().url().default("http://localhost:3000"),
|
|
1265
1757
|
CRON_SECRET: z.string().min(1).optional(),
|
|
1266
1758
|
DEVTOOLS_ENABLED: BooleanEnv.optional(),
|
|
1759
|
+
BETTER_AUTH_SECRET: z
|
|
1760
|
+
.string()
|
|
1761
|
+
.min(32)
|
|
1762
|
+
.default("local-dev-better-auth-secret-change-me"),
|
|
1763
|
+
BETTER_AUTH_URL: z.string().url().default("http://localhost:3000"),
|
|
1764
|
+
BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
|
|
1765
|
+
TURSO_DB_URL: z.string().default("file:local.db"),
|
|
1766
|
+
TURSO_DB_AUTH_TOKEN: z.string().optional(),
|
|
1267
1767
|
LOG_LEVEL: z
|
|
1268
1768
|
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
|
1269
1769
|
.default("info"),
|
|
@@ -1275,24 +1775,28 @@ export const env = createEnv({
|
|
|
1275
1775
|
|
|
1276
1776
|
export const isProduction = env.NODE_ENV === "production";
|
|
1277
1777
|
`,
|
|
1278
|
-
productionAppContext: `import type {
|
|
1279
|
-
import type {
|
|
1778
|
+
productionAppContext: `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
|
|
1779
|
+
import type { InferProviderPorts } from "@beignet/core/providers";
|
|
1780
|
+
import type { TraceContext } from "@beignet/core/tracing";
|
|
1280
1781
|
import type { AppGate, AppPorts } from "@/ports";
|
|
1281
1782
|
import type { AuthSession } from "@/ports/auth";
|
|
1783
|
+
import type { providers } from "@/server/providers";
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* App ports plus the ports contributed by the server's providers at startup.
|
|
1787
|
+
*/
|
|
1788
|
+
export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
|
|
1282
1789
|
|
|
1283
1790
|
export type AppContext = {
|
|
1284
1791
|
requestId: string;
|
|
1285
1792
|
actor: ActivityActor;
|
|
1286
1793
|
auth: AuthSession | null;
|
|
1287
1794
|
gate: AppGate;
|
|
1288
|
-
ports:
|
|
1289
|
-
devtools: DevtoolsPort;
|
|
1290
|
-
storage: StoragePort;
|
|
1291
|
-
};
|
|
1795
|
+
ports: AppRuntimePorts;
|
|
1292
1796
|
tenant?: ActivityTenant;
|
|
1293
|
-
} & Partial<
|
|
1797
|
+
} & Partial<TraceContext>;
|
|
1294
1798
|
`,
|
|
1295
|
-
|
|
1799
|
+
productionSharedErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
|
|
1296
1800
|
|
|
1297
1801
|
export const errors = defineErrors({
|
|
1298
1802
|
Unauthorized: {
|
|
@@ -1329,10 +1833,14 @@ export const ListTodosInputSchema = z.object({
|
|
|
1329
1833
|
});
|
|
1330
1834
|
|
|
1331
1835
|
export const ListTodosOutputSchema = z.object({
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1836
|
+
items: z.array(TodoSchema),
|
|
1837
|
+
page: z.object({
|
|
1838
|
+
kind: z.literal("offset"),
|
|
1839
|
+
limit: z.number().int().min(1),
|
|
1840
|
+
offset: z.number().int().min(0),
|
|
1841
|
+
total: z.number().int().min(0),
|
|
1842
|
+
hasMore: z.boolean(),
|
|
1843
|
+
}),
|
|
1336
1844
|
});
|
|
1337
1845
|
|
|
1338
1846
|
export const GetTodoInputSchema = z.object({
|
|
@@ -1347,20 +1855,43 @@ export type Todo = z.infer<typeof TodoSchema>;
|
|
|
1347
1855
|
export type CreateTodoInput = z.infer<typeof CreateTodoInputSchema>;
|
|
1348
1856
|
export type ListTodosInput = z.infer<typeof ListTodosInputSchema>;
|
|
1349
1857
|
`,
|
|
1350
|
-
productionUseCaseBuilder: `import {
|
|
1351
|
-
import {
|
|
1858
|
+
productionUseCaseBuilder: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1859
|
+
import { createUseCase } from "@beignet/core/application";
|
|
1352
1860
|
import type { AppContext } from "@/app-context";
|
|
1353
1861
|
|
|
1354
|
-
export const useCase = createUseCase<AppContext>(
|
|
1355
|
-
onRun: createDevtoolsUseCaseObserver<AppContext>(),
|
|
1356
|
-
});
|
|
1862
|
+
export const useCase = createUseCase<AppContext>();
|
|
1357
1863
|
`,
|
|
1358
|
-
|
|
1359
|
-
import type {
|
|
1360
|
-
import { appError } from "@/features/shared/errors";
|
|
1864
|
+
productionListenersBuilder: `import { createListeners } from "@beignet/core/events";
|
|
1865
|
+
import type { AppContext } from "@/app-context";
|
|
1361
1866
|
|
|
1362
|
-
export
|
|
1363
|
-
|
|
1867
|
+
export const { defineListener } = createListeners<AppContext>();
|
|
1868
|
+
`,
|
|
1869
|
+
productionJobsBuilder: `import { createJobs } from "@beignet/core/jobs";
|
|
1870
|
+
import type { AppContext } from "@/app-context";
|
|
1871
|
+
|
|
1872
|
+
export const { defineJob } = createJobs<AppContext>();
|
|
1873
|
+
`,
|
|
1874
|
+
productionSchedulesBuilder: `import { createSchedules } from "@beignet/core/schedules";
|
|
1875
|
+
import type { AppContext } from "@/app-context";
|
|
1876
|
+
|
|
1877
|
+
export const { defineSchedule } = createSchedules<AppContext>();
|
|
1878
|
+
`,
|
|
1879
|
+
productionNotificationsBuilder: `import { createNotifications } from "@beignet/core/notifications";
|
|
1880
|
+
import type { AppContext } from "@/app-context";
|
|
1881
|
+
|
|
1882
|
+
export const { defineNotification } = createNotifications<AppContext>();
|
|
1883
|
+
`,
|
|
1884
|
+
productionTasksBuilder: `import { createTasks } from "@beignet/core/tasks";
|
|
1885
|
+
import type { AppContext } from "@/app-context";
|
|
1886
|
+
|
|
1887
|
+
export const { defineTask } = createTasks<AppContext>();
|
|
1888
|
+
`,
|
|
1889
|
+
productionAuthHelpers: `import type { AppContext } from "@/app-context";
|
|
1890
|
+
import type { AuthSession, AuthUser } from "@/ports/auth";
|
|
1891
|
+
import { appError } from "@/features/shared/errors";
|
|
1892
|
+
|
|
1893
|
+
export function requireSession(ctx: AppContext): AuthSession {
|
|
1894
|
+
if (!ctx.auth) {
|
|
1364
1895
|
throw appError("Unauthorized");
|
|
1365
1896
|
}
|
|
1366
1897
|
|
|
@@ -1371,26 +1902,28 @@ export function requireUser(ctx: AppContext): AuthUser {
|
|
|
1371
1902
|
return requireSession(ctx).user;
|
|
1372
1903
|
}
|
|
1373
1904
|
`,
|
|
1374
|
-
productionListTodosUseCase: `import {
|
|
1375
|
-
import {
|
|
1905
|
+
productionListTodosUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1906
|
+
import { normalizeOffsetPage } from "@beignet/core/pagination";
|
|
1907
|
+
import { useCase } from "@/lib/use-case";
|
|
1908
|
+
import { ListTodosInputSchema, ListTodosOutputSchema } from "../schemas";
|
|
1376
1909
|
|
|
1377
1910
|
export const listTodosUseCase = useCase
|
|
1378
1911
|
.query("todos.list")
|
|
1379
1912
|
.input(ListTodosInputSchema)
|
|
1380
1913
|
.output(ListTodosOutputSchema)
|
|
1381
1914
|
.run(async ({ ctx, input }) => {
|
|
1382
|
-
const
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
};
|
|
1915
|
+
const page = normalizeOffsetPage(input, {
|
|
1916
|
+
defaultLimit: 20,
|
|
1917
|
+
maxLimit: 100,
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
return ctx.ports.todos.list(page);
|
|
1389
1921
|
});
|
|
1390
1922
|
`,
|
|
1391
|
-
productionGetTodoUseCase: `import {
|
|
1923
|
+
productionGetTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1924
|
+
import { appError } from "@/features/shared/errors";
|
|
1392
1925
|
import { useCase } from "@/lib/use-case";
|
|
1393
|
-
import { GetTodoInputSchema, TodoSchema } from "
|
|
1926
|
+
import { GetTodoInputSchema, TodoSchema } from "../schemas";
|
|
1394
1927
|
|
|
1395
1928
|
export const getTodoUseCase = useCase
|
|
1396
1929
|
.query("todos.get")
|
|
@@ -1405,16 +1938,36 @@ export const getTodoUseCase = useCase
|
|
|
1405
1938
|
return todo;
|
|
1406
1939
|
});
|
|
1407
1940
|
`,
|
|
1408
|
-
productionCreateTodoUseCase: `import {
|
|
1409
|
-
import {
|
|
1941
|
+
productionCreateTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1942
|
+
import { TodoCreated } from "@/features/todos/domain/events";
|
|
1943
|
+
import { auditEntry } from "@/lib/audit";
|
|
1944
|
+
import { useCase } from "@/lib/use-case";
|
|
1945
|
+
import { CreateTodoInputSchema, TodoSchema } from "../schemas";
|
|
1410
1946
|
|
|
1411
1947
|
export const createTodoUseCase = useCase
|
|
1412
1948
|
.command("todos.create")
|
|
1413
1949
|
.input(CreateTodoInputSchema)
|
|
1414
1950
|
.output(TodoSchema)
|
|
1415
|
-
.
|
|
1951
|
+
.emits([TodoCreated])
|
|
1952
|
+
.run(async ({ ctx, input, events }) => {
|
|
1416
1953
|
await ctx.gate.authorize("todos.create");
|
|
1417
|
-
|
|
1954
|
+
const todo = await ctx.ports.uow.transaction(async (tx) => {
|
|
1955
|
+
const created = await tx.todos.create(input);
|
|
1956
|
+
await events.record(tx.events, TodoCreated, {
|
|
1957
|
+
todoId: created.id,
|
|
1958
|
+
title: created.title,
|
|
1959
|
+
});
|
|
1960
|
+
await tx.audit.record(
|
|
1961
|
+
auditEntry(ctx, {
|
|
1962
|
+
action: "todos.create",
|
|
1963
|
+
resource: { type: "todo", id: created.id, name: created.title },
|
|
1964
|
+
metadata: { completed: created.completed },
|
|
1965
|
+
}),
|
|
1966
|
+
);
|
|
1967
|
+
return created;
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
return todo;
|
|
1418
1971
|
});
|
|
1419
1972
|
`,
|
|
1420
1973
|
productionUseCasesIndex: `export { createTodoUseCase } from "./create-todo";
|
|
@@ -1429,24 +1982,40 @@ export {
|
|
|
1429
1982
|
type CreateTodoInput,
|
|
1430
1983
|
type ListTodosInput,
|
|
1431
1984
|
type Todo,
|
|
1432
|
-
} from "
|
|
1985
|
+
} from "../schemas";
|
|
1433
1986
|
`,
|
|
1434
1987
|
productionTodoRepositoryPort: `import type {
|
|
1988
|
+
OffsetPage,
|
|
1989
|
+
OffsetPageInfo,
|
|
1990
|
+
PageResult,
|
|
1991
|
+
} from "@beignet/core/pagination";
|
|
1992
|
+
import type {
|
|
1435
1993
|
CreateTodoInput,
|
|
1436
|
-
ListTodosInput,
|
|
1437
1994
|
Todo,
|
|
1438
|
-
} from "@/features/todos/
|
|
1995
|
+
} from "@/features/todos/schemas";
|
|
1996
|
+
|
|
1997
|
+
export type ListTodosResult = PageResult<Todo, OffsetPageInfo>;
|
|
1439
1998
|
|
|
1440
|
-
export type
|
|
1441
|
-
|
|
1442
|
-
|
|
1999
|
+
export type TodoAttachment = {
|
|
2000
|
+
id: string;
|
|
2001
|
+
todoId: string;
|
|
2002
|
+
key: string;
|
|
2003
|
+
fileName: string;
|
|
2004
|
+
contentType: string;
|
|
2005
|
+
size: number;
|
|
2006
|
+
createdAt: string;
|
|
1443
2007
|
};
|
|
1444
2008
|
|
|
1445
2009
|
export interface TodoRepository {
|
|
1446
|
-
list(
|
|
2010
|
+
list(page: OffsetPage): Promise<ListTodosResult>;
|
|
1447
2011
|
findById(id: string): Promise<Todo | null>;
|
|
1448
2012
|
create(input: CreateTodoInput): Promise<Todo>;
|
|
1449
2013
|
}
|
|
2014
|
+
|
|
2015
|
+
export interface TodoAttachmentRepository {
|
|
2016
|
+
create(input: Omit<TodoAttachment, "createdAt">): Promise<TodoAttachment>;
|
|
2017
|
+
findMany(todoId: string): Promise<TodoAttachment[]>;
|
|
2018
|
+
}
|
|
1450
2019
|
`,
|
|
1451
2020
|
productionAuthPort: `import type {
|
|
1452
2021
|
AuthPort as BeignetAuthPort,
|
|
@@ -1516,12 +2085,16 @@ export const todoPolicy = definePolicy({
|
|
|
1516
2085
|
},
|
|
1517
2086
|
});
|
|
1518
2087
|
`,
|
|
1519
|
-
productionInMemoryTodoRepository: `import type {
|
|
2088
|
+
productionInMemoryTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2089
|
+
import { offsetPageResult } from "@beignet/core/pagination";
|
|
2090
|
+
import type {
|
|
1520
2091
|
CreateTodoInput,
|
|
1521
|
-
ListTodosInput,
|
|
1522
2092
|
Todo,
|
|
1523
|
-
} from "@/features/todos/
|
|
1524
|
-
import type {
|
|
2093
|
+
} from "@/features/todos/schemas";
|
|
2094
|
+
import type {
|
|
2095
|
+
TodoAttachmentRepository,
|
|
2096
|
+
TodoRepository,
|
|
2097
|
+
} from "@/features/todos/ports";
|
|
1525
2098
|
|
|
1526
2099
|
export function createInMemoryTodoRepository(
|
|
1527
2100
|
seed: Todo[] = [],
|
|
@@ -1529,15 +2102,16 @@ export function createInMemoryTodoRepository(
|
|
|
1529
2102
|
const todos = new Map(seed.map((todo) => [todo.id, todo]));
|
|
1530
2103
|
|
|
1531
2104
|
return {
|
|
1532
|
-
async list(
|
|
2105
|
+
async list(page) {
|
|
1533
2106
|
const allTodos = Array.from(todos.values()).sort((left, right) =>
|
|
1534
2107
|
left.createdAt.localeCompare(right.createdAt),
|
|
1535
2108
|
);
|
|
1536
2109
|
|
|
1537
|
-
return
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
2110
|
+
return offsetPageResult(
|
|
2111
|
+
allTodos.slice(page.offset, page.offset + page.limit),
|
|
2112
|
+
page,
|
|
2113
|
+
allTodos.length,
|
|
2114
|
+
);
|
|
1541
2115
|
},
|
|
1542
2116
|
async findById(id: string) {
|
|
1543
2117
|
return todos.get(id) ?? null;
|
|
@@ -1600,28 +2174,6 @@ export const appPorts = definePorts({
|
|
|
1600
2174
|
todos,
|
|
1601
2175
|
})),
|
|
1602
2176
|
});
|
|
1603
|
-
`,
|
|
1604
|
-
productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
|
|
1605
|
-
import { todoPolicy } from "@/features/todos/policy";
|
|
1606
|
-
import { appError } from "@/features/shared/errors";
|
|
1607
|
-
import { createAnonymousAuth } from "./auth/anonymous-auth";
|
|
1608
|
-
import { fallbackLogger } from "./logger";
|
|
1609
|
-
|
|
1610
|
-
const gate = createGate({
|
|
1611
|
-
policies: [todoPolicy],
|
|
1612
|
-
onDeny(decision) {
|
|
1613
|
-
return appError("Forbidden", {
|
|
1614
|
-
message: decision.reason ?? "Forbidden",
|
|
1615
|
-
details: decision.details,
|
|
1616
|
-
});
|
|
1617
|
-
},
|
|
1618
|
-
});
|
|
1619
|
-
|
|
1620
|
-
export const appPorts = definePorts({
|
|
1621
|
-
auth: createAnonymousAuth(),
|
|
1622
|
-
gate,
|
|
1623
|
-
logger: fallbackLogger,
|
|
1624
|
-
});
|
|
1625
2177
|
`,
|
|
1626
2178
|
productionDrizzleConfig: `export default {
|
|
1627
2179
|
schema: "./infra/db/schema/index.ts",
|
|
@@ -1633,7 +2185,63 @@ export const appPorts = definePorts({
|
|
|
1633
2185
|
},
|
|
1634
2186
|
};
|
|
1635
2187
|
`,
|
|
1636
|
-
productionDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2188
|
+
productionDbSchema: `import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2189
|
+
|
|
2190
|
+
export const user = sqliteTable("user", {
|
|
2191
|
+
id: text("id").primaryKey(),
|
|
2192
|
+
name: text("name").notNull(),
|
|
2193
|
+
email: text("email").notNull().unique(),
|
|
2194
|
+
emailVerified: integer("email_verified", { mode: "boolean" })
|
|
2195
|
+
.notNull()
|
|
2196
|
+
.default(false),
|
|
2197
|
+
image: text("image"),
|
|
2198
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
2199
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
export const session = sqliteTable("session", {
|
|
2203
|
+
id: text("id").primaryKey(),
|
|
2204
|
+
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
2205
|
+
token: text("token").notNull().unique(),
|
|
2206
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
2207
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
2208
|
+
ipAddress: text("ip_address"),
|
|
2209
|
+
userAgent: text("user_agent"),
|
|
2210
|
+
userId: text("user_id")
|
|
2211
|
+
.notNull()
|
|
2212
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
export const account = sqliteTable("account", {
|
|
2216
|
+
id: text("id").primaryKey(),
|
|
2217
|
+
accountId: text("account_id").notNull(),
|
|
2218
|
+
providerId: text("provider_id").notNull(),
|
|
2219
|
+
userId: text("user_id")
|
|
2220
|
+
.notNull()
|
|
2221
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
2222
|
+
accessToken: text("access_token"),
|
|
2223
|
+
refreshToken: text("refresh_token"),
|
|
2224
|
+
idToken: text("id_token"),
|
|
2225
|
+
accessTokenExpiresAt: integer("access_token_expires_at", {
|
|
2226
|
+
mode: "timestamp",
|
|
2227
|
+
}),
|
|
2228
|
+
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
|
2229
|
+
mode: "timestamp",
|
|
2230
|
+
}),
|
|
2231
|
+
scope: text("scope"),
|
|
2232
|
+
password: text("password"),
|
|
2233
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
2234
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
export const verification = sqliteTable("verification", {
|
|
2238
|
+
id: text("id").primaryKey(),
|
|
2239
|
+
identifier: text("identifier").notNull(),
|
|
2240
|
+
value: text("value").notNull(),
|
|
2241
|
+
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
2242
|
+
createdAt: integer("created_at", { mode: "timestamp" }),
|
|
2243
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }),
|
|
2244
|
+
});
|
|
1637
2245
|
|
|
1638
2246
|
export const todos = sqliteTable("todos", {
|
|
1639
2247
|
id: text("id").primaryKey(),
|
|
@@ -1641,15 +2249,139 @@ export const todos = sqliteTable("todos", {
|
|
|
1641
2249
|
completed: integer("completed", { mode: "boolean" }).notNull().default(false),
|
|
1642
2250
|
createdAt: text("created_at").notNull(),
|
|
1643
2251
|
});
|
|
2252
|
+
|
|
2253
|
+
export const todoAttachments = sqliteTable(
|
|
2254
|
+
"todo_attachments",
|
|
2255
|
+
{
|
|
2256
|
+
id: text("id").primaryKey(),
|
|
2257
|
+
todoId: text("todo_id")
|
|
2258
|
+
.notNull()
|
|
2259
|
+
.references(() => todos.id, { onDelete: "cascade" }),
|
|
2260
|
+
key: text("key").notNull(),
|
|
2261
|
+
fileName: text("file_name").notNull(),
|
|
2262
|
+
contentType: text("content_type").notNull(),
|
|
2263
|
+
size: integer("size").notNull(),
|
|
2264
|
+
createdAt: text("created_at").notNull(),
|
|
2265
|
+
},
|
|
2266
|
+
(table) => ({
|
|
2267
|
+
todoIdx: index("todo_attachments_todo_idx").on(
|
|
2268
|
+
table.todoId,
|
|
2269
|
+
table.createdAt,
|
|
2270
|
+
),
|
|
2271
|
+
keyIdx: index("todo_attachments_key_idx").on(table.key),
|
|
2272
|
+
}),
|
|
2273
|
+
);
|
|
2274
|
+
|
|
2275
|
+
export const auditLog = sqliteTable(
|
|
2276
|
+
"audit_log",
|
|
2277
|
+
{
|
|
2278
|
+
id: text("id").primaryKey(),
|
|
2279
|
+
action: text("action").notNull(),
|
|
2280
|
+
actorType: text("actor_type", {
|
|
2281
|
+
enum: ["anonymous", "service", "system", "user"],
|
|
2282
|
+
}).notNull(),
|
|
2283
|
+
actorId: text("actor_id"),
|
|
2284
|
+
actorDisplayName: text("actor_display_name"),
|
|
2285
|
+
tenantId: text("tenant_id"),
|
|
2286
|
+
tenantSlug: text("tenant_slug"),
|
|
2287
|
+
resourceType: text("resource_type"),
|
|
2288
|
+
resourceId: text("resource_id"),
|
|
2289
|
+
resourceName: text("resource_name"),
|
|
2290
|
+
outcome: text("outcome", { enum: ["success", "failure"] })
|
|
2291
|
+
.notNull()
|
|
2292
|
+
.default("success"),
|
|
2293
|
+
requestId: text("request_id"),
|
|
2294
|
+
traceId: text("trace_id"),
|
|
2295
|
+
message: text("message"),
|
|
2296
|
+
metadata: text("metadata"),
|
|
2297
|
+
actorMetadata: text("actor_metadata"),
|
|
2298
|
+
tenantMetadata: text("tenant_metadata"),
|
|
2299
|
+
resourceMetadata: text("resource_metadata"),
|
|
2300
|
+
occurredAt: text("occurred_at").notNull(),
|
|
2301
|
+
},
|
|
2302
|
+
(table) => ({
|
|
2303
|
+
actionIdx: index("audit_log_action_idx").on(table.action),
|
|
2304
|
+
actorIdx: index("audit_log_actor_idx").on(table.actorType, table.actorId),
|
|
2305
|
+
occurredAtIdx: index("audit_log_occurred_at_idx").on(table.occurredAt),
|
|
2306
|
+
requestIdx: index("audit_log_request_idx").on(table.requestId),
|
|
2307
|
+
resourceIdx: index("audit_log_resource_idx").on(
|
|
2308
|
+
table.resourceType,
|
|
2309
|
+
table.resourceId,
|
|
2310
|
+
),
|
|
2311
|
+
tenantIdx: index("audit_log_tenant_idx").on(table.tenantId),
|
|
2312
|
+
}),
|
|
2313
|
+
);
|
|
2314
|
+
|
|
2315
|
+
export const outboxMessages = sqliteTable(
|
|
2316
|
+
"outbox_messages",
|
|
2317
|
+
{
|
|
2318
|
+
id: text("id").primaryKey(),
|
|
2319
|
+
kind: text("kind", { enum: ["event", "job"] }).notNull(),
|
|
2320
|
+
name: text("name").notNull(),
|
|
2321
|
+
payloadJson: text("payload_json").notNull(),
|
|
2322
|
+
status: text("status", {
|
|
2323
|
+
enum: ["pending", "claimed", "delivered", "deadLettered"],
|
|
2324
|
+
}).notNull(),
|
|
2325
|
+
attempts: integer("attempts").notNull().default(0),
|
|
2326
|
+
maxAttempts: integer("max_attempts").notNull().default(3),
|
|
2327
|
+
availableAt: text("available_at").notNull(),
|
|
2328
|
+
claimedAt: text("claimed_at"),
|
|
2329
|
+
lockedUntil: text("locked_until"),
|
|
2330
|
+
claimToken: text("claim_token"),
|
|
2331
|
+
deliveredAt: text("delivered_at"),
|
|
2332
|
+
lastErrorJson: text("last_error_json"),
|
|
2333
|
+
createdAt: text("created_at").notNull(),
|
|
2334
|
+
updatedAt: text("updated_at").notNull(),
|
|
2335
|
+
},
|
|
2336
|
+
(table) => ({
|
|
2337
|
+
availableIdx: index("outbox_messages_available_idx").on(
|
|
2338
|
+
table.status,
|
|
2339
|
+
table.availableAt,
|
|
2340
|
+
),
|
|
2341
|
+
lockedIdx: index("outbox_messages_locked_idx").on(
|
|
2342
|
+
table.status,
|
|
2343
|
+
table.lockedUntil,
|
|
2344
|
+
),
|
|
2345
|
+
}),
|
|
2346
|
+
);
|
|
2347
|
+
|
|
2348
|
+
export const idempotencyRecords = sqliteTable(
|
|
2349
|
+
"idempotency_records",
|
|
2350
|
+
{
|
|
2351
|
+
storageKey: text("storage_key").primaryKey(),
|
|
2352
|
+
namespace: text("namespace").notNull(),
|
|
2353
|
+
idempotencyKey: text("idempotency_key").notNull(),
|
|
2354
|
+
scopeKey: text("scope_key").notNull(),
|
|
2355
|
+
fingerprint: text("fingerprint").notNull(),
|
|
2356
|
+
status: text("status", { enum: ["in-progress", "completed"] }).notNull(),
|
|
2357
|
+
resultJson: text("result_json"),
|
|
2358
|
+
reservedAt: text("reserved_at").notNull(),
|
|
2359
|
+
completedAt: text("completed_at"),
|
|
2360
|
+
expiresAt: text("expires_at"),
|
|
2361
|
+
updatedAt: text("updated_at").notNull(),
|
|
2362
|
+
},
|
|
2363
|
+
(table) => ({
|
|
2364
|
+
lookupIdx: index("idempotency_records_lookup_idx").on(
|
|
2365
|
+
table.namespace,
|
|
2366
|
+
table.scopeKey,
|
|
2367
|
+
table.idempotencyKey,
|
|
2368
|
+
),
|
|
2369
|
+
expiresIdx: index("idempotency_records_expires_idx").on(table.expiresAt),
|
|
2370
|
+
}),
|
|
2371
|
+
);
|
|
1644
2372
|
`,
|
|
1645
|
-
productionDrizzleTodoRepository: `import {
|
|
2373
|
+
productionDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2374
|
+
import { offsetPageResult } from "@beignet/core/pagination";
|
|
2375
|
+
import { count, desc, eq } from "drizzle-orm";
|
|
1646
2376
|
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
1647
|
-
import type {
|
|
2377
|
+
import type {
|
|
2378
|
+
TodoAttachmentRepository,
|
|
2379
|
+
TodoRepository,
|
|
2380
|
+
} from "@/features/todos/ports";
|
|
1648
2381
|
import type {
|
|
1649
2382
|
CreateTodoInput,
|
|
1650
|
-
ListTodosInput,
|
|
1651
2383
|
Todo,
|
|
1652
|
-
} from "@/features/todos/
|
|
2384
|
+
} from "@/features/todos/schemas";
|
|
1653
2385
|
import * as schema from "@/infra/db/schema";
|
|
1654
2386
|
|
|
1655
2387
|
type TodoRow = typeof schema.todos.$inferSelect;
|
|
@@ -1667,19 +2399,16 @@ export function createDrizzleTodoRepository(
|
|
|
1667
2399
|
db: DrizzleTursoDatabase<typeof schema>,
|
|
1668
2400
|
): TodoRepository {
|
|
1669
2401
|
return {
|
|
1670
|
-
async list(
|
|
2402
|
+
async list(page) {
|
|
1671
2403
|
const rows = await db
|
|
1672
2404
|
.select()
|
|
1673
2405
|
.from(schema.todos)
|
|
1674
2406
|
.orderBy(desc(schema.todos.createdAt))
|
|
1675
|
-
.limit(
|
|
1676
|
-
.offset(
|
|
2407
|
+
.limit(page.limit)
|
|
2408
|
+
.offset(page.offset);
|
|
1677
2409
|
const [{ total }] = await db.select({ total: count() }).from(schema.todos);
|
|
1678
2410
|
|
|
1679
|
-
return
|
|
1680
|
-
todos: rows.map(toTodo),
|
|
1681
|
-
total,
|
|
1682
|
-
};
|
|
2411
|
+
return offsetPageResult(rows.map(toTodo), page, total);
|
|
1683
2412
|
},
|
|
1684
2413
|
async findById(id: string) {
|
|
1685
2414
|
const [row] = await db
|
|
@@ -1707,28 +2436,445 @@ export function createDrizzleTodoRepository(
|
|
|
1707
2436
|
},
|
|
1708
2437
|
};
|
|
1709
2438
|
}
|
|
2439
|
+
|
|
2440
|
+
export function createDrizzleTodoAttachmentRepository(
|
|
2441
|
+
db: DrizzleTursoDatabase<typeof schema>,
|
|
2442
|
+
): TodoAttachmentRepository {
|
|
2443
|
+
return {
|
|
2444
|
+
async create(input) {
|
|
2445
|
+
const attachment = {
|
|
2446
|
+
...input,
|
|
2447
|
+
createdAt: new Date().toISOString(),
|
|
2448
|
+
};
|
|
2449
|
+
const [row] = await db
|
|
2450
|
+
.insert(schema.todoAttachments)
|
|
2451
|
+
.values(attachment)
|
|
2452
|
+
.returning();
|
|
2453
|
+
|
|
2454
|
+
if (!row) {
|
|
2455
|
+
throw new Error("Failed to create todo attachment");
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
return {
|
|
2459
|
+
id: row.id,
|
|
2460
|
+
todoId: row.todoId,
|
|
2461
|
+
key: row.key,
|
|
2462
|
+
fileName: row.fileName,
|
|
2463
|
+
contentType: row.contentType,
|
|
2464
|
+
size: row.size,
|
|
2465
|
+
createdAt: row.createdAt,
|
|
2466
|
+
};
|
|
2467
|
+
},
|
|
2468
|
+
async findMany(todoId) {
|
|
2469
|
+
const rows = await db
|
|
2470
|
+
.select()
|
|
2471
|
+
.from(schema.todoAttachments)
|
|
2472
|
+
.where(eq(schema.todoAttachments.todoId, todoId))
|
|
2473
|
+
.orderBy(desc(schema.todoAttachments.createdAt));
|
|
2474
|
+
|
|
2475
|
+
return rows.map((row) => ({
|
|
2476
|
+
id: row.id,
|
|
2477
|
+
todoId: row.todoId,
|
|
2478
|
+
key: row.key,
|
|
2479
|
+
fileName: row.fileName,
|
|
2480
|
+
contentType: row.contentType,
|
|
2481
|
+
size: row.size,
|
|
2482
|
+
createdAt: row.createdAt,
|
|
2483
|
+
}));
|
|
2484
|
+
},
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
1710
2487
|
`,
|
|
1711
2488
|
productionDbRepositories: `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
1712
|
-
import {
|
|
2489
|
+
import {
|
|
2490
|
+
createDrizzleTodoAttachmentRepository,
|
|
2491
|
+
createDrizzleTodoRepository,
|
|
2492
|
+
} from "@/infra/todos/drizzle-todo-repository";
|
|
1713
2493
|
import type { AppTransactionPorts } from "@/ports";
|
|
1714
2494
|
import * as schema from "./schema";
|
|
1715
2495
|
|
|
1716
2496
|
export function createRepositories(
|
|
1717
2497
|
db: DrizzleTursoDatabase<typeof schema>,
|
|
1718
|
-
): Omit<
|
|
2498
|
+
): Omit<
|
|
2499
|
+
AppTransactionPorts,
|
|
2500
|
+
"audit" | "events" | "idempotency" | "jobs" | "outbox"
|
|
2501
|
+
> {
|
|
1719
2502
|
return {
|
|
2503
|
+
todoAttachments: createDrizzleTodoAttachmentRepository(db),
|
|
1720
2504
|
todos: createDrizzleTodoRepository(db),
|
|
1721
2505
|
};
|
|
1722
2506
|
}
|
|
1723
2507
|
`,
|
|
1724
|
-
|
|
2508
|
+
productionDrizzleAuditLog: `import type { AuditLogEntry, AuditLogPort } from "@beignet/core/ports";
|
|
2509
|
+
import {
|
|
2510
|
+
normalizeAuditLogEntry,
|
|
2511
|
+
redactAuditLogEntry,
|
|
2512
|
+
} from "@beignet/core/ports";
|
|
2513
|
+
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
2514
|
+
import * as schema from "@/infra/db/schema";
|
|
2515
|
+
|
|
2516
|
+
function serialize(value: unknown): string | null {
|
|
2517
|
+
return value === undefined ? null : JSON.stringify(value);
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
export function createDrizzleAuditLog(
|
|
2521
|
+
db: DrizzleTursoDatabase<typeof schema>,
|
|
2522
|
+
): AuditLogPort {
|
|
2523
|
+
return {
|
|
2524
|
+
async record(input) {
|
|
2525
|
+
const entry: AuditLogEntry = redactAuditLogEntry(
|
|
2526
|
+
normalizeAuditLogEntry(input),
|
|
2527
|
+
);
|
|
2528
|
+
|
|
2529
|
+
await db.insert(schema.auditLog).values({
|
|
2530
|
+
id: crypto.randomUUID(),
|
|
2531
|
+
action: entry.action,
|
|
2532
|
+
actorType: entry.actor.type,
|
|
2533
|
+
actorId: entry.actor.id ?? null,
|
|
2534
|
+
actorDisplayName: entry.actor.displayName ?? null,
|
|
2535
|
+
tenantId: entry.tenant?.id ?? null,
|
|
2536
|
+
tenantSlug: entry.tenant?.slug ?? null,
|
|
2537
|
+
resourceType: entry.resource?.type ?? null,
|
|
2538
|
+
resourceId: entry.resource?.id ?? null,
|
|
2539
|
+
resourceName: entry.resource?.name ?? null,
|
|
2540
|
+
outcome: entry.outcome,
|
|
2541
|
+
requestId: entry.requestId ?? null,
|
|
2542
|
+
traceId: entry.traceId ?? null,
|
|
2543
|
+
message: entry.message ?? null,
|
|
2544
|
+
metadata: serialize(entry.metadata),
|
|
2545
|
+
actorMetadata: serialize(entry.actor.metadata),
|
|
2546
|
+
tenantMetadata: serialize(entry.tenant?.metadata),
|
|
2547
|
+
resourceMetadata: serialize(entry.resource?.metadata),
|
|
2548
|
+
occurredAt: entry.occurredAt.toISOString(),
|
|
2549
|
+
});
|
|
2550
|
+
},
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
`,
|
|
2554
|
+
productionDbBootstrap: `import { createDrizzleTursoIdempotencySetupStatements, createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
|
|
2555
|
+
import type { Client } from "@libsql/client";
|
|
2556
|
+
|
|
2557
|
+
type BootstrapOptions = {
|
|
2558
|
+
seed?: boolean;
|
|
2559
|
+
};
|
|
2560
|
+
|
|
2561
|
+
const setupStatements = [
|
|
2562
|
+
\`CREATE TABLE IF NOT EXISTS user (
|
|
2563
|
+
id text PRIMARY KEY NOT NULL,
|
|
2564
|
+
name text NOT NULL,
|
|
2565
|
+
email text NOT NULL UNIQUE,
|
|
2566
|
+
email_verified integer DEFAULT false NOT NULL,
|
|
2567
|
+
image text,
|
|
2568
|
+
created_at integer NOT NULL,
|
|
2569
|
+
updated_at integer NOT NULL
|
|
2570
|
+
)\`,
|
|
2571
|
+
\`CREATE TABLE IF NOT EXISTS session (
|
|
2572
|
+
id text PRIMARY KEY NOT NULL,
|
|
2573
|
+
expires_at integer NOT NULL,
|
|
2574
|
+
token text NOT NULL UNIQUE,
|
|
2575
|
+
created_at integer NOT NULL,
|
|
2576
|
+
updated_at integer NOT NULL,
|
|
2577
|
+
ip_address text,
|
|
2578
|
+
user_agent text,
|
|
2579
|
+
user_id text NOT NULL REFERENCES user(id) ON DELETE cascade
|
|
2580
|
+
)\`,
|
|
2581
|
+
\`CREATE TABLE IF NOT EXISTS account (
|
|
2582
|
+
id text PRIMARY KEY NOT NULL,
|
|
2583
|
+
account_id text NOT NULL,
|
|
2584
|
+
provider_id text NOT NULL,
|
|
2585
|
+
user_id text NOT NULL REFERENCES user(id) ON DELETE cascade,
|
|
2586
|
+
access_token text,
|
|
2587
|
+
refresh_token text,
|
|
2588
|
+
id_token text,
|
|
2589
|
+
access_token_expires_at integer,
|
|
2590
|
+
refresh_token_expires_at integer,
|
|
2591
|
+
scope text,
|
|
2592
|
+
password text,
|
|
2593
|
+
created_at integer NOT NULL,
|
|
2594
|
+
updated_at integer NOT NULL
|
|
2595
|
+
)\`,
|
|
2596
|
+
\`CREATE TABLE IF NOT EXISTS verification (
|
|
2597
|
+
id text PRIMARY KEY NOT NULL,
|
|
2598
|
+
identifier text NOT NULL,
|
|
2599
|
+
value text NOT NULL,
|
|
2600
|
+
expires_at integer NOT NULL,
|
|
2601
|
+
created_at integer,
|
|
2602
|
+
updated_at integer
|
|
2603
|
+
)\`,
|
|
2604
|
+
\`CREATE TABLE IF NOT EXISTS todos (
|
|
2605
|
+
id text PRIMARY KEY NOT NULL,
|
|
2606
|
+
title text NOT NULL,
|
|
2607
|
+
completed integer DEFAULT false NOT NULL,
|
|
2608
|
+
created_at text NOT NULL
|
|
2609
|
+
)\`,
|
|
2610
|
+
\`CREATE TABLE IF NOT EXISTS todo_attachments (
|
|
2611
|
+
id text PRIMARY KEY NOT NULL,
|
|
2612
|
+
todo_id text NOT NULL REFERENCES todos(id) ON DELETE cascade,
|
|
2613
|
+
key text NOT NULL,
|
|
2614
|
+
file_name text NOT NULL,
|
|
2615
|
+
content_type text NOT NULL,
|
|
2616
|
+
size integer NOT NULL,
|
|
2617
|
+
created_at text NOT NULL
|
|
2618
|
+
)\`,
|
|
2619
|
+
"CREATE INDEX IF NOT EXISTS todo_attachments_todo_idx ON todo_attachments (todo_id, created_at)",
|
|
2620
|
+
"CREATE INDEX IF NOT EXISTS todo_attachments_key_idx ON todo_attachments (key)",
|
|
2621
|
+
\`CREATE TABLE IF NOT EXISTS audit_log (
|
|
2622
|
+
id text PRIMARY KEY NOT NULL,
|
|
2623
|
+
action text NOT NULL,
|
|
2624
|
+
actor_type text NOT NULL,
|
|
2625
|
+
actor_id text,
|
|
2626
|
+
actor_display_name text,
|
|
2627
|
+
tenant_id text,
|
|
2628
|
+
tenant_slug text,
|
|
2629
|
+
resource_type text,
|
|
2630
|
+
resource_id text,
|
|
2631
|
+
resource_name text,
|
|
2632
|
+
outcome text DEFAULT 'success' NOT NULL,
|
|
2633
|
+
request_id text,
|
|
2634
|
+
trace_id text,
|
|
2635
|
+
message text,
|
|
2636
|
+
metadata text,
|
|
2637
|
+
actor_metadata text,
|
|
2638
|
+
tenant_metadata text,
|
|
2639
|
+
resource_metadata text,
|
|
2640
|
+
occurred_at text NOT NULL
|
|
2641
|
+
)\`,
|
|
2642
|
+
"CREATE INDEX IF NOT EXISTS audit_log_action_idx ON audit_log (action)",
|
|
2643
|
+
"CREATE INDEX IF NOT EXISTS audit_log_actor_idx ON audit_log (actor_type, actor_id)",
|
|
2644
|
+
"CREATE INDEX IF NOT EXISTS audit_log_occurred_at_idx ON audit_log (occurred_at)",
|
|
2645
|
+
"CREATE INDEX IF NOT EXISTS audit_log_request_idx ON audit_log (request_id)",
|
|
2646
|
+
"CREATE INDEX IF NOT EXISTS audit_log_resource_idx ON audit_log (resource_type, resource_id)",
|
|
2647
|
+
"CREATE INDEX IF NOT EXISTS audit_log_tenant_idx ON audit_log (tenant_id)",
|
|
2648
|
+
...createDrizzleTursoIdempotencySetupStatements(),
|
|
2649
|
+
...createDrizzleTursoOutboxSetupStatements(),
|
|
2650
|
+
];
|
|
2651
|
+
|
|
2652
|
+
const seedTodos = [
|
|
2653
|
+
{
|
|
2654
|
+
id: "00000000-0000-4000-8000-000000000001",
|
|
2655
|
+
title: "Review the starter boundaries",
|
|
2656
|
+
completed: 0,
|
|
2657
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
2658
|
+
},
|
|
2659
|
+
{
|
|
2660
|
+
id: "00000000-0000-4000-8000-000000000002",
|
|
2661
|
+
title: "Create your first feature",
|
|
2662
|
+
completed: 0,
|
|
2663
|
+
createdAt: "2026-01-01T00:05:00.000Z",
|
|
2664
|
+
},
|
|
2665
|
+
] as const;
|
|
2666
|
+
|
|
2667
|
+
const resetTables = [
|
|
2668
|
+
"verification",
|
|
2669
|
+
"account",
|
|
2670
|
+
"session",
|
|
2671
|
+
"user",
|
|
2672
|
+
"todo_attachments",
|
|
2673
|
+
"todos",
|
|
2674
|
+
"audit_log",
|
|
2675
|
+
"idempotency_records",
|
|
2676
|
+
"outbox_messages",
|
|
2677
|
+
] as const;
|
|
2678
|
+
|
|
2679
|
+
export async function ensureStarterDatabase(
|
|
2680
|
+
client: Client,
|
|
2681
|
+
options: BootstrapOptions = {},
|
|
2682
|
+
): Promise<void> {
|
|
2683
|
+
for (const statement of setupStatements) {
|
|
2684
|
+
await client.execute(statement);
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
if (options.seed === false) return;
|
|
2688
|
+
|
|
2689
|
+
const result = await client.execute("SELECT count(*) as total FROM todos");
|
|
2690
|
+
const total = Number(result.rows[0]?.total ?? 0);
|
|
2691
|
+
if (total > 0) return;
|
|
2692
|
+
|
|
2693
|
+
for (const todo of seedTodos) {
|
|
2694
|
+
await client.execute({
|
|
2695
|
+
sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
|
|
2696
|
+
args: [todo.id, todo.title, todo.completed, todo.createdAt],
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
export async function resetStarterDatabase(client: Client): Promise<void> {
|
|
2702
|
+
await client.execute("PRAGMA foreign_keys = OFF");
|
|
2703
|
+
try {
|
|
2704
|
+
for (const table of resetTables) {
|
|
2705
|
+
await client.execute(\`DROP TABLE IF EXISTS \${table}\`);
|
|
2706
|
+
}
|
|
2707
|
+
} finally {
|
|
2708
|
+
await client.execute("PRAGMA foreign_keys = ON");
|
|
2709
|
+
}
|
|
2710
|
+
await ensureStarterDatabase(client, { seed: false });
|
|
2711
|
+
}
|
|
2712
|
+
`,
|
|
2713
|
+
productionDbProvider: `import { registerListeners } from "@beignet/core/events";
|
|
2714
|
+
import { createInlineJobDispatcher } from "@beignet/core/jobs";
|
|
2715
|
+
import { createMemoryMailer } from "@beignet/core/mail";
|
|
2716
|
+
import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
|
|
2717
|
+
import {
|
|
2718
|
+
createOutboxEventRecorder,
|
|
2719
|
+
createOutboxJobDispatcher,
|
|
2720
|
+
} from "@beignet/core/outbox";
|
|
2721
|
+
import {
|
|
2722
|
+
createInstrumentedAuditLog,
|
|
2723
|
+
createSystemActor,
|
|
2724
|
+
} from "@beignet/core/ports";
|
|
2725
|
+
import {
|
|
2726
|
+
createProvider,
|
|
2727
|
+
createProviderInstrumentation,
|
|
2728
|
+
} from "@beignet/core/providers";
|
|
2729
|
+
import {
|
|
2730
|
+
createDrizzleTursoIdempotencyPort,
|
|
2731
|
+
createDrizzleTursoOutboxPort,
|
|
2732
|
+
createDrizzleTursoUnitOfWork,
|
|
2733
|
+
type DbPort,
|
|
2734
|
+
} from "@beignet/provider-drizzle-turso";
|
|
2735
|
+
import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
|
|
2736
|
+
import type { AppContext } from "@/app-context";
|
|
2737
|
+
import { todoListeners } from "@/features/todos/listeners";
|
|
2738
|
+
import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
|
|
2739
|
+
import type { AppPorts } from "@/ports";
|
|
2740
|
+
import type { AppServiceContextInput } from "@/server/context";
|
|
2741
|
+
import { ensureStarterDatabase } from "./bootstrap";
|
|
2742
|
+
import { createRepositories } from "./repositories";
|
|
2743
|
+
import type * as schema from "./schema";
|
|
2744
|
+
|
|
2745
|
+
export const starterDatabaseProvider = createProvider<
|
|
2746
|
+
{ db: DbPort<typeof schema> },
|
|
2747
|
+
AppContext,
|
|
2748
|
+
AppServiceContextInput
|
|
2749
|
+
>()({
|
|
2750
|
+
name: "starter-database",
|
|
2751
|
+
|
|
2752
|
+
async setup({ ports, createServiceContext }) {
|
|
2753
|
+
const dbPort = ports.db;
|
|
2754
|
+
if (!dbPort) {
|
|
2755
|
+
throw new Error(
|
|
2756
|
+
"starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
|
|
2757
|
+
);
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
const createBackgroundContext = () =>
|
|
2761
|
+
createServiceContext({
|
|
2762
|
+
actor: createSystemActor("starter-background"),
|
|
2763
|
+
});
|
|
2764
|
+
|
|
2765
|
+
const mailInstrumentation = createProviderInstrumentation(ports, {
|
|
2766
|
+
providerName: "memory-mailer",
|
|
2767
|
+
watcher: "mail",
|
|
2768
|
+
});
|
|
2769
|
+
const eventBus = createInMemoryEventBus({
|
|
2770
|
+
instrumentation: ports,
|
|
2771
|
+
onHandlerError(error: unknown, eventName: string) {
|
|
2772
|
+
console.error("Event handler failed", { error, eventName });
|
|
2773
|
+
},
|
|
2774
|
+
});
|
|
2775
|
+
const jobs = createInlineJobDispatcher<AppContext>({
|
|
2776
|
+
ctx: createBackgroundContext,
|
|
2777
|
+
});
|
|
2778
|
+
const notifications = createInlineNotificationDispatcher<AppContext>({
|
|
2779
|
+
ctx: createBackgroundContext,
|
|
2780
|
+
instrumentation: ports,
|
|
2781
|
+
});
|
|
2782
|
+
const mailer = createMemoryMailer({
|
|
2783
|
+
defaultFrom: "Beignet Starter <noreply@example.local>",
|
|
2784
|
+
onSend(delivery) {
|
|
2785
|
+
mailInstrumentation.custom({
|
|
2786
|
+
name: "mail.sent",
|
|
2787
|
+
label: "Mail sent",
|
|
2788
|
+
summary: delivery.message.subject,
|
|
2789
|
+
details: {
|
|
2790
|
+
to: delivery.message.to,
|
|
2791
|
+
subject: delivery.message.subject,
|
|
2792
|
+
id: delivery.id,
|
|
2793
|
+
},
|
|
2794
|
+
});
|
|
2795
|
+
},
|
|
2796
|
+
});
|
|
2797
|
+
const audit = createInstrumentedAuditLog({
|
|
2798
|
+
audit: createDrizzleAuditLog(dbPort.db),
|
|
2799
|
+
instrumentation: ports,
|
|
2800
|
+
});
|
|
2801
|
+
const repositories = createRepositories(dbPort.db);
|
|
2802
|
+
const idempotency = createDrizzleTursoIdempotencyPort(dbPort.db);
|
|
2803
|
+
const outbox = createDrizzleTursoOutboxPort(dbPort.db);
|
|
2804
|
+
const unregisterListeners = registerListeners(eventBus, todoListeners, {
|
|
2805
|
+
ctx: createBackgroundContext,
|
|
2806
|
+
onError(error, listener) {
|
|
2807
|
+
console.error("Event listener failed", {
|
|
2808
|
+
error,
|
|
2809
|
+
listenerName: listener.name,
|
|
2810
|
+
});
|
|
2811
|
+
},
|
|
2812
|
+
});
|
|
2813
|
+
|
|
2814
|
+
const providedPorts: Pick<
|
|
2815
|
+
AppPorts,
|
|
2816
|
+
| "audit"
|
|
2817
|
+
| "eventBus"
|
|
2818
|
+
| "idempotency"
|
|
2819
|
+
| "jobs"
|
|
2820
|
+
| "mailer"
|
|
2821
|
+
| "notifications"
|
|
2822
|
+
| "outbox"
|
|
2823
|
+
| "todoAttachments"
|
|
2824
|
+
| "todos"
|
|
2825
|
+
| "uow"
|
|
2826
|
+
> = {
|
|
2827
|
+
audit,
|
|
2828
|
+
...repositories,
|
|
2829
|
+
eventBus,
|
|
2830
|
+
idempotency,
|
|
2831
|
+
jobs,
|
|
2832
|
+
mailer,
|
|
2833
|
+
notifications,
|
|
2834
|
+
outbox,
|
|
2835
|
+
uow: createDrizzleTursoUnitOfWork({
|
|
2836
|
+
db: dbPort.db,
|
|
2837
|
+
createTransactionPorts: (tx) => {
|
|
2838
|
+
const txIdempotency = createDrizzleTursoIdempotencyPort(tx);
|
|
2839
|
+
const txOutbox = createDrizzleTursoOutboxPort(tx);
|
|
2840
|
+
|
|
2841
|
+
return {
|
|
2842
|
+
audit: createInstrumentedAuditLog({
|
|
2843
|
+
audit: createDrizzleAuditLog(tx),
|
|
2844
|
+
instrumentation: ports,
|
|
2845
|
+
}),
|
|
2846
|
+
...createRepositories(tx),
|
|
2847
|
+
events: createOutboxEventRecorder(txOutbox),
|
|
2848
|
+
idempotency: txIdempotency,
|
|
2849
|
+
jobs: createOutboxJobDispatcher(txOutbox),
|
|
2850
|
+
outbox: txOutbox,
|
|
2851
|
+
};
|
|
2852
|
+
},
|
|
2853
|
+
}),
|
|
2854
|
+
};
|
|
2855
|
+
|
|
2856
|
+
return {
|
|
2857
|
+
ports: providedPorts,
|
|
2858
|
+
async start() {
|
|
2859
|
+
await ensureStarterDatabase(dbPort.client);
|
|
2860
|
+
},
|
|
2861
|
+
stop() {
|
|
2862
|
+
unregisterListeners();
|
|
2863
|
+
},
|
|
2864
|
+
};
|
|
2865
|
+
},
|
|
2866
|
+
});
|
|
2867
|
+
`,
|
|
2868
|
+
productionContractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
|
|
1725
2869
|
import { z } from "zod";
|
|
1726
2870
|
import { errors } from "@/features/shared/errors";
|
|
1727
2871
|
import {
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2872
|
+
CreateTodoInputSchema,
|
|
2873
|
+
GetTodoInputSchema,
|
|
2874
|
+
ListTodosInputSchema,
|
|
2875
|
+
ListTodosOutputSchema,
|
|
2876
|
+
TodoSchema,
|
|
2877
|
+
} from "@/features/todos/schemas";
|
|
1732
2878
|
|
|
1733
2879
|
const ErrorResponseSchema = z.object({
|
|
1734
2880
|
code: z.string(),
|
|
@@ -1736,7 +2882,7 @@ const ErrorResponseSchema = z.object({
|
|
|
1736
2882
|
requestId: z.string().optional(),
|
|
1737
2883
|
});
|
|
1738
2884
|
|
|
1739
|
-
const todos =
|
|
2885
|
+
const todos = defineContractGroup()
|
|
1740
2886
|
.namespace("todos")
|
|
1741
2887
|
.errors({ Unauthorized: errors.Unauthorized })
|
|
1742
2888
|
.responses({
|
|
@@ -1745,28 +2891,29 @@ const todos = createContractGroup()
|
|
|
1745
2891
|
|
|
1746
2892
|
export const listTodos = todos
|
|
1747
2893
|
.get("/api/todos")
|
|
1748
|
-
.query(
|
|
2894
|
+
.query(ListTodosInputSchema)
|
|
1749
2895
|
.responses({
|
|
1750
|
-
200:
|
|
2896
|
+
200: ListTodosOutputSchema,
|
|
1751
2897
|
});
|
|
1752
2898
|
|
|
1753
2899
|
export const createTodo = todos
|
|
1754
2900
|
.post("/api/todos")
|
|
1755
|
-
.body(
|
|
2901
|
+
.body(CreateTodoInputSchema)
|
|
1756
2902
|
.errors({ Forbidden: errors.Forbidden })
|
|
1757
2903
|
.responses({
|
|
1758
|
-
201:
|
|
2904
|
+
201: TodoSchema,
|
|
1759
2905
|
});
|
|
1760
2906
|
|
|
1761
2907
|
export const getTodo = todos
|
|
1762
2908
|
.get("/api/todos/:id")
|
|
1763
|
-
.pathParams(
|
|
2909
|
+
.pathParams(GetTodoInputSchema)
|
|
1764
2910
|
.errors({ TodoNotFound: errors.TodoNotFound })
|
|
1765
2911
|
.responses({
|
|
1766
|
-
200:
|
|
2912
|
+
200: TodoSchema,
|
|
1767
2913
|
});
|
|
1768
2914
|
`,
|
|
1769
|
-
productionTodoRoutes: `import {
|
|
2915
|
+
productionTodoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2916
|
+
import { defineRouteGroup } from "@beignet/next";
|
|
1770
2917
|
import type { AppContext } from "@/app-context";
|
|
1771
2918
|
import { createTodo, getTodo, listTodos } from "@/features/todos/contracts";
|
|
1772
2919
|
import {
|
|
@@ -1778,31 +2925,14 @@ import {
|
|
|
1778
2925
|
export const todoRoutes = defineRouteGroup<AppContext>({
|
|
1779
2926
|
name: "todos",
|
|
1780
2927
|
routes: [
|
|
1781
|
-
{
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
status: 200,
|
|
1785
|
-
body: await listTodosUseCase.run({ ctx, input: query }),
|
|
1786
|
-
}),
|
|
1787
|
-
},
|
|
1788
|
-
{
|
|
1789
|
-
contract: createTodo,
|
|
1790
|
-
handle: async ({ ctx, body }) => ({
|
|
1791
|
-
status: 201,
|
|
1792
|
-
body: await createTodoUseCase.run({ ctx, input: body }),
|
|
1793
|
-
}),
|
|
1794
|
-
},
|
|
1795
|
-
{
|
|
1796
|
-
contract: getTodo,
|
|
1797
|
-
handle: async ({ ctx, path }) => ({
|
|
1798
|
-
status: 200,
|
|
1799
|
-
body: await getTodoUseCase.run({ ctx, input: path }),
|
|
1800
|
-
}),
|
|
1801
|
-
},
|
|
2928
|
+
{ contract: listTodos, useCase: listTodosUseCase },
|
|
2929
|
+
{ contract: createTodo, useCase: createTodoUseCase },
|
|
2930
|
+
{ contract: getTodo, useCase: getTodoUseCase },
|
|
1802
2931
|
],
|
|
1803
2932
|
});
|
|
1804
2933
|
`,
|
|
1805
|
-
productionServerRoutes: `import {
|
|
2934
|
+
productionServerRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2935
|
+
import { contractsFromRoutes, defineRoutes } from "@beignet/next";
|
|
1806
2936
|
import type { AppContext } from "@/app-context";
|
|
1807
2937
|
import { todoRoutes } from "@/features/todos/routes";
|
|
1808
2938
|
|
|
@@ -1811,45 +2941,126 @@ export const routes = defineRoutes<AppContext>([
|
|
|
1811
2941
|
]);
|
|
1812
2942
|
export const contracts = contractsFromRoutes(routes);
|
|
1813
2943
|
`,
|
|
1814
|
-
|
|
1815
|
-
import { createNextServer } from "@beignet/next";
|
|
2944
|
+
productionServerContext: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1816
2945
|
import {
|
|
2946
|
+
type ActivityActor,
|
|
1817
2947
|
createAnonymousActor,
|
|
2948
|
+
createServiceActor,
|
|
1818
2949
|
createTenant,
|
|
1819
2950
|
createUserActor,
|
|
1820
2951
|
} from "@beignet/core/ports";
|
|
1821
|
-
import
|
|
1822
|
-
import {
|
|
1823
|
-
import {
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2952
|
+
import { defineServerContext } from "@beignet/core/server";
|
|
2953
|
+
import type { TraceContext } from "@beignet/core/tracing";
|
|
2954
|
+
import type { AppContext, AppRuntimePorts } from "@/app-context";
|
|
2955
|
+
|
|
2956
|
+
export type AppServiceContextInput =
|
|
2957
|
+
| {
|
|
2958
|
+
actor?: ActivityActor;
|
|
2959
|
+
tenantId?: string;
|
|
2960
|
+
}
|
|
2961
|
+
| undefined;
|
|
2962
|
+
|
|
2963
|
+
/**
|
|
2964
|
+
* Context blueprint shared by the runtime server and route tests.
|
|
2965
|
+
*/
|
|
2966
|
+
export const appContext = defineServerContext<AppContext, AppRuntimePorts>()({
|
|
2967
|
+
gate: (ports) => ports.gate,
|
|
2968
|
+
request: async ({ req, ports, requestId, trace }) => {
|
|
1836
2969
|
const auth = await ports.auth.getSession(req);
|
|
1837
2970
|
const tenantId = req.headers.get("x-tenant-id") || undefined;
|
|
1838
|
-
|
|
1839
|
-
|
|
2971
|
+
|
|
2972
|
+
return {
|
|
2973
|
+
requestId,
|
|
1840
2974
|
actor: auth
|
|
1841
2975
|
? createUserActor(auth.user.id, { displayName: auth.user.name })
|
|
1842
2976
|
: createAnonymousActor(),
|
|
1843
2977
|
auth,
|
|
2978
|
+
...trace,
|
|
1844
2979
|
ports,
|
|
1845
2980
|
tenant: tenantId ? createTenant(tenantId) : undefined,
|
|
1846
2981
|
};
|
|
2982
|
+
},
|
|
2983
|
+
service: ({
|
|
2984
|
+
ports,
|
|
2985
|
+
input,
|
|
2986
|
+
requestId,
|
|
2987
|
+
trace,
|
|
2988
|
+
}: {
|
|
2989
|
+
ports: AppRuntimePorts;
|
|
2990
|
+
input: AppServiceContextInput;
|
|
2991
|
+
requestId: string;
|
|
2992
|
+
trace: TraceContext;
|
|
2993
|
+
}) => ({
|
|
2994
|
+
requestId,
|
|
2995
|
+
actor: input?.actor ?? createServiceActor("beignet-service"),
|
|
2996
|
+
auth: null,
|
|
2997
|
+
...trace,
|
|
2998
|
+
ports,
|
|
2999
|
+
tenant: createTenant(input?.tenantId ?? "tenant_default"),
|
|
3000
|
+
}),
|
|
3001
|
+
});
|
|
3002
|
+
`,
|
|
3003
|
+
productionServer: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
3004
|
+
import { createNextServer } from "@beignet/next";
|
|
3005
|
+
import { createIdempotencyHooks } from "@beignet/core/server";
|
|
3006
|
+
import type { AppContext } from "@/app-context";
|
|
3007
|
+
import { appPorts } from "@/infra/app-ports";
|
|
3008
|
+
import { env } from "@/lib/env";
|
|
3009
|
+
import { appContext } from "./context";
|
|
3010
|
+
import { providers } from "./providers";
|
|
3011
|
+
import { routes } from "./routes";
|
|
3012
|
+
|
|
3013
|
+
export const server = await createNextServer({
|
|
3014
|
+
ports: appPorts,
|
|
3015
|
+
providers,
|
|
3016
|
+
providerConfig: {
|
|
3017
|
+
"drizzle-turso": {
|
|
3018
|
+
DB_URL: env.TURSO_DB_URL,
|
|
3019
|
+
DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
|
|
3020
|
+
},
|
|
3021
|
+
},
|
|
3022
|
+
hooks: [createIdempotencyHooks<AppContext>()],
|
|
3023
|
+
context: appContext,
|
|
3024
|
+
routes,
|
|
3025
|
+
mapUnhandledError: ({ err, ctx }) => {
|
|
3026
|
+
ctx?.ports.logger.error("Unhandled API error", {
|
|
3027
|
+
error: err,
|
|
3028
|
+
requestId: ctx?.requestId,
|
|
3029
|
+
environment: env.NODE_ENV,
|
|
3030
|
+
});
|
|
1847
3031
|
|
|
1848
3032
|
return {
|
|
1849
|
-
|
|
1850
|
-
|
|
3033
|
+
status: 500,
|
|
3034
|
+
body: {
|
|
3035
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
3036
|
+
message: "Internal server error",
|
|
3037
|
+
requestId: ctx?.requestId,
|
|
3038
|
+
},
|
|
1851
3039
|
};
|
|
1852
3040
|
},
|
|
3041
|
+
});
|
|
3042
|
+
`,
|
|
3043
|
+
productionServerWithDrizzleTurso: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
3044
|
+
import { createNextServer } from "@beignet/next";
|
|
3045
|
+
import { createIdempotencyHooks } from "@beignet/core/server";
|
|
3046
|
+
import type { AppContext } from "@/app-context";
|
|
3047
|
+
import { appPorts } from "@/infra/app-ports";
|
|
3048
|
+
import { env } from "@/lib/env";
|
|
3049
|
+
import { appContext } from "./context";
|
|
3050
|
+
import { providers } from "./providers";
|
|
3051
|
+
import { routes } from "./routes";
|
|
3052
|
+
|
|
3053
|
+
export const server = await createNextServer({
|
|
3054
|
+
ports: appPorts,
|
|
3055
|
+
providers,
|
|
3056
|
+
providerConfig: {
|
|
3057
|
+
"drizzle-turso": {
|
|
3058
|
+
DB_URL: env.TURSO_DB_URL,
|
|
3059
|
+
DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
|
|
3060
|
+
},
|
|
3061
|
+
},
|
|
3062
|
+
hooks: [createIdempotencyHooks<AppContext>()],
|
|
3063
|
+
context: appContext,
|
|
1853
3064
|
routes,
|
|
1854
3065
|
mapUnhandledError: ({ err, ctx }) => {
|
|
1855
3066
|
ctx?.ports.logger.error("Unhandled API error", {
|
|
@@ -1869,133 +3080,511 @@ export const server = await createNextServer({
|
|
|
1869
3080
|
},
|
|
1870
3081
|
});
|
|
1871
3082
|
`,
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
import
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
3083
|
+
productionApiCatchAllRoute: `import { server } from "@/server";
|
|
3084
|
+
|
|
3085
|
+
export const GET = server.api;
|
|
3086
|
+
export const HEAD = server.api;
|
|
3087
|
+
export const OPTIONS = server.api;
|
|
3088
|
+
export const PATCH = server.api;
|
|
3089
|
+
export const POST = server.api;
|
|
3090
|
+
export const PUT = server.api;
|
|
3091
|
+
export const DELETE = server.api;
|
|
3092
|
+
`,
|
|
3093
|
+
productionHealthRoute: `import { env } from "@/lib/env";
|
|
3094
|
+
|
|
3095
|
+
export function GET() {
|
|
3096
|
+
return Response.json(
|
|
3097
|
+
{
|
|
3098
|
+
status: "ok",
|
|
3099
|
+
environment: env.NODE_ENV,
|
|
3100
|
+
timestamp: new Date().toISOString(),
|
|
3101
|
+
},
|
|
3102
|
+
{
|
|
3103
|
+
headers: {
|
|
3104
|
+
"cache-control": "no-store",
|
|
3105
|
+
},
|
|
3106
|
+
},
|
|
3107
|
+
);
|
|
3108
|
+
}
|
|
3109
|
+
`,
|
|
3110
|
+
productionDevtoolsRoute: `import { createDevtoolsRoute } from "@beignet/devtools";
|
|
3111
|
+
import { server } from "@/server";
|
|
3112
|
+
|
|
3113
|
+
export const { GET, POST } = createDevtoolsRoute(server.ports.devtools, {
|
|
3114
|
+
basePath: "/api/devtools",
|
|
3115
|
+
});
|
|
3116
|
+
`,
|
|
3117
|
+
productionStorageRoute: `import { createStorageRoute } from "@beignet/next";
|
|
3118
|
+
import { server } from "@/server";
|
|
3119
|
+
|
|
3120
|
+
export const { GET, HEAD } = createStorageRoute(server.ports.storage, {
|
|
3121
|
+
basePath: "/storage",
|
|
3122
|
+
});
|
|
3123
|
+
`,
|
|
3124
|
+
productionOpenApiRoute: `import { createOpenAPIHandler } from "@beignet/next";
|
|
3125
|
+
import { server } from "@/server";
|
|
3126
|
+
|
|
3127
|
+
export const GET = createOpenAPIHandler(server.contracts, {
|
|
3128
|
+
title: "Beignet starter API",
|
|
3129
|
+
version: "0.1.0",
|
|
3130
|
+
});
|
|
3131
|
+
`,
|
|
3132
|
+
productionAuthRoute: `import { toNextJsHandler } from "better-auth/next-js";
|
|
3133
|
+
import { auth } from "@/lib/better-auth";
|
|
3134
|
+
|
|
3135
|
+
export const { GET, POST } = toNextJsHandler(auth);
|
|
3136
|
+
`,
|
|
3137
|
+
productionBetterAuth: `import { createClient } from "@libsql/client";
|
|
3138
|
+
import { betterAuth } from "better-auth";
|
|
3139
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
3140
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
3141
|
+
import * as schema from "@/infra/db/schema";
|
|
3142
|
+
import { ensureStarterDatabase } from "@/infra/db/bootstrap";
|
|
3143
|
+
import { env } from "@/lib/env";
|
|
3144
|
+
|
|
3145
|
+
const client = createClient({
|
|
3146
|
+
url: env.TURSO_DB_URL,
|
|
3147
|
+
authToken: env.TURSO_DB_AUTH_TOKEN,
|
|
3148
|
+
});
|
|
3149
|
+
|
|
3150
|
+
await ensureStarterDatabase(client);
|
|
3151
|
+
|
|
3152
|
+
const db = drizzle(client, { schema });
|
|
3153
|
+
|
|
3154
|
+
const trustedOrigins = [
|
|
3155
|
+
env.APP_URL,
|
|
3156
|
+
env.BETTER_AUTH_URL,
|
|
3157
|
+
...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",")
|
|
3158
|
+
.map((origin) => origin.trim())
|
|
3159
|
+
.filter(Boolean) ?? []),
|
|
3160
|
+
];
|
|
3161
|
+
|
|
3162
|
+
export const auth = betterAuth({
|
|
3163
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
3164
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
3165
|
+
trustedOrigins: [...new Set(trustedOrigins)],
|
|
3166
|
+
database: drizzleAdapter(db, {
|
|
3167
|
+
provider: "sqlite",
|
|
3168
|
+
schema,
|
|
3169
|
+
}),
|
|
3170
|
+
emailAndPassword: {
|
|
3171
|
+
enabled: true,
|
|
3172
|
+
},
|
|
3173
|
+
});
|
|
3174
|
+
`,
|
|
3175
|
+
productionDbSeed: `import { createClient } from "@libsql/client";
|
|
3176
|
+
import { ensureStarterDatabase } from "@/infra/db/bootstrap";
|
|
3177
|
+
import { env } from "@/lib/env";
|
|
3178
|
+
|
|
3179
|
+
const client = createClient({
|
|
3180
|
+
url: env.TURSO_DB_URL,
|
|
3181
|
+
authToken: env.TURSO_DB_AUTH_TOKEN,
|
|
3182
|
+
});
|
|
3183
|
+
|
|
3184
|
+
try {
|
|
3185
|
+
await ensureStarterDatabase(client, { seed: true });
|
|
3186
|
+
console.log("Database seeded.");
|
|
3187
|
+
} finally {
|
|
3188
|
+
client.close();
|
|
3189
|
+
}
|
|
3190
|
+
`,
|
|
3191
|
+
productionDbReset: `import { createClient } from "@libsql/client";
|
|
3192
|
+
import { resetStarterDatabase } from "@/infra/db/bootstrap";
|
|
3193
|
+
import { env } from "@/lib/env";
|
|
3194
|
+
|
|
3195
|
+
if (!env.TURSO_DB_URL.startsWith("file:") && process.env.BEIGNET_ALLOW_DATABASE_RESET !== "true") {
|
|
3196
|
+
throw new Error(
|
|
3197
|
+
"Refusing to reset a non-local database. Set BEIGNET_ALLOW_DATABASE_RESET=true if this is intentional.",
|
|
3198
|
+
);
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
const client = createClient({
|
|
3202
|
+
url: env.TURSO_DB_URL,
|
|
3203
|
+
authToken: env.TURSO_DB_AUTH_TOKEN,
|
|
3204
|
+
});
|
|
3205
|
+
|
|
3206
|
+
try {
|
|
3207
|
+
await resetStarterDatabase(client);
|
|
3208
|
+
console.log("Database reset.");
|
|
3209
|
+
} finally {
|
|
3210
|
+
client.close();
|
|
3211
|
+
}
|
|
3212
|
+
`,
|
|
3213
|
+
productionDbTestDatabase: `import { existsSync, unlinkSync } from "node:fs";
|
|
3214
|
+
import { tmpdir } from "node:os";
|
|
3215
|
+
import { join } from "node:path";
|
|
3216
|
+
import type { OutboxPort } from "@beignet/core/outbox";
|
|
3217
|
+
import { createDrizzleTursoOutboxPort } from "@beignet/provider-drizzle-turso";
|
|
3218
|
+
import { type Client, createClient } from "@libsql/client";
|
|
3219
|
+
import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
|
|
3220
|
+
import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
|
|
3221
|
+
import { ensureStarterDatabase, resetStarterDatabase } from "./bootstrap";
|
|
3222
|
+
import { createRepositories } from "./repositories";
|
|
3223
|
+
import * as schema from "./schema";
|
|
3224
|
+
|
|
3225
|
+
export type TestDatabase = {
|
|
3226
|
+
\tclient: Client;
|
|
3227
|
+
\tdb: LibSQLDatabase<typeof schema>;
|
|
3228
|
+
\taudit: ReturnType<typeof createDrizzleAuditLog>;
|
|
3229
|
+
\toutbox: OutboxPort;
|
|
3230
|
+
\trepositories: ReturnType<typeof createRepositories>;
|
|
3231
|
+
\tpath: string;
|
|
3232
|
+
\treset(): Promise<void>;
|
|
3233
|
+
\tclose(): Promise<void>;
|
|
3234
|
+
};
|
|
3235
|
+
|
|
3236
|
+
export async function createTestDatabase(): Promise<TestDatabase> {
|
|
3237
|
+
\tconst path = join(tmpdir(), "beignet-test-" + crypto.randomUUID() + ".db");
|
|
3238
|
+
\tconst client = createClient({ url: "file:" + path });
|
|
3239
|
+
\tawait ensureStarterDatabase(client, { seed: false });
|
|
3240
|
+
|
|
3241
|
+
\tconst db = drizzle(client, { schema });
|
|
3242
|
+
|
|
3243
|
+
\treturn {
|
|
3244
|
+
\t\tclient,
|
|
3245
|
+
\t\tdb,
|
|
3246
|
+
\t\taudit: createDrizzleAuditLog(db),
|
|
3247
|
+
\t\toutbox: createDrizzleTursoOutboxPort(db),
|
|
3248
|
+
\t\trepositories: createRepositories(db),
|
|
3249
|
+
\t\tpath,
|
|
3250
|
+
\t\treset: async () => {
|
|
3251
|
+
\t\t\tawait resetStarterDatabase(client);
|
|
3252
|
+
\t\t},
|
|
3253
|
+
\t\tclose: async () => {
|
|
3254
|
+
\t\t\tclient.close();
|
|
3255
|
+
\t\t\tif (existsSync(path)) {
|
|
3256
|
+
\t\t\t\tunlinkSync(path);
|
|
3257
|
+
\t\t\t}
|
|
3258
|
+
\t\t},
|
|
3259
|
+
\t};
|
|
3260
|
+
}
|
|
3261
|
+
`,
|
|
3262
|
+
productionAuditHelper: `import type { AuditLogEntryInput } from "@beignet/core/ports";
|
|
3263
|
+
import type { AppContext } from "@/app-context";
|
|
3264
|
+
|
|
3265
|
+
export function auditEntry(
|
|
3266
|
+
ctx: AppContext,
|
|
3267
|
+
entry: Omit<
|
|
3268
|
+
AuditLogEntryInput,
|
|
3269
|
+
"actor" | "tenant" | "requestId" | "traceId"
|
|
3270
|
+
>,
|
|
3271
|
+
): AuditLogEntryInput {
|
|
3272
|
+
return {
|
|
3273
|
+
...entry,
|
|
3274
|
+
actor: ctx.actor,
|
|
3275
|
+
tenant: ctx.tenant,
|
|
3276
|
+
requestId: ctx.requestId,
|
|
3277
|
+
traceId: ctx.traceId,
|
|
3278
|
+
};
|
|
3279
|
+
}
|
|
3280
|
+
`,
|
|
3281
|
+
productionTodoEvents: `import { defineEvent } from "@beignet/core/events";
|
|
3282
|
+
import { z } from "zod";
|
|
3283
|
+
|
|
3284
|
+
export const TodoCreated = defineEvent("todos.created", {
|
|
3285
|
+
payload: z.object({
|
|
3286
|
+
todoId: z.string().uuid(),
|
|
3287
|
+
title: z.string(),
|
|
3288
|
+
}),
|
|
3289
|
+
});
|
|
3290
|
+
|
|
3291
|
+
export const todoEvents = [TodoCreated] as const;
|
|
3292
|
+
`,
|
|
3293
|
+
productionTodoJobs: `import { retry } from "@beignet/core/jobs";
|
|
3294
|
+
import { z } from "zod";
|
|
3295
|
+
import { auditEntry } from "@/lib/audit";
|
|
3296
|
+
import { defineJob } from "@/lib/jobs";
|
|
3297
|
+
|
|
3298
|
+
export const LogTodoCreatedJob = defineJob("todos.log-created", {
|
|
3299
|
+
payload: z.object({
|
|
3300
|
+
todoId: z.string().uuid(),
|
|
3301
|
+
title: z.string(),
|
|
3302
|
+
}),
|
|
3303
|
+
retry: retry.exponential({
|
|
3304
|
+
attempts: 3,
|
|
3305
|
+
initialDelay: "1s",
|
|
3306
|
+
maxDelay: "1m",
|
|
3307
|
+
}),
|
|
3308
|
+
async handle({ ctx, payload }) {
|
|
3309
|
+
ctx.ports.logger.info("Todo created job handled", {
|
|
3310
|
+
todoId: payload.todoId,
|
|
3311
|
+
});
|
|
3312
|
+
await ctx.ports.audit.record(
|
|
3313
|
+
auditEntry(ctx, {
|
|
3314
|
+
action: "jobs.todos.log-created",
|
|
3315
|
+
resource: { type: "todo", id: payload.todoId, name: payload.title },
|
|
3316
|
+
metadata: { jobName: "todos.log-created" },
|
|
3317
|
+
}),
|
|
3318
|
+
);
|
|
3319
|
+
},
|
|
3320
|
+
});
|
|
3321
|
+
|
|
3322
|
+
export const todoJobs = [LogTodoCreatedJob] as const;
|
|
3323
|
+
`,
|
|
3324
|
+
productionTodoNotifications: `import { defineMailNotificationChannel } from "@beignet/core/notifications";
|
|
3325
|
+
import { z } from "zod";
|
|
3326
|
+
import { defineNotification } from "@/lib/notifications";
|
|
1888
3327
|
|
|
1889
|
-
export const
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
3328
|
+
export const TodoCreatedNotification = defineNotification(
|
|
3329
|
+
"todos.created",
|
|
3330
|
+
{
|
|
3331
|
+
payload: z.object({
|
|
3332
|
+
todoId: z.string().uuid(),
|
|
3333
|
+
title: z.string(),
|
|
1895
3334
|
}),
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
3335
|
+
channels: {
|
|
3336
|
+
email: defineMailNotificationChannel(({ payload }) => ({
|
|
3337
|
+
to: "ops@example.local",
|
|
3338
|
+
subject: \`Todo created: \${payload.title}\`,
|
|
3339
|
+
text: \`Todo \${payload.todoId} was created.\`,
|
|
3340
|
+
})),
|
|
3341
|
+
},
|
|
3342
|
+
},
|
|
3343
|
+
);
|
|
3344
|
+
`,
|
|
3345
|
+
productionTodoListeners: `import { TodoCreated } from "@/features/todos/domain/events";
|
|
3346
|
+
import { LogTodoCreatedJob } from "@/features/todos/jobs";
|
|
3347
|
+
import { TodoCreatedNotification } from "@/features/todos/notifications";
|
|
3348
|
+
import { auditEntry } from "@/lib/audit";
|
|
3349
|
+
import { defineListener } from "@/lib/listeners";
|
|
3350
|
+
|
|
3351
|
+
export const enqueueTodoCreatedWork = defineListener(TodoCreated, {
|
|
3352
|
+
name: "todos.enqueue-created-work",
|
|
3353
|
+
async handle({ ctx, payload }) {
|
|
3354
|
+
await ctx.ports.jobs.dispatch(LogTodoCreatedJob, payload);
|
|
3355
|
+
await ctx.ports.notifications.send(TodoCreatedNotification, payload);
|
|
3356
|
+
await ctx.ports.audit.record(
|
|
3357
|
+
auditEntry(ctx, {
|
|
3358
|
+
action: "listeners.todos.enqueue-created-work",
|
|
3359
|
+
resource: { type: "todo", id: payload.todoId, name: payload.title },
|
|
3360
|
+
metadata: {
|
|
3361
|
+
eventName: TodoCreated.name,
|
|
3362
|
+
jobName: LogTodoCreatedJob.name,
|
|
3363
|
+
notificationName: TodoCreatedNotification.name,
|
|
3364
|
+
},
|
|
3365
|
+
}),
|
|
3366
|
+
);
|
|
3367
|
+
},
|
|
3368
|
+
});
|
|
1901
3369
|
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
3370
|
+
export const todoListeners = [enqueueTodoCreatedWork] as const;
|
|
3371
|
+
`,
|
|
3372
|
+
productionTodoSchedules: `import { normalizeOffsetPage } from "@beignet/core/pagination";
|
|
3373
|
+
import { z } from "zod";
|
|
3374
|
+
import { auditEntry } from "@/lib/audit";
|
|
3375
|
+
import { defineSchedule } from "@/lib/schedules";
|
|
3376
|
+
|
|
3377
|
+
export const LogDailyTodoSummarySchedule = defineSchedule(
|
|
3378
|
+
"todos.log-daily-summary",
|
|
3379
|
+
{
|
|
3380
|
+
cron: "0 9 * * *",
|
|
3381
|
+
timezone: "America/Chicago",
|
|
3382
|
+
payload: z.object({
|
|
3383
|
+
date: z.string(),
|
|
3384
|
+
}),
|
|
3385
|
+
createPayload({ run }) {
|
|
3386
|
+
const date = run.scheduledAt ?? run.triggeredAt;
|
|
3387
|
+
return { date: date.toISOString().slice(0, 10) };
|
|
3388
|
+
},
|
|
3389
|
+
async handle({ ctx, payload, run }) {
|
|
3390
|
+
const { page } = await ctx.ports.todos.list(
|
|
3391
|
+
normalizeOffsetPage(
|
|
3392
|
+
{ limit: 1, offset: 0 },
|
|
3393
|
+
{ defaultLimit: 1, maxLimit: 1 },
|
|
3394
|
+
),
|
|
3395
|
+
);
|
|
3396
|
+
ctx.ports.logger.info("Daily todo summary schedule handled", {
|
|
3397
|
+
date: payload.date,
|
|
3398
|
+
todoCount: page.total,
|
|
3399
|
+
});
|
|
3400
|
+
await ctx.ports.audit.record(
|
|
3401
|
+
auditEntry(ctx, {
|
|
3402
|
+
action: "schedules.todos.daily-summary",
|
|
3403
|
+
resource: {
|
|
3404
|
+
type: "schedule",
|
|
3405
|
+
id: "todos.log-daily-summary",
|
|
3406
|
+
name: "Daily todo summary",
|
|
3407
|
+
},
|
|
3408
|
+
metadata: {
|
|
3409
|
+
date: payload.date,
|
|
3410
|
+
todoCount: page.total,
|
|
3411
|
+
source: run.source ?? "inline",
|
|
3412
|
+
triggeredAt: run.triggeredAt.toISOString(),
|
|
3413
|
+
},
|
|
1916
3414
|
}),
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
3415
|
+
);
|
|
3416
|
+
},
|
|
3417
|
+
},
|
|
3418
|
+
);
|
|
3419
|
+
|
|
3420
|
+
export const todoSchedules = [LogDailyTodoSummarySchedule] as const;
|
|
3421
|
+
`,
|
|
3422
|
+
productionSchedules: `import { createServiceActor } from "@beignet/core/ports";
|
|
3423
|
+
import type { AppContext } from "@/app-context";
|
|
3424
|
+
import { todoSchedules } from "@/features/todos/schedules";
|
|
3425
|
+
import { server } from "./index.js";
|
|
3426
|
+
|
|
3427
|
+
export const schedules = [...todoSchedules] as const;
|
|
3428
|
+
|
|
3429
|
+
export async function createScheduleContext(): Promise<AppContext> {
|
|
3430
|
+
return server.createServiceContext({
|
|
3431
|
+
actor: createServiceActor("beignet-schedule"),
|
|
3432
|
+
});
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
export async function stopScheduleContext(): Promise<void> {
|
|
3436
|
+
await server.stop();
|
|
3437
|
+
}
|
|
3438
|
+
`,
|
|
3439
|
+
productionTodoUploads: `import { defineUpload, defineUploads } from "@beignet/core/uploads";
|
|
3440
|
+
import { z } from "zod";
|
|
3441
|
+
import type { AppContext } from "@/app-context";
|
|
3442
|
+
import { auditEntry } from "@/lib/audit";
|
|
3443
|
+
|
|
3444
|
+
const todoAttachmentMetadataSchema = z.object({
|
|
3445
|
+
todoId: z.string().uuid(),
|
|
3446
|
+
});
|
|
1920
3447
|
|
|
3448
|
+
export const TodoAttachmentUpload = defineUpload<
|
|
3449
|
+
"todos.attachment",
|
|
3450
|
+
typeof todoAttachmentMetadataSchema,
|
|
3451
|
+
AppContext,
|
|
3452
|
+
{ attachmentIds: string[] }
|
|
3453
|
+
>("todos.attachment", {
|
|
3454
|
+
metadata: todoAttachmentMetadataSchema,
|
|
3455
|
+
file: {
|
|
3456
|
+
contentTypes: ["application/pdf", "text/plain", "text/plain;charset=utf-8"],
|
|
3457
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
3458
|
+
maxFiles: 3,
|
|
3459
|
+
visibility: "private",
|
|
3460
|
+
cacheControl: "private, max-age=0",
|
|
3461
|
+
},
|
|
3462
|
+
authorize({ ctx }) {
|
|
3463
|
+
return ctx.actor.type === "user"
|
|
3464
|
+
? true
|
|
3465
|
+
: {
|
|
3466
|
+
allowed: false,
|
|
3467
|
+
reason: "You must be signed in to upload todo attachments.",
|
|
3468
|
+
};
|
|
3469
|
+
},
|
|
3470
|
+
key({ ctx, metadata, file, uploadId }) {
|
|
3471
|
+
const tenantId = ctx.tenant?.id ?? "tenant_default";
|
|
3472
|
+
const extension = file.name.includes(".")
|
|
3473
|
+
? file.name.split(".").pop()
|
|
3474
|
+
: undefined;
|
|
3475
|
+
const suffix = extension ? \`.\${extension}\` : "";
|
|
3476
|
+
return \`todos/\${tenantId}/\${metadata.todoId}/attachments/\${uploadId}\${suffix}\`;
|
|
3477
|
+
},
|
|
3478
|
+
storageMetadata({ ctx, metadata }) {
|
|
1921
3479
|
return {
|
|
1922
|
-
|
|
1923
|
-
|
|
3480
|
+
tenantId: ctx.tenant?.id ?? "tenant_default",
|
|
3481
|
+
todoId: metadata.todoId,
|
|
1924
3482
|
};
|
|
1925
3483
|
},
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
3484
|
+
async onComplete({ ctx, metadata, files }) {
|
|
3485
|
+
const attachments = await Promise.all(
|
|
3486
|
+
files.map((file) =>
|
|
3487
|
+
ctx.ports.todoAttachments.create({
|
|
3488
|
+
id: file.uploadId,
|
|
3489
|
+
todoId: metadata.todoId,
|
|
3490
|
+
key: file.key,
|
|
3491
|
+
fileName: file.name,
|
|
3492
|
+
contentType: file.contentType,
|
|
3493
|
+
size: file.object.size,
|
|
3494
|
+
}),
|
|
3495
|
+
),
|
|
3496
|
+
);
|
|
3497
|
+
await ctx.ports.audit.record(
|
|
3498
|
+
auditEntry(ctx, {
|
|
3499
|
+
action: "todos.attachment.upload",
|
|
3500
|
+
resource: { type: "todo", id: metadata.todoId },
|
|
3501
|
+
metadata: {
|
|
3502
|
+
attachmentCount: attachments.length,
|
|
3503
|
+
keys: attachments.map((attachment) => attachment.key),
|
|
3504
|
+
},
|
|
3505
|
+
}),
|
|
3506
|
+
);
|
|
1933
3507
|
|
|
1934
3508
|
return {
|
|
1935
|
-
|
|
1936
|
-
body: {
|
|
1937
|
-
code: "INTERNAL_SERVER_ERROR",
|
|
1938
|
-
message: "Internal server error",
|
|
1939
|
-
requestId: ctx?.requestId,
|
|
1940
|
-
},
|
|
3509
|
+
attachmentIds: attachments.map((attachment) => attachment.id),
|
|
1941
3510
|
};
|
|
1942
3511
|
},
|
|
1943
3512
|
});
|
|
1944
|
-
`,
|
|
1945
|
-
productionApiCatchAllRoute: `import { server } from "@/server";
|
|
1946
3513
|
|
|
1947
|
-
export const
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
export const PATCH = server.api;
|
|
1951
|
-
export const POST = server.api;
|
|
1952
|
-
export const PUT = server.api;
|
|
1953
|
-
export const DELETE = server.api;
|
|
3514
|
+
export const todoUploads = defineUploads({
|
|
3515
|
+
todoAttachment: TodoAttachmentUpload,
|
|
3516
|
+
});
|
|
1954
3517
|
`,
|
|
1955
|
-
|
|
3518
|
+
productionOutbox: `import { defineOutboxRegistry } from "@beignet/core/outbox";
|
|
3519
|
+
import { createServiceActor } from "@beignet/core/ports";
|
|
3520
|
+
import type { AppContext } from "@/app-context";
|
|
3521
|
+
import { todoEvents } from "@/features/todos/domain/events";
|
|
3522
|
+
import { todoJobs } from "@/features/todos/jobs";
|
|
3523
|
+
import { server } from "./index.js";
|
|
1956
3524
|
|
|
1957
|
-
export
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
);
|
|
3525
|
+
export const outboxRegistry = defineOutboxRegistry({
|
|
3526
|
+
events: todoEvents,
|
|
3527
|
+
jobs: todoJobs,
|
|
3528
|
+
});
|
|
3529
|
+
|
|
3530
|
+
export async function createOutboxDrainContext(): Promise<AppContext> {
|
|
3531
|
+
return server.createServiceContext({
|
|
3532
|
+
actor: createServiceActor("beignet-outbox"),
|
|
3533
|
+
});
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
export async function stopOutboxDrainContext(): Promise<void> {
|
|
3537
|
+
await server.stop();
|
|
1970
3538
|
}
|
|
1971
3539
|
`,
|
|
1972
|
-
|
|
3540
|
+
productionOutboxDrainRoute: `import { createOutboxDrainRoute } from "@beignet/next";
|
|
3541
|
+
import { env } from "@/lib/env";
|
|
1973
3542
|
import { server } from "@/server";
|
|
3543
|
+
import { outboxRegistry } from "@/server/outbox";
|
|
1974
3544
|
|
|
1975
|
-
export const
|
|
1976
|
-
|
|
3545
|
+
export const runtime = "nodejs";
|
|
3546
|
+
|
|
3547
|
+
export const { GET, POST } = createOutboxDrainRoute({
|
|
3548
|
+
server,
|
|
3549
|
+
registry: outboxRegistry,
|
|
3550
|
+
secret: env.CRON_SECRET,
|
|
1977
3551
|
});
|
|
1978
3552
|
`,
|
|
1979
|
-
|
|
3553
|
+
productionScheduleRoute: `import { createScheduleRoute } from "@beignet/next";
|
|
3554
|
+
import { env } from "@/lib/env";
|
|
1980
3555
|
import { server } from "@/server";
|
|
3556
|
+
import { schedules } from "@/server/schedules";
|
|
1981
3557
|
|
|
1982
|
-
export const
|
|
1983
|
-
|
|
3558
|
+
export const runtime = "nodejs";
|
|
3559
|
+
|
|
3560
|
+
export const { GET, POST } = createScheduleRoute({
|
|
3561
|
+
server,
|
|
3562
|
+
schedules,
|
|
3563
|
+
schedule: "todos.log-daily-summary",
|
|
3564
|
+
secret: env.CRON_SECRET,
|
|
3565
|
+
source: "starter-cron-route",
|
|
1984
3566
|
});
|
|
1985
3567
|
`,
|
|
1986
|
-
|
|
3568
|
+
productionUploadsRoute: `import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
|
|
3569
|
+
import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
|
|
3570
|
+
import { createUploadRoute } from "@beignet/next";
|
|
3571
|
+
import type { AppContext } from "@/app-context";
|
|
3572
|
+
import { todoUploads } from "@/features/todos/uploads";
|
|
1987
3573
|
import { server } from "@/server";
|
|
1988
3574
|
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
3575
|
+
const uploadRouter = createUploadRouter<AppContext>({
|
|
3576
|
+
uploads: uploadsFromRegistry(todoUploads),
|
|
3577
|
+
ctx: () => server.createContextFromNext(),
|
|
3578
|
+
storage: server.ports.storage,
|
|
3579
|
+
instrumentation: resolveProviderInstrumentationPort(server.ports),
|
|
1992
3580
|
});
|
|
3581
|
+
|
|
3582
|
+
export const { POST } = createUploadRoute(uploadRouter);
|
|
1993
3583
|
`,
|
|
1994
3584
|
};
|
|
1995
3585
|
|
|
1996
3586
|
function productionProviderPortImports(ctx: TemplateContext): string[] {
|
|
1997
3587
|
const imports: string[] = [];
|
|
1998
|
-
if (hasIntegration(ctx, "inngest")) imports.push("\tJobDispatcherPort,");
|
|
1999
3588
|
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
2000
3589
|
imports.push("\tRateLimitPort,");
|
|
2001
3590
|
}
|
|
@@ -2004,43 +3593,128 @@ function productionProviderPortImports(ctx: TemplateContext): string[] {
|
|
|
2004
3593
|
|
|
2005
3594
|
function productionProviderPortFields(ctx: TemplateContext): string[] {
|
|
2006
3595
|
const fields: string[] = [];
|
|
2007
|
-
if (hasIntegration(ctx, "inngest")) fields.push("\tjobs: JobDispatcherPort;");
|
|
2008
|
-
if (hasIntegration(ctx, "resend")) fields.push("\tmailer: MailerPort;");
|
|
2009
3596
|
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
2010
3597
|
fields.push("\trateLimit: RateLimitPort;");
|
|
2011
3598
|
}
|
|
2012
3599
|
return fields;
|
|
2013
3600
|
}
|
|
2014
3601
|
|
|
3602
|
+
function productionInfrastructurePortsWithDrizzleTurso(
|
|
3603
|
+
ctx: TemplateContext,
|
|
3604
|
+
): string {
|
|
3605
|
+
const deferred = [
|
|
3606
|
+
'\t\t"audit",',
|
|
3607
|
+
hasIntegration(ctx, "better-auth") ? '\t\t"auth",' : undefined,
|
|
3608
|
+
'\t\t"eventBus",',
|
|
3609
|
+
'\t\t"idempotency",',
|
|
3610
|
+
'\t\t"jobs",',
|
|
3611
|
+
'\t\t"logger",',
|
|
3612
|
+
'\t\t"mailer",',
|
|
3613
|
+
'\t\t"notifications",',
|
|
3614
|
+
'\t\t"outbox",',
|
|
3615
|
+
hasIntegration(ctx, "upstash-rate-limit") ? '\t\t"rateLimit",' : undefined,
|
|
3616
|
+
'\t\t"storage",',
|
|
3617
|
+
'\t\t"todoAttachments",',
|
|
3618
|
+
'\t\t"todos",',
|
|
3619
|
+
'\t\t"uow",',
|
|
3620
|
+
]
|
|
3621
|
+
.filter((entry): entry is string => Boolean(entry))
|
|
3622
|
+
.sort();
|
|
3623
|
+
const bound = [
|
|
3624
|
+
hasIntegration(ctx, "better-auth")
|
|
3625
|
+
? undefined
|
|
3626
|
+
: "\t\tauth: createAnonymousAuth(),",
|
|
3627
|
+
"\t\tgate,",
|
|
3628
|
+
].filter((entry): entry is string => Boolean(entry));
|
|
3629
|
+
const imports = [
|
|
3630
|
+
'import { createGate, definePorts } from "@beignet/core/ports";',
|
|
3631
|
+
'import { todoPolicy } from "@/features/todos/policy";',
|
|
3632
|
+
'import { appError } from "@/features/shared/errors";',
|
|
3633
|
+
'import type { AppPorts } from "@/ports";',
|
|
3634
|
+
hasIntegration(ctx, "better-auth")
|
|
3635
|
+
? undefined
|
|
3636
|
+
: 'import { createAnonymousAuth } from "./auth/anonymous-auth";',
|
|
3637
|
+
].filter((entry): entry is string => Boolean(entry));
|
|
3638
|
+
|
|
3639
|
+
return `${imports.join("\n")}
|
|
3640
|
+
|
|
3641
|
+
const gate = createGate({
|
|
3642
|
+
policies: [todoPolicy],
|
|
3643
|
+
onDeny(decision) {
|
|
3644
|
+
return appError("Forbidden", {
|
|
3645
|
+
message: decision.reason ?? "Forbidden",
|
|
3646
|
+
details: decision.details,
|
|
3647
|
+
});
|
|
3648
|
+
},
|
|
3649
|
+
});
|
|
3650
|
+
|
|
3651
|
+
/**
|
|
3652
|
+
* App ports the server boots with.
|
|
3653
|
+
*
|
|
3654
|
+
* Bound ports are app-owned. Every deferred key is contributed by a provider
|
|
3655
|
+
* in server/providers.ts during startup; createServer(...) fails boot if any
|
|
3656
|
+
* of them is still unbound after providers have started.
|
|
3657
|
+
*/
|
|
3658
|
+
export const appPorts = definePorts<AppPorts>()({
|
|
3659
|
+
bound: {
|
|
3660
|
+
${bound.join("\n")}
|
|
3661
|
+
},
|
|
3662
|
+
deferred: [
|
|
3663
|
+
${deferred.join("\n")}
|
|
3664
|
+
],
|
|
3665
|
+
});
|
|
3666
|
+
`;
|
|
3667
|
+
}
|
|
3668
|
+
|
|
2015
3669
|
function productionPorts(ctx: TemplateContext): string {
|
|
2016
|
-
const mailImport =
|
|
2017
|
-
? 'import type { MailerPort } from "@beignet/core/mail";\n'
|
|
2018
|
-
: "";
|
|
3670
|
+
const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
|
|
2019
3671
|
const imports = [
|
|
3672
|
+
"\tAuditLogPort,",
|
|
2020
3673
|
"\tBoundGate,",
|
|
3674
|
+
"\tEventBusPort,",
|
|
2021
3675
|
"\tGatePort,",
|
|
3676
|
+
"\tJobDispatcherPort,",
|
|
2022
3677
|
"\tLoggerPort,",
|
|
2023
3678
|
...productionProviderPortImports(ctx),
|
|
3679
|
+
"\tStoragePort,",
|
|
2024
3680
|
"\tUnitOfWorkPort,",
|
|
2025
3681
|
];
|
|
2026
3682
|
const providerFields = productionProviderPortFields(ctx);
|
|
2027
3683
|
|
|
2028
|
-
return `import type {
|
|
3684
|
+
return `import type { IdempotencyPort } from "@beignet/core/idempotency";
|
|
3685
|
+
${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
|
|
3686
|
+
import type { OutboxPort } from "@beignet/core/outbox";
|
|
3687
|
+
import type {
|
|
2029
3688
|
${imports.join("\n")}
|
|
2030
3689
|
} from "@beignet/core/ports";
|
|
2031
|
-
|
|
3690
|
+
import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
|
|
2032
3691
|
import type { AuthPort } from "./auth";
|
|
2033
|
-
import type { TodoRepository } from "@/features/todos/ports";
|
|
3692
|
+
import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
|
|
2034
3693
|
|
|
2035
3694
|
export type AppTransactionPorts = {
|
|
3695
|
+
audit: AuditLogPort;
|
|
3696
|
+
events: import("@beignet/core/ports").BufferedDomainEventRecorder;
|
|
3697
|
+
idempotency: IdempotencyPort;
|
|
3698
|
+
jobs: JobDispatcherPort;
|
|
3699
|
+
outbox: OutboxPort;
|
|
3700
|
+
todoAttachments: TodoAttachmentRepository;
|
|
2036
3701
|
todos: TodoRepository;
|
|
2037
3702
|
};
|
|
2038
3703
|
|
|
2039
3704
|
export type AppGate = BoundGate<[typeof todoPolicy]>;
|
|
2040
3705
|
|
|
2041
3706
|
export type AppPorts = {
|
|
3707
|
+
audit: AuditLogPort;
|
|
2042
3708
|
auth: AuthPort;
|
|
3709
|
+
eventBus: EventBusPort;
|
|
2043
3710
|
gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
|
|
3711
|
+
idempotency: IdempotencyPort;
|
|
3712
|
+
jobs: JobDispatcherPort;
|
|
3713
|
+
mailer: MailerPort;
|
|
3714
|
+
notifications: NotificationPort;
|
|
3715
|
+
outbox: OutboxPort;
|
|
3716
|
+
storage: StoragePort;
|
|
3717
|
+
todoAttachments: TodoAttachmentRepository;
|
|
2044
3718
|
todos: TodoRepository;
|
|
2045
3719
|
logger: LoggerPort;
|
|
2046
3720
|
${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
|
|
@@ -2049,37 +3723,54 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
|
|
|
2049
3723
|
}
|
|
2050
3724
|
|
|
2051
3725
|
function productionPortsWithDrizzleTurso(ctx: TemplateContext): string {
|
|
2052
|
-
const mailImport =
|
|
2053
|
-
? 'import type { MailerPort } from "@beignet/core/mail";\n'
|
|
2054
|
-
: "";
|
|
3726
|
+
const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
|
|
2055
3727
|
const imports = [
|
|
3728
|
+
"\tAuditLogPort,",
|
|
2056
3729
|
"\tBoundGate,",
|
|
3730
|
+
"\tEventBusPort,",
|
|
2057
3731
|
"\tGatePort,",
|
|
3732
|
+
"\tJobDispatcherPort,",
|
|
2058
3733
|
"\tLoggerPort,",
|
|
2059
3734
|
...productionProviderPortImports(ctx),
|
|
3735
|
+
"\tStoragePort,",
|
|
2060
3736
|
"\tUnitOfWorkPort,",
|
|
2061
3737
|
];
|
|
2062
3738
|
const providerFields = productionProviderPortFields(ctx);
|
|
2063
3739
|
|
|
2064
|
-
return `import type {
|
|
3740
|
+
return `import type { IdempotencyPort } from "@beignet/core/idempotency";
|
|
3741
|
+
${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
|
|
3742
|
+
import type { OutboxPort } from "@beignet/core/outbox";
|
|
3743
|
+
import type {
|
|
2065
3744
|
${imports.join("\n")}
|
|
2066
3745
|
} from "@beignet/core/ports";
|
|
2067
|
-
${mailImport}import type { DbPort } from "@beignet/provider-drizzle-turso";
|
|
2068
|
-
import * as schema from "@/infra/db/schema";
|
|
2069
3746
|
import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
|
|
2070
3747
|
import type { AuthPort } from "./auth";
|
|
2071
|
-
import type { TodoRepository } from "@/features/todos/ports";
|
|
3748
|
+
import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
|
|
2072
3749
|
|
|
2073
3750
|
export type AppTransactionPorts = {
|
|
3751
|
+
audit: AuditLogPort;
|
|
3752
|
+
events: import("@beignet/core/ports").BufferedDomainEventRecorder;
|
|
3753
|
+
idempotency: IdempotencyPort;
|
|
3754
|
+
jobs: JobDispatcherPort;
|
|
3755
|
+
outbox: OutboxPort;
|
|
3756
|
+
todoAttachments: TodoAttachmentRepository;
|
|
2074
3757
|
todos: TodoRepository;
|
|
2075
3758
|
};
|
|
2076
3759
|
|
|
2077
3760
|
export type AppGate = BoundGate<[typeof todoPolicy]>;
|
|
2078
3761
|
|
|
2079
3762
|
export type AppPorts = {
|
|
3763
|
+
audit: AuditLogPort;
|
|
2080
3764
|
auth: AuthPort;
|
|
2081
|
-
|
|
3765
|
+
eventBus: EventBusPort;
|
|
2082
3766
|
gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
|
|
3767
|
+
idempotency: IdempotencyPort;
|
|
3768
|
+
jobs: JobDispatcherPort;
|
|
3769
|
+
mailer: MailerPort;
|
|
3770
|
+
notifications: NotificationPort;
|
|
3771
|
+
outbox: OutboxPort;
|
|
3772
|
+
storage: StoragePort;
|
|
3773
|
+
todoAttachments: TodoAttachmentRepository;
|
|
2083
3774
|
todos: TodoRepository;
|
|
2084
3775
|
logger: LoggerPort;
|
|
2085
3776
|
${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
|
|
@@ -2089,7 +3780,11 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
|
|
|
2089
3780
|
|
|
2090
3781
|
function productionServerProviders(ctx: TemplateContext): string {
|
|
2091
3782
|
const imports = [
|
|
3783
|
+
'import type { beignetServerOnly } from "@beignet/core/server-only";',
|
|
2092
3784
|
'import { createDevtoolsProvider } from "@beignet/devtools";',
|
|
3785
|
+
hasIntegration(ctx, "better-auth")
|
|
3786
|
+
? 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";'
|
|
3787
|
+
: undefined,
|
|
2093
3788
|
hasDrizzleTurso(ctx)
|
|
2094
3789
|
? 'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";'
|
|
2095
3790
|
: undefined,
|
|
@@ -2107,6 +3802,13 @@ function productionServerProviders(ctx: TemplateContext): string {
|
|
|
2107
3802
|
hasDrizzleTurso(ctx)
|
|
2108
3803
|
? 'import * as schema from "@/infra/db/schema";'
|
|
2109
3804
|
: undefined,
|
|
3805
|
+
hasIntegration(ctx, "better-auth")
|
|
3806
|
+
? 'import { auth } from "@/lib/better-auth";'
|
|
3807
|
+
: undefined,
|
|
3808
|
+
'import { starterDatabaseProvider } from "@/infra/db/provider";',
|
|
3809
|
+
hasIntegration(ctx, "better-auth")
|
|
3810
|
+
? 'import type { AuthSessionMetadata, AuthUser } from "@/ports/auth";'
|
|
3811
|
+
: undefined,
|
|
2110
3812
|
].filter((line): line is string => Boolean(line));
|
|
2111
3813
|
|
|
2112
3814
|
const declarations = hasDrizzleTurso(ctx)
|
|
@@ -2114,9 +3816,13 @@ function productionServerProviders(ctx: TemplateContext): string {
|
|
|
2114
3816
|
: "";
|
|
2115
3817
|
const entries = [
|
|
2116
3818
|
"\tcreateDevtoolsProvider(),",
|
|
3819
|
+
hasIntegration(ctx, "better-auth")
|
|
3820
|
+
? "\tcreateAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth),"
|
|
3821
|
+
: undefined,
|
|
2117
3822
|
"\tlocalStorageProvider,",
|
|
2118
3823
|
"\tloggerPinoProvider,",
|
|
2119
3824
|
hasDrizzleTurso(ctx) ? "\tdrizzleTursoProvider," : undefined,
|
|
3825
|
+
"\tstarterDatabaseProvider,",
|
|
2120
3826
|
hasIntegration(ctx, "inngest") ? "\tinngestProvider," : undefined,
|
|
2121
3827
|
hasIntegration(ctx, "resend") ? "\tmailResendProvider," : undefined,
|
|
2122
3828
|
hasIntegration(ctx, "upstash-rate-limit")
|
|
@@ -2128,46 +3834,26 @@ function productionServerProviders(ctx: TemplateContext): string {
|
|
|
2128
3834
|
${declarations}
|
|
2129
3835
|
export const providers = [
|
|
2130
3836
|
${entries.join("\n")}
|
|
2131
|
-
];
|
|
3837
|
+
] as const;
|
|
2132
3838
|
`;
|
|
2133
3839
|
}
|
|
2134
3840
|
|
|
2135
|
-
function productionTodosTest(
|
|
2136
|
-
const portImports = [
|
|
2137
|
-
hasIntegration(ctx, "upstash-rate-limit")
|
|
2138
|
-
? "createMemoryRateLimiter"
|
|
2139
|
-
: undefined,
|
|
2140
|
-
"createMemoryStorage",
|
|
2141
|
-
"createNoopUnitOfWork",
|
|
2142
|
-
"createUserActor",
|
|
2143
|
-
].filter((item): item is string => Boolean(item));
|
|
2144
|
-
const extraPorts = [
|
|
2145
|
-
hasDrizzleTurso(ctx)
|
|
2146
|
-
? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
|
|
2147
|
-
: 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
|
-
hasIntegration(ctx, "upstash-rate-limit")
|
|
2155
|
-
? "\t\t\trateLimit: createMemoryRateLimiter(),"
|
|
2156
|
-
: undefined,
|
|
2157
|
-
].filter((entry): entry is string => Boolean(entry));
|
|
2158
|
-
|
|
3841
|
+
function productionTodosTest(_ctx: TemplateContext): string {
|
|
2159
3842
|
return `import { describe, expect, it } from "bun:test";
|
|
2160
3843
|
import { createUseCaseTester } from "@beignet/core/application";
|
|
3844
|
+
import { offsetPageResult, type OffsetPage } from "@beignet/core/pagination";
|
|
2161
3845
|
import { createInMemoryDevtools } from "@beignet/devtools";
|
|
2162
|
-
import {
|
|
3846
|
+
import { createDomainEventRecorder } from "@beignet/core/ports";
|
|
3847
|
+
import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
|
|
3848
|
+
import { createTestUserActor } from "@beignet/core/ports/testing";
|
|
2163
3849
|
import type { AppContext } from "@/app-context";
|
|
2164
3850
|
import { appPorts } from "@/infra/app-ports";
|
|
3851
|
+
import type { AppTransactionPorts } from "@/ports";
|
|
2165
3852
|
import {
|
|
2166
3853
|
createTodoUseCase,
|
|
2167
3854
|
getTodoUseCase,
|
|
2168
3855
|
listTodosUseCase,
|
|
2169
3856
|
type CreateTodoInput,
|
|
2170
|
-
type ListTodosInput,
|
|
2171
3857
|
type Todo,
|
|
2172
3858
|
} from "../use-cases";
|
|
2173
3859
|
|
|
@@ -2175,15 +3861,16 @@ function createTestTodoRepository() {
|
|
|
2175
3861
|
const todos = new Map<string, Todo>();
|
|
2176
3862
|
|
|
2177
3863
|
return {
|
|
2178
|
-
async list(
|
|
3864
|
+
async list(page: OffsetPage) {
|
|
2179
3865
|
const allTodos = Array.from(todos.values()).sort((left, right) =>
|
|
2180
3866
|
left.createdAt.localeCompare(right.createdAt),
|
|
2181
3867
|
);
|
|
2182
3868
|
|
|
2183
|
-
return
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
3869
|
+
return offsetPageResult(
|
|
3870
|
+
allTodos.slice(page.offset, page.offset + page.limit),
|
|
3871
|
+
page,
|
|
3872
|
+
allTodos.length,
|
|
3873
|
+
);
|
|
2187
3874
|
},
|
|
2188
3875
|
async findById(id: string) {
|
|
2189
3876
|
return todos.get(id) ?? null;
|
|
@@ -2201,9 +3888,31 @@ function createTestTodoRepository() {
|
|
|
2201
3888
|
};
|
|
2202
3889
|
}
|
|
2203
3890
|
|
|
3891
|
+
function createTestTodoAttachmentRepository() {
|
|
3892
|
+
return {
|
|
3893
|
+
async create(input: {
|
|
3894
|
+
id: string;
|
|
3895
|
+
todoId: string;
|
|
3896
|
+
key: string;
|
|
3897
|
+
fileName: string;
|
|
3898
|
+
contentType: string;
|
|
3899
|
+
size: number;
|
|
3900
|
+
}) {
|
|
3901
|
+
return {
|
|
3902
|
+
...input,
|
|
3903
|
+
createdAt: new Date().toISOString(),
|
|
3904
|
+
};
|
|
3905
|
+
},
|
|
3906
|
+
async findMany() {
|
|
3907
|
+
return [];
|
|
3908
|
+
},
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
|
|
2204
3912
|
describe("todos resource", () => {
|
|
2205
3913
|
it("creates, gets, and lists todos", async () => {
|
|
2206
3914
|
const todos = createTestTodoRepository();
|
|
3915
|
+
const todoAttachments = createTestTodoAttachmentRepository();
|
|
2207
3916
|
const auth = {
|
|
2208
3917
|
user: {
|
|
2209
3918
|
id: "user_test",
|
|
@@ -2212,23 +3921,31 @@ describe("todos resource", () => {
|
|
|
2212
3921
|
},
|
|
2213
3922
|
session: { id: "session_test" },
|
|
2214
3923
|
};
|
|
2215
|
-
const
|
|
2216
|
-
|
|
3924
|
+
const testFixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
|
|
3925
|
+
base: appPorts,
|
|
3926
|
+
overrides: {
|
|
3927
|
+
gate: appPorts.gate,
|
|
3928
|
+
todoAttachments,
|
|
3929
|
+
todos,
|
|
3930
|
+
devtools: createInMemoryDevtools(),
|
|
3931
|
+
},
|
|
3932
|
+
transaction: {
|
|
3933
|
+
ports: (ports) => ({
|
|
3934
|
+
...ports,
|
|
3935
|
+
events: createDomainEventRecorder(),
|
|
3936
|
+
todoAttachments,
|
|
3937
|
+
todos,
|
|
3938
|
+
}),
|
|
3939
|
+
},
|
|
2217
3940
|
});
|
|
2218
|
-
const
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
storage: createMemoryStorage(),
|
|
2224
|
-
};
|
|
2225
|
-
const tester = createUseCaseTester<AppContext>(() => ({
|
|
2226
|
-
requestId: "test-request",
|
|
2227
|
-
actor,
|
|
3941
|
+
const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
|
|
3942
|
+
ports: testFixture.ports,
|
|
3943
|
+
actor: createTestUserActor(auth.user.id, {
|
|
3944
|
+
displayName: auth.user.name,
|
|
3945
|
+
}),
|
|
2228
3946
|
auth,
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
}));
|
|
3947
|
+
});
|
|
3948
|
+
const tester = createUseCaseTester<AppContext>(createTestContext);
|
|
2232
3949
|
|
|
2233
3950
|
const ctx = await tester.ctx();
|
|
2234
3951
|
const created = await tester.run(
|
|
@@ -2245,8 +3962,8 @@ ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUn
|
|
|
2245
3962
|
|
|
2246
3963
|
expect(created.title).toBe("First todo");
|
|
2247
3964
|
expect(found.id).toBe(created.id);
|
|
2248
|
-
expect(result.total).toBe(1);
|
|
2249
|
-
expect(result.
|
|
3965
|
+
expect(result.page.total).toBe(1);
|
|
3966
|
+
expect(result.items).toEqual([created]);
|
|
2250
3967
|
});
|
|
2251
3968
|
});
|
|
2252
3969
|
`;
|
|
@@ -2284,8 +4001,24 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
2284
4001
|
path: "app/api/openapi/route.ts",
|
|
2285
4002
|
content: files.productionOpenApiRoute,
|
|
2286
4003
|
},
|
|
2287
|
-
{
|
|
2288
|
-
|
|
4004
|
+
{
|
|
4005
|
+
path: "app/api/auth/[...all]/route.ts",
|
|
4006
|
+
content: files.productionAuthRoute,
|
|
4007
|
+
},
|
|
4008
|
+
{
|
|
4009
|
+
path: "app/api/cron/outbox/drain/route.ts",
|
|
4010
|
+
content: files.productionOutboxDrainRoute,
|
|
4011
|
+
},
|
|
4012
|
+
{
|
|
4013
|
+
path: "app/api/cron/todos/daily-summary/route.ts",
|
|
4014
|
+
content: files.productionScheduleRoute,
|
|
4015
|
+
},
|
|
4016
|
+
{
|
|
4017
|
+
path: "app/api/uploads/[uploadName]/[action]/route.ts",
|
|
4018
|
+
content: files.productionUploadsRoute,
|
|
4019
|
+
},
|
|
4020
|
+
{ path: "client/index.ts", content: clientIndex(ctx) },
|
|
4021
|
+
{ path: "client/forms.ts", content: files.formsClient },
|
|
2289
4022
|
{
|
|
2290
4023
|
path: "features/todos/contracts.ts",
|
|
2291
4024
|
content: files.productionContractsTodos,
|
|
@@ -2296,16 +4029,43 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
2296
4029
|
{
|
|
2297
4030
|
path: "infra/app-ports.ts",
|
|
2298
4031
|
content: usesDrizzleTurso
|
|
2299
|
-
?
|
|
4032
|
+
? productionInfrastructurePortsWithDrizzleTurso(ctx)
|
|
2300
4033
|
: files.productionInfrastructurePorts,
|
|
2301
4034
|
},
|
|
2302
4035
|
{ path: "lib/env.ts", content: files.productionEnv },
|
|
4036
|
+
{ path: "lib/audit.ts", content: files.productionAuditHelper },
|
|
2303
4037
|
{ path: "lib/auth.ts", content: files.productionAuthHelpers },
|
|
4038
|
+
{ path: "lib/better-auth.ts", content: files.productionBetterAuth },
|
|
2304
4039
|
{ path: "features/todos/policy.ts", content: files.productionTodoPolicy },
|
|
2305
4040
|
{
|
|
2306
|
-
path: "
|
|
2307
|
-
content: files.
|
|
4041
|
+
path: "features/todos/domain/events/index.ts",
|
|
4042
|
+
content: files.productionTodoEvents,
|
|
4043
|
+
},
|
|
4044
|
+
{ path: "features/todos/jobs/index.ts", content: files.productionTodoJobs },
|
|
4045
|
+
{
|
|
4046
|
+
path: "features/todos/listeners/index.ts",
|
|
4047
|
+
content: files.productionTodoListeners,
|
|
4048
|
+
},
|
|
4049
|
+
{
|
|
4050
|
+
path: "features/todos/notifications/index.ts",
|
|
4051
|
+
content: files.productionTodoNotifications,
|
|
4052
|
+
},
|
|
4053
|
+
{
|
|
4054
|
+
path: "features/todos/schedules/index.ts",
|
|
4055
|
+
content: files.productionTodoSchedules,
|
|
2308
4056
|
},
|
|
4057
|
+
{
|
|
4058
|
+
path: "features/todos/uploads/index.ts",
|
|
4059
|
+
content: files.productionTodoUploads,
|
|
4060
|
+
},
|
|
4061
|
+
...(!hasIntegration(ctx, "better-auth")
|
|
4062
|
+
? [
|
|
4063
|
+
{
|
|
4064
|
+
path: "infra/auth/anonymous-auth.ts",
|
|
4065
|
+
content: files.productionAnonymousAuth,
|
|
4066
|
+
},
|
|
4067
|
+
]
|
|
4068
|
+
: []),
|
|
2309
4069
|
{ path: "ports/auth.ts", content: files.productionAuthPort },
|
|
2310
4070
|
{
|
|
2311
4071
|
path: "ports/index.ts",
|
|
@@ -2319,7 +4079,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
2319
4079
|
},
|
|
2320
4080
|
{
|
|
2321
4081
|
path: "features/shared/errors.ts",
|
|
2322
|
-
content: files.
|
|
4082
|
+
content: files.productionSharedErrors,
|
|
2323
4083
|
},
|
|
2324
4084
|
{
|
|
2325
4085
|
path: "server/index.ts",
|
|
@@ -2327,12 +4087,22 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
2327
4087
|
? files.productionServerWithDrizzleTurso
|
|
2328
4088
|
: files.productionServer,
|
|
2329
4089
|
},
|
|
4090
|
+
{ path: "server/context.ts", content: files.productionServerContext },
|
|
2330
4091
|
{ path: "server/routes.ts", content: files.productionServerRoutes },
|
|
4092
|
+
{ path: "server/outbox.ts", content: files.productionOutbox },
|
|
4093
|
+
{ path: "server/schedules.ts", content: files.productionSchedules },
|
|
2331
4094
|
{
|
|
2332
4095
|
path: "server/providers.ts",
|
|
2333
4096
|
content: productionServerProviders(ctx),
|
|
2334
4097
|
},
|
|
2335
4098
|
{ path: "lib/use-case.ts", content: files.productionUseCaseBuilder },
|
|
4099
|
+
{ path: "lib/listeners.ts", content: files.productionListenersBuilder },
|
|
4100
|
+
{ path: "lib/jobs.ts", content: files.productionJobsBuilder },
|
|
4101
|
+
{ path: "lib/schedules.ts", content: files.productionSchedulesBuilder },
|
|
4102
|
+
{
|
|
4103
|
+
path: "lib/notifications.ts",
|
|
4104
|
+
content: files.productionNotificationsBuilder,
|
|
4105
|
+
},
|
|
2336
4106
|
{
|
|
2337
4107
|
path: "features/todos/use-cases/create-todo.ts",
|
|
2338
4108
|
content: files.productionCreateTodoUseCase,
|
|
@@ -2350,7 +4120,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
2350
4120
|
content: files.productionUseCasesIndex,
|
|
2351
4121
|
},
|
|
2352
4122
|
{
|
|
2353
|
-
path: "features/todos/
|
|
4123
|
+
path: "features/todos/schemas.ts",
|
|
2354
4124
|
content: files.productionTodoSchemas,
|
|
2355
4125
|
},
|
|
2356
4126
|
{
|
|
@@ -2363,6 +4133,30 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
2363
4133
|
if (usesDrizzleTurso) {
|
|
2364
4134
|
templateFiles.push(
|
|
2365
4135
|
{ path: "drizzle.config.ts", content: files.productionDrizzleConfig },
|
|
4136
|
+
{
|
|
4137
|
+
path: "infra/audit/drizzle-audit-log.ts",
|
|
4138
|
+
content: files.productionDrizzleAuditLog,
|
|
4139
|
+
},
|
|
4140
|
+
{
|
|
4141
|
+
path: "infra/db/bootstrap.ts",
|
|
4142
|
+
content: files.productionDbBootstrap,
|
|
4143
|
+
},
|
|
4144
|
+
{
|
|
4145
|
+
path: "infra/db/seed.ts",
|
|
4146
|
+
content: files.productionDbSeed,
|
|
4147
|
+
},
|
|
4148
|
+
{
|
|
4149
|
+
path: "infra/db/reset.ts",
|
|
4150
|
+
content: files.productionDbReset,
|
|
4151
|
+
},
|
|
4152
|
+
{
|
|
4153
|
+
path: "infra/db/test-database.ts",
|
|
4154
|
+
content: files.productionDbTestDatabase,
|
|
4155
|
+
},
|
|
4156
|
+
{
|
|
4157
|
+
path: "infra/db/provider.ts",
|
|
4158
|
+
content: files.productionDbProvider,
|
|
4159
|
+
},
|
|
2366
4160
|
{
|
|
2367
4161
|
path: "infra/db/schema/index.ts",
|
|
2368
4162
|
content: files.productionDbSchema,
|
|
@@ -2386,6 +4180,9 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
2386
4180
|
return templateFiles;
|
|
2387
4181
|
}
|
|
2388
4182
|
|
|
4183
|
+
/**
|
|
4184
|
+
* Render all template files for a new Beignet app.
|
|
4185
|
+
*/
|
|
2389
4186
|
export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
2390
4187
|
if (isStandardPreset(ctx)) {
|
|
2391
4188
|
return getProductionTemplateFiles(ctx);
|
|
@@ -2402,29 +4199,37 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
2402
4199
|
{ path: "app/layout.tsx", content: layout(ctx) },
|
|
2403
4200
|
{ path: "app/page.tsx", content: page(ctx) },
|
|
2404
4201
|
{ path: "app/api/[[...path]]/route.ts", content: files.apiCatchAllRoute },
|
|
4202
|
+
{ path: "app-context.ts", content: minimalAppContext(ctx) },
|
|
2405
4203
|
{ path: "features/todos/contracts.ts", content: files.contractsTodos },
|
|
2406
4204
|
{ path: "features/todos/routes.ts", content: files.todoRoutes },
|
|
2407
|
-
{ path: "ports/index.ts", content:
|
|
4205
|
+
{ path: "ports/index.ts", content: minimalPorts(ctx) },
|
|
2408
4206
|
{ path: "server/index.ts", content: server(ctx) },
|
|
4207
|
+
{ path: "server/context.ts", content: serverContext(ctx) },
|
|
2409
4208
|
{ path: "server/routes.ts", content: files.serverRoutes },
|
|
2410
|
-
{ path: "features/todos/use-cases.ts", content: files.useCasesTodos },
|
|
4209
|
+
{ path: "features/todos/use-cases/index.ts", content: files.useCasesTodos },
|
|
2411
4210
|
];
|
|
2412
4211
|
|
|
2413
4212
|
if (hasFeature(ctx, "client")) {
|
|
2414
4213
|
templateFiles.push({
|
|
2415
|
-
path: "client/
|
|
2416
|
-
content:
|
|
4214
|
+
path: "client/index.ts",
|
|
4215
|
+
content: clientIndex(ctx),
|
|
2417
4216
|
});
|
|
2418
4217
|
}
|
|
2419
4218
|
|
|
2420
4219
|
if (hasFeature(ctx, "react-query")) {
|
|
2421
4220
|
templateFiles.push(
|
|
2422
4221
|
{ path: "app/providers.tsx", content: files.appProviders },
|
|
2423
|
-
{ path: "client/rq.ts", content: files.rq },
|
|
2424
4222
|
{ path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) },
|
|
2425
4223
|
);
|
|
2426
4224
|
}
|
|
2427
4225
|
|
|
4226
|
+
if (hasFeature(ctx, "forms")) {
|
|
4227
|
+
templateFiles.push({
|
|
4228
|
+
path: "client/forms.ts",
|
|
4229
|
+
content: files.formsClient,
|
|
4230
|
+
});
|
|
4231
|
+
}
|
|
4232
|
+
|
|
2428
4233
|
if (hasFeature(ctx, "openapi")) {
|
|
2429
4234
|
templateFiles.push({
|
|
2430
4235
|
path: "app/api/openapi/route.ts",
|
|
@@ -2432,19 +4237,34 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
2432
4237
|
});
|
|
2433
4238
|
}
|
|
2434
4239
|
|
|
2435
|
-
if (
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
4240
|
+
if (hasIntegration(ctx, "better-auth")) {
|
|
4241
|
+
templateFiles.push(
|
|
4242
|
+
{ path: "lib/better-auth.ts", content: files.minimalBetterAuth },
|
|
4243
|
+
{
|
|
4244
|
+
path: "app/api/auth/[...all]/route.ts",
|
|
4245
|
+
content: files.productionAuthRoute,
|
|
4246
|
+
},
|
|
4247
|
+
);
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
if (hasDrizzleTurso(ctx)) {
|
|
4251
|
+
templateFiles.push(
|
|
4252
|
+
{ path: "drizzle.config.ts", content: files.productionDrizzleConfig },
|
|
4253
|
+
{ path: "infra/db/provider.ts", content: files.minimalDbProvider },
|
|
4254
|
+
{ path: "infra/db/schema/index.ts", content: files.minimalDbSchema },
|
|
4255
|
+
{
|
|
4256
|
+
path: "infra/todos/drizzle-todo-repository.ts",
|
|
4257
|
+
content: files.minimalDrizzleTodoRepository,
|
|
4258
|
+
},
|
|
4259
|
+
);
|
|
2444
4260
|
}
|
|
2445
4261
|
|
|
2446
4262
|
if (ctx.integrations.length > 0) {
|
|
2447
4263
|
templateFiles.push(
|
|
4264
|
+
{
|
|
4265
|
+
path: "server/providers.ts",
|
|
4266
|
+
content: serverProviders(ctx),
|
|
4267
|
+
},
|
|
2448
4268
|
{
|
|
2449
4269
|
path: ".env.example",
|
|
2450
4270
|
content: envExample(ctx),
|