@beignet/cli 0.0.3 → 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 +205 -0
- package/README.md +379 -61
- 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 +8 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -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 +4 -1
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +16 -26
- package/dist/create.js.map +1 -1
- package/dist/db.d.ts +1 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +37 -2
- package/dist/db.js.map +1 -1
- 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 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +657 -588
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts +21 -2
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +1938 -131
- 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 +10 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +340 -33
- package/dist/lint.js.map +1 -1
- package/dist/make.d.ts +20 -3
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +1756 -394
- 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 +3 -32
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +1002 -527
- 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 +16 -0
- package/src/create-prompts.ts +182 -0
- package/src/create.ts +24 -31
- package/src/db.ts +60 -4
- package/src/github-annotations.ts +37 -0
- package/src/index.ts +1067 -803
- package/src/inspect.ts +2859 -115
- package/src/lib.ts +45 -0
- package/src/lint.ts +493 -39
- package/src/make.ts +2181 -405
- package/src/outbox.ts +249 -0
- package/src/schedule.ts +272 -0
- package/src/task.ts +169 -0
- package/src/templates.ts +1073 -567
- 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,29 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export type
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export type IntegrationName =
|
|
21
|
-
| "better-auth"
|
|
22
|
-
| "drizzle-turso"
|
|
23
|
-
| "inngest"
|
|
24
|
-
| "pino"
|
|
25
|
-
| "resend"
|
|
26
|
-
| "upstash-rate-limit";
|
|
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";
|
|
27
20
|
|
|
28
21
|
/**
|
|
29
22
|
* One generated file emitted by the template generator.
|
|
@@ -58,9 +51,9 @@ const externalVersions = {
|
|
|
58
51
|
tanstackReactQuery: "^5.100.7",
|
|
59
52
|
hookformResolvers: "^5.0.0",
|
|
60
53
|
reactHookForm: "^7.74.0",
|
|
61
|
-
betterAuth: "
|
|
62
|
-
drizzleKit: "^0.
|
|
63
|
-
drizzleOrm: "^0.
|
|
54
|
+
betterAuth: "1.6.11",
|
|
55
|
+
drizzleKit: "^0.31.10",
|
|
56
|
+
drizzleOrm: "^0.45.2",
|
|
64
57
|
inngest: "^3.0.0",
|
|
65
58
|
libsqlClient: "^0.14.0",
|
|
66
59
|
pino: "^9.7.0",
|
|
@@ -80,46 +73,16 @@ function hasDrizzleTurso(ctx: TemplateContext): boolean {
|
|
|
80
73
|
function packageRunner(ctx: TemplateContext): string {
|
|
81
74
|
switch (ctx.packageManager) {
|
|
82
75
|
case "npm":
|
|
83
|
-
return "
|
|
76
|
+
return "npm run beignet --";
|
|
84
77
|
case "pnpm":
|
|
85
|
-
return "pnpm
|
|
78
|
+
return "pnpm beignet";
|
|
86
79
|
case "yarn":
|
|
87
|
-
return "yarn
|
|
80
|
+
return "yarn beignet";
|
|
88
81
|
default:
|
|
89
|
-
return "
|
|
82
|
+
return "bun beignet";
|
|
90
83
|
}
|
|
91
84
|
}
|
|
92
85
|
|
|
93
|
-
/**
|
|
94
|
-
* Supported scaffold feature choices.
|
|
95
|
-
*/
|
|
96
|
-
export const featureChoices = [
|
|
97
|
-
"client",
|
|
98
|
-
"react-query",
|
|
99
|
-
"forms",
|
|
100
|
-
"openapi",
|
|
101
|
-
] as const satisfies readonly FeatureName[];
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Supported scaffold preset choices.
|
|
105
|
-
*/
|
|
106
|
-
export const presetChoices = [
|
|
107
|
-
"minimal",
|
|
108
|
-
"standard",
|
|
109
|
-
] as const satisfies readonly PresetName[];
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Supported scaffold integration choices.
|
|
113
|
-
*/
|
|
114
|
-
export const integrationChoices = [
|
|
115
|
-
"better-auth",
|
|
116
|
-
"drizzle-turso",
|
|
117
|
-
"inngest",
|
|
118
|
-
"pino",
|
|
119
|
-
"resend",
|
|
120
|
-
"upstash-rate-limit",
|
|
121
|
-
] as const satisfies readonly IntegrationName[];
|
|
122
|
-
|
|
123
86
|
const integrationDeps: Record<IntegrationName, Record<string, string>> = {
|
|
124
87
|
"better-auth": {
|
|
125
88
|
"@beignet/provider-auth-better-auth": "",
|
|
@@ -233,6 +196,7 @@ function packageJson(ctx: TemplateContext): string {
|
|
|
233
196
|
}
|
|
234
197
|
|
|
235
198
|
const devDependencies: Record<string, string> = {
|
|
199
|
+
"@beignet/cli": ctx.beignetVersion,
|
|
236
200
|
"@types/bun": externalVersions.typesBun,
|
|
237
201
|
"@types/node": externalVersions.typesNode,
|
|
238
202
|
"@types/react": externalVersions.typesReact,
|
|
@@ -249,6 +213,7 @@ function packageJson(ctx: TemplateContext): string {
|
|
|
249
213
|
}
|
|
250
214
|
|
|
251
215
|
const scripts: Record<string, string> = {
|
|
216
|
+
beignet: "beignet",
|
|
252
217
|
dev: "next dev",
|
|
253
218
|
build: "next build",
|
|
254
219
|
start: "next start",
|
|
@@ -259,8 +224,13 @@ function packageJson(ctx: TemplateContext): string {
|
|
|
259
224
|
if (hasDrizzleTurso(ctx)) {
|
|
260
225
|
scripts["db:generate"] = "drizzle-kit generate";
|
|
261
226
|
scripts["db:migrate"] = "drizzle-kit migrate";
|
|
262
|
-
|
|
263
|
-
|
|
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
|
+
}
|
|
264
234
|
}
|
|
265
235
|
|
|
266
236
|
return json({
|
|
@@ -284,6 +254,9 @@ function readme(ctx: TemplateContext): string {
|
|
|
284
254
|
ctx.packageManager === "npm"
|
|
285
255
|
? "npm run dev"
|
|
286
256
|
: `${ctx.packageManager} run dev`;
|
|
257
|
+
const cli = packageRunner(ctx);
|
|
258
|
+
const envSetup =
|
|
259
|
+
ctx.integrations.length > 0 ? "cp .env.example .env.local\n" : "";
|
|
287
260
|
const featureList =
|
|
288
261
|
ctx.features.length > 0
|
|
289
262
|
? ctx.features.map((feature) => `- \`${feature}\``).join("\n")
|
|
@@ -292,6 +265,20 @@ function readme(ctx: TemplateContext): string {
|
|
|
292
265
|
ctx.integrations.length > 0
|
|
293
266
|
? ctx.integrations.map((integration) => `- \`${integration}\``).join("\n")
|
|
294
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
|
+
: "";
|
|
295
282
|
|
|
296
283
|
return `# ${ctx.name}
|
|
297
284
|
|
|
@@ -301,19 +288,21 @@ Beignet app scaffolded with \`@beignet/cli\`.
|
|
|
301
288
|
|
|
302
289
|
\`\`\`bash
|
|
303
290
|
${install}
|
|
304
|
-
${run}
|
|
291
|
+
${envSetup}${run}
|
|
305
292
|
\`\`\`
|
|
306
293
|
|
|
307
|
-
Open http://localhost:3000 and start from the todos workflow.
|
|
308
|
-
|
|
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}
|
|
309
296
|
## Files to know
|
|
310
297
|
|
|
311
298
|
- \`features/todos/contracts.ts\` defines the API contract.
|
|
312
|
-
- \`features/todos/use-cases
|
|
299
|
+
- \`features/todos/use-cases/\` contains application logic with input and output validation.
|
|
313
300
|
- \`ports/index.ts\` defines the app's external dependencies.
|
|
314
|
-
- \`
|
|
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.
|
|
315
304
|
- \`app/api/[[...path]]/route.ts\` exposes contract-backed API routes to Next.js.
|
|
316
|
-
${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" : ""}
|
|
317
306
|
|
|
318
307
|
## Selected preset
|
|
319
308
|
|
|
@@ -336,6 +325,10 @@ function standardReadme(ctx: TemplateContext): string {
|
|
|
336
325
|
ctx.packageManager === "npm"
|
|
337
326
|
? "npm run dev"
|
|
338
327
|
: `${ctx.packageManager} run dev`;
|
|
328
|
+
const test =
|
|
329
|
+
ctx.packageManager === "npm"
|
|
330
|
+
? "npm run test"
|
|
331
|
+
: `${ctx.packageManager} run test`;
|
|
339
332
|
const typecheck =
|
|
340
333
|
ctx.packageManager === "npm"
|
|
341
334
|
? "npm run typecheck"
|
|
@@ -358,10 +351,24 @@ Beignet app scaffolded with \`@beignet/cli\`.
|
|
|
358
351
|
\`\`\`bash
|
|
359
352
|
${install}
|
|
360
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
|
|
361
368
|
${run}
|
|
362
369
|
\`\`\`
|
|
363
370
|
|
|
364
|
-
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.
|
|
365
372
|
|
|
366
373
|
## First checks
|
|
367
374
|
|
|
@@ -375,15 +382,32 @@ ${typecheck}
|
|
|
375
382
|
|
|
376
383
|
\`routes\` shows the contracts Beignet can inspect. \`lint\` checks dependency direction. \`doctor\` catches route, OpenAPI, and resource drift.
|
|
377
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
|
+
|
|
378
399
|
## App map
|
|
379
400
|
|
|
380
|
-
- \`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.
|
|
381
403
|
- \`features/todos/use-cases/\` owns application behavior and validation.
|
|
382
404
|
- \`ports/\` defines app-owned dependencies.
|
|
383
405
|
- \`infra/\` implements ports for the selected runtime.
|
|
384
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.
|
|
385
408
|
- \`server/\` wires context, providers, routes, hooks, and error handling.
|
|
386
|
-
- \`server/outbox.ts\` registers durable events and jobs for the outbox drain route.
|
|
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.
|
|
387
411
|
- \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
|
|
388
412
|
- \`features/todos/domain/events/\`, \`jobs/\`, \`listeners/\`, \`notifications/\`, \`schedules/\`, and \`uploads/\` show the feature-owned background workflow shape.
|
|
389
413
|
- \`app/storage/[...key]/route.ts\` serves public local storage objects.
|
|
@@ -400,6 +424,8 @@ ${beforeDeploy}
|
|
|
400
424
|
- Keep \`/api/devtools\` development-only unless you add authentication and stricter redaction.
|
|
401
425
|
- Set \`APP_URL\`, \`BETTER_AUTH_SECRET\`, \`CRON_SECRET\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
|
|
402
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.
|
|
403
429
|
- Review the starter authorization policy before exposing user-owned data.
|
|
404
430
|
${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
|
|
405
431
|
`;
|
|
@@ -420,10 +446,17 @@ function integrationsDoc(ctx: TemplateContext): string {
|
|
|
420
446
|
"",
|
|
421
447
|
"- Package: `@beignet/provider-auth-better-auth`",
|
|
422
448
|
"- Peer dependency: `better-auth`",
|
|
423
|
-
"- The
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
+
]),
|
|
427
460
|
"",
|
|
428
461
|
);
|
|
429
462
|
}
|
|
@@ -436,9 +469,19 @@ function integrationsDoc(ctx: TemplateContext): string {
|
|
|
436
469
|
"- Peer dependencies: `@libsql/client` and `drizzle-orm`",
|
|
437
470
|
"- Dev dependency: `drizzle-kit`",
|
|
438
471
|
"- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.",
|
|
439
|
-
|
|
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
|
+
]),
|
|
440
479
|
`- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`,
|
|
441
|
-
|
|
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
|
+
: []),
|
|
442
485
|
"",
|
|
443
486
|
);
|
|
444
487
|
}
|
|
@@ -451,7 +494,7 @@ function integrationsDoc(ctx: TemplateContext): string {
|
|
|
451
494
|
"- Package: `@beignet/provider-inngest`",
|
|
452
495
|
"- Peer dependency: `inngest`",
|
|
453
496
|
"- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.",
|
|
454
|
-
"- Define jobs with `
|
|
497
|
+
"- Define jobs with `defineJob(...)` from `lib/jobs.ts` and dispatch them through `ctx.ports.jobs.dispatch(...)`.",
|
|
455
498
|
"- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.",
|
|
456
499
|
"",
|
|
457
500
|
);
|
|
@@ -544,7 +587,7 @@ function envExample(ctx: TemplateContext): string {
|
|
|
544
587
|
lines.push(
|
|
545
588
|
"UPSTASH_REDIS_REST_URL=",
|
|
546
589
|
"UPSTASH_REDIS_REST_TOKEN=",
|
|
547
|
-
"UPSTASH_PREFIX=
|
|
590
|
+
"UPSTASH_PREFIX=beignet:ratelimit",
|
|
548
591
|
"",
|
|
549
592
|
);
|
|
550
593
|
}
|
|
@@ -621,6 +664,11 @@ function page(ctx: TemplateContext): string {
|
|
|
621
664
|
<p>
|
|
622
665
|
<a href="/api/todos">Open todos API</a>
|
|
623
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>
|
|
624
672
|
</section>
|
|
625
673
|
</div>
|
|
626
674
|
</main>
|
|
@@ -645,6 +693,14 @@ export default function HomePage() {
|
|
|
645
693
|
</p>
|
|
646
694
|
</header>
|
|
647
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
|
+
|
|
648
704
|
<TodoApp />
|
|
649
705
|
</div>
|
|
650
706
|
</main>
|
|
@@ -654,64 +710,126 @@ export default function HomePage() {
|
|
|
654
710
|
}
|
|
655
711
|
|
|
656
712
|
function serverProviders(ctx: TemplateContext): string {
|
|
657
|
-
const
|
|
713
|
+
const packageImports: string[] = [];
|
|
714
|
+
const appImports: string[] = [];
|
|
715
|
+
const declarations: string[] = [];
|
|
658
716
|
const entries: string[] = [];
|
|
659
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
|
+
|
|
660
731
|
if (hasIntegration(ctx, "pino")) {
|
|
661
|
-
|
|
732
|
+
packageImports.push(
|
|
662
733
|
'import { loggerPinoProvider } from "@beignet/provider-logger-pino";',
|
|
663
734
|
);
|
|
664
735
|
entries.push("loggerPinoProvider");
|
|
665
736
|
}
|
|
666
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
|
+
|
|
667
752
|
if (hasIntegration(ctx, "inngest")) {
|
|
668
|
-
|
|
753
|
+
packageImports.push(
|
|
669
754
|
'import { inngestProvider } from "@beignet/provider-inngest";',
|
|
670
755
|
);
|
|
671
756
|
entries.push("inngestProvider");
|
|
672
757
|
}
|
|
673
758
|
|
|
674
759
|
if (hasIntegration(ctx, "resend")) {
|
|
675
|
-
|
|
760
|
+
packageImports.push(
|
|
676
761
|
'import { mailResendProvider } from "@beignet/provider-mail-resend";',
|
|
677
762
|
);
|
|
678
763
|
entries.push("mailResendProvider");
|
|
679
764
|
}
|
|
680
765
|
|
|
681
766
|
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
682
|
-
|
|
767
|
+
packageImports.push(
|
|
683
768
|
'import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";',
|
|
684
769
|
);
|
|
685
770
|
entries.push("upstashRateLimitProvider");
|
|
686
771
|
}
|
|
687
772
|
|
|
688
|
-
|
|
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}
|
|
689
779
|
export const providers = [
|
|
690
780
|
${entries.join(",\n\t")},
|
|
691
781
|
];
|
|
692
782
|
`;
|
|
693
783
|
}
|
|
694
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
|
+
|
|
695
811
|
function server(ctx: TemplateContext): string {
|
|
696
|
-
const hasProviders = ctx.integrations.
|
|
697
|
-
|
|
698
|
-
|
|
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
|
+
: "";
|
|
699
822
|
|
|
700
|
-
return `import {
|
|
823
|
+
return `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
824
|
+
import { createNextServer } from "@beignet/next";
|
|
825
|
+
import { appContext } from "./context";
|
|
701
826
|
${hasProviders ? 'import { providers } from "./providers";\n' : ""}import { routes } from "./routes";
|
|
827
|
+
import type { AppContext } from "@/app-context";
|
|
702
828
|
import { appPorts, type AppPorts } from "@/ports";
|
|
703
829
|
|
|
704
|
-
export type AppContext = {
|
|
705
|
-
requestId: string;
|
|
706
|
-
ports: AppPorts;
|
|
707
|
-
};
|
|
708
|
-
|
|
709
830
|
export const server = await createNextServer<AppContext, AppPorts>({
|
|
710
831
|
ports: appPorts,
|
|
711
|
-
${hasProviders ? "\tproviders,\n" : ""}
|
|
712
|
-
requestId: crypto.randomUUID(),
|
|
713
|
-
ports,
|
|
714
|
-
}),
|
|
832
|
+
${hasProviders ? "\tproviders,\n" : ""}${providerConfigBlock} context: appContext,
|
|
715
833
|
routes,
|
|
716
834
|
mapUnhandledError: ({ err, ctx }) => {
|
|
717
835
|
const ports = ctx?.ports as
|
|
@@ -753,6 +871,127 @@ ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
|
|
|
753
871
|
`;
|
|
754
872
|
}
|
|
755
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
|
+
|
|
756
995
|
function todoApp(ctx: TemplateContext): string {
|
|
757
996
|
const listTodosQueryOptions =
|
|
758
997
|
ctx.preset === "standard" ? "{ query: {} }" : "";
|
|
@@ -760,12 +999,12 @@ function todoApp(ctx: TemplateContext): string {
|
|
|
760
999
|
if (hasFeature(ctx, "forms")) {
|
|
761
1000
|
return `"use client";
|
|
762
1001
|
|
|
763
|
-
import {
|
|
1002
|
+
import { rootFormError } from "@beignet/react-hook-form";
|
|
764
1003
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
1004
|
+
import { rq } from "@/client";
|
|
1005
|
+
import { rhf } from "@/client/forms";
|
|
765
1006
|
import { createTodo, listTodos } from "@/features/todos/contracts";
|
|
766
|
-
import { rq } from "@/client/rq";
|
|
767
1007
|
|
|
768
|
-
const rhf = createReactHookForm();
|
|
769
1008
|
const createTodoForm = rhf(createTodo);
|
|
770
1009
|
|
|
771
1010
|
export function TodoApp() {
|
|
@@ -778,19 +1017,21 @@ export function TodoApp() {
|
|
|
778
1017
|
rq(createTodo).mutationOptions({
|
|
779
1018
|
onSuccess: async () => {
|
|
780
1019
|
form.reset();
|
|
781
|
-
await
|
|
782
|
-
|
|
783
|
-
|
|
1020
|
+
await rq(listTodos).invalidate(queryClient);
|
|
1021
|
+
},
|
|
1022
|
+
onError: (error) => {
|
|
1023
|
+
form.setError("root", rootFormError(error, "Could not create the todo."));
|
|
784
1024
|
},
|
|
785
1025
|
}),
|
|
786
1026
|
);
|
|
787
1027
|
|
|
788
1028
|
const onSubmit = form.handleSubmit((body) => {
|
|
1029
|
+
form.clearErrors("root");
|
|
789
1030
|
createTodoMutation.mutate({ body });
|
|
790
1031
|
});
|
|
791
1032
|
|
|
792
1033
|
return (
|
|
793
|
-
<section className="workspace">
|
|
1034
|
+
<section id="todos" className="workspace">
|
|
794
1035
|
<form className="composer" onSubmit={onSubmit}>
|
|
795
1036
|
<label htmlFor="todo-title">New todo</label>
|
|
796
1037
|
<div className="composer-row">
|
|
@@ -806,6 +1047,9 @@ export function TodoApp() {
|
|
|
806
1047
|
{form.formState.errors.title ? (
|
|
807
1048
|
<p className="error">{form.formState.errors.title.message}</p>
|
|
808
1049
|
) : null}
|
|
1050
|
+
{form.formState.errors.root ? (
|
|
1051
|
+
<p className="error">{form.formState.errors.root.message}</p>
|
|
1052
|
+
) : null}
|
|
809
1053
|
</form>
|
|
810
1054
|
|
|
811
1055
|
<div className="todos-panel">
|
|
@@ -836,8 +1080,8 @@ export function TodoApp() {
|
|
|
836
1080
|
|
|
837
1081
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
838
1082
|
import { useState } from "react";
|
|
1083
|
+
import { rq } from "@/client";
|
|
839
1084
|
import { createTodo, listTodos } from "@/features/todos/contracts";
|
|
840
|
-
import { rq } from "@/client/rq";
|
|
841
1085
|
|
|
842
1086
|
export function TodoApp() {
|
|
843
1087
|
const [title, setTitle] = useState("");
|
|
@@ -847,15 +1091,13 @@ export function TodoApp() {
|
|
|
847
1091
|
rq(createTodo).mutationOptions({
|
|
848
1092
|
onSuccess: async () => {
|
|
849
1093
|
setTitle("");
|
|
850
|
-
await
|
|
851
|
-
queryKey: rq(listTodos).key(),
|
|
852
|
-
});
|
|
1094
|
+
await rq(listTodos).invalidate(queryClient);
|
|
853
1095
|
},
|
|
854
1096
|
}),
|
|
855
1097
|
);
|
|
856
1098
|
|
|
857
1099
|
return (
|
|
858
|
-
<section className="workspace">
|
|
1100
|
+
<section id="todos" className="workspace">
|
|
859
1101
|
<form
|
|
860
1102
|
className="composer"
|
|
861
1103
|
onSubmit={(event) => {
|
|
@@ -897,6 +1139,58 @@ export function TodoApp() {
|
|
|
897
1139
|
`;
|
|
898
1140
|
}
|
|
899
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
|
+
|
|
900
1194
|
const files = {
|
|
901
1195
|
gitignore: `.next
|
|
902
1196
|
node_modules
|
|
@@ -997,6 +1291,29 @@ input {
|
|
|
997
1291
|
color: #475569;
|
|
998
1292
|
}
|
|
999
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
|
+
|
|
1000
1317
|
.panel {
|
|
1001
1318
|
margin-top: 24px;
|
|
1002
1319
|
padding: 20px;
|
|
@@ -1123,32 +1440,26 @@ input {
|
|
|
1123
1440
|
`,
|
|
1124
1441
|
appProviders: `"use client";
|
|
1125
1442
|
|
|
1126
|
-
import {
|
|
1443
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
1127
1444
|
import { type ReactNode, useState } from "react";
|
|
1445
|
+
import { makeQueryClient } from "@/client";
|
|
1128
1446
|
|
|
1129
1447
|
export function Providers({ children }: { children: ReactNode }) {
|
|
1130
|
-
const [queryClient] = useState(() =>
|
|
1448
|
+
const [queryClient] = useState(() => makeQueryClient());
|
|
1131
1449
|
|
|
1132
1450
|
return (
|
|
1133
1451
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
1134
1452
|
);
|
|
1135
1453
|
}
|
|
1136
1454
|
`,
|
|
1137
|
-
|
|
1455
|
+
formsClient: `import { createReactHookForm } from "@beignet/react-hook-form";
|
|
1138
1456
|
|
|
1139
|
-
export const
|
|
1140
|
-
validate: true,
|
|
1141
|
-
});
|
|
1457
|
+
export const rhf = createReactHookForm();
|
|
1142
1458
|
`,
|
|
1143
|
-
|
|
1144
|
-
import { apiClient } from "./api-client";
|
|
1145
|
-
|
|
1146
|
-
export const rq = createReactQuery(apiClient);
|
|
1147
|
-
`,
|
|
1148
|
-
contractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
|
|
1459
|
+
contractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
|
|
1149
1460
|
import { z } from "zod";
|
|
1150
1461
|
|
|
1151
|
-
const todos =
|
|
1462
|
+
const todos = defineContractGroup().namespace("todos");
|
|
1152
1463
|
|
|
1153
1464
|
export const TodoSchema = z.object({
|
|
1154
1465
|
id: z.string(),
|
|
@@ -1213,15 +1524,145 @@ export const appPorts = definePorts({
|
|
|
1213
1524
|
|
|
1214
1525
|
export type AppPorts = typeof appPorts;
|
|
1215
1526
|
`,
|
|
1216
|
-
|
|
1217
|
-
import { z } from "zod";
|
|
1218
|
-
import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
|
|
1219
|
-
import type { AppPorts } from "@/ports";
|
|
1527
|
+
appContext: `import type { AppPorts } from "@/ports";
|
|
1220
1528
|
|
|
1221
|
-
type AppContext = {
|
|
1529
|
+
export type AppContext = {
|
|
1222
1530
|
requestId: string;
|
|
1223
1531
|
ports: AppPorts;
|
|
1224
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";
|
|
1225
1666
|
|
|
1226
1667
|
const useCase = createUseCase<AppContext>();
|
|
1227
1668
|
|
|
@@ -1238,7 +1679,7 @@ export const ListTodosOutputSchema = z.object({
|
|
|
1238
1679
|
|
|
1239
1680
|
export const listTodos = useCase
|
|
1240
1681
|
.query("listTodos")
|
|
1241
|
-
.input(z.
|
|
1682
|
+
.input(z.object({}))
|
|
1242
1683
|
.output(ListTodosOutputSchema)
|
|
1243
1684
|
.run(async ({ ctx }) => {
|
|
1244
1685
|
const todos = await ctx.ports.todos.list();
|
|
@@ -1260,33 +1701,23 @@ export const createTodo = useCase
|
|
|
1260
1701
|
.output(TodoSchema)
|
|
1261
1702
|
.run(async ({ ctx, input }) => ctx.ports.todos.create(input));
|
|
1262
1703
|
`,
|
|
1263
|
-
todoRoutes: `import {
|
|
1264
|
-
import
|
|
1704
|
+
todoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1705
|
+
import { defineRouteGroup } from "@beignet/next";
|
|
1706
|
+
import type { AppContext } from "@/app-context";
|
|
1265
1707
|
import { createTodo, listTodos } from "@/features/todos/use-cases";
|
|
1266
1708
|
import * as contracts from "@/features/todos/contracts";
|
|
1267
1709
|
|
|
1268
1710
|
export const todoRoutes = defineRouteGroup<AppContext>({
|
|
1269
1711
|
name: "todos",
|
|
1270
1712
|
routes: [
|
|
1271
|
-
{
|
|
1272
|
-
|
|
1273
|
-
handle: async ({ ctx }) => ({
|
|
1274
|
-
status: 200,
|
|
1275
|
-
body: await listTodos.run({ ctx, input: undefined }),
|
|
1276
|
-
}),
|
|
1277
|
-
},
|
|
1278
|
-
{
|
|
1279
|
-
contract: contracts.createTodo,
|
|
1280
|
-
handle: async ({ ctx, body }) => ({
|
|
1281
|
-
status: 201,
|
|
1282
|
-
body: await createTodo.run({ ctx, input: body }),
|
|
1283
|
-
}),
|
|
1284
|
-
},
|
|
1713
|
+
{ contract: contracts.listTodos, useCase: listTodos },
|
|
1714
|
+
{ contract: contracts.createTodo, useCase: createTodo },
|
|
1285
1715
|
],
|
|
1286
1716
|
});
|
|
1287
1717
|
`,
|
|
1288
|
-
serverRoutes: `import {
|
|
1289
|
-
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";
|
|
1290
1721
|
import { todoRoutes } from "@/features/todos/routes";
|
|
1291
1722
|
|
|
1292
1723
|
export const routes = defineRoutes<AppContext>([
|
|
@@ -1344,24 +1775,28 @@ export const env = createEnv({
|
|
|
1344
1775
|
|
|
1345
1776
|
export const isProduction = env.NODE_ENV === "production";
|
|
1346
1777
|
`,
|
|
1347
|
-
productionAppContext: `import type {
|
|
1348
|
-
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";
|
|
1349
1781
|
import type { AppGate, AppPorts } from "@/ports";
|
|
1350
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>;
|
|
1351
1789
|
|
|
1352
1790
|
export type AppContext = {
|
|
1353
1791
|
requestId: string;
|
|
1354
1792
|
actor: ActivityActor;
|
|
1355
1793
|
auth: AuthSession | null;
|
|
1356
1794
|
gate: AppGate;
|
|
1357
|
-
ports:
|
|
1358
|
-
devtools: DevtoolsPort;
|
|
1359
|
-
storage: StoragePort;
|
|
1360
|
-
};
|
|
1795
|
+
ports: AppRuntimePorts;
|
|
1361
1796
|
tenant?: ActivityTenant;
|
|
1362
|
-
} & Partial<
|
|
1797
|
+
} & Partial<TraceContext>;
|
|
1363
1798
|
`,
|
|
1364
|
-
|
|
1799
|
+
productionSharedErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
|
|
1365
1800
|
|
|
1366
1801
|
export const errors = defineErrors({
|
|
1367
1802
|
Unauthorized: {
|
|
@@ -1420,13 +1855,36 @@ export type Todo = z.infer<typeof TodoSchema>;
|
|
|
1420
1855
|
export type CreateTodoInput = z.infer<typeof CreateTodoInputSchema>;
|
|
1421
1856
|
export type ListTodosInput = z.infer<typeof ListTodosInputSchema>;
|
|
1422
1857
|
`,
|
|
1423
|
-
productionUseCaseBuilder: `import {
|
|
1424
|
-
import {
|
|
1858
|
+
productionUseCaseBuilder: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1859
|
+
import { createUseCase } from "@beignet/core/application";
|
|
1425
1860
|
import type { AppContext } from "@/app-context";
|
|
1426
1861
|
|
|
1427
|
-
export const useCase = createUseCase<AppContext>(
|
|
1428
|
-
|
|
1429
|
-
}
|
|
1862
|
+
export const useCase = createUseCase<AppContext>();
|
|
1863
|
+
`,
|
|
1864
|
+
productionListenersBuilder: `import { createListeners } from "@beignet/core/events";
|
|
1865
|
+
import type { AppContext } from "@/app-context";
|
|
1866
|
+
|
|
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>();
|
|
1430
1888
|
`,
|
|
1431
1889
|
productionAuthHelpers: `import type { AppContext } from "@/app-context";
|
|
1432
1890
|
import type { AuthSession, AuthUser } from "@/ports/auth";
|
|
@@ -1444,9 +1902,10 @@ export function requireUser(ctx: AppContext): AuthUser {
|
|
|
1444
1902
|
return requireSession(ctx).user;
|
|
1445
1903
|
}
|
|
1446
1904
|
`,
|
|
1447
|
-
productionListTodosUseCase: `import {
|
|
1905
|
+
productionListTodosUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1906
|
+
import { normalizeOffsetPage } from "@beignet/core/pagination";
|
|
1448
1907
|
import { useCase } from "@/lib/use-case";
|
|
1449
|
-
import { ListTodosInputSchema, ListTodosOutputSchema } from "
|
|
1908
|
+
import { ListTodosInputSchema, ListTodosOutputSchema } from "../schemas";
|
|
1450
1909
|
|
|
1451
1910
|
export const listTodosUseCase = useCase
|
|
1452
1911
|
.query("todos.list")
|
|
@@ -1461,9 +1920,10 @@ export const listTodosUseCase = useCase
|
|
|
1461
1920
|
return ctx.ports.todos.list(page);
|
|
1462
1921
|
});
|
|
1463
1922
|
`,
|
|
1464
|
-
productionGetTodoUseCase: `import {
|
|
1923
|
+
productionGetTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1924
|
+
import { appError } from "@/features/shared/errors";
|
|
1465
1925
|
import { useCase } from "@/lib/use-case";
|
|
1466
|
-
import { GetTodoInputSchema, TodoSchema } from "
|
|
1926
|
+
import { GetTodoInputSchema, TodoSchema } from "../schemas";
|
|
1467
1927
|
|
|
1468
1928
|
export const getTodoUseCase = useCase
|
|
1469
1929
|
.query("todos.get")
|
|
@@ -1478,10 +1938,11 @@ export const getTodoUseCase = useCase
|
|
|
1478
1938
|
return todo;
|
|
1479
1939
|
});
|
|
1480
1940
|
`,
|
|
1481
|
-
productionCreateTodoUseCase: `import {
|
|
1941
|
+
productionCreateTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1942
|
+
import { TodoCreated } from "@/features/todos/domain/events";
|
|
1482
1943
|
import { auditEntry } from "@/lib/audit";
|
|
1483
1944
|
import { useCase } from "@/lib/use-case";
|
|
1484
|
-
import { CreateTodoInputSchema, TodoSchema } from "
|
|
1945
|
+
import { CreateTodoInputSchema, TodoSchema } from "../schemas";
|
|
1485
1946
|
|
|
1486
1947
|
export const createTodoUseCase = useCase
|
|
1487
1948
|
.command("todos.create")
|
|
@@ -1521,7 +1982,7 @@ export {
|
|
|
1521
1982
|
type CreateTodoInput,
|
|
1522
1983
|
type ListTodosInput,
|
|
1523
1984
|
type Todo,
|
|
1524
|
-
} from "
|
|
1985
|
+
} from "../schemas";
|
|
1525
1986
|
`,
|
|
1526
1987
|
productionTodoRepositoryPort: `import type {
|
|
1527
1988
|
OffsetPage,
|
|
@@ -1531,7 +1992,7 @@ export {
|
|
|
1531
1992
|
import type {
|
|
1532
1993
|
CreateTodoInput,
|
|
1533
1994
|
Todo,
|
|
1534
|
-
} from "@/features/todos/
|
|
1995
|
+
} from "@/features/todos/schemas";
|
|
1535
1996
|
|
|
1536
1997
|
export type ListTodosResult = PageResult<Todo, OffsetPageInfo>;
|
|
1537
1998
|
|
|
@@ -1624,11 +2085,12 @@ export const todoPolicy = definePolicy({
|
|
|
1624
2085
|
},
|
|
1625
2086
|
});
|
|
1626
2087
|
`,
|
|
1627
|
-
productionInMemoryTodoRepository: `import {
|
|
2088
|
+
productionInMemoryTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2089
|
+
import { offsetPageResult } from "@beignet/core/pagination";
|
|
1628
2090
|
import type {
|
|
1629
2091
|
CreateTodoInput,
|
|
1630
2092
|
Todo,
|
|
1631
|
-
} from "@/features/todos/
|
|
2093
|
+
} from "@/features/todos/schemas";
|
|
1632
2094
|
import type {
|
|
1633
2095
|
TodoAttachmentRepository,
|
|
1634
2096
|
TodoRepository,
|
|
@@ -1712,26 +2174,6 @@ export const appPorts = definePorts({
|
|
|
1712
2174
|
todos,
|
|
1713
2175
|
})),
|
|
1714
2176
|
});
|
|
1715
|
-
`,
|
|
1716
|
-
productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
|
|
1717
|
-
import { todoPolicy } from "@/features/todos/policy";
|
|
1718
|
-
import { appError } from "@/features/shared/errors";
|
|
1719
|
-
import { fallbackLogger } from "./logger";
|
|
1720
|
-
|
|
1721
|
-
const gate = createGate({
|
|
1722
|
-
policies: [todoPolicy],
|
|
1723
|
-
onDeny(decision) {
|
|
1724
|
-
return appError("Forbidden", {
|
|
1725
|
-
message: decision.reason ?? "Forbidden",
|
|
1726
|
-
details: decision.details,
|
|
1727
|
-
});
|
|
1728
|
-
},
|
|
1729
|
-
});
|
|
1730
|
-
|
|
1731
|
-
export const appPorts = definePorts({
|
|
1732
|
-
gate,
|
|
1733
|
-
logger: fallbackLogger,
|
|
1734
|
-
});
|
|
1735
2177
|
`,
|
|
1736
2178
|
productionDrizzleConfig: `export default {
|
|
1737
2179
|
schema: "./infra/db/schema/index.ts",
|
|
@@ -1902,8 +2344,34 @@ export const outboxMessages = sqliteTable(
|
|
|
1902
2344
|
),
|
|
1903
2345
|
}),
|
|
1904
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
|
+
);
|
|
1905
2372
|
`,
|
|
1906
|
-
productionDrizzleTodoRepository: `import {
|
|
2373
|
+
productionDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2374
|
+
import { offsetPageResult } from "@beignet/core/pagination";
|
|
1907
2375
|
import { count, desc, eq } from "drizzle-orm";
|
|
1908
2376
|
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
1909
2377
|
import type {
|
|
@@ -1913,7 +2381,7 @@ import type {
|
|
|
1913
2381
|
import type {
|
|
1914
2382
|
CreateTodoInput,
|
|
1915
2383
|
Todo,
|
|
1916
|
-
} from "@/features/todos/
|
|
2384
|
+
} from "@/features/todos/schemas";
|
|
1917
2385
|
import * as schema from "@/infra/db/schema";
|
|
1918
2386
|
|
|
1919
2387
|
type TodoRow = typeof schema.todos.$inferSelect;
|
|
@@ -2027,7 +2495,10 @@ import * as schema from "./schema";
|
|
|
2027
2495
|
|
|
2028
2496
|
export function createRepositories(
|
|
2029
2497
|
db: DrizzleTursoDatabase<typeof schema>,
|
|
2030
|
-
): Omit<
|
|
2498
|
+
): Omit<
|
|
2499
|
+
AppTransactionPorts,
|
|
2500
|
+
"audit" | "events" | "idempotency" | "jobs" | "outbox"
|
|
2501
|
+
> {
|
|
2031
2502
|
return {
|
|
2032
2503
|
todoAttachments: createDrizzleTodoAttachmentRepository(db),
|
|
2033
2504
|
todos: createDrizzleTodoRepository(db),
|
|
@@ -2080,7 +2551,7 @@ export function createDrizzleAuditLog(
|
|
|
2080
2551
|
};
|
|
2081
2552
|
}
|
|
2082
2553
|
`,
|
|
2083
|
-
productionDbBootstrap: `import { createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
|
|
2554
|
+
productionDbBootstrap: `import { createDrizzleTursoIdempotencySetupStatements, createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
|
|
2084
2555
|
import type { Client } from "@libsql/client";
|
|
2085
2556
|
|
|
2086
2557
|
type BootstrapOptions = {
|
|
@@ -2174,6 +2645,7 @@ const setupStatements = [
|
|
|
2174
2645
|
"CREATE INDEX IF NOT EXISTS audit_log_request_idx ON audit_log (request_id)",
|
|
2175
2646
|
"CREATE INDEX IF NOT EXISTS audit_log_resource_idx ON audit_log (resource_type, resource_id)",
|
|
2176
2647
|
"CREATE INDEX IF NOT EXISTS audit_log_tenant_idx ON audit_log (tenant_id)",
|
|
2648
|
+
...createDrizzleTursoIdempotencySetupStatements(),
|
|
2177
2649
|
...createDrizzleTursoOutboxSetupStatements(),
|
|
2178
2650
|
];
|
|
2179
2651
|
|
|
@@ -2200,6 +2672,7 @@ const resetTables = [
|
|
|
2200
2672
|
"todo_attachments",
|
|
2201
2673
|
"todos",
|
|
2202
2674
|
"audit_log",
|
|
2675
|
+
"idempotency_records",
|
|
2203
2676
|
"outbox_messages",
|
|
2204
2677
|
] as const;
|
|
2205
2678
|
|
|
@@ -2238,7 +2711,6 @@ export async function resetStarterDatabase(client: Client): Promise<void> {
|
|
|
2238
2711
|
}
|
|
2239
2712
|
`,
|
|
2240
2713
|
productionDbProvider: `import { registerListeners } from "@beignet/core/events";
|
|
2241
|
-
import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
|
|
2242
2714
|
import { createInlineJobDispatcher } from "@beignet/core/jobs";
|
|
2243
2715
|
import { createMemoryMailer } from "@beignet/core/mail";
|
|
2244
2716
|
import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
|
|
@@ -2247,19 +2719,15 @@ import {
|
|
|
2247
2719
|
createOutboxJobDispatcher,
|
|
2248
2720
|
} from "@beignet/core/outbox";
|
|
2249
2721
|
import {
|
|
2722
|
+
createInstrumentedAuditLog,
|
|
2250
2723
|
createSystemActor,
|
|
2251
|
-
createTenant,
|
|
2252
2724
|
} from "@beignet/core/ports";
|
|
2253
2725
|
import {
|
|
2254
2726
|
createProvider,
|
|
2255
2727
|
createProviderInstrumentation,
|
|
2256
2728
|
} from "@beignet/core/providers";
|
|
2257
2729
|
import {
|
|
2258
|
-
|
|
2259
|
-
createDevtoolsTraceContext,
|
|
2260
|
-
type DevtoolsPort,
|
|
2261
|
-
} from "@beignet/devtools";
|
|
2262
|
-
import {
|
|
2730
|
+
createDrizzleTursoIdempotencyPort,
|
|
2263
2731
|
createDrizzleTursoOutboxPort,
|
|
2264
2732
|
createDrizzleTursoUnitOfWork,
|
|
2265
2733
|
type DbPort,
|
|
@@ -2269,42 +2737,30 @@ import type { AppContext } from "@/app-context";
|
|
|
2269
2737
|
import { todoListeners } from "@/features/todos/listeners";
|
|
2270
2738
|
import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
|
|
2271
2739
|
import type { AppPorts } from "@/ports";
|
|
2740
|
+
import type { AppServiceContextInput } from "@/server/context";
|
|
2272
2741
|
import { ensureStarterDatabase } from "./bootstrap";
|
|
2273
2742
|
import { createRepositories } from "./repositories";
|
|
2274
2743
|
import type * as schema from "./schema";
|
|
2275
2744
|
|
|
2276
|
-
export const starterDatabaseProvider = createProvider
|
|
2745
|
+
export const starterDatabaseProvider = createProvider<
|
|
2746
|
+
{ db: DbPort<typeof schema> },
|
|
2747
|
+
AppContext,
|
|
2748
|
+
AppServiceContextInput
|
|
2749
|
+
>()({
|
|
2277
2750
|
name: "starter-database",
|
|
2278
2751
|
|
|
2279
|
-
async setup({ ports }) {
|
|
2280
|
-
const dbPort =
|
|
2281
|
-
const devtools = (ports as { devtools?: DevtoolsPort }).devtools;
|
|
2752
|
+
async setup({ ports, createServiceContext }) {
|
|
2753
|
+
const dbPort = ports.db;
|
|
2282
2754
|
if (!dbPort) {
|
|
2283
2755
|
throw new Error(
|
|
2284
2756
|
"starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
|
|
2285
2757
|
);
|
|
2286
2758
|
}
|
|
2287
2759
|
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
const actor = createSystemActor("starter-background");
|
|
2295
|
-
const tenant = createTenant("tenant_default");
|
|
2296
|
-
const traceContext = createDevtoolsTraceContext();
|
|
2297
|
-
|
|
2298
|
-
return {
|
|
2299
|
-
actor,
|
|
2300
|
-
auth: null,
|
|
2301
|
-
gate: currentPorts.gate.bind({ actor, auth: null }),
|
|
2302
|
-
requestId: crypto.randomUUID(),
|
|
2303
|
-
...traceContext,
|
|
2304
|
-
ports: currentPorts,
|
|
2305
|
-
tenant,
|
|
2306
|
-
};
|
|
2307
|
-
};
|
|
2760
|
+
const createBackgroundContext = () =>
|
|
2761
|
+
createServiceContext({
|
|
2762
|
+
actor: createSystemActor("starter-background"),
|
|
2763
|
+
});
|
|
2308
2764
|
|
|
2309
2765
|
const mailInstrumentation = createProviderInstrumentation(ports, {
|
|
2310
2766
|
providerName: "memory-mailer",
|
|
@@ -2338,12 +2794,12 @@ export const starterDatabaseProvider = createProvider({
|
|
|
2338
2794
|
});
|
|
2339
2795
|
},
|
|
2340
2796
|
});
|
|
2341
|
-
const audit =
|
|
2797
|
+
const audit = createInstrumentedAuditLog({
|
|
2342
2798
|
audit: createDrizzleAuditLog(dbPort.db),
|
|
2343
|
-
|
|
2799
|
+
instrumentation: ports,
|
|
2344
2800
|
});
|
|
2345
2801
|
const repositories = createRepositories(dbPort.db);
|
|
2346
|
-
const idempotency =
|
|
2802
|
+
const idempotency = createDrizzleTursoIdempotencyPort(dbPort.db);
|
|
2347
2803
|
const outbox = createDrizzleTursoOutboxPort(dbPort.db);
|
|
2348
2804
|
const unregisterListeners = registerListeners(eventBus, todoListeners, {
|
|
2349
2805
|
ctx: createBackgroundContext,
|
|
@@ -2355,34 +2811,52 @@ export const starterDatabaseProvider = createProvider({
|
|
|
2355
2811
|
},
|
|
2356
2812
|
});
|
|
2357
2813
|
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
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({
|
|
2374
2843
|
audit: createDrizzleAuditLog(tx),
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
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() {
|
|
2384
2859
|
await ensureStarterDatabase(dbPort.client);
|
|
2385
|
-
currentPorts = ctx.ports as AppContext["ports"];
|
|
2386
2860
|
},
|
|
2387
2861
|
stop() {
|
|
2388
2862
|
unregisterListeners();
|
|
@@ -2391,14 +2865,16 @@ export const starterDatabaseProvider = createProvider({
|
|
|
2391
2865
|
},
|
|
2392
2866
|
});
|
|
2393
2867
|
`,
|
|
2394
|
-
productionContractsTodos: `import {
|
|
2868
|
+
productionContractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
|
|
2395
2869
|
import { z } from "zod";
|
|
2396
2870
|
import { errors } from "@/features/shared/errors";
|
|
2397
2871
|
import {
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2872
|
+
CreateTodoInputSchema,
|
|
2873
|
+
GetTodoInputSchema,
|
|
2874
|
+
ListTodosInputSchema,
|
|
2875
|
+
ListTodosOutputSchema,
|
|
2876
|
+
TodoSchema,
|
|
2877
|
+
} from "@/features/todos/schemas";
|
|
2402
2878
|
|
|
2403
2879
|
const ErrorResponseSchema = z.object({
|
|
2404
2880
|
code: z.string(),
|
|
@@ -2406,7 +2882,7 @@ const ErrorResponseSchema = z.object({
|
|
|
2406
2882
|
requestId: z.string().optional(),
|
|
2407
2883
|
});
|
|
2408
2884
|
|
|
2409
|
-
const todos =
|
|
2885
|
+
const todos = defineContractGroup()
|
|
2410
2886
|
.namespace("todos")
|
|
2411
2887
|
.errors({ Unauthorized: errors.Unauthorized })
|
|
2412
2888
|
.responses({
|
|
@@ -2415,28 +2891,29 @@ const todos = createContractGroup()
|
|
|
2415
2891
|
|
|
2416
2892
|
export const listTodos = todos
|
|
2417
2893
|
.get("/api/todos")
|
|
2418
|
-
.query(
|
|
2894
|
+
.query(ListTodosInputSchema)
|
|
2419
2895
|
.responses({
|
|
2420
|
-
200:
|
|
2896
|
+
200: ListTodosOutputSchema,
|
|
2421
2897
|
});
|
|
2422
2898
|
|
|
2423
2899
|
export const createTodo = todos
|
|
2424
2900
|
.post("/api/todos")
|
|
2425
|
-
.body(
|
|
2901
|
+
.body(CreateTodoInputSchema)
|
|
2426
2902
|
.errors({ Forbidden: errors.Forbidden })
|
|
2427
2903
|
.responses({
|
|
2428
|
-
201:
|
|
2904
|
+
201: TodoSchema,
|
|
2429
2905
|
});
|
|
2430
2906
|
|
|
2431
2907
|
export const getTodo = todos
|
|
2432
2908
|
.get("/api/todos/:id")
|
|
2433
|
-
.pathParams(
|
|
2909
|
+
.pathParams(GetTodoInputSchema)
|
|
2434
2910
|
.errors({ TodoNotFound: errors.TodoNotFound })
|
|
2435
2911
|
.responses({
|
|
2436
|
-
200:
|
|
2912
|
+
200: TodoSchema,
|
|
2437
2913
|
});
|
|
2438
2914
|
`,
|
|
2439
|
-
productionTodoRoutes: `import {
|
|
2915
|
+
productionTodoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2916
|
+
import { defineRouteGroup } from "@beignet/next";
|
|
2440
2917
|
import type { AppContext } from "@/app-context";
|
|
2441
2918
|
import { createTodo, getTodo, listTodos } from "@/features/todos/contracts";
|
|
2442
2919
|
import {
|
|
@@ -2448,31 +2925,14 @@ import {
|
|
|
2448
2925
|
export const todoRoutes = defineRouteGroup<AppContext>({
|
|
2449
2926
|
name: "todos",
|
|
2450
2927
|
routes: [
|
|
2451
|
-
{
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
status: 200,
|
|
2455
|
-
body: await listTodosUseCase.run({ ctx, input: query }),
|
|
2456
|
-
}),
|
|
2457
|
-
},
|
|
2458
|
-
{
|
|
2459
|
-
contract: createTodo,
|
|
2460
|
-
handle: async ({ ctx, body }) => ({
|
|
2461
|
-
status: 201,
|
|
2462
|
-
body: await createTodoUseCase.run({ ctx, input: body }),
|
|
2463
|
-
}),
|
|
2464
|
-
},
|
|
2465
|
-
{
|
|
2466
|
-
contract: getTodo,
|
|
2467
|
-
handle: async ({ ctx, path }) => ({
|
|
2468
|
-
status: 200,
|
|
2469
|
-
body: await getTodoUseCase.run({ ctx, input: path }),
|
|
2470
|
-
}),
|
|
2471
|
-
},
|
|
2928
|
+
{ contract: listTodos, useCase: listTodosUseCase },
|
|
2929
|
+
{ contract: createTodo, useCase: createTodoUseCase },
|
|
2930
|
+
{ contract: getTodo, useCase: getTodoUseCase },
|
|
2472
2931
|
],
|
|
2473
2932
|
});
|
|
2474
2933
|
`,
|
|
2475
|
-
productionServerRoutes: `import {
|
|
2934
|
+
productionServerRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2935
|
+
import { contractsFromRoutes, defineRoutes } from "@beignet/next";
|
|
2476
2936
|
import type { AppContext } from "@/app-context";
|
|
2477
2937
|
import { todoRoutes } from "@/features/todos/routes";
|
|
2478
2938
|
|
|
@@ -2481,16 +2941,72 @@ export const routes = defineRoutes<AppContext>([
|
|
|
2481
2941
|
]);
|
|
2482
2942
|
export const contracts = contractsFromRoutes(routes);
|
|
2483
2943
|
`,
|
|
2484
|
-
|
|
2485
|
-
import { createNextServer } from "@beignet/next";
|
|
2944
|
+
productionServerContext: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2486
2945
|
import {
|
|
2946
|
+
type ActivityActor,
|
|
2487
2947
|
createAnonymousActor,
|
|
2948
|
+
createServiceActor,
|
|
2488
2949
|
createTenant,
|
|
2489
2950
|
createUserActor,
|
|
2490
2951
|
} from "@beignet/core/ports";
|
|
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 }) => {
|
|
2969
|
+
const auth = await ports.auth.getSession(req);
|
|
2970
|
+
const tenantId = req.headers.get("x-tenant-id") || undefined;
|
|
2971
|
+
|
|
2972
|
+
return {
|
|
2973
|
+
requestId,
|
|
2974
|
+
actor: auth
|
|
2975
|
+
? createUserActor(auth.user.id, { displayName: auth.user.name })
|
|
2976
|
+
: createAnonymousActor(),
|
|
2977
|
+
auth,
|
|
2978
|
+
...trace,
|
|
2979
|
+
ports,
|
|
2980
|
+
tenant: tenantId ? createTenant(tenantId) : undefined,
|
|
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";
|
|
2491
3006
|
import type { AppContext } from "@/app-context";
|
|
2492
3007
|
import { appPorts } from "@/infra/app-ports";
|
|
2493
3008
|
import { env } from "@/lib/env";
|
|
3009
|
+
import { appContext } from "./context";
|
|
2494
3010
|
import { providers } from "./providers";
|
|
2495
3011
|
import { routes } from "./routes";
|
|
2496
3012
|
|
|
@@ -2503,29 +3019,8 @@ export const server = await createNextServer({
|
|
|
2503
3019
|
DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
|
|
2504
3020
|
},
|
|
2505
3021
|
},
|
|
2506
|
-
hooks: [
|
|
2507
|
-
|
|
2508
|
-
requestIdHeader: "x-request-id",
|
|
2509
|
-
}),
|
|
2510
|
-
],
|
|
2511
|
-
createContext: async ({ req, ports }) => {
|
|
2512
|
-
const auth = await ports.auth.getSession(req);
|
|
2513
|
-
const tenantId = req.headers.get("x-tenant-id") || undefined;
|
|
2514
|
-
const context = {
|
|
2515
|
-
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
|
|
2516
|
-
actor: auth
|
|
2517
|
-
? createUserActor(auth.user.id, { displayName: auth.user.name })
|
|
2518
|
-
: createAnonymousActor(),
|
|
2519
|
-
auth,
|
|
2520
|
-
ports,
|
|
2521
|
-
tenant: tenantId ? createTenant(tenantId) : undefined,
|
|
2522
|
-
};
|
|
2523
|
-
|
|
2524
|
-
return {
|
|
2525
|
-
...context,
|
|
2526
|
-
gate: ports.gate.bind(context),
|
|
2527
|
-
};
|
|
2528
|
-
},
|
|
3022
|
+
hooks: [createIdempotencyHooks<AppContext>()],
|
|
3023
|
+
context: appContext,
|
|
2529
3024
|
routes,
|
|
2530
3025
|
mapUnhandledError: ({ err, ctx }) => {
|
|
2531
3026
|
ctx?.ports.logger.error("Unhandled API error", {
|
|
@@ -2545,16 +3040,13 @@ export const server = await createNextServer({
|
|
|
2545
3040
|
},
|
|
2546
3041
|
});
|
|
2547
3042
|
`,
|
|
2548
|
-
productionServerWithDrizzleTurso: `import {
|
|
3043
|
+
productionServerWithDrizzleTurso: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2549
3044
|
import { createNextServer } from "@beignet/next";
|
|
2550
|
-
import {
|
|
2551
|
-
createAnonymousActor,
|
|
2552
|
-
createTenant,
|
|
2553
|
-
createUserActor,
|
|
2554
|
-
} from "@beignet/core/ports";
|
|
3045
|
+
import { createIdempotencyHooks } from "@beignet/core/server";
|
|
2555
3046
|
import type { AppContext } from "@/app-context";
|
|
2556
3047
|
import { appPorts } from "@/infra/app-ports";
|
|
2557
3048
|
import { env } from "@/lib/env";
|
|
3049
|
+
import { appContext } from "./context";
|
|
2558
3050
|
import { providers } from "./providers";
|
|
2559
3051
|
import { routes } from "./routes";
|
|
2560
3052
|
|
|
@@ -2567,30 +3059,8 @@ export const server = await createNextServer({
|
|
|
2567
3059
|
DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
|
|
2568
3060
|
},
|
|
2569
3061
|
},
|
|
2570
|
-
hooks: [
|
|
2571
|
-
|
|
2572
|
-
requestIdHeader: "x-request-id",
|
|
2573
|
-
}),
|
|
2574
|
-
],
|
|
2575
|
-
createContext: async ({ req, ports }) => {
|
|
2576
|
-
const auth = await ports.auth.getSession(req);
|
|
2577
|
-
const tenantId = req.headers.get("x-tenant-id") || undefined;
|
|
2578
|
-
|
|
2579
|
-
const context = {
|
|
2580
|
-
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
|
|
2581
|
-
actor: auth
|
|
2582
|
-
? createUserActor(auth.user.id, { displayName: auth.user.name })
|
|
2583
|
-
: createAnonymousActor(),
|
|
2584
|
-
auth,
|
|
2585
|
-
ports,
|
|
2586
|
-
tenant: tenantId ? createTenant(tenantId) : undefined,
|
|
2587
|
-
};
|
|
2588
|
-
|
|
2589
|
-
return {
|
|
2590
|
-
...context,
|
|
2591
|
-
gate: ports.gate.bind(context),
|
|
2592
|
-
};
|
|
2593
|
-
},
|
|
3062
|
+
hooks: [createIdempotencyHooks<AppContext>()],
|
|
3063
|
+
context: appContext,
|
|
2594
3064
|
routes,
|
|
2595
3065
|
mapUnhandledError: ({ err, ctx }) => {
|
|
2596
3066
|
ctx?.ports.logger.error("Unhandled API error", {
|
|
@@ -2739,6 +3209,55 @@ try {
|
|
|
2739
3209
|
} finally {
|
|
2740
3210
|
client.close();
|
|
2741
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
|
+
}
|
|
2742
3261
|
`,
|
|
2743
3262
|
productionAuditHelper: `import type { AuditLogEntryInput } from "@beignet/core/ports";
|
|
2744
3263
|
import type { AppContext } from "@/app-context";
|
|
@@ -2771,14 +3290,12 @@ export const TodoCreated = defineEvent("todos.created", {
|
|
|
2771
3290
|
|
|
2772
3291
|
export const todoEvents = [TodoCreated] as const;
|
|
2773
3292
|
`,
|
|
2774
|
-
productionTodoJobs: `import {
|
|
3293
|
+
productionTodoJobs: `import { retry } from "@beignet/core/jobs";
|
|
2775
3294
|
import { z } from "zod";
|
|
2776
|
-
import type { AppContext } from "@/app-context";
|
|
2777
3295
|
import { auditEntry } from "@/lib/audit";
|
|
3296
|
+
import { defineJob } from "@/lib/jobs";
|
|
2778
3297
|
|
|
2779
|
-
const
|
|
2780
|
-
|
|
2781
|
-
export const LogTodoCreatedJob = jobs.defineJob("todos.log-created", {
|
|
3298
|
+
export const LogTodoCreatedJob = defineJob("todos.log-created", {
|
|
2782
3299
|
payload: z.object({
|
|
2783
3300
|
todoId: z.string().uuid(),
|
|
2784
3301
|
title: z.string(),
|
|
@@ -2804,16 +3321,11 @@ export const LogTodoCreatedJob = jobs.defineJob("todos.log-created", {
|
|
|
2804
3321
|
|
|
2805
3322
|
export const todoJobs = [LogTodoCreatedJob] as const;
|
|
2806
3323
|
`,
|
|
2807
|
-
productionTodoNotifications: `import {
|
|
2808
|
-
createNotificationHandlers,
|
|
2809
|
-
defineMailNotificationChannel,
|
|
2810
|
-
} from "@beignet/core/notifications";
|
|
3324
|
+
productionTodoNotifications: `import { defineMailNotificationChannel } from "@beignet/core/notifications";
|
|
2811
3325
|
import { z } from "zod";
|
|
2812
|
-
import
|
|
3326
|
+
import { defineNotification } from "@/lib/notifications";
|
|
2813
3327
|
|
|
2814
|
-
const
|
|
2815
|
-
|
|
2816
|
-
export const TodoCreatedNotification = notifications.defineNotification(
|
|
3328
|
+
export const TodoCreatedNotification = defineNotification(
|
|
2817
3329
|
"todos.created",
|
|
2818
3330
|
{
|
|
2819
3331
|
payload: z.object({
|
|
@@ -2830,16 +3342,13 @@ export const TodoCreatedNotification = notifications.defineNotification(
|
|
|
2830
3342
|
},
|
|
2831
3343
|
);
|
|
2832
3344
|
`,
|
|
2833
|
-
productionTodoListeners: `import {
|
|
2834
|
-
import type { AppContext } from "@/app-context";
|
|
2835
|
-
import { TodoCreated } from "@/features/todos/domain/events";
|
|
3345
|
+
productionTodoListeners: `import { TodoCreated } from "@/features/todos/domain/events";
|
|
2836
3346
|
import { LogTodoCreatedJob } from "@/features/todos/jobs";
|
|
2837
3347
|
import { TodoCreatedNotification } from "@/features/todos/notifications";
|
|
2838
3348
|
import { auditEntry } from "@/lib/audit";
|
|
3349
|
+
import { defineListener } from "@/lib/listeners";
|
|
2839
3350
|
|
|
2840
|
-
const
|
|
2841
|
-
|
|
2842
|
-
export const enqueueTodoCreatedWork = listeners.defineListener(TodoCreated, {
|
|
3351
|
+
export const enqueueTodoCreatedWork = defineListener(TodoCreated, {
|
|
2843
3352
|
name: "todos.enqueue-created-work",
|
|
2844
3353
|
async handle({ ctx, payload }) {
|
|
2845
3354
|
await ctx.ports.jobs.dispatch(LogTodoCreatedJob, payload);
|
|
@@ -2861,14 +3370,11 @@ export const enqueueTodoCreatedWork = listeners.defineListener(TodoCreated, {
|
|
|
2861
3370
|
export const todoListeners = [enqueueTodoCreatedWork] as const;
|
|
2862
3371
|
`,
|
|
2863
3372
|
productionTodoSchedules: `import { normalizeOffsetPage } from "@beignet/core/pagination";
|
|
2864
|
-
import { createScheduleHandlers } from "@beignet/core/schedules";
|
|
2865
3373
|
import { z } from "zod";
|
|
2866
|
-
import type { AppContext } from "@/app-context";
|
|
2867
3374
|
import { auditEntry } from "@/lib/audit";
|
|
3375
|
+
import { defineSchedule } from "@/lib/schedules";
|
|
2868
3376
|
|
|
2869
|
-
const
|
|
2870
|
-
|
|
2871
|
-
export const LogDailyTodoSummarySchedule = schedules.defineSchedule(
|
|
3377
|
+
export const LogDailyTodoSummarySchedule = defineSchedule(
|
|
2872
3378
|
"todos.log-daily-summary",
|
|
2873
3379
|
{
|
|
2874
3380
|
cron: "0 9 * * *",
|
|
@@ -2912,6 +3418,23 @@ export const LogDailyTodoSummarySchedule = schedules.defineSchedule(
|
|
|
2912
3418
|
);
|
|
2913
3419
|
|
|
2914
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
|
+
}
|
|
2915
3438
|
`,
|
|
2916
3439
|
productionTodoUploads: `import { defineUpload, defineUploads } from "@beignet/core/uploads";
|
|
2917
3440
|
import { z } from "zod";
|
|
@@ -2993,13 +3516,26 @@ export const todoUploads = defineUploads({
|
|
|
2993
3516
|
});
|
|
2994
3517
|
`,
|
|
2995
3518
|
productionOutbox: `import { defineOutboxRegistry } from "@beignet/core/outbox";
|
|
3519
|
+
import { createServiceActor } from "@beignet/core/ports";
|
|
3520
|
+
import type { AppContext } from "@/app-context";
|
|
2996
3521
|
import { todoEvents } from "@/features/todos/domain/events";
|
|
2997
3522
|
import { todoJobs } from "@/features/todos/jobs";
|
|
3523
|
+
import { server } from "./index.js";
|
|
2998
3524
|
|
|
2999
3525
|
export const outboxRegistry = defineOutboxRegistry({
|
|
3000
3526
|
events: todoEvents,
|
|
3001
3527
|
jobs: todoJobs,
|
|
3002
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();
|
|
3538
|
+
}
|
|
3003
3539
|
`,
|
|
3004
3540
|
productionOutboxDrainRoute: `import { createOutboxDrainRoute } from "@beignet/next";
|
|
3005
3541
|
import { env } from "@/lib/env";
|
|
@@ -3014,111 +3550,23 @@ export const { GET, POST } = createOutboxDrainRoute({
|
|
|
3014
3550
|
secret: env.CRON_SECRET,
|
|
3015
3551
|
});
|
|
3016
3552
|
`,
|
|
3017
|
-
productionScheduleRoute: `import {
|
|
3018
|
-
import type { AppContext } from "@/app-context";
|
|
3019
|
-
import { LogDailyTodoSummarySchedule } from "@/features/todos/schedules";
|
|
3553
|
+
productionScheduleRoute: `import { createScheduleRoute } from "@beignet/next";
|
|
3020
3554
|
import { env } from "@/lib/env";
|
|
3021
3555
|
import { server } from "@/server";
|
|
3556
|
+
import { schedules } from "@/server/schedules";
|
|
3022
3557
|
|
|
3023
|
-
|
|
3024
|
-
if (!env.CRON_SECRET) {
|
|
3025
|
-
return Response.json(
|
|
3026
|
-
{
|
|
3027
|
-
ok: false,
|
|
3028
|
-
error: "CRON_SECRET is not configured.",
|
|
3029
|
-
scheduleName: LogDailyTodoSummarySchedule.name,
|
|
3030
|
-
},
|
|
3031
|
-
{ status: 500 },
|
|
3032
|
-
);
|
|
3033
|
-
}
|
|
3034
|
-
|
|
3035
|
-
if (request.headers.get("authorization") !== \`Bearer \${env.CRON_SECRET}\`) {
|
|
3036
|
-
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
3037
|
-
}
|
|
3038
|
-
|
|
3039
|
-
const ctx = await server.createContextFromNext();
|
|
3040
|
-
const runner = createInlineScheduleRunner<AppContext>({
|
|
3041
|
-
ctx,
|
|
3042
|
-
onStart({ run, schedule }) {
|
|
3043
|
-
ctx.ports.devtools.record({
|
|
3044
|
-
type: "schedule",
|
|
3045
|
-
watcher: "schedules",
|
|
3046
|
-
requestId: ctx.requestId,
|
|
3047
|
-
traceId: ctx.traceId,
|
|
3048
|
-
scheduleName: schedule.name,
|
|
3049
|
-
status: "started",
|
|
3050
|
-
cron: schedule.cron,
|
|
3051
|
-
timezone: schedule.timezone,
|
|
3052
|
-
details: {
|
|
3053
|
-
source: run.source,
|
|
3054
|
-
scheduledAt: run.scheduledAt?.toISOString(),
|
|
3055
|
-
},
|
|
3056
|
-
});
|
|
3057
|
-
},
|
|
3058
|
-
onSuccess({ run, schedule }) {
|
|
3059
|
-
ctx.ports.devtools.record({
|
|
3060
|
-
type: "schedule",
|
|
3061
|
-
watcher: "schedules",
|
|
3062
|
-
requestId: ctx.requestId,
|
|
3063
|
-
traceId: ctx.traceId,
|
|
3064
|
-
scheduleName: schedule.name,
|
|
3065
|
-
status: "completed",
|
|
3066
|
-
cron: schedule.cron,
|
|
3067
|
-
timezone: schedule.timezone,
|
|
3068
|
-
details: {
|
|
3069
|
-
source: run.source,
|
|
3070
|
-
scheduledAt: run.scheduledAt?.toISOString(),
|
|
3071
|
-
},
|
|
3072
|
-
});
|
|
3073
|
-
},
|
|
3074
|
-
onError({ error, run, schedule }) {
|
|
3075
|
-
ctx.ports.devtools.record({
|
|
3076
|
-
type: "schedule",
|
|
3077
|
-
watcher: "schedules",
|
|
3078
|
-
requestId: ctx.requestId,
|
|
3079
|
-
traceId: ctx.traceId,
|
|
3080
|
-
scheduleName: schedule.name,
|
|
3081
|
-
status: "failed",
|
|
3082
|
-
cron: schedule.cron,
|
|
3083
|
-
timezone: schedule.timezone,
|
|
3084
|
-
details: {
|
|
3085
|
-
error,
|
|
3086
|
-
source: run.source,
|
|
3087
|
-
scheduledAt: run.scheduledAt?.toISOString(),
|
|
3088
|
-
},
|
|
3089
|
-
});
|
|
3090
|
-
ctx.ports.logger.error("Daily todo summary schedule failed", {
|
|
3091
|
-
error,
|
|
3092
|
-
scheduleName: schedule.name,
|
|
3093
|
-
});
|
|
3094
|
-
},
|
|
3095
|
-
});
|
|
3096
|
-
|
|
3097
|
-
try {
|
|
3098
|
-
await runner.run(LogDailyTodoSummarySchedule, {
|
|
3099
|
-
source: "starter-cron-route",
|
|
3100
|
-
});
|
|
3101
|
-
} catch {
|
|
3102
|
-
return Response.json(
|
|
3103
|
-
{
|
|
3104
|
-
ok: false,
|
|
3105
|
-
error: "Schedule failed",
|
|
3106
|
-
scheduleName: LogDailyTodoSummarySchedule.name,
|
|
3107
|
-
},
|
|
3108
|
-
{ status: 500 },
|
|
3109
|
-
);
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3112
|
-
return Response.json({
|
|
3113
|
-
ok: true,
|
|
3114
|
-
scheduleName: LogDailyTodoSummarySchedule.name,
|
|
3115
|
-
});
|
|
3116
|
-
}
|
|
3558
|
+
export const runtime = "nodejs";
|
|
3117
3559
|
|
|
3118
|
-
export const GET =
|
|
3119
|
-
|
|
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",
|
|
3566
|
+
});
|
|
3120
3567
|
`,
|
|
3121
|
-
productionUploadsRoute: `import {
|
|
3568
|
+
productionUploadsRoute: `import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
|
|
3569
|
+
import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
|
|
3122
3570
|
import { createUploadRoute } from "@beignet/next";
|
|
3123
3571
|
import type { AppContext } from "@/app-context";
|
|
3124
3572
|
import { todoUploads } from "@/features/todos/uploads";
|
|
@@ -3128,17 +3576,10 @@ const uploadRouter = createUploadRouter<AppContext>({
|
|
|
3128
3576
|
uploads: uploadsFromRegistry(todoUploads),
|
|
3129
3577
|
ctx: () => server.createContextFromNext(),
|
|
3130
3578
|
storage: server.ports.storage,
|
|
3131
|
-
instrumentation: server.ports
|
|
3579
|
+
instrumentation: resolveProviderInstrumentationPort(server.ports),
|
|
3132
3580
|
});
|
|
3133
3581
|
|
|
3134
3582
|
export const { POST } = createUploadRoute(uploadRouter);
|
|
3135
|
-
`,
|
|
3136
|
-
productionUploadClient: `import { createUploadClient } from "@beignet/core/uploads/client";
|
|
3137
|
-
import type { todoUploads } from "@/features/todos/uploads";
|
|
3138
|
-
|
|
3139
|
-
export const uploads = createUploadClient<typeof todoUploads>({
|
|
3140
|
-
baseUrl: "/api/uploads",
|
|
3141
|
-
});
|
|
3142
3583
|
`,
|
|
3143
3584
|
};
|
|
3144
3585
|
|
|
@@ -3158,6 +3599,73 @@ function productionProviderPortFields(ctx: TemplateContext): string[] {
|
|
|
3158
3599
|
return fields;
|
|
3159
3600
|
}
|
|
3160
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
|
+
|
|
3161
3669
|
function productionPorts(ctx: TemplateContext): string {
|
|
3162
3670
|
const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
|
|
3163
3671
|
const imports = [
|
|
@@ -3186,6 +3694,7 @@ import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/
|
|
|
3186
3694
|
export type AppTransactionPorts = {
|
|
3187
3695
|
audit: AuditLogPort;
|
|
3188
3696
|
events: import("@beignet/core/ports").BufferedDomainEventRecorder;
|
|
3697
|
+
idempotency: IdempotencyPort;
|
|
3189
3698
|
jobs: JobDispatcherPort;
|
|
3190
3699
|
outbox: OutboxPort;
|
|
3191
3700
|
todoAttachments: TodoAttachmentRepository;
|
|
@@ -3238,14 +3747,10 @@ import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
|
|
|
3238
3747
|
import type { AuthPort } from "./auth";
|
|
3239
3748
|
import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
|
|
3240
3749
|
|
|
3241
|
-
export type AppDatabasePort = {
|
|
3242
|
-
db: unknown;
|
|
3243
|
-
client: unknown;
|
|
3244
|
-
};
|
|
3245
|
-
|
|
3246
3750
|
export type AppTransactionPorts = {
|
|
3247
3751
|
audit: AuditLogPort;
|
|
3248
3752
|
events: import("@beignet/core/ports").BufferedDomainEventRecorder;
|
|
3753
|
+
idempotency: IdempotencyPort;
|
|
3249
3754
|
jobs: JobDispatcherPort;
|
|
3250
3755
|
outbox: OutboxPort;
|
|
3251
3756
|
todoAttachments: TodoAttachmentRepository;
|
|
@@ -3257,7 +3762,6 @@ export type AppGate = BoundGate<[typeof todoPolicy]>;
|
|
|
3257
3762
|
export type AppPorts = {
|
|
3258
3763
|
audit: AuditLogPort;
|
|
3259
3764
|
auth: AuthPort;
|
|
3260
|
-
db: AppDatabasePort;
|
|
3261
3765
|
eventBus: EventBusPort;
|
|
3262
3766
|
gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
|
|
3263
3767
|
idempotency: IdempotencyPort;
|
|
@@ -3276,6 +3780,7 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
|
|
|
3276
3780
|
|
|
3277
3781
|
function productionServerProviders(ctx: TemplateContext): string {
|
|
3278
3782
|
const imports = [
|
|
3783
|
+
'import type { beignetServerOnly } from "@beignet/core/server-only";',
|
|
3279
3784
|
'import { createDevtoolsProvider } from "@beignet/devtools";',
|
|
3280
3785
|
hasIntegration(ctx, "better-auth")
|
|
3281
3786
|
? 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";'
|
|
@@ -3329,40 +3834,21 @@ function productionServerProviders(ctx: TemplateContext): string {
|
|
|
3329
3834
|
${declarations}
|
|
3330
3835
|
export const providers = [
|
|
3331
3836
|
${entries.join("\n")}
|
|
3332
|
-
];
|
|
3837
|
+
] as const;
|
|
3333
3838
|
`;
|
|
3334
3839
|
}
|
|
3335
3840
|
|
|
3336
|
-
function productionTodosTest(
|
|
3337
|
-
const portImports = [
|
|
3338
|
-
hasIntegration(ctx, "upstash-rate-limit")
|
|
3339
|
-
? "createMemoryRateLimiter"
|
|
3340
|
-
: undefined,
|
|
3341
|
-
"createMemoryStorage",
|
|
3342
|
-
"createNoopUnitOfWork",
|
|
3343
|
-
"createUserActor",
|
|
3344
|
-
].filter((item): item is string => Boolean(item));
|
|
3345
|
-
const extraPorts = [
|
|
3346
|
-
hasDrizzleTurso(ctx)
|
|
3347
|
-
? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
|
|
3348
|
-
: undefined,
|
|
3349
|
-
hasIntegration(ctx, "upstash-rate-limit")
|
|
3350
|
-
? "\t\t\trateLimit: createMemoryRateLimiter(),"
|
|
3351
|
-
: undefined,
|
|
3352
|
-
].filter((entry): entry is string => Boolean(entry));
|
|
3353
|
-
|
|
3841
|
+
function productionTodosTest(_ctx: TemplateContext): string {
|
|
3354
3842
|
return `import { describe, expect, it } from "bun:test";
|
|
3355
3843
|
import { createUseCaseTester } from "@beignet/core/application";
|
|
3356
|
-
import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
|
|
3357
|
-
import { createMemoryMailer } from "@beignet/core/mail";
|
|
3358
|
-
import { createMemoryNotificationPort } from "@beignet/core/notifications";
|
|
3359
|
-
import { createMemoryOutbox } from "@beignet/core/outbox";
|
|
3360
3844
|
import { offsetPageResult, type OffsetPage } from "@beignet/core/pagination";
|
|
3361
3845
|
import { createInMemoryDevtools } from "@beignet/devtools";
|
|
3362
|
-
import {
|
|
3363
|
-
import {
|
|
3846
|
+
import { createDomainEventRecorder } from "@beignet/core/ports";
|
|
3847
|
+
import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
|
|
3848
|
+
import { createTestUserActor } from "@beignet/core/ports/testing";
|
|
3364
3849
|
import type { AppContext } from "@/app-context";
|
|
3365
3850
|
import { appPorts } from "@/infra/app-ports";
|
|
3851
|
+
import type { AppTransactionPorts } from "@/ports";
|
|
3366
3852
|
import {
|
|
3367
3853
|
createTodoUseCase,
|
|
3368
3854
|
getTodoUseCase,
|
|
@@ -3427,14 +3913,6 @@ describe("todos resource", () => {
|
|
|
3427
3913
|
it("creates, gets, and lists todos", async () => {
|
|
3428
3914
|
const todos = createTestTodoRepository();
|
|
3429
3915
|
const todoAttachments = createTestTodoAttachmentRepository();
|
|
3430
|
-
const audit = createMemoryAuditLog();
|
|
3431
|
-
const eventBus = createInMemoryEventBus();
|
|
3432
|
-
const jobs = { dispatch: async () => undefined };
|
|
3433
|
-
const mailer = createMemoryMailer({
|
|
3434
|
-
defaultFrom: "Test <test@example.local>",
|
|
3435
|
-
});
|
|
3436
|
-
const notifications = createMemoryNotificationPort();
|
|
3437
|
-
const outbox = createMemoryOutbox();
|
|
3438
3916
|
const auth = {
|
|
3439
3917
|
user: {
|
|
3440
3918
|
id: "user_test",
|
|
@@ -3443,38 +3921,31 @@ describe("todos resource", () => {
|
|
|
3443
3921
|
},
|
|
3444
3922
|
session: { id: "session_test" },
|
|
3445
3923
|
};
|
|
3446
|
-
const
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
...appPorts,
|
|
3451
|
-
audit,
|
|
3452
|
-
eventBus,
|
|
3453
|
-
idempotency: createMemoryIdempotencyStore(),
|
|
3454
|
-
jobs,
|
|
3455
|
-
mailer,
|
|
3456
|
-
notifications,
|
|
3457
|
-
outbox,
|
|
3458
|
-
todoAttachments,
|
|
3459
|
-
todos,
|
|
3460
|
-
${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({
|
|
3461
|
-
audit,
|
|
3462
|
-
events: createDomainEventRecorder(),
|
|
3463
|
-
jobs,
|
|
3464
|
-
outbox,
|
|
3924
|
+
const testFixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
|
|
3925
|
+
base: appPorts,
|
|
3926
|
+
overrides: {
|
|
3927
|
+
gate: appPorts.gate,
|
|
3465
3928
|
todoAttachments,
|
|
3466
3929
|
todos,
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3930
|
+
devtools: createInMemoryDevtools(),
|
|
3931
|
+
},
|
|
3932
|
+
transaction: {
|
|
3933
|
+
ports: (ports) => ({
|
|
3934
|
+
...ports,
|
|
3935
|
+
events: createDomainEventRecorder(),
|
|
3936
|
+
todoAttachments,
|
|
3937
|
+
todos,
|
|
3938
|
+
}),
|
|
3939
|
+
},
|
|
3940
|
+
});
|
|
3941
|
+
const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
|
|
3942
|
+
ports: testFixture.ports,
|
|
3943
|
+
actor: createTestUserActor(auth.user.id, {
|
|
3944
|
+
displayName: auth.user.name,
|
|
3945
|
+
}),
|
|
3474
3946
|
auth,
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
}));
|
|
3947
|
+
});
|
|
3948
|
+
const tester = createUseCaseTester<AppContext>(createTestContext);
|
|
3478
3949
|
|
|
3479
3950
|
const ctx = await tester.ctx();
|
|
3480
3951
|
const created = await tester.run(
|
|
@@ -3546,9 +4017,8 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
3546
4017
|
path: "app/api/uploads/[uploadName]/[action]/route.ts",
|
|
3547
4018
|
content: files.productionUploadsRoute,
|
|
3548
4019
|
},
|
|
3549
|
-
{ path: "client/
|
|
3550
|
-
{ path: "client/
|
|
3551
|
-
{ path: "client/uploads.ts", content: files.productionUploadClient },
|
|
4020
|
+
{ path: "client/index.ts", content: clientIndex(ctx) },
|
|
4021
|
+
{ path: "client/forms.ts", content: files.formsClient },
|
|
3552
4022
|
{
|
|
3553
4023
|
path: "features/todos/contracts.ts",
|
|
3554
4024
|
content: files.productionContractsTodos,
|
|
@@ -3559,7 +4029,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
3559
4029
|
{
|
|
3560
4030
|
path: "infra/app-ports.ts",
|
|
3561
4031
|
content: usesDrizzleTurso
|
|
3562
|
-
?
|
|
4032
|
+
? productionInfrastructurePortsWithDrizzleTurso(ctx)
|
|
3563
4033
|
: files.productionInfrastructurePorts,
|
|
3564
4034
|
},
|
|
3565
4035
|
{ path: "lib/env.ts", content: files.productionEnv },
|
|
@@ -3609,7 +4079,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
3609
4079
|
},
|
|
3610
4080
|
{
|
|
3611
4081
|
path: "features/shared/errors.ts",
|
|
3612
|
-
content: files.
|
|
4082
|
+
content: files.productionSharedErrors,
|
|
3613
4083
|
},
|
|
3614
4084
|
{
|
|
3615
4085
|
path: "server/index.ts",
|
|
@@ -3617,13 +4087,22 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
3617
4087
|
? files.productionServerWithDrizzleTurso
|
|
3618
4088
|
: files.productionServer,
|
|
3619
4089
|
},
|
|
4090
|
+
{ path: "server/context.ts", content: files.productionServerContext },
|
|
3620
4091
|
{ path: "server/routes.ts", content: files.productionServerRoutes },
|
|
3621
4092
|
{ path: "server/outbox.ts", content: files.productionOutbox },
|
|
4093
|
+
{ path: "server/schedules.ts", content: files.productionSchedules },
|
|
3622
4094
|
{
|
|
3623
4095
|
path: "server/providers.ts",
|
|
3624
4096
|
content: productionServerProviders(ctx),
|
|
3625
4097
|
},
|
|
3626
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
|
+
},
|
|
3627
4106
|
{
|
|
3628
4107
|
path: "features/todos/use-cases/create-todo.ts",
|
|
3629
4108
|
content: files.productionCreateTodoUseCase,
|
|
@@ -3641,7 +4120,7 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
3641
4120
|
content: files.productionUseCasesIndex,
|
|
3642
4121
|
},
|
|
3643
4122
|
{
|
|
3644
|
-
path: "features/todos/
|
|
4123
|
+
path: "features/todos/schemas.ts",
|
|
3645
4124
|
content: files.productionTodoSchemas,
|
|
3646
4125
|
},
|
|
3647
4126
|
{
|
|
@@ -3670,6 +4149,10 @@ function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
3670
4149
|
path: "infra/db/reset.ts",
|
|
3671
4150
|
content: files.productionDbReset,
|
|
3672
4151
|
},
|
|
4152
|
+
{
|
|
4153
|
+
path: "infra/db/test-database.ts",
|
|
4154
|
+
content: files.productionDbTestDatabase,
|
|
4155
|
+
},
|
|
3673
4156
|
{
|
|
3674
4157
|
path: "infra/db/provider.ts",
|
|
3675
4158
|
content: files.productionDbProvider,
|
|
@@ -3716,29 +4199,37 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
3716
4199
|
{ path: "app/layout.tsx", content: layout(ctx) },
|
|
3717
4200
|
{ path: "app/page.tsx", content: page(ctx) },
|
|
3718
4201
|
{ path: "app/api/[[...path]]/route.ts", content: files.apiCatchAllRoute },
|
|
4202
|
+
{ path: "app-context.ts", content: minimalAppContext(ctx) },
|
|
3719
4203
|
{ path: "features/todos/contracts.ts", content: files.contractsTodos },
|
|
3720
4204
|
{ path: "features/todos/routes.ts", content: files.todoRoutes },
|
|
3721
|
-
{ path: "ports/index.ts", content:
|
|
4205
|
+
{ path: "ports/index.ts", content: minimalPorts(ctx) },
|
|
3722
4206
|
{ path: "server/index.ts", content: server(ctx) },
|
|
4207
|
+
{ path: "server/context.ts", content: serverContext(ctx) },
|
|
3723
4208
|
{ path: "server/routes.ts", content: files.serverRoutes },
|
|
3724
|
-
{ path: "features/todos/use-cases.ts", content: files.useCasesTodos },
|
|
4209
|
+
{ path: "features/todos/use-cases/index.ts", content: files.useCasesTodos },
|
|
3725
4210
|
];
|
|
3726
4211
|
|
|
3727
4212
|
if (hasFeature(ctx, "client")) {
|
|
3728
4213
|
templateFiles.push({
|
|
3729
|
-
path: "client/
|
|
3730
|
-
content:
|
|
4214
|
+
path: "client/index.ts",
|
|
4215
|
+
content: clientIndex(ctx),
|
|
3731
4216
|
});
|
|
3732
4217
|
}
|
|
3733
4218
|
|
|
3734
4219
|
if (hasFeature(ctx, "react-query")) {
|
|
3735
4220
|
templateFiles.push(
|
|
3736
4221
|
{ path: "app/providers.tsx", content: files.appProviders },
|
|
3737
|
-
{ path: "client/rq.ts", content: files.rq },
|
|
3738
4222
|
{ path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) },
|
|
3739
4223
|
);
|
|
3740
4224
|
}
|
|
3741
4225
|
|
|
4226
|
+
if (hasFeature(ctx, "forms")) {
|
|
4227
|
+
templateFiles.push({
|
|
4228
|
+
path: "client/forms.ts",
|
|
4229
|
+
content: files.formsClient,
|
|
4230
|
+
});
|
|
4231
|
+
}
|
|
4232
|
+
|
|
3742
4233
|
if (hasFeature(ctx, "openapi")) {
|
|
3743
4234
|
templateFiles.push({
|
|
3744
4235
|
path: "app/api/openapi/route.ts",
|
|
@@ -3746,19 +4237,34 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
3746
4237
|
});
|
|
3747
4238
|
}
|
|
3748
4239
|
|
|
3749
|
-
if (
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
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
|
+
);
|
|
3758
4260
|
}
|
|
3759
4261
|
|
|
3760
4262
|
if (ctx.integrations.length > 0) {
|
|
3761
4263
|
templateFiles.push(
|
|
4264
|
+
{
|
|
4265
|
+
path: "server/providers.ts",
|
|
4266
|
+
content: serverProviders(ctx),
|
|
4267
|
+
},
|
|
3762
4268
|
{
|
|
3763
4269
|
path: ".env.example",
|
|
3764
4270
|
content: envExample(ctx),
|