@beignet/cli 0.0.1 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +231 -0
- package/README.md +497 -90
- package/dist/ansi.d.ts +10 -0
- package/dist/ansi.d.ts.map +1 -0
- package/dist/ansi.js +20 -0
- package/dist/ansi.js.map +1 -0
- package/dist/choices.d.ts +72 -0
- package/dist/choices.d.ts.map +1 -0
- package/dist/choices.js +88 -0
- package/dist/choices.js.map +1 -0
- package/dist/completion.d.ts +47 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +123 -0
- package/dist/completion.js.map +1 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +26 -0
- package/dist/config.js.map +1 -1
- package/dist/create-prompts.d.ts +42 -0
- package/dist/create-prompts.d.ts.map +1 -0
- package/dist/create-prompts.js +136 -0
- package/dist/create-prompts.js.map +1 -0
- package/dist/create.d.ts +12 -0
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +19 -24
- package/dist/create.js.map +1 -1
- package/dist/db.d.ts +37 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +146 -0
- package/dist/db.js.map +1 -0
- package/dist/github-annotations.d.ts +18 -0
- package/dist/github-annotations.d.ts.map +1 -0
- package/dist/github-annotations.js +22 -0
- package/dist/github-annotations.js.map +1 -0
- package/dist/index.d.ts +1 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +710 -400
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts +45 -2
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +2191 -100
- package/dist/inspect.js.map +1 -1
- package/dist/lib.d.ts +20 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +17 -0
- package/dist/lib.js.map +1 -0
- package/dist/lint.d.ts +22 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +370 -38
- package/dist/lint.js.map +1 -1
- package/dist/make.d.ts +109 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +2225 -333
- package/dist/make.js.map +1 -1
- package/dist/outbox.d.ts +24 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +138 -0
- package/dist/outbox.js.map +1 -0
- package/dist/schedule.d.ts +36 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +155 -0
- package/dist/schedule.js.map +1 -0
- package/dist/task.d.ts +26 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +106 -0
- package/dist/task.js.map +1 -0
- package/dist/templates.d.ts +12 -8
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +2144 -385
- package/dist/templates.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +18 -0
- package/dist/version.js.map +1 -0
- package/package.json +9 -8
- package/src/ansi.ts +30 -0
- package/src/choices.ts +137 -0
- package/src/completion.ts +169 -0
- package/src/config.ts +47 -0
- package/src/create-prompts.ts +182 -0
- package/src/create.ts +32 -28
- package/src/db.ts +222 -0
- package/src/github-annotations.ts +37 -0
- package/src/index.ts +1119 -535
- package/src/inspect.ts +3372 -134
- package/src/lib.ts +45 -0
- package/src/lint.ts +533 -45
- package/src/make.ts +3010 -397
- package/src/outbox.ts +249 -0
- package/src/schedule.ts +272 -0
- package/src/task.ts +169 -0
- package/src/templates.ts +2282 -462
- package/src/version.ts +20 -0
- package/dist/create-bin.d.ts +0 -3
- package/dist/create-bin.d.ts.map +0 -1
- package/dist/create-bin.js +0 -9
- package/dist/create-bin.js.map +0 -1
- package/src/create-bin.ts +0 -11
package/dist/templates.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { featureChoices, integrationChoices, presetChoices, } from "./choices.js";
|
|
1
2
|
const externalVersions = {
|
|
2
3
|
next: "^16.2.4",
|
|
3
4
|
react: "^19.2.5",
|
|
@@ -11,9 +12,9 @@ const externalVersions = {
|
|
|
11
12
|
tanstackReactQuery: "^5.100.7",
|
|
12
13
|
hookformResolvers: "^5.0.0",
|
|
13
14
|
reactHookForm: "^7.74.0",
|
|
14
|
-
betterAuth: "
|
|
15
|
-
drizzleKit: "^0.
|
|
16
|
-
drizzleOrm: "^0.
|
|
15
|
+
betterAuth: "1.6.11",
|
|
16
|
+
drizzleKit: "^0.31.10",
|
|
17
|
+
drizzleOrm: "^0.45.2",
|
|
17
18
|
inngest: "^3.0.0",
|
|
18
19
|
libsqlClient: "^0.14.0",
|
|
19
20
|
pino: "^9.7.0",
|
|
@@ -30,33 +31,15 @@ function hasDrizzleTurso(ctx) {
|
|
|
30
31
|
function packageRunner(ctx) {
|
|
31
32
|
switch (ctx.packageManager) {
|
|
32
33
|
case "npm":
|
|
33
|
-
return "
|
|
34
|
+
return "npm run beignet --";
|
|
34
35
|
case "pnpm":
|
|
35
|
-
return "pnpm
|
|
36
|
+
return "pnpm beignet";
|
|
36
37
|
case "yarn":
|
|
37
|
-
return "yarn
|
|
38
|
+
return "yarn beignet";
|
|
38
39
|
default:
|
|
39
|
-
return "
|
|
40
|
+
return "bun beignet";
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
|
-
export const featureChoices = [
|
|
43
|
-
"client",
|
|
44
|
-
"react-query",
|
|
45
|
-
"forms",
|
|
46
|
-
"openapi",
|
|
47
|
-
];
|
|
48
|
-
export const presetChoices = [
|
|
49
|
-
"minimal",
|
|
50
|
-
"standard",
|
|
51
|
-
];
|
|
52
|
-
export const integrationChoices = [
|
|
53
|
-
"better-auth",
|
|
54
|
-
"drizzle-turso",
|
|
55
|
-
"inngest",
|
|
56
|
-
"pino",
|
|
57
|
-
"resend",
|
|
58
|
-
"upstash-rate-limit",
|
|
59
|
-
];
|
|
60
43
|
const integrationDeps = {
|
|
61
44
|
"better-auth": {
|
|
62
45
|
"@beignet/provider-auth-better-auth": "",
|
|
@@ -135,6 +118,7 @@ function packageJson(ctx) {
|
|
|
135
118
|
};
|
|
136
119
|
if (isStandardPreset(ctx)) {
|
|
137
120
|
dependencies["@beignet/devtools"] = ctx.beignetVersion;
|
|
121
|
+
dependencies["@beignet/provider-event-bus-memory"] = ctx.beignetVersion;
|
|
138
122
|
dependencies["@beignet/provider-storage-local"] = ctx.beignetVersion;
|
|
139
123
|
}
|
|
140
124
|
for (const integration of ctx.integrations) {
|
|
@@ -148,6 +132,7 @@ function packageJson(ctx) {
|
|
|
148
132
|
}
|
|
149
133
|
}
|
|
150
134
|
const devDependencies = {
|
|
135
|
+
"@beignet/cli": ctx.beignetVersion,
|
|
151
136
|
"@types/bun": externalVersions.typesBun,
|
|
152
137
|
"@types/node": externalVersions.typesNode,
|
|
153
138
|
"@types/react": externalVersions.typesReact,
|
|
@@ -160,6 +145,7 @@ function packageJson(ctx) {
|
|
|
160
145
|
}
|
|
161
146
|
}
|
|
162
147
|
const scripts = {
|
|
148
|
+
beignet: "beignet",
|
|
163
149
|
dev: "next dev",
|
|
164
150
|
build: "next build",
|
|
165
151
|
start: "next start",
|
|
@@ -169,6 +155,12 @@ function packageJson(ctx) {
|
|
|
169
155
|
if (hasDrizzleTurso(ctx)) {
|
|
170
156
|
scripts["db:generate"] = "drizzle-kit generate";
|
|
171
157
|
scripts["db:migrate"] = "drizzle-kit migrate";
|
|
158
|
+
// The minimal preset does not scaffold infra/db/seed.ts or
|
|
159
|
+
// infra/db/reset.ts, so only the standard preset gets their scripts.
|
|
160
|
+
if (isStandardPreset(ctx)) {
|
|
161
|
+
scripts["db:seed"] = "bun infra/db/seed.ts";
|
|
162
|
+
scripts["db:reset"] = "bun infra/db/reset.ts";
|
|
163
|
+
}
|
|
172
164
|
}
|
|
173
165
|
return json({
|
|
174
166
|
name: ctx.name,
|
|
@@ -188,12 +180,28 @@ function readme(ctx) {
|
|
|
188
180
|
const run = ctx.packageManager === "npm"
|
|
189
181
|
? "npm run dev"
|
|
190
182
|
: `${ctx.packageManager} run dev`;
|
|
183
|
+
const cli = packageRunner(ctx);
|
|
184
|
+
const envSetup = ctx.integrations.length > 0 ? "cp .env.example .env.local\n" : "";
|
|
191
185
|
const featureList = ctx.features.length > 0
|
|
192
186
|
? ctx.features.map((feature) => `- \`${feature}\``).join("\n")
|
|
193
187
|
: "- API routes only";
|
|
194
188
|
const integrationList = ctx.integrations.length > 0
|
|
195
189
|
? ctx.integrations.map((integration) => `- \`${integration}\``).join("\n")
|
|
196
190
|
: "- None";
|
|
191
|
+
const databaseSection = hasDrizzleTurso(ctx)
|
|
192
|
+
? `
|
|
193
|
+
## Database
|
|
194
|
+
|
|
195
|
+
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\`).
|
|
196
|
+
|
|
197
|
+
Regenerate and apply migrations after changing \`infra/db/schema/index.ts\`:
|
|
198
|
+
|
|
199
|
+
\`\`\`bash
|
|
200
|
+
${cli} db generate
|
|
201
|
+
${cli} db migrate
|
|
202
|
+
\`\`\`
|
|
203
|
+
`
|
|
204
|
+
: "";
|
|
197
205
|
return `# ${ctx.name}
|
|
198
206
|
|
|
199
207
|
Beignet app scaffolded with \`@beignet/cli\`.
|
|
@@ -202,19 +210,21 @@ Beignet app scaffolded with \`@beignet/cli\`.
|
|
|
202
210
|
|
|
203
211
|
\`\`\`bash
|
|
204
212
|
${install}
|
|
205
|
-
${run}
|
|
213
|
+
${envSetup}${run}
|
|
206
214
|
\`\`\`
|
|
207
215
|
|
|
208
|
-
Open http://localhost:3000 and start from the todos workflow.
|
|
209
|
-
|
|
216
|
+
Open http://localhost:3000 and start from the todos workflow. The homepage links to the health check, OpenAPI document, and Beignet docs.
|
|
217
|
+
${databaseSection}
|
|
210
218
|
## Files to know
|
|
211
219
|
|
|
212
220
|
- \`features/todos/contracts.ts\` defines the API contract.
|
|
213
|
-
- \`features/todos/use-cases
|
|
221
|
+
- \`features/todos/use-cases/\` contains application logic with input and output validation.
|
|
214
222
|
- \`ports/index.ts\` defines the app's external dependencies.
|
|
215
|
-
- \`
|
|
223
|
+
- \`features/todos/routes.ts\` binds contracts to use cases.
|
|
224
|
+
- \`server/context.ts\` declares the context blueprint shared by the server and tests.
|
|
225
|
+
- \`server/index.ts\` composes the server from ports, context, and routes.
|
|
216
226
|
- \`app/api/[[...path]]/route.ts\` exposes contract-backed API routes to Next.js.
|
|
217
|
-
${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
|
|
227
|
+
${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" : ""}
|
|
218
228
|
|
|
219
229
|
## Selected preset
|
|
220
230
|
|
|
@@ -235,6 +245,9 @@ function standardReadme(ctx) {
|
|
|
235
245
|
const run = ctx.packageManager === "npm"
|
|
236
246
|
? "npm run dev"
|
|
237
247
|
: `${ctx.packageManager} run dev`;
|
|
248
|
+
const test = ctx.packageManager === "npm"
|
|
249
|
+
? "npm run test"
|
|
250
|
+
: `${ctx.packageManager} run test`;
|
|
238
251
|
const typecheck = ctx.packageManager === "npm"
|
|
239
252
|
? "npm run typecheck"
|
|
240
253
|
: `${ctx.packageManager} run typecheck`;
|
|
@@ -242,7 +255,8 @@ function standardReadme(ctx) {
|
|
|
242
255
|
const beforeDeploy = hasDrizzleTurso(ctx)
|
|
243
256
|
? [
|
|
244
257
|
"- Create a Turso database or keep `TURSO_DB_URL=file:local.db` for local libSQL development.",
|
|
245
|
-
`- Run \`${
|
|
258
|
+
`- Run \`${cli} db generate\` and \`${cli} db migrate\` after changing the Drizzle schema.`,
|
|
259
|
+
`- Run \`${cli} db seed\` to load starter data or \`${cli} db reset\` to rebuild a local SQLite database.`,
|
|
246
260
|
].join("\n")
|
|
247
261
|
: "- Replace the in-memory todo repository with a durable adapter.";
|
|
248
262
|
return `# ${ctx.name}
|
|
@@ -254,10 +268,24 @@ Beignet app scaffolded with \`@beignet/cli\`.
|
|
|
254
268
|
\`\`\`bash
|
|
255
269
|
${install}
|
|
256
270
|
cp .env.example .env.local
|
|
271
|
+
\`\`\`
|
|
272
|
+
|
|
273
|
+
## Prepare the database
|
|
274
|
+
|
|
275
|
+
\`\`\`bash
|
|
276
|
+
${cli} db generate
|
|
277
|
+
${cli} db migrate
|
|
278
|
+
\`\`\`
|
|
279
|
+
|
|
280
|
+
\`db generate\` creates Drizzle migrations from \`infra/db/schema/\`. \`db migrate\` applies them to the local SQLite database configured in \`.env.local\`.
|
|
281
|
+
|
|
282
|
+
## Start the app
|
|
283
|
+
|
|
284
|
+
\`\`\`bash
|
|
257
285
|
${run}
|
|
258
286
|
\`\`\`
|
|
259
287
|
|
|
260
|
-
Open http://localhost:3000 for the todo workflow
|
|
288
|
+
Open http://localhost:3000 for the todo workflow. The homepage links to the health check, OpenAPI document, devtools, and Beignet docs.
|
|
261
289
|
|
|
262
290
|
## First checks
|
|
263
291
|
|
|
@@ -271,18 +299,39 @@ ${typecheck}
|
|
|
271
299
|
|
|
272
300
|
\`routes\` shows the contracts Beignet can inspect. \`lint\` checks dependency direction. \`doctor\` catches route, OpenAPI, and resource drift.
|
|
273
301
|
|
|
302
|
+
## Generate a feature
|
|
303
|
+
|
|
304
|
+
\`\`\`bash
|
|
305
|
+
${cli} make feature projects
|
|
306
|
+
${cli} db generate
|
|
307
|
+
${cli} db migrate
|
|
308
|
+
${test}
|
|
309
|
+
${typecheck}
|
|
310
|
+
${cli} lint
|
|
311
|
+
${cli} doctor
|
|
312
|
+
\`\`\`
|
|
313
|
+
|
|
314
|
+
\`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.
|
|
315
|
+
|
|
274
316
|
## App map
|
|
275
317
|
|
|
276
|
-
- \`features/todos/contracts.ts\` owns the HTTP contract and reuses
|
|
318
|
+
- \`features/todos/contracts.ts\` owns the HTTP contract and reuses shared feature schemas.
|
|
319
|
+
- \`features/todos/schemas.ts\` owns shared DTO and validation schemas used by contracts, use cases, ports, tests, and clients.
|
|
277
320
|
- \`features/todos/use-cases/\` owns application behavior and validation.
|
|
278
321
|
- \`ports/\` defines app-owned dependencies.
|
|
279
322
|
- \`infra/\` implements ports for the selected runtime.
|
|
280
323
|
- \`server/routes.ts\` keeps the central route registry and OpenAPI contract list.
|
|
324
|
+
- \`server/context.ts\` declares the context blueprint shared by the server and route tests.
|
|
281
325
|
- \`server/\` wires context, providers, routes, hooks, and error handling.
|
|
326
|
+
- \`server/outbox.ts\` registers durable events and jobs for the outbox drain route and CLI drain runner.
|
|
327
|
+
- \`server/schedules.ts\` registers schedules for cron routes and the CLI schedule runner.
|
|
282
328
|
- \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
|
|
329
|
+
- \`features/todos/domain/events/\`, \`jobs/\`, \`listeners/\`, \`notifications/\`, \`schedules/\`, and \`uploads/\` show the feature-owned background workflow shape.
|
|
283
330
|
- \`app/storage/[...key]/route.ts\` serves public local storage objects.
|
|
331
|
+
- \`app/api/cron/outbox/drain/route.ts\` is the bounded serverless entrypoint for durable event/job delivery.
|
|
284
332
|
- \`lib/env.ts\` validates deployment configuration at startup.
|
|
285
333
|
- \`lib/auth.ts\` exposes \`requireUser(ctx)\` for protected use cases.
|
|
334
|
+
- \`lib/better-auth.ts\` owns Better Auth setup and keeps provider-specific auth details outside use cases.
|
|
286
335
|
- \`features/todos/policy.ts\` defines authorization rules registered with \`createGate(...)\`.
|
|
287
336
|
${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `infra/db/repositories.ts` creates app repositories, and `infra/todos/` contains the durable todo repository adapter.\n" : ""}
|
|
288
337
|
|
|
@@ -290,8 +339,11 @@ ${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `inf
|
|
|
290
339
|
|
|
291
340
|
${beforeDeploy}
|
|
292
341
|
- Keep \`/api/devtools\` development-only unless you add authentication and stricter redaction.
|
|
293
|
-
- Set \`APP_URL\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
|
|
294
|
-
-
|
|
342
|
+
- Set \`APP_URL\`, \`BETTER_AUTH_SECRET\`, \`CRON_SECRET\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
|
|
343
|
+
- Configure a scheduled request to \`/api/cron/outbox/drain\` with \`Authorization: Bearer <CRON_SECRET>\`.
|
|
344
|
+
- Use \`${cli} outbox drain\` for bounded local, CI, or worker-hosted drain runs.
|
|
345
|
+
- Use \`${cli} schedule run todos.log-daily-summary\` for bounded local, CI, or worker-hosted schedule runs.
|
|
346
|
+
- Review the starter authorization policy before exposing user-owned data.
|
|
295
347
|
${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
|
|
296
348
|
`;
|
|
297
349
|
}
|
|
@@ -304,13 +356,32 @@ function integrationsDoc(ctx) {
|
|
|
304
356
|
];
|
|
305
357
|
for (const integration of ctx.integrations) {
|
|
306
358
|
if (integration === "better-auth") {
|
|
307
|
-
lines.push("## Better Auth", "", "- Package: `@beignet/provider-auth-better-auth`", "- Peer dependency: `better-auth`", "- The starter
|
|
359
|
+
lines.push("## Better Auth", "", "- Package: `@beignet/provider-auth-better-auth`", "- Peer dependency: `better-auth`", "- The starter wires `lib/better-auth.ts`, `app/api/auth/[...all]/route.ts`, and `createAuthBetterAuthProvider(...)`.", ...(isStandardPreset(ctx)
|
|
360
|
+
? [
|
|
361
|
+
"- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
|
|
362
|
+
"- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
|
|
363
|
+
"- The generated SQLite schema includes the Better Auth `user`, `session`, `account`, and `verification` tables for local development.",
|
|
364
|
+
]
|
|
365
|
+
: [
|
|
366
|
+
"- Keep auth behind the shared `AuthPort`, then read `ctx.auth` in use cases that need a signed-in user.",
|
|
367
|
+
"- 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.",
|
|
368
|
+
]), "");
|
|
308
369
|
}
|
|
309
370
|
if (integration === "drizzle-turso") {
|
|
310
|
-
lines.push("## Drizzle + Turso", "", "- Package: `@beignet/provider-drizzle-turso`", "- Peer dependencies: `@libsql/client` and `drizzle-orm`", "- Dev dependency: `drizzle-kit`", "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.",
|
|
371
|
+
lines.push("## Drizzle + Turso", "", "- Package: `@beignet/provider-drizzle-turso`", "- Peer dependencies: `@libsql/client` and `drizzle-orm`", "- Dev dependency: `drizzle-kit`", "- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.", ...(isStandardPreset(ctx)
|
|
372
|
+
? [
|
|
373
|
+
"- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
|
|
374
|
+
]
|
|
375
|
+
: [
|
|
376
|
+
"- 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.",
|
|
377
|
+
]), `- Run \`${packageRunner(ctx)} db generate\` and \`${packageRunner(ctx)} db migrate\` after changing \`infra/db/schema/index.ts\`.`, ...(isStandardPreset(ctx)
|
|
378
|
+
? [
|
|
379
|
+
`- Run \`${packageRunner(ctx)} db seed\` for idempotent starter data and \`${packageRunner(ctx)} db reset\` for destructive local resets.`,
|
|
380
|
+
]
|
|
381
|
+
: []), "");
|
|
311
382
|
}
|
|
312
383
|
if (integration === "inngest") {
|
|
313
|
-
lines.push("## Inngest", "", "- Package: `@beignet/core` (`@beignet/core/events`, `@beignet/core/jobs`)", "- Package: `@beignet/provider-inngest`", "- Peer dependency: `inngest`", "- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.", "- Define jobs with `
|
|
384
|
+
lines.push("## Inngest", "", "- Package: `@beignet/core` (`@beignet/core/events`, `@beignet/core/jobs`)", "- Package: `@beignet/provider-inngest`", "- Peer dependency: `inngest`", "- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.", "- Define jobs with `defineJob(...)` from `lib/jobs.ts` and dispatch them through `ctx.ports.jobs.dispatch(...)`.", "- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.", "");
|
|
314
385
|
}
|
|
315
386
|
if (integration === "pino") {
|
|
316
387
|
lines.push("## Pino logger", "", "- Package: `@beignet/provider-logger-pino`", "- Peer dependency: `pino`", "- Use this provider when you want structured request and handler logging.", "");
|
|
@@ -349,10 +420,10 @@ function envExample(ctx) {
|
|
|
349
420
|
lines.push("RESEND_API_KEY=", "RESEND_FROM=", "");
|
|
350
421
|
}
|
|
351
422
|
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
352
|
-
lines.push("UPSTASH_REDIS_REST_URL=", "UPSTASH_REDIS_REST_TOKEN=", "UPSTASH_PREFIX=
|
|
423
|
+
lines.push("UPSTASH_REDIS_REST_URL=", "UPSTASH_REDIS_REST_TOKEN=", "UPSTASH_PREFIX=beignet:ratelimit", "");
|
|
353
424
|
}
|
|
354
425
|
if (hasIntegration(ctx, "better-auth")) {
|
|
355
|
-
lines.push("
|
|
426
|
+
lines.push("BETTER_AUTH_SECRET=local-dev-better-auth-secret-change-me", "BETTER_AUTH_URL=http://localhost:3000", "# BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000", "");
|
|
356
427
|
}
|
|
357
428
|
return `${lines.join("\n")}\n`;
|
|
358
429
|
}
|
|
@@ -414,6 +485,11 @@ function page(ctx) {
|
|
|
414
485
|
<p>
|
|
415
486
|
<a href="/api/todos">Open todos API</a>
|
|
416
487
|
</p>
|
|
488
|
+
<nav className="starter-links" aria-label="Starter links">
|
|
489
|
+
<a href="/api/health">Health</a>
|
|
490
|
+
<a href="/api/openapi">OpenAPI</a>
|
|
491
|
+
<a href="https://beignetjs.com">Docs</a>
|
|
492
|
+
</nav>
|
|
417
493
|
</section>
|
|
418
494
|
</div>
|
|
419
495
|
</main>
|
|
@@ -437,6 +513,14 @@ export default function HomePage() {
|
|
|
437
513
|
</p>
|
|
438
514
|
</header>
|
|
439
515
|
|
|
516
|
+
<nav className="starter-links" aria-label="Starter links">
|
|
517
|
+
<a href="#todos">Todo feature</a>
|
|
518
|
+
<a href="/api/health">Health</a>
|
|
519
|
+
<a href="/api/openapi">OpenAPI</a>
|
|
520
|
+
<a href="/api/devtools">Devtools</a>
|
|
521
|
+
<a href="https://beignetjs.com">Docs</a>
|
|
522
|
+
</nav>
|
|
523
|
+
|
|
440
524
|
<TodoApp />
|
|
441
525
|
</div>
|
|
442
526
|
</main>
|
|
@@ -445,47 +529,91 @@ export default function HomePage() {
|
|
|
445
529
|
`;
|
|
446
530
|
}
|
|
447
531
|
function serverProviders(ctx) {
|
|
448
|
-
const
|
|
532
|
+
const packageImports = [];
|
|
533
|
+
const appImports = [];
|
|
534
|
+
const declarations = [];
|
|
449
535
|
const entries = [];
|
|
536
|
+
if (hasIntegration(ctx, "better-auth")) {
|
|
537
|
+
packageImports.push('import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";');
|
|
538
|
+
appImports.push('import { auth } from "@/lib/better-auth";', 'import type { AuthSessionMetadata, AuthUser } from "@/ports";');
|
|
539
|
+
entries.push("createAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth)");
|
|
540
|
+
}
|
|
450
541
|
if (hasIntegration(ctx, "pino")) {
|
|
451
|
-
|
|
542
|
+
packageImports.push('import { loggerPinoProvider } from "@beignet/provider-logger-pino";');
|
|
452
543
|
entries.push("loggerPinoProvider");
|
|
453
544
|
}
|
|
545
|
+
if (hasDrizzleTurso(ctx)) {
|
|
546
|
+
packageImports.push('import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";');
|
|
547
|
+
appImports.unshift('import { starterDatabaseProvider } from "@/infra/db/provider";', 'import * as schema from "@/infra/db/schema";');
|
|
548
|
+
declarations.push("const drizzleTursoProvider = createDrizzleTursoProvider({ schema });");
|
|
549
|
+
entries.push("drizzleTursoProvider", "starterDatabaseProvider");
|
|
550
|
+
}
|
|
454
551
|
if (hasIntegration(ctx, "inngest")) {
|
|
455
|
-
|
|
552
|
+
packageImports.push('import { inngestProvider } from "@beignet/provider-inngest";');
|
|
456
553
|
entries.push("inngestProvider");
|
|
457
554
|
}
|
|
458
555
|
if (hasIntegration(ctx, "resend")) {
|
|
459
|
-
|
|
556
|
+
packageImports.push('import { mailResendProvider } from "@beignet/provider-mail-resend";');
|
|
460
557
|
entries.push("mailResendProvider");
|
|
461
558
|
}
|
|
462
559
|
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
463
|
-
|
|
560
|
+
packageImports.push('import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";');
|
|
464
561
|
entries.push("upstashRateLimitProvider");
|
|
465
562
|
}
|
|
466
|
-
|
|
563
|
+
const importBlock = [...packageImports.sort(), ...appImports].join("\n");
|
|
564
|
+
const declarationBlock = declarations.length > 0 ? `\n${declarations.join("\n")}\n` : "";
|
|
565
|
+
return `${importBlock}
|
|
566
|
+
${declarationBlock}
|
|
467
567
|
export const providers = [
|
|
468
568
|
${entries.join(",\n\t")},
|
|
469
569
|
];
|
|
470
570
|
`;
|
|
471
571
|
}
|
|
572
|
+
function serverContext(ctx) {
|
|
573
|
+
const blueprint = hasIntegration(ctx, "better-auth")
|
|
574
|
+
? ` async ({ req, ports }) => ({
|
|
575
|
+
requestId: crypto.randomUUID(),
|
|
576
|
+
auth: await ports.auth.getSession(req),
|
|
577
|
+
ports,
|
|
578
|
+
}),`
|
|
579
|
+
: ` async ({ ports }) => ({
|
|
580
|
+
requestId: crypto.randomUUID(),
|
|
581
|
+
ports,
|
|
582
|
+
}),`;
|
|
583
|
+
return `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
584
|
+
import { defineServerContext } from "@beignet/core/server";
|
|
585
|
+
import type { AppContext } from "@/app-context";
|
|
586
|
+
import type { AppPorts } from "@/ports";
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Context blueprint shared by the runtime server and route tests.
|
|
590
|
+
*/
|
|
591
|
+
export const appContext = defineServerContext<AppContext, AppPorts>()(
|
|
592
|
+
${blueprint}
|
|
593
|
+
);
|
|
594
|
+
`;
|
|
595
|
+
}
|
|
472
596
|
function server(ctx) {
|
|
473
|
-
const hasProviders = ctx.integrations.
|
|
474
|
-
|
|
597
|
+
const hasProviders = ctx.integrations.length > 0;
|
|
598
|
+
const providerConfigBlock = hasDrizzleTurso(ctx)
|
|
599
|
+
? ` providerConfig: {
|
|
600
|
+
"drizzle-turso": {
|
|
601
|
+
DB_URL: process.env.TURSO_DB_URL ?? "file:local.db",
|
|
602
|
+
DB_AUTH_TOKEN: process.env.TURSO_DB_AUTH_TOKEN,
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
`
|
|
606
|
+
: "";
|
|
607
|
+
return `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
608
|
+
import { createNextServer } from "@beignet/next";
|
|
609
|
+
import { appContext } from "./context";
|
|
475
610
|
${hasProviders ? 'import { providers } from "./providers";\n' : ""}import { routes } from "./routes";
|
|
611
|
+
import type { AppContext } from "@/app-context";
|
|
476
612
|
import { appPorts, type AppPorts } from "@/ports";
|
|
477
613
|
|
|
478
|
-
export type AppContext = {
|
|
479
|
-
requestId: string;
|
|
480
|
-
ports: AppPorts;
|
|
481
|
-
};
|
|
482
|
-
|
|
483
614
|
export const server = await createNextServer<AppContext, AppPorts>({
|
|
484
615
|
ports: appPorts,
|
|
485
|
-
${hasProviders ? "\tproviders,\n" : ""}
|
|
486
|
-
requestId: crypto.randomUUID(),
|
|
487
|
-
ports,
|
|
488
|
-
}),
|
|
616
|
+
${hasProviders ? "\tproviders,\n" : ""}${providerConfigBlock} context: appContext,
|
|
489
617
|
routes,
|
|
490
618
|
mapUnhandledError: ({ err, ctx }) => {
|
|
491
619
|
const ports = ctx?.ports as
|
|
@@ -526,17 +654,128 @@ ${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
|
|
|
526
654
|
});
|
|
527
655
|
`;
|
|
528
656
|
}
|
|
657
|
+
function minimalPorts(ctx) {
|
|
658
|
+
const usesDrizzleTurso = hasDrizzleTurso(ctx);
|
|
659
|
+
const usesBetterAuth = hasIntegration(ctx, "better-auth");
|
|
660
|
+
if (!usesDrizzleTurso && !usesBetterAuth) {
|
|
661
|
+
return files.ports;
|
|
662
|
+
}
|
|
663
|
+
const imports = [
|
|
664
|
+
'import { definePorts } from "@beignet/core/ports";',
|
|
665
|
+
...(usesBetterAuth
|
|
666
|
+
? [
|
|
667
|
+
`import type {
|
|
668
|
+
AuthPort as BeignetAuthPort,
|
|
669
|
+
AuthSession as BeignetAuthSession,
|
|
670
|
+
} from "@beignet/core/ports";`,
|
|
671
|
+
]
|
|
672
|
+
: []),
|
|
673
|
+
'import type { z } from "zod";',
|
|
674
|
+
'import type { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";',
|
|
675
|
+
].join("\n");
|
|
676
|
+
const authTypes = usesBetterAuth
|
|
677
|
+
? `
|
|
678
|
+
|
|
679
|
+
export type AuthUser = {
|
|
680
|
+
id: string;
|
|
681
|
+
email?: string;
|
|
682
|
+
name?: string;
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
export type AuthSessionMetadata = {
|
|
686
|
+
id?: string;
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
export type AuthSession = BeignetAuthSession<AuthUser, AuthSessionMetadata>;
|
|
690
|
+
|
|
691
|
+
export type AuthPort = BeignetAuthPort<AuthUser, AuthSessionMetadata>;`
|
|
692
|
+
: "";
|
|
693
|
+
const appPortsType = [
|
|
694
|
+
"export type AppPorts = {",
|
|
695
|
+
...(usesBetterAuth ? ["\tauth: AuthPort;"] : []),
|
|
696
|
+
"\ttodos: TodoRepository;",
|
|
697
|
+
"};",
|
|
698
|
+
].join("\n");
|
|
699
|
+
const inMemoryTodos = usesDrizzleTurso
|
|
700
|
+
? ""
|
|
701
|
+
: `
|
|
702
|
+
|
|
703
|
+
const todos = new Map<string, Todo>();
|
|
704
|
+
|
|
705
|
+
function createTodo(input: CreateTodoInput): Todo {
|
|
706
|
+
const todo = {
|
|
707
|
+
id: crypto.randomUUID(),
|
|
708
|
+
title: input.title,
|
|
709
|
+
completed: false,
|
|
710
|
+
createdAt: new Date().toISOString(),
|
|
711
|
+
};
|
|
712
|
+
todos.set(todo.id, todo);
|
|
713
|
+
return todo;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
createTodo({ title: "Read the generated contract" });
|
|
717
|
+
createTodo({ title: "Add your first use case" });`;
|
|
718
|
+
const bound = usesDrizzleTurso
|
|
719
|
+
? "\tbound: {},"
|
|
720
|
+
: ` bound: {
|
|
721
|
+
todos: {
|
|
722
|
+
list: async () => Array.from(todos.values()),
|
|
723
|
+
create: async (input: CreateTodoInput) => createTodo(input),
|
|
724
|
+
},
|
|
725
|
+
},`;
|
|
726
|
+
const deferred = [
|
|
727
|
+
...(usesBetterAuth ? ['"auth"'] : []),
|
|
728
|
+
...(usesDrizzleTurso ? ['"todos"'] : []),
|
|
729
|
+
].join(", ");
|
|
730
|
+
return `${imports}
|
|
731
|
+
|
|
732
|
+
export type Todo = z.infer<typeof TodoSchema>;
|
|
733
|
+
export type CreateTodoInput = z.infer<typeof CreateTodoSchema>;
|
|
734
|
+
|
|
735
|
+
export interface TodoRepository {
|
|
736
|
+
list(): Promise<Todo[]>;
|
|
737
|
+
create(input: CreateTodoInput): Promise<Todo>;
|
|
738
|
+
}${authTypes}
|
|
739
|
+
|
|
740
|
+
${appPortsType}${inMemoryTodos}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* App ports the server boots with.
|
|
744
|
+
*
|
|
745
|
+
* Bound ports are app-owned. Every deferred key is contributed by a provider
|
|
746
|
+
* in server/providers.ts during startup; createNextServer(...) fails boot if
|
|
747
|
+
* any of them is still unbound after providers have started.
|
|
748
|
+
*/
|
|
749
|
+
export const appPorts = definePorts<AppPorts>()({
|
|
750
|
+
${bound}
|
|
751
|
+
deferred: [${deferred}],
|
|
752
|
+
});
|
|
753
|
+
`;
|
|
754
|
+
}
|
|
755
|
+
function minimalAppContext(ctx) {
|
|
756
|
+
if (!hasIntegration(ctx, "better-auth")) {
|
|
757
|
+
return files.appContext;
|
|
758
|
+
}
|
|
759
|
+
return `import type { AppPorts, AuthSession } from "@/ports";
|
|
760
|
+
|
|
761
|
+
export type AppContext = {
|
|
762
|
+
requestId: string;
|
|
763
|
+
auth: AuthSession | null;
|
|
764
|
+
ports: AppPorts;
|
|
765
|
+
};
|
|
766
|
+
`;
|
|
767
|
+
}
|
|
529
768
|
function todoApp(ctx) {
|
|
530
769
|
const listTodosQueryOptions = ctx.preset === "standard" ? "{ query: {} }" : "";
|
|
531
770
|
if (hasFeature(ctx, "forms")) {
|
|
532
771
|
return `"use client";
|
|
533
772
|
|
|
534
|
-
import {
|
|
773
|
+
import { rootFormError } from "@beignet/react-hook-form";
|
|
535
774
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
775
|
+
import { rq } from "@/client";
|
|
776
|
+
import { rhf } from "@/client/forms";
|
|
536
777
|
import { createTodo, listTodos } from "@/features/todos/contracts";
|
|
537
|
-
import { rq } from "@/client/rq";
|
|
538
778
|
|
|
539
|
-
const rhf = createReactHookForm();
|
|
540
779
|
const createTodoForm = rhf(createTodo);
|
|
541
780
|
|
|
542
781
|
export function TodoApp() {
|
|
@@ -549,19 +788,21 @@ export function TodoApp() {
|
|
|
549
788
|
rq(createTodo).mutationOptions({
|
|
550
789
|
onSuccess: async () => {
|
|
551
790
|
form.reset();
|
|
552
|
-
await
|
|
553
|
-
|
|
554
|
-
|
|
791
|
+
await rq(listTodos).invalidate(queryClient);
|
|
792
|
+
},
|
|
793
|
+
onError: (error) => {
|
|
794
|
+
form.setError("root", rootFormError(error, "Could not create the todo."));
|
|
555
795
|
},
|
|
556
796
|
}),
|
|
557
797
|
);
|
|
558
798
|
|
|
559
799
|
const onSubmit = form.handleSubmit((body) => {
|
|
800
|
+
form.clearErrors("root");
|
|
560
801
|
createTodoMutation.mutate({ body });
|
|
561
802
|
});
|
|
562
803
|
|
|
563
804
|
return (
|
|
564
|
-
<section className="workspace">
|
|
805
|
+
<section id="todos" className="workspace">
|
|
565
806
|
<form className="composer" onSubmit={onSubmit}>
|
|
566
807
|
<label htmlFor="todo-title">New todo</label>
|
|
567
808
|
<div className="composer-row">
|
|
@@ -577,19 +818,22 @@ export function TodoApp() {
|
|
|
577
818
|
{form.formState.errors.title ? (
|
|
578
819
|
<p className="error">{form.formState.errors.title.message}</p>
|
|
579
820
|
) : null}
|
|
821
|
+
{form.formState.errors.root ? (
|
|
822
|
+
<p className="error">{form.formState.errors.root.message}</p>
|
|
823
|
+
) : null}
|
|
580
824
|
</form>
|
|
581
825
|
|
|
582
826
|
<div className="todos-panel">
|
|
583
827
|
<div className="panel-heading">
|
|
584
828
|
<h2>Todos</h2>
|
|
585
|
-
<span>{todosQuery.data?.total ?? 0} total</span>
|
|
829
|
+
<span>{todosQuery.data?.page.total ?? 0} total</span>
|
|
586
830
|
</div>
|
|
587
831
|
{todosQuery.isLoading ? <p className="muted">Loading todos...</p> : null}
|
|
588
832
|
{todosQuery.isError ? (
|
|
589
833
|
<p className="error">Could not load todos.</p>
|
|
590
834
|
) : null}
|
|
591
835
|
<ul className="todo-list">
|
|
592
|
-
{todosQuery.data?.
|
|
836
|
+
{todosQuery.data?.items.map((todo) => (
|
|
593
837
|
<li key={todo.id}>
|
|
594
838
|
<span>{todo.title}</span>
|
|
595
839
|
<small>{todo.completed ? "Done" : "Open"}</small>
|
|
@@ -606,8 +850,8 @@ export function TodoApp() {
|
|
|
606
850
|
|
|
607
851
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
608
852
|
import { useState } from "react";
|
|
853
|
+
import { rq } from "@/client";
|
|
609
854
|
import { createTodo, listTodos } from "@/features/todos/contracts";
|
|
610
|
-
import { rq } from "@/client/rq";
|
|
611
855
|
|
|
612
856
|
export function TodoApp() {
|
|
613
857
|
const [title, setTitle] = useState("");
|
|
@@ -617,15 +861,13 @@ export function TodoApp() {
|
|
|
617
861
|
rq(createTodo).mutationOptions({
|
|
618
862
|
onSuccess: async () => {
|
|
619
863
|
setTitle("");
|
|
620
|
-
await
|
|
621
|
-
queryKey: rq(listTodos).key(),
|
|
622
|
-
});
|
|
864
|
+
await rq(listTodos).invalidate(queryClient);
|
|
623
865
|
},
|
|
624
866
|
}),
|
|
625
867
|
);
|
|
626
868
|
|
|
627
869
|
return (
|
|
628
|
-
<section className="workspace">
|
|
870
|
+
<section id="todos" className="workspace">
|
|
629
871
|
<form
|
|
630
872
|
className="composer"
|
|
631
873
|
onSubmit={(event) => {
|
|
@@ -650,10 +892,10 @@ export function TodoApp() {
|
|
|
650
892
|
<div className="todos-panel">
|
|
651
893
|
<div className="panel-heading">
|
|
652
894
|
<h2>Todos</h2>
|
|
653
|
-
<span>{todosQuery.data?.total ?? 0} total</span>
|
|
895
|
+
<span>{todosQuery.data?.page.total ?? 0} total</span>
|
|
654
896
|
</div>
|
|
655
897
|
<ul className="todo-list">
|
|
656
|
-
{todosQuery.data?.
|
|
898
|
+
{todosQuery.data?.items.map((todo) => (
|
|
657
899
|
<li key={todo.id}>
|
|
658
900
|
<span>{todo.title}</span>
|
|
659
901
|
<small>{todo.completed ? "Done" : "Open"}</small>
|
|
@@ -666,6 +908,55 @@ export function TodoApp() {
|
|
|
666
908
|
}
|
|
667
909
|
`;
|
|
668
910
|
}
|
|
911
|
+
function clientIndex(ctx) {
|
|
912
|
+
const imports = [
|
|
913
|
+
'import { createClient } from "@beignet/core/client";',
|
|
914
|
+
...(hasFeature(ctx, "react-query")
|
|
915
|
+
? [
|
|
916
|
+
'import { createReactQuery } from "@beignet/react-query";',
|
|
917
|
+
'import { QueryClient } from "@tanstack/react-query";',
|
|
918
|
+
]
|
|
919
|
+
: []),
|
|
920
|
+
...(isStandardPreset(ctx)
|
|
921
|
+
? [
|
|
922
|
+
'import { createUploadClient } from "@beignet/core/uploads/client";',
|
|
923
|
+
'import type { todoUploads } from "@/features/todos/uploads";',
|
|
924
|
+
]
|
|
925
|
+
: []),
|
|
926
|
+
].join("\n");
|
|
927
|
+
const exports = [
|
|
928
|
+
`export const apiClient = createClient({
|
|
929
|
+
\tvalidateInput: true,
|
|
930
|
+
});`,
|
|
931
|
+
...(hasFeature(ctx, "react-query")
|
|
932
|
+
? ["export const rq = createReactQuery(apiClient);"]
|
|
933
|
+
: []),
|
|
934
|
+
...(isStandardPreset(ctx)
|
|
935
|
+
? [
|
|
936
|
+
`export const uploads = createUploadClient<typeof todoUploads>({
|
|
937
|
+
\tbaseUrl: "/api/uploads",
|
|
938
|
+
});`,
|
|
939
|
+
]
|
|
940
|
+
: []),
|
|
941
|
+
...(hasFeature(ctx, "react-query")
|
|
942
|
+
? [
|
|
943
|
+
`export function makeQueryClient() {
|
|
944
|
+
\treturn new QueryClient({
|
|
945
|
+
\t\tdefaultOptions: {
|
|
946
|
+
\t\t\tqueries: {
|
|
947
|
+
\t\t\t\tstaleTime: 60 * 1000,
|
|
948
|
+
\t\t\t},
|
|
949
|
+
\t\t},
|
|
950
|
+
\t});
|
|
951
|
+
}`,
|
|
952
|
+
]
|
|
953
|
+
: []),
|
|
954
|
+
].join("\n\n");
|
|
955
|
+
return `${imports}
|
|
956
|
+
|
|
957
|
+
${exports}
|
|
958
|
+
`;
|
|
959
|
+
}
|
|
669
960
|
const files = {
|
|
670
961
|
gitignore: `.next
|
|
671
962
|
node_modules
|
|
@@ -766,6 +1057,29 @@ input {
|
|
|
766
1057
|
color: #475569;
|
|
767
1058
|
}
|
|
768
1059
|
|
|
1060
|
+
.starter-links {
|
|
1061
|
+
display: flex;
|
|
1062
|
+
flex-wrap: wrap;
|
|
1063
|
+
gap: 10px;
|
|
1064
|
+
margin: 22px 0;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
.starter-links a {
|
|
1068
|
+
border: 1px solid #cbd5e1;
|
|
1069
|
+
border-radius: 8px;
|
|
1070
|
+
padding: 8px 10px;
|
|
1071
|
+
background: #ffffff;
|
|
1072
|
+
color: #172033;
|
|
1073
|
+
font-size: 14px;
|
|
1074
|
+
font-weight: 700;
|
|
1075
|
+
text-decoration: none;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
.starter-links a:hover {
|
|
1079
|
+
border-color: #4f46e5;
|
|
1080
|
+
color: #4f46e5;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
769
1083
|
.panel {
|
|
770
1084
|
margin-top: 24px;
|
|
771
1085
|
padding: 20px;
|
|
@@ -892,32 +1206,26 @@ input {
|
|
|
892
1206
|
`,
|
|
893
1207
|
appProviders: `"use client";
|
|
894
1208
|
|
|
895
|
-
import {
|
|
1209
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
896
1210
|
import { type ReactNode, useState } from "react";
|
|
1211
|
+
import { makeQueryClient } from "@/client";
|
|
897
1212
|
|
|
898
1213
|
export function Providers({ children }: { children: ReactNode }) {
|
|
899
|
-
const [queryClient] = useState(() =>
|
|
1214
|
+
const [queryClient] = useState(() => makeQueryClient());
|
|
900
1215
|
|
|
901
1216
|
return (
|
|
902
1217
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
903
1218
|
);
|
|
904
1219
|
}
|
|
905
1220
|
`,
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
export const apiClient = createNextClient({
|
|
909
|
-
validate: true,
|
|
910
|
-
});
|
|
911
|
-
`,
|
|
912
|
-
rq: `import { createReactQuery } from "@beignet/react-query";
|
|
913
|
-
import { apiClient } from "./api-client";
|
|
1221
|
+
formsClient: `import { createReactHookForm } from "@beignet/react-hook-form";
|
|
914
1222
|
|
|
915
|
-
export const
|
|
1223
|
+
export const rhf = createReactHookForm();
|
|
916
1224
|
`,
|
|
917
|
-
contractsTodos: `import {
|
|
1225
|
+
contractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
|
|
918
1226
|
import { z } from "zod";
|
|
919
1227
|
|
|
920
|
-
const todos =
|
|
1228
|
+
const todos = defineContractGroup().namespace("todos");
|
|
921
1229
|
|
|
922
1230
|
export const TodoSchema = z.object({
|
|
923
1231
|
id: z.string(),
|
|
@@ -932,8 +1240,14 @@ export const CreateTodoSchema = z.object({
|
|
|
932
1240
|
|
|
933
1241
|
export const listTodos = todos.get("/api/todos").responses({
|
|
934
1242
|
200: z.object({
|
|
935
|
-
|
|
936
|
-
|
|
1243
|
+
items: z.array(TodoSchema),
|
|
1244
|
+
page: z.object({
|
|
1245
|
+
kind: z.literal("offset"),
|
|
1246
|
+
limit: z.number(),
|
|
1247
|
+
offset: z.number(),
|
|
1248
|
+
total: z.number(),
|
|
1249
|
+
hasMore: z.boolean(),
|
|
1250
|
+
}),
|
|
937
1251
|
}),
|
|
938
1252
|
});
|
|
939
1253
|
|
|
@@ -976,30 +1290,175 @@ export const appPorts = definePorts({
|
|
|
976
1290
|
|
|
977
1291
|
export type AppPorts = typeof appPorts;
|
|
978
1292
|
`,
|
|
979
|
-
|
|
980
|
-
import { z } from "zod";
|
|
981
|
-
import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
|
|
982
|
-
import type { AppPorts } from "@/ports";
|
|
1293
|
+
appContext: `import type { AppPorts } from "@/ports";
|
|
983
1294
|
|
|
984
|
-
type AppContext = {
|
|
1295
|
+
export type AppContext = {
|
|
985
1296
|
requestId: string;
|
|
986
1297
|
ports: AppPorts;
|
|
987
1298
|
};
|
|
1299
|
+
`,
|
|
1300
|
+
minimalBetterAuth: `import { betterAuth } from "better-auth";
|
|
1301
|
+
import { memoryAdapter, type MemoryDB } from "better-auth/adapters/memory";
|
|
1302
|
+
|
|
1303
|
+
// The minimal starter keeps auth storage in memory, matching its in-memory
|
|
1304
|
+
// ports. Users and sessions reset on restart; switch to a database adapter
|
|
1305
|
+
// such as better-auth/adapters/drizzle before production.
|
|
1306
|
+
const database: MemoryDB = {
|
|
1307
|
+
account: [],
|
|
1308
|
+
session: [],
|
|
1309
|
+
user: [],
|
|
1310
|
+
verification: [],
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1313
|
+
export const auth = betterAuth({
|
|
1314
|
+
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
|
1315
|
+
secret:
|
|
1316
|
+
process.env.BETTER_AUTH_SECRET ?? "local-dev-better-auth-secret-change-me",
|
|
1317
|
+
database: memoryAdapter(database),
|
|
1318
|
+
emailAndPassword: {
|
|
1319
|
+
enabled: true,
|
|
1320
|
+
},
|
|
1321
|
+
});
|
|
1322
|
+
`,
|
|
1323
|
+
minimalDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
1324
|
+
|
|
1325
|
+
export const todos = sqliteTable("todos", {
|
|
1326
|
+
id: text("id").primaryKey(),
|
|
1327
|
+
title: text("title").notNull(),
|
|
1328
|
+
completed: integer("completed", { mode: "boolean" }).notNull().default(false),
|
|
1329
|
+
createdAt: text("created_at").notNull(),
|
|
1330
|
+
});
|
|
1331
|
+
`,
|
|
1332
|
+
minimalDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1333
|
+
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
1334
|
+
import { asc } from "drizzle-orm";
|
|
1335
|
+
import * as schema from "@/infra/db/schema";
|
|
1336
|
+
import type { CreateTodoInput, TodoRepository } from "@/ports";
|
|
1337
|
+
|
|
1338
|
+
export function createDrizzleTodoRepository(
|
|
1339
|
+
db: DrizzleTursoDatabase<typeof schema>,
|
|
1340
|
+
): TodoRepository {
|
|
1341
|
+
return {
|
|
1342
|
+
async list() {
|
|
1343
|
+
return db
|
|
1344
|
+
.select()
|
|
1345
|
+
.from(schema.todos)
|
|
1346
|
+
.orderBy(asc(schema.todos.createdAt));
|
|
1347
|
+
},
|
|
1348
|
+
async create(input: CreateTodoInput) {
|
|
1349
|
+
const todo = {
|
|
1350
|
+
id: crypto.randomUUID(),
|
|
1351
|
+
title: input.title,
|
|
1352
|
+
completed: false,
|
|
1353
|
+
createdAt: new Date().toISOString(),
|
|
1354
|
+
};
|
|
1355
|
+
const [row] = await db.insert(schema.todos).values(todo).returning();
|
|
1356
|
+
|
|
1357
|
+
if (!row) {
|
|
1358
|
+
throw new Error("Failed to create todo");
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
return row;
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
`,
|
|
1366
|
+
minimalDbProvider: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1367
|
+
import { createProvider } from "@beignet/core/providers";
|
|
1368
|
+
import type { DbPort } from "@beignet/provider-drizzle-turso";
|
|
1369
|
+
import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
|
|
1370
|
+
import type { AppPorts } from "@/ports";
|
|
1371
|
+
import * as schema from "./schema";
|
|
1372
|
+
|
|
1373
|
+
const setupStatements = [
|
|
1374
|
+
\`CREATE TABLE IF NOT EXISTS todos (
|
|
1375
|
+
id text PRIMARY KEY NOT NULL,
|
|
1376
|
+
title text NOT NULL,
|
|
1377
|
+
completed integer DEFAULT false NOT NULL,
|
|
1378
|
+
created_at text NOT NULL
|
|
1379
|
+
)\`,
|
|
1380
|
+
];
|
|
1381
|
+
|
|
1382
|
+
const seedTitles = [
|
|
1383
|
+
"Read the generated contract",
|
|
1384
|
+
"Add your first use case",
|
|
1385
|
+
] as const;
|
|
1386
|
+
|
|
1387
|
+
export const starterDatabaseProvider = createProvider<{
|
|
1388
|
+
db: DbPort<typeof schema>;
|
|
1389
|
+
}>()({
|
|
1390
|
+
name: "starter-database",
|
|
1391
|
+
|
|
1392
|
+
async setup({ ports }) {
|
|
1393
|
+
const dbPort = ports.db;
|
|
1394
|
+
if (!dbPort) {
|
|
1395
|
+
throw new Error(
|
|
1396
|
+
"starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
const providedPorts: Pick<AppPorts, "todos"> = {
|
|
1401
|
+
todos: createDrizzleTodoRepository(dbPort.db),
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
return {
|
|
1405
|
+
ports: providedPorts,
|
|
1406
|
+
async start() {
|
|
1407
|
+
for (const statement of setupStatements) {
|
|
1408
|
+
await dbPort.client.execute(statement);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const result = await dbPort.client.execute(
|
|
1412
|
+
"SELECT count(*) as total FROM todos",
|
|
1413
|
+
);
|
|
1414
|
+
const total = Number(result.rows[0]?.total ?? 0);
|
|
1415
|
+
if (total > 0) return;
|
|
1416
|
+
|
|
1417
|
+
for (const title of seedTitles) {
|
|
1418
|
+
await dbPort.client.execute({
|
|
1419
|
+
sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
|
|
1420
|
+
args: [crypto.randomUUID(), title, 0, new Date().toISOString()],
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
};
|
|
1425
|
+
},
|
|
1426
|
+
});
|
|
1427
|
+
`,
|
|
1428
|
+
useCasesTodos: `import { createUseCase } from "@beignet/core/application";
|
|
1429
|
+
import { z } from "zod";
|
|
1430
|
+
import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
|
|
1431
|
+
import type { AppContext } from "@/app-context";
|
|
988
1432
|
|
|
989
1433
|
const useCase = createUseCase<AppContext>();
|
|
990
1434
|
|
|
991
1435
|
export const ListTodosOutputSchema = z.object({
|
|
992
|
-
|
|
993
|
-
|
|
1436
|
+
items: z.array(TodoSchema),
|
|
1437
|
+
page: z.object({
|
|
1438
|
+
kind: z.literal("offset"),
|
|
1439
|
+
limit: z.number(),
|
|
1440
|
+
offset: z.number(),
|
|
1441
|
+
total: z.number(),
|
|
1442
|
+
hasMore: z.boolean(),
|
|
1443
|
+
}),
|
|
994
1444
|
});
|
|
995
1445
|
|
|
996
1446
|
export const listTodos = useCase
|
|
997
1447
|
.query("listTodos")
|
|
998
|
-
.input(z.
|
|
1448
|
+
.input(z.object({}))
|
|
999
1449
|
.output(ListTodosOutputSchema)
|
|
1000
1450
|
.run(async ({ ctx }) => {
|
|
1001
1451
|
const todos = await ctx.ports.todos.list();
|
|
1002
|
-
return {
|
|
1452
|
+
return {
|
|
1453
|
+
items: todos,
|
|
1454
|
+
page: {
|
|
1455
|
+
kind: "offset" as const,
|
|
1456
|
+
limit: todos.length,
|
|
1457
|
+
offset: 0,
|
|
1458
|
+
total: todos.length,
|
|
1459
|
+
hasMore: false,
|
|
1460
|
+
},
|
|
1461
|
+
};
|
|
1003
1462
|
});
|
|
1004
1463
|
|
|
1005
1464
|
export const createTodo = useCase
|
|
@@ -1008,33 +1467,23 @@ export const createTodo = useCase
|
|
|
1008
1467
|
.output(TodoSchema)
|
|
1009
1468
|
.run(async ({ ctx, input }) => ctx.ports.todos.create(input));
|
|
1010
1469
|
`,
|
|
1011
|
-
todoRoutes: `import {
|
|
1012
|
-
import
|
|
1470
|
+
todoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1471
|
+
import { defineRouteGroup } from "@beignet/next";
|
|
1472
|
+
import type { AppContext } from "@/app-context";
|
|
1013
1473
|
import { createTodo, listTodos } from "@/features/todos/use-cases";
|
|
1014
1474
|
import * as contracts from "@/features/todos/contracts";
|
|
1015
1475
|
|
|
1016
1476
|
export const todoRoutes = defineRouteGroup<AppContext>({
|
|
1017
1477
|
name: "todos",
|
|
1018
1478
|
routes: [
|
|
1019
|
-
{
|
|
1020
|
-
|
|
1021
|
-
handle: async ({ ctx }) => ({
|
|
1022
|
-
status: 200,
|
|
1023
|
-
body: await listTodos.run({ ctx, input: undefined }),
|
|
1024
|
-
}),
|
|
1025
|
-
},
|
|
1026
|
-
{
|
|
1027
|
-
contract: contracts.createTodo,
|
|
1028
|
-
handle: async ({ ctx, body }) => ({
|
|
1029
|
-
status: 201,
|
|
1030
|
-
body: await createTodo.run({ ctx, input: body }),
|
|
1031
|
-
}),
|
|
1032
|
-
},
|
|
1479
|
+
{ contract: contracts.listTodos, useCase: listTodos },
|
|
1480
|
+
{ contract: contracts.createTodo, useCase: createTodo },
|
|
1033
1481
|
],
|
|
1034
1482
|
});
|
|
1035
1483
|
`,
|
|
1036
|
-
serverRoutes: `import {
|
|
1037
|
-
import
|
|
1484
|
+
serverRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1485
|
+
import { contractsFromRoutes, defineRoutes } from "@beignet/next";
|
|
1486
|
+
import type { AppContext } from "@/app-context";
|
|
1038
1487
|
import { todoRoutes } from "@/features/todos/routes";
|
|
1039
1488
|
|
|
1040
1489
|
export const routes = defineRoutes<AppContext>([
|
|
@@ -1073,6 +1522,14 @@ export const env = createEnv({
|
|
|
1073
1522
|
APP_URL: z.string().url().default("http://localhost:3000"),
|
|
1074
1523
|
CRON_SECRET: z.string().min(1).optional(),
|
|
1075
1524
|
DEVTOOLS_ENABLED: BooleanEnv.optional(),
|
|
1525
|
+
BETTER_AUTH_SECRET: z
|
|
1526
|
+
.string()
|
|
1527
|
+
.min(32)
|
|
1528
|
+
.default("local-dev-better-auth-secret-change-me"),
|
|
1529
|
+
BETTER_AUTH_URL: z.string().url().default("http://localhost:3000"),
|
|
1530
|
+
BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
|
|
1531
|
+
TURSO_DB_URL: z.string().default("file:local.db"),
|
|
1532
|
+
TURSO_DB_AUTH_TOKEN: z.string().optional(),
|
|
1076
1533
|
LOG_LEVEL: z
|
|
1077
1534
|
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
|
1078
1535
|
.default("info"),
|
|
@@ -1084,24 +1541,28 @@ export const env = createEnv({
|
|
|
1084
1541
|
|
|
1085
1542
|
export const isProduction = env.NODE_ENV === "production";
|
|
1086
1543
|
`,
|
|
1087
|
-
productionAppContext: `import type {
|
|
1088
|
-
import type {
|
|
1544
|
+
productionAppContext: `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
|
|
1545
|
+
import type { InferProviderPorts } from "@beignet/core/providers";
|
|
1546
|
+
import type { TraceContext } from "@beignet/core/tracing";
|
|
1089
1547
|
import type { AppGate, AppPorts } from "@/ports";
|
|
1090
1548
|
import type { AuthSession } from "@/ports/auth";
|
|
1549
|
+
import type { providers } from "@/server/providers";
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* App ports plus the ports contributed by the server's providers at startup.
|
|
1553
|
+
*/
|
|
1554
|
+
export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
|
|
1091
1555
|
|
|
1092
1556
|
export type AppContext = {
|
|
1093
1557
|
requestId: string;
|
|
1094
1558
|
actor: ActivityActor;
|
|
1095
1559
|
auth: AuthSession | null;
|
|
1096
1560
|
gate: AppGate;
|
|
1097
|
-
ports:
|
|
1098
|
-
devtools: DevtoolsPort;
|
|
1099
|
-
storage: StoragePort;
|
|
1100
|
-
};
|
|
1561
|
+
ports: AppRuntimePorts;
|
|
1101
1562
|
tenant?: ActivityTenant;
|
|
1102
|
-
} & Partial<
|
|
1563
|
+
} & Partial<TraceContext>;
|
|
1103
1564
|
`,
|
|
1104
|
-
|
|
1565
|
+
productionSharedErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
|
|
1105
1566
|
|
|
1106
1567
|
export const errors = defineErrors({
|
|
1107
1568
|
Unauthorized: {
|
|
@@ -1138,10 +1599,14 @@ export const ListTodosInputSchema = z.object({
|
|
|
1138
1599
|
});
|
|
1139
1600
|
|
|
1140
1601
|
export const ListTodosOutputSchema = z.object({
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1602
|
+
items: z.array(TodoSchema),
|
|
1603
|
+
page: z.object({
|
|
1604
|
+
kind: z.literal("offset"),
|
|
1605
|
+
limit: z.number().int().min(1),
|
|
1606
|
+
offset: z.number().int().min(0),
|
|
1607
|
+
total: z.number().int().min(0),
|
|
1608
|
+
hasMore: z.boolean(),
|
|
1609
|
+
}),
|
|
1145
1610
|
});
|
|
1146
1611
|
|
|
1147
1612
|
export const GetTodoInputSchema = z.object({
|
|
@@ -1156,13 +1621,36 @@ export type Todo = z.infer<typeof TodoSchema>;
|
|
|
1156
1621
|
export type CreateTodoInput = z.infer<typeof CreateTodoInputSchema>;
|
|
1157
1622
|
export type ListTodosInput = z.infer<typeof ListTodosInputSchema>;
|
|
1158
1623
|
`,
|
|
1159
|
-
productionUseCaseBuilder: `import {
|
|
1160
|
-
import {
|
|
1624
|
+
productionUseCaseBuilder: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1625
|
+
import { createUseCase } from "@beignet/core/application";
|
|
1161
1626
|
import type { AppContext } from "@/app-context";
|
|
1162
1627
|
|
|
1163
|
-
export const useCase = createUseCase<AppContext>(
|
|
1164
|
-
|
|
1165
|
-
}
|
|
1628
|
+
export const useCase = createUseCase<AppContext>();
|
|
1629
|
+
`,
|
|
1630
|
+
productionListenersBuilder: `import { createListeners } from "@beignet/core/events";
|
|
1631
|
+
import type { AppContext } from "@/app-context";
|
|
1632
|
+
|
|
1633
|
+
export const { defineListener } = createListeners<AppContext>();
|
|
1634
|
+
`,
|
|
1635
|
+
productionJobsBuilder: `import { createJobs } from "@beignet/core/jobs";
|
|
1636
|
+
import type { AppContext } from "@/app-context";
|
|
1637
|
+
|
|
1638
|
+
export const { defineJob } = createJobs<AppContext>();
|
|
1639
|
+
`,
|
|
1640
|
+
productionSchedulesBuilder: `import { createSchedules } from "@beignet/core/schedules";
|
|
1641
|
+
import type { AppContext } from "@/app-context";
|
|
1642
|
+
|
|
1643
|
+
export const { defineSchedule } = createSchedules<AppContext>();
|
|
1644
|
+
`,
|
|
1645
|
+
productionNotificationsBuilder: `import { createNotifications } from "@beignet/core/notifications";
|
|
1646
|
+
import type { AppContext } from "@/app-context";
|
|
1647
|
+
|
|
1648
|
+
export const { defineNotification } = createNotifications<AppContext>();
|
|
1649
|
+
`,
|
|
1650
|
+
productionTasksBuilder: `import { createTasks } from "@beignet/core/tasks";
|
|
1651
|
+
import type { AppContext } from "@/app-context";
|
|
1652
|
+
|
|
1653
|
+
export const { defineTask } = createTasks<AppContext>();
|
|
1166
1654
|
`,
|
|
1167
1655
|
productionAuthHelpers: `import type { AppContext } from "@/app-context";
|
|
1168
1656
|
import type { AuthSession, AuthUser } from "@/ports/auth";
|
|
@@ -1180,26 +1668,28 @@ export function requireUser(ctx: AppContext): AuthUser {
|
|
|
1180
1668
|
return requireSession(ctx).user;
|
|
1181
1669
|
}
|
|
1182
1670
|
`,
|
|
1183
|
-
productionListTodosUseCase: `import {
|
|
1184
|
-
import {
|
|
1671
|
+
productionListTodosUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1672
|
+
import { normalizeOffsetPage } from "@beignet/core/pagination";
|
|
1673
|
+
import { useCase } from "@/lib/use-case";
|
|
1674
|
+
import { ListTodosInputSchema, ListTodosOutputSchema } from "../schemas";
|
|
1185
1675
|
|
|
1186
1676
|
export const listTodosUseCase = useCase
|
|
1187
1677
|
.query("todos.list")
|
|
1188
1678
|
.input(ListTodosInputSchema)
|
|
1189
1679
|
.output(ListTodosOutputSchema)
|
|
1190
1680
|
.run(async ({ ctx, input }) => {
|
|
1191
|
-
const
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
};
|
|
1681
|
+
const page = normalizeOffsetPage(input, {
|
|
1682
|
+
defaultLimit: 20,
|
|
1683
|
+
maxLimit: 100,
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
return ctx.ports.todos.list(page);
|
|
1198
1687
|
});
|
|
1199
1688
|
`,
|
|
1200
|
-
productionGetTodoUseCase: `import {
|
|
1689
|
+
productionGetTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1690
|
+
import { appError } from "@/features/shared/errors";
|
|
1201
1691
|
import { useCase } from "@/lib/use-case";
|
|
1202
|
-
import { GetTodoInputSchema, TodoSchema } from "
|
|
1692
|
+
import { GetTodoInputSchema, TodoSchema } from "../schemas";
|
|
1203
1693
|
|
|
1204
1694
|
export const getTodoUseCase = useCase
|
|
1205
1695
|
.query("todos.get")
|
|
@@ -1214,16 +1704,36 @@ export const getTodoUseCase = useCase
|
|
|
1214
1704
|
return todo;
|
|
1215
1705
|
});
|
|
1216
1706
|
`,
|
|
1217
|
-
productionCreateTodoUseCase: `import {
|
|
1218
|
-
import {
|
|
1707
|
+
productionCreateTodoUseCase: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1708
|
+
import { TodoCreated } from "@/features/todos/domain/events";
|
|
1709
|
+
import { auditEntry } from "@/lib/audit";
|
|
1710
|
+
import { useCase } from "@/lib/use-case";
|
|
1711
|
+
import { CreateTodoInputSchema, TodoSchema } from "../schemas";
|
|
1219
1712
|
|
|
1220
1713
|
export const createTodoUseCase = useCase
|
|
1221
1714
|
.command("todos.create")
|
|
1222
1715
|
.input(CreateTodoInputSchema)
|
|
1223
1716
|
.output(TodoSchema)
|
|
1224
|
-
.
|
|
1717
|
+
.emits([TodoCreated])
|
|
1718
|
+
.run(async ({ ctx, input, events }) => {
|
|
1225
1719
|
await ctx.gate.authorize("todos.create");
|
|
1226
|
-
|
|
1720
|
+
const todo = await ctx.ports.uow.transaction(async (tx) => {
|
|
1721
|
+
const created = await tx.todos.create(input);
|
|
1722
|
+
await events.record(tx.events, TodoCreated, {
|
|
1723
|
+
todoId: created.id,
|
|
1724
|
+
title: created.title,
|
|
1725
|
+
});
|
|
1726
|
+
await tx.audit.record(
|
|
1727
|
+
auditEntry(ctx, {
|
|
1728
|
+
action: "todos.create",
|
|
1729
|
+
resource: { type: "todo", id: created.id, name: created.title },
|
|
1730
|
+
metadata: { completed: created.completed },
|
|
1731
|
+
}),
|
|
1732
|
+
);
|
|
1733
|
+
return created;
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
return todo;
|
|
1227
1737
|
});
|
|
1228
1738
|
`,
|
|
1229
1739
|
productionUseCasesIndex: `export { createTodoUseCase } from "./create-todo";
|
|
@@ -1238,24 +1748,40 @@ export {
|
|
|
1238
1748
|
type CreateTodoInput,
|
|
1239
1749
|
type ListTodosInput,
|
|
1240
1750
|
type Todo,
|
|
1241
|
-
} from "
|
|
1751
|
+
} from "../schemas";
|
|
1242
1752
|
`,
|
|
1243
1753
|
productionTodoRepositoryPort: `import type {
|
|
1754
|
+
OffsetPage,
|
|
1755
|
+
OffsetPageInfo,
|
|
1756
|
+
PageResult,
|
|
1757
|
+
} from "@beignet/core/pagination";
|
|
1758
|
+
import type {
|
|
1244
1759
|
CreateTodoInput,
|
|
1245
|
-
ListTodosInput,
|
|
1246
1760
|
Todo,
|
|
1247
|
-
} from "@/features/todos/
|
|
1761
|
+
} from "@/features/todos/schemas";
|
|
1248
1762
|
|
|
1249
|
-
export type ListTodosResult =
|
|
1250
|
-
|
|
1251
|
-
|
|
1763
|
+
export type ListTodosResult = PageResult<Todo, OffsetPageInfo>;
|
|
1764
|
+
|
|
1765
|
+
export type TodoAttachment = {
|
|
1766
|
+
id: string;
|
|
1767
|
+
todoId: string;
|
|
1768
|
+
key: string;
|
|
1769
|
+
fileName: string;
|
|
1770
|
+
contentType: string;
|
|
1771
|
+
size: number;
|
|
1772
|
+
createdAt: string;
|
|
1252
1773
|
};
|
|
1253
1774
|
|
|
1254
1775
|
export interface TodoRepository {
|
|
1255
|
-
list(
|
|
1776
|
+
list(page: OffsetPage): Promise<ListTodosResult>;
|
|
1256
1777
|
findById(id: string): Promise<Todo | null>;
|
|
1257
1778
|
create(input: CreateTodoInput): Promise<Todo>;
|
|
1258
1779
|
}
|
|
1780
|
+
|
|
1781
|
+
export interface TodoAttachmentRepository {
|
|
1782
|
+
create(input: Omit<TodoAttachment, "createdAt">): Promise<TodoAttachment>;
|
|
1783
|
+
findMany(todoId: string): Promise<TodoAttachment[]>;
|
|
1784
|
+
}
|
|
1259
1785
|
`,
|
|
1260
1786
|
productionAuthPort: `import type {
|
|
1261
1787
|
AuthPort as BeignetAuthPort,
|
|
@@ -1325,12 +1851,16 @@ export const todoPolicy = definePolicy({
|
|
|
1325
1851
|
},
|
|
1326
1852
|
});
|
|
1327
1853
|
`,
|
|
1328
|
-
productionInMemoryTodoRepository: `import type {
|
|
1854
|
+
productionInMemoryTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1855
|
+
import { offsetPageResult } from "@beignet/core/pagination";
|
|
1856
|
+
import type {
|
|
1329
1857
|
CreateTodoInput,
|
|
1330
|
-
ListTodosInput,
|
|
1331
1858
|
Todo,
|
|
1332
|
-
} from "@/features/todos/
|
|
1333
|
-
import type {
|
|
1859
|
+
} from "@/features/todos/schemas";
|
|
1860
|
+
import type {
|
|
1861
|
+
TodoAttachmentRepository,
|
|
1862
|
+
TodoRepository,
|
|
1863
|
+
} from "@/features/todos/ports";
|
|
1334
1864
|
|
|
1335
1865
|
export function createInMemoryTodoRepository(
|
|
1336
1866
|
seed: Todo[] = [],
|
|
@@ -1338,15 +1868,16 @@ export function createInMemoryTodoRepository(
|
|
|
1338
1868
|
const todos = new Map(seed.map((todo) => [todo.id, todo]));
|
|
1339
1869
|
|
|
1340
1870
|
return {
|
|
1341
|
-
async list(
|
|
1871
|
+
async list(page) {
|
|
1342
1872
|
const allTodos = Array.from(todos.values()).sort((left, right) =>
|
|
1343
1873
|
left.createdAt.localeCompare(right.createdAt),
|
|
1344
1874
|
);
|
|
1345
1875
|
|
|
1346
|
-
return
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1876
|
+
return offsetPageResult(
|
|
1877
|
+
allTodos.slice(page.offset, page.offset + page.limit),
|
|
1878
|
+
page,
|
|
1879
|
+
allTodos.length,
|
|
1880
|
+
);
|
|
1350
1881
|
},
|
|
1351
1882
|
async findById(id: string) {
|
|
1352
1883
|
return todos.get(id) ?? null;
|
|
@@ -1409,28 +1940,6 @@ export const appPorts = definePorts({
|
|
|
1409
1940
|
todos,
|
|
1410
1941
|
})),
|
|
1411
1942
|
});
|
|
1412
|
-
`,
|
|
1413
|
-
productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
|
|
1414
|
-
import { todoPolicy } from "@/features/todos/policy";
|
|
1415
|
-
import { appError } from "@/features/shared/errors";
|
|
1416
|
-
import { createAnonymousAuth } from "./auth/anonymous-auth";
|
|
1417
|
-
import { fallbackLogger } from "./logger";
|
|
1418
|
-
|
|
1419
|
-
const gate = createGate({
|
|
1420
|
-
policies: [todoPolicy],
|
|
1421
|
-
onDeny(decision) {
|
|
1422
|
-
return appError("Forbidden", {
|
|
1423
|
-
message: decision.reason ?? "Forbidden",
|
|
1424
|
-
details: decision.details,
|
|
1425
|
-
});
|
|
1426
|
-
},
|
|
1427
|
-
});
|
|
1428
|
-
|
|
1429
|
-
export const appPorts = definePorts({
|
|
1430
|
-
auth: createAnonymousAuth(),
|
|
1431
|
-
gate,
|
|
1432
|
-
logger: fallbackLogger,
|
|
1433
|
-
});
|
|
1434
1943
|
`,
|
|
1435
1944
|
productionDrizzleConfig: `export default {
|
|
1436
1945
|
schema: "./infra/db/schema/index.ts",
|
|
@@ -1442,7 +1951,63 @@ export const appPorts = definePorts({
|
|
|
1442
1951
|
},
|
|
1443
1952
|
};
|
|
1444
1953
|
`,
|
|
1445
|
-
productionDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
1954
|
+
productionDbSchema: `import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
1955
|
+
|
|
1956
|
+
export const user = sqliteTable("user", {
|
|
1957
|
+
id: text("id").primaryKey(),
|
|
1958
|
+
name: text("name").notNull(),
|
|
1959
|
+
email: text("email").notNull().unique(),
|
|
1960
|
+
emailVerified: integer("email_verified", { mode: "boolean" })
|
|
1961
|
+
.notNull()
|
|
1962
|
+
.default(false),
|
|
1963
|
+
image: text("image"),
|
|
1964
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
1965
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
export const session = sqliteTable("session", {
|
|
1969
|
+
id: text("id").primaryKey(),
|
|
1970
|
+
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
1971
|
+
token: text("token").notNull().unique(),
|
|
1972
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
1973
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
1974
|
+
ipAddress: text("ip_address"),
|
|
1975
|
+
userAgent: text("user_agent"),
|
|
1976
|
+
userId: text("user_id")
|
|
1977
|
+
.notNull()
|
|
1978
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
export const account = sqliteTable("account", {
|
|
1982
|
+
id: text("id").primaryKey(),
|
|
1983
|
+
accountId: text("account_id").notNull(),
|
|
1984
|
+
providerId: text("provider_id").notNull(),
|
|
1985
|
+
userId: text("user_id")
|
|
1986
|
+
.notNull()
|
|
1987
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
1988
|
+
accessToken: text("access_token"),
|
|
1989
|
+
refreshToken: text("refresh_token"),
|
|
1990
|
+
idToken: text("id_token"),
|
|
1991
|
+
accessTokenExpiresAt: integer("access_token_expires_at", {
|
|
1992
|
+
mode: "timestamp",
|
|
1993
|
+
}),
|
|
1994
|
+
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
|
1995
|
+
mode: "timestamp",
|
|
1996
|
+
}),
|
|
1997
|
+
scope: text("scope"),
|
|
1998
|
+
password: text("password"),
|
|
1999
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
2000
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
export const verification = sqliteTable("verification", {
|
|
2004
|
+
id: text("id").primaryKey(),
|
|
2005
|
+
identifier: text("identifier").notNull(),
|
|
2006
|
+
value: text("value").notNull(),
|
|
2007
|
+
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
2008
|
+
createdAt: integer("created_at", { mode: "timestamp" }),
|
|
2009
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }),
|
|
2010
|
+
});
|
|
1446
2011
|
|
|
1447
2012
|
export const todos = sqliteTable("todos", {
|
|
1448
2013
|
id: text("id").primaryKey(),
|
|
@@ -1450,15 +2015,139 @@ export const todos = sqliteTable("todos", {
|
|
|
1450
2015
|
completed: integer("completed", { mode: "boolean" }).notNull().default(false),
|
|
1451
2016
|
createdAt: text("created_at").notNull(),
|
|
1452
2017
|
});
|
|
2018
|
+
|
|
2019
|
+
export const todoAttachments = sqliteTable(
|
|
2020
|
+
"todo_attachments",
|
|
2021
|
+
{
|
|
2022
|
+
id: text("id").primaryKey(),
|
|
2023
|
+
todoId: text("todo_id")
|
|
2024
|
+
.notNull()
|
|
2025
|
+
.references(() => todos.id, { onDelete: "cascade" }),
|
|
2026
|
+
key: text("key").notNull(),
|
|
2027
|
+
fileName: text("file_name").notNull(),
|
|
2028
|
+
contentType: text("content_type").notNull(),
|
|
2029
|
+
size: integer("size").notNull(),
|
|
2030
|
+
createdAt: text("created_at").notNull(),
|
|
2031
|
+
},
|
|
2032
|
+
(table) => ({
|
|
2033
|
+
todoIdx: index("todo_attachments_todo_idx").on(
|
|
2034
|
+
table.todoId,
|
|
2035
|
+
table.createdAt,
|
|
2036
|
+
),
|
|
2037
|
+
keyIdx: index("todo_attachments_key_idx").on(table.key),
|
|
2038
|
+
}),
|
|
2039
|
+
);
|
|
2040
|
+
|
|
2041
|
+
export const auditLog = sqliteTable(
|
|
2042
|
+
"audit_log",
|
|
2043
|
+
{
|
|
2044
|
+
id: text("id").primaryKey(),
|
|
2045
|
+
action: text("action").notNull(),
|
|
2046
|
+
actorType: text("actor_type", {
|
|
2047
|
+
enum: ["anonymous", "service", "system", "user"],
|
|
2048
|
+
}).notNull(),
|
|
2049
|
+
actorId: text("actor_id"),
|
|
2050
|
+
actorDisplayName: text("actor_display_name"),
|
|
2051
|
+
tenantId: text("tenant_id"),
|
|
2052
|
+
tenantSlug: text("tenant_slug"),
|
|
2053
|
+
resourceType: text("resource_type"),
|
|
2054
|
+
resourceId: text("resource_id"),
|
|
2055
|
+
resourceName: text("resource_name"),
|
|
2056
|
+
outcome: text("outcome", { enum: ["success", "failure"] })
|
|
2057
|
+
.notNull()
|
|
2058
|
+
.default("success"),
|
|
2059
|
+
requestId: text("request_id"),
|
|
2060
|
+
traceId: text("trace_id"),
|
|
2061
|
+
message: text("message"),
|
|
2062
|
+
metadata: text("metadata"),
|
|
2063
|
+
actorMetadata: text("actor_metadata"),
|
|
2064
|
+
tenantMetadata: text("tenant_metadata"),
|
|
2065
|
+
resourceMetadata: text("resource_metadata"),
|
|
2066
|
+
occurredAt: text("occurred_at").notNull(),
|
|
2067
|
+
},
|
|
2068
|
+
(table) => ({
|
|
2069
|
+
actionIdx: index("audit_log_action_idx").on(table.action),
|
|
2070
|
+
actorIdx: index("audit_log_actor_idx").on(table.actorType, table.actorId),
|
|
2071
|
+
occurredAtIdx: index("audit_log_occurred_at_idx").on(table.occurredAt),
|
|
2072
|
+
requestIdx: index("audit_log_request_idx").on(table.requestId),
|
|
2073
|
+
resourceIdx: index("audit_log_resource_idx").on(
|
|
2074
|
+
table.resourceType,
|
|
2075
|
+
table.resourceId,
|
|
2076
|
+
),
|
|
2077
|
+
tenantIdx: index("audit_log_tenant_idx").on(table.tenantId),
|
|
2078
|
+
}),
|
|
2079
|
+
);
|
|
2080
|
+
|
|
2081
|
+
export const outboxMessages = sqliteTable(
|
|
2082
|
+
"outbox_messages",
|
|
2083
|
+
{
|
|
2084
|
+
id: text("id").primaryKey(),
|
|
2085
|
+
kind: text("kind", { enum: ["event", "job"] }).notNull(),
|
|
2086
|
+
name: text("name").notNull(),
|
|
2087
|
+
payloadJson: text("payload_json").notNull(),
|
|
2088
|
+
status: text("status", {
|
|
2089
|
+
enum: ["pending", "claimed", "delivered", "deadLettered"],
|
|
2090
|
+
}).notNull(),
|
|
2091
|
+
attempts: integer("attempts").notNull().default(0),
|
|
2092
|
+
maxAttempts: integer("max_attempts").notNull().default(3),
|
|
2093
|
+
availableAt: text("available_at").notNull(),
|
|
2094
|
+
claimedAt: text("claimed_at"),
|
|
2095
|
+
lockedUntil: text("locked_until"),
|
|
2096
|
+
claimToken: text("claim_token"),
|
|
2097
|
+
deliveredAt: text("delivered_at"),
|
|
2098
|
+
lastErrorJson: text("last_error_json"),
|
|
2099
|
+
createdAt: text("created_at").notNull(),
|
|
2100
|
+
updatedAt: text("updated_at").notNull(),
|
|
2101
|
+
},
|
|
2102
|
+
(table) => ({
|
|
2103
|
+
availableIdx: index("outbox_messages_available_idx").on(
|
|
2104
|
+
table.status,
|
|
2105
|
+
table.availableAt,
|
|
2106
|
+
),
|
|
2107
|
+
lockedIdx: index("outbox_messages_locked_idx").on(
|
|
2108
|
+
table.status,
|
|
2109
|
+
table.lockedUntil,
|
|
2110
|
+
),
|
|
2111
|
+
}),
|
|
2112
|
+
);
|
|
2113
|
+
|
|
2114
|
+
export const idempotencyRecords = sqliteTable(
|
|
2115
|
+
"idempotency_records",
|
|
2116
|
+
{
|
|
2117
|
+
storageKey: text("storage_key").primaryKey(),
|
|
2118
|
+
namespace: text("namespace").notNull(),
|
|
2119
|
+
idempotencyKey: text("idempotency_key").notNull(),
|
|
2120
|
+
scopeKey: text("scope_key").notNull(),
|
|
2121
|
+
fingerprint: text("fingerprint").notNull(),
|
|
2122
|
+
status: text("status", { enum: ["in-progress", "completed"] }).notNull(),
|
|
2123
|
+
resultJson: text("result_json"),
|
|
2124
|
+
reservedAt: text("reserved_at").notNull(),
|
|
2125
|
+
completedAt: text("completed_at"),
|
|
2126
|
+
expiresAt: text("expires_at"),
|
|
2127
|
+
updatedAt: text("updated_at").notNull(),
|
|
2128
|
+
},
|
|
2129
|
+
(table) => ({
|
|
2130
|
+
lookupIdx: index("idempotency_records_lookup_idx").on(
|
|
2131
|
+
table.namespace,
|
|
2132
|
+
table.scopeKey,
|
|
2133
|
+
table.idempotencyKey,
|
|
2134
|
+
),
|
|
2135
|
+
expiresIdx: index("idempotency_records_expires_idx").on(table.expiresAt),
|
|
2136
|
+
}),
|
|
2137
|
+
);
|
|
1453
2138
|
`,
|
|
1454
|
-
productionDrizzleTodoRepository: `import {
|
|
2139
|
+
productionDrizzleTodoRepository: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2140
|
+
import { offsetPageResult } from "@beignet/core/pagination";
|
|
2141
|
+
import { count, desc, eq } from "drizzle-orm";
|
|
1455
2142
|
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
1456
|
-
import type {
|
|
2143
|
+
import type {
|
|
2144
|
+
TodoAttachmentRepository,
|
|
2145
|
+
TodoRepository,
|
|
2146
|
+
} from "@/features/todos/ports";
|
|
1457
2147
|
import type {
|
|
1458
2148
|
CreateTodoInput,
|
|
1459
|
-
ListTodosInput,
|
|
1460
2149
|
Todo,
|
|
1461
|
-
} from "@/features/todos/
|
|
2150
|
+
} from "@/features/todos/schemas";
|
|
1462
2151
|
import * as schema from "@/infra/db/schema";
|
|
1463
2152
|
|
|
1464
2153
|
type TodoRow = typeof schema.todos.$inferSelect;
|
|
@@ -1476,19 +2165,16 @@ export function createDrizzleTodoRepository(
|
|
|
1476
2165
|
db: DrizzleTursoDatabase<typeof schema>,
|
|
1477
2166
|
): TodoRepository {
|
|
1478
2167
|
return {
|
|
1479
|
-
async list(
|
|
2168
|
+
async list(page) {
|
|
1480
2169
|
const rows = await db
|
|
1481
2170
|
.select()
|
|
1482
2171
|
.from(schema.todos)
|
|
1483
2172
|
.orderBy(desc(schema.todos.createdAt))
|
|
1484
|
-
.limit(
|
|
1485
|
-
.offset(
|
|
2173
|
+
.limit(page.limit)
|
|
2174
|
+
.offset(page.offset);
|
|
1486
2175
|
const [{ total }] = await db.select({ total: count() }).from(schema.todos);
|
|
1487
2176
|
|
|
1488
|
-
return
|
|
1489
|
-
todos: rows.map(toTodo),
|
|
1490
|
-
total,
|
|
1491
|
-
};
|
|
2177
|
+
return offsetPageResult(rows.map(toTodo), page, total);
|
|
1492
2178
|
},
|
|
1493
2179
|
async findById(id: string) {
|
|
1494
2180
|
const [row] = await db
|
|
@@ -1516,28 +2202,445 @@ export function createDrizzleTodoRepository(
|
|
|
1516
2202
|
},
|
|
1517
2203
|
};
|
|
1518
2204
|
}
|
|
2205
|
+
|
|
2206
|
+
export function createDrizzleTodoAttachmentRepository(
|
|
2207
|
+
db: DrizzleTursoDatabase<typeof schema>,
|
|
2208
|
+
): TodoAttachmentRepository {
|
|
2209
|
+
return {
|
|
2210
|
+
async create(input) {
|
|
2211
|
+
const attachment = {
|
|
2212
|
+
...input,
|
|
2213
|
+
createdAt: new Date().toISOString(),
|
|
2214
|
+
};
|
|
2215
|
+
const [row] = await db
|
|
2216
|
+
.insert(schema.todoAttachments)
|
|
2217
|
+
.values(attachment)
|
|
2218
|
+
.returning();
|
|
2219
|
+
|
|
2220
|
+
if (!row) {
|
|
2221
|
+
throw new Error("Failed to create todo attachment");
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
return {
|
|
2225
|
+
id: row.id,
|
|
2226
|
+
todoId: row.todoId,
|
|
2227
|
+
key: row.key,
|
|
2228
|
+
fileName: row.fileName,
|
|
2229
|
+
contentType: row.contentType,
|
|
2230
|
+
size: row.size,
|
|
2231
|
+
createdAt: row.createdAt,
|
|
2232
|
+
};
|
|
2233
|
+
},
|
|
2234
|
+
async findMany(todoId) {
|
|
2235
|
+
const rows = await db
|
|
2236
|
+
.select()
|
|
2237
|
+
.from(schema.todoAttachments)
|
|
2238
|
+
.where(eq(schema.todoAttachments.todoId, todoId))
|
|
2239
|
+
.orderBy(desc(schema.todoAttachments.createdAt));
|
|
2240
|
+
|
|
2241
|
+
return rows.map((row) => ({
|
|
2242
|
+
id: row.id,
|
|
2243
|
+
todoId: row.todoId,
|
|
2244
|
+
key: row.key,
|
|
2245
|
+
fileName: row.fileName,
|
|
2246
|
+
contentType: row.contentType,
|
|
2247
|
+
size: row.size,
|
|
2248
|
+
createdAt: row.createdAt,
|
|
2249
|
+
}));
|
|
2250
|
+
},
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
1519
2253
|
`,
|
|
1520
2254
|
productionDbRepositories: `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
1521
|
-
import {
|
|
2255
|
+
import {
|
|
2256
|
+
createDrizzleTodoAttachmentRepository,
|
|
2257
|
+
createDrizzleTodoRepository,
|
|
2258
|
+
} from "@/infra/todos/drizzle-todo-repository";
|
|
1522
2259
|
import type { AppTransactionPorts } from "@/ports";
|
|
1523
2260
|
import * as schema from "./schema";
|
|
1524
2261
|
|
|
1525
2262
|
export function createRepositories(
|
|
1526
2263
|
db: DrizzleTursoDatabase<typeof schema>,
|
|
1527
|
-
): Omit<
|
|
2264
|
+
): Omit<
|
|
2265
|
+
AppTransactionPorts,
|
|
2266
|
+
"audit" | "events" | "idempotency" | "jobs" | "outbox"
|
|
2267
|
+
> {
|
|
1528
2268
|
return {
|
|
2269
|
+
todoAttachments: createDrizzleTodoAttachmentRepository(db),
|
|
1529
2270
|
todos: createDrizzleTodoRepository(db),
|
|
1530
2271
|
};
|
|
1531
2272
|
}
|
|
1532
2273
|
`,
|
|
1533
|
-
|
|
2274
|
+
productionDrizzleAuditLog: `import type { AuditLogEntry, AuditLogPort } from "@beignet/core/ports";
|
|
2275
|
+
import {
|
|
2276
|
+
normalizeAuditLogEntry,
|
|
2277
|
+
redactAuditLogEntry,
|
|
2278
|
+
} from "@beignet/core/ports";
|
|
2279
|
+
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
2280
|
+
import * as schema from "@/infra/db/schema";
|
|
2281
|
+
|
|
2282
|
+
function serialize(value: unknown): string | null {
|
|
2283
|
+
return value === undefined ? null : JSON.stringify(value);
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
export function createDrizzleAuditLog(
|
|
2287
|
+
db: DrizzleTursoDatabase<typeof schema>,
|
|
2288
|
+
): AuditLogPort {
|
|
2289
|
+
return {
|
|
2290
|
+
async record(input) {
|
|
2291
|
+
const entry: AuditLogEntry = redactAuditLogEntry(
|
|
2292
|
+
normalizeAuditLogEntry(input),
|
|
2293
|
+
);
|
|
2294
|
+
|
|
2295
|
+
await db.insert(schema.auditLog).values({
|
|
2296
|
+
id: crypto.randomUUID(),
|
|
2297
|
+
action: entry.action,
|
|
2298
|
+
actorType: entry.actor.type,
|
|
2299
|
+
actorId: entry.actor.id ?? null,
|
|
2300
|
+
actorDisplayName: entry.actor.displayName ?? null,
|
|
2301
|
+
tenantId: entry.tenant?.id ?? null,
|
|
2302
|
+
tenantSlug: entry.tenant?.slug ?? null,
|
|
2303
|
+
resourceType: entry.resource?.type ?? null,
|
|
2304
|
+
resourceId: entry.resource?.id ?? null,
|
|
2305
|
+
resourceName: entry.resource?.name ?? null,
|
|
2306
|
+
outcome: entry.outcome,
|
|
2307
|
+
requestId: entry.requestId ?? null,
|
|
2308
|
+
traceId: entry.traceId ?? null,
|
|
2309
|
+
message: entry.message ?? null,
|
|
2310
|
+
metadata: serialize(entry.metadata),
|
|
2311
|
+
actorMetadata: serialize(entry.actor.metadata),
|
|
2312
|
+
tenantMetadata: serialize(entry.tenant?.metadata),
|
|
2313
|
+
resourceMetadata: serialize(entry.resource?.metadata),
|
|
2314
|
+
occurredAt: entry.occurredAt.toISOString(),
|
|
2315
|
+
});
|
|
2316
|
+
},
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
`,
|
|
2320
|
+
productionDbBootstrap: `import { createDrizzleTursoIdempotencySetupStatements, createDrizzleTursoOutboxSetupStatements } from "@beignet/provider-drizzle-turso";
|
|
2321
|
+
import type { Client } from "@libsql/client";
|
|
2322
|
+
|
|
2323
|
+
type BootstrapOptions = {
|
|
2324
|
+
seed?: boolean;
|
|
2325
|
+
};
|
|
2326
|
+
|
|
2327
|
+
const setupStatements = [
|
|
2328
|
+
\`CREATE TABLE IF NOT EXISTS user (
|
|
2329
|
+
id text PRIMARY KEY NOT NULL,
|
|
2330
|
+
name text NOT NULL,
|
|
2331
|
+
email text NOT NULL UNIQUE,
|
|
2332
|
+
email_verified integer DEFAULT false NOT NULL,
|
|
2333
|
+
image text,
|
|
2334
|
+
created_at integer NOT NULL,
|
|
2335
|
+
updated_at integer NOT NULL
|
|
2336
|
+
)\`,
|
|
2337
|
+
\`CREATE TABLE IF NOT EXISTS session (
|
|
2338
|
+
id text PRIMARY KEY NOT NULL,
|
|
2339
|
+
expires_at integer NOT NULL,
|
|
2340
|
+
token text NOT NULL UNIQUE,
|
|
2341
|
+
created_at integer NOT NULL,
|
|
2342
|
+
updated_at integer NOT NULL,
|
|
2343
|
+
ip_address text,
|
|
2344
|
+
user_agent text,
|
|
2345
|
+
user_id text NOT NULL REFERENCES user(id) ON DELETE cascade
|
|
2346
|
+
)\`,
|
|
2347
|
+
\`CREATE TABLE IF NOT EXISTS account (
|
|
2348
|
+
id text PRIMARY KEY NOT NULL,
|
|
2349
|
+
account_id text NOT NULL,
|
|
2350
|
+
provider_id text NOT NULL,
|
|
2351
|
+
user_id text NOT NULL REFERENCES user(id) ON DELETE cascade,
|
|
2352
|
+
access_token text,
|
|
2353
|
+
refresh_token text,
|
|
2354
|
+
id_token text,
|
|
2355
|
+
access_token_expires_at integer,
|
|
2356
|
+
refresh_token_expires_at integer,
|
|
2357
|
+
scope text,
|
|
2358
|
+
password text,
|
|
2359
|
+
created_at integer NOT NULL,
|
|
2360
|
+
updated_at integer NOT NULL
|
|
2361
|
+
)\`,
|
|
2362
|
+
\`CREATE TABLE IF NOT EXISTS verification (
|
|
2363
|
+
id text PRIMARY KEY NOT NULL,
|
|
2364
|
+
identifier text NOT NULL,
|
|
2365
|
+
value text NOT NULL,
|
|
2366
|
+
expires_at integer NOT NULL,
|
|
2367
|
+
created_at integer,
|
|
2368
|
+
updated_at integer
|
|
2369
|
+
)\`,
|
|
2370
|
+
\`CREATE TABLE IF NOT EXISTS todos (
|
|
2371
|
+
id text PRIMARY KEY NOT NULL,
|
|
2372
|
+
title text NOT NULL,
|
|
2373
|
+
completed integer DEFAULT false NOT NULL,
|
|
2374
|
+
created_at text NOT NULL
|
|
2375
|
+
)\`,
|
|
2376
|
+
\`CREATE TABLE IF NOT EXISTS todo_attachments (
|
|
2377
|
+
id text PRIMARY KEY NOT NULL,
|
|
2378
|
+
todo_id text NOT NULL REFERENCES todos(id) ON DELETE cascade,
|
|
2379
|
+
key text NOT NULL,
|
|
2380
|
+
file_name text NOT NULL,
|
|
2381
|
+
content_type text NOT NULL,
|
|
2382
|
+
size integer NOT NULL,
|
|
2383
|
+
created_at text NOT NULL
|
|
2384
|
+
)\`,
|
|
2385
|
+
"CREATE INDEX IF NOT EXISTS todo_attachments_todo_idx ON todo_attachments (todo_id, created_at)",
|
|
2386
|
+
"CREATE INDEX IF NOT EXISTS todo_attachments_key_idx ON todo_attachments (key)",
|
|
2387
|
+
\`CREATE TABLE IF NOT EXISTS audit_log (
|
|
2388
|
+
id text PRIMARY KEY NOT NULL,
|
|
2389
|
+
action text NOT NULL,
|
|
2390
|
+
actor_type text NOT NULL,
|
|
2391
|
+
actor_id text,
|
|
2392
|
+
actor_display_name text,
|
|
2393
|
+
tenant_id text,
|
|
2394
|
+
tenant_slug text,
|
|
2395
|
+
resource_type text,
|
|
2396
|
+
resource_id text,
|
|
2397
|
+
resource_name text,
|
|
2398
|
+
outcome text DEFAULT 'success' NOT NULL,
|
|
2399
|
+
request_id text,
|
|
2400
|
+
trace_id text,
|
|
2401
|
+
message text,
|
|
2402
|
+
metadata text,
|
|
2403
|
+
actor_metadata text,
|
|
2404
|
+
tenant_metadata text,
|
|
2405
|
+
resource_metadata text,
|
|
2406
|
+
occurred_at text NOT NULL
|
|
2407
|
+
)\`,
|
|
2408
|
+
"CREATE INDEX IF NOT EXISTS audit_log_action_idx ON audit_log (action)",
|
|
2409
|
+
"CREATE INDEX IF NOT EXISTS audit_log_actor_idx ON audit_log (actor_type, actor_id)",
|
|
2410
|
+
"CREATE INDEX IF NOT EXISTS audit_log_occurred_at_idx ON audit_log (occurred_at)",
|
|
2411
|
+
"CREATE INDEX IF NOT EXISTS audit_log_request_idx ON audit_log (request_id)",
|
|
2412
|
+
"CREATE INDEX IF NOT EXISTS audit_log_resource_idx ON audit_log (resource_type, resource_id)",
|
|
2413
|
+
"CREATE INDEX IF NOT EXISTS audit_log_tenant_idx ON audit_log (tenant_id)",
|
|
2414
|
+
...createDrizzleTursoIdempotencySetupStatements(),
|
|
2415
|
+
...createDrizzleTursoOutboxSetupStatements(),
|
|
2416
|
+
];
|
|
2417
|
+
|
|
2418
|
+
const seedTodos = [
|
|
2419
|
+
{
|
|
2420
|
+
id: "00000000-0000-4000-8000-000000000001",
|
|
2421
|
+
title: "Review the starter boundaries",
|
|
2422
|
+
completed: 0,
|
|
2423
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
2424
|
+
},
|
|
2425
|
+
{
|
|
2426
|
+
id: "00000000-0000-4000-8000-000000000002",
|
|
2427
|
+
title: "Create your first feature",
|
|
2428
|
+
completed: 0,
|
|
2429
|
+
createdAt: "2026-01-01T00:05:00.000Z",
|
|
2430
|
+
},
|
|
2431
|
+
] as const;
|
|
2432
|
+
|
|
2433
|
+
const resetTables = [
|
|
2434
|
+
"verification",
|
|
2435
|
+
"account",
|
|
2436
|
+
"session",
|
|
2437
|
+
"user",
|
|
2438
|
+
"todo_attachments",
|
|
2439
|
+
"todos",
|
|
2440
|
+
"audit_log",
|
|
2441
|
+
"idempotency_records",
|
|
2442
|
+
"outbox_messages",
|
|
2443
|
+
] as const;
|
|
2444
|
+
|
|
2445
|
+
export async function ensureStarterDatabase(
|
|
2446
|
+
client: Client,
|
|
2447
|
+
options: BootstrapOptions = {},
|
|
2448
|
+
): Promise<void> {
|
|
2449
|
+
for (const statement of setupStatements) {
|
|
2450
|
+
await client.execute(statement);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
if (options.seed === false) return;
|
|
2454
|
+
|
|
2455
|
+
const result = await client.execute("SELECT count(*) as total FROM todos");
|
|
2456
|
+
const total = Number(result.rows[0]?.total ?? 0);
|
|
2457
|
+
if (total > 0) return;
|
|
2458
|
+
|
|
2459
|
+
for (const todo of seedTodos) {
|
|
2460
|
+
await client.execute({
|
|
2461
|
+
sql: "INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
|
|
2462
|
+
args: [todo.id, todo.title, todo.completed, todo.createdAt],
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
export async function resetStarterDatabase(client: Client): Promise<void> {
|
|
2468
|
+
await client.execute("PRAGMA foreign_keys = OFF");
|
|
2469
|
+
try {
|
|
2470
|
+
for (const table of resetTables) {
|
|
2471
|
+
await client.execute(\`DROP TABLE IF EXISTS \${table}\`);
|
|
2472
|
+
}
|
|
2473
|
+
} finally {
|
|
2474
|
+
await client.execute("PRAGMA foreign_keys = ON");
|
|
2475
|
+
}
|
|
2476
|
+
await ensureStarterDatabase(client, { seed: false });
|
|
2477
|
+
}
|
|
2478
|
+
`,
|
|
2479
|
+
productionDbProvider: `import { registerListeners } from "@beignet/core/events";
|
|
2480
|
+
import { createInlineJobDispatcher } from "@beignet/core/jobs";
|
|
2481
|
+
import { createMemoryMailer } from "@beignet/core/mail";
|
|
2482
|
+
import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
|
|
2483
|
+
import {
|
|
2484
|
+
createOutboxEventRecorder,
|
|
2485
|
+
createOutboxJobDispatcher,
|
|
2486
|
+
} from "@beignet/core/outbox";
|
|
2487
|
+
import {
|
|
2488
|
+
createInstrumentedAuditLog,
|
|
2489
|
+
createSystemActor,
|
|
2490
|
+
} from "@beignet/core/ports";
|
|
2491
|
+
import {
|
|
2492
|
+
createProvider,
|
|
2493
|
+
createProviderInstrumentation,
|
|
2494
|
+
} from "@beignet/core/providers";
|
|
2495
|
+
import {
|
|
2496
|
+
createDrizzleTursoIdempotencyPort,
|
|
2497
|
+
createDrizzleTursoOutboxPort,
|
|
2498
|
+
createDrizzleTursoUnitOfWork,
|
|
2499
|
+
type DbPort,
|
|
2500
|
+
} from "@beignet/provider-drizzle-turso";
|
|
2501
|
+
import { createInMemoryEventBus } from "@beignet/provider-event-bus-memory";
|
|
2502
|
+
import type { AppContext } from "@/app-context";
|
|
2503
|
+
import { todoListeners } from "@/features/todos/listeners";
|
|
2504
|
+
import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
|
|
2505
|
+
import type { AppPorts } from "@/ports";
|
|
2506
|
+
import type { AppServiceContextInput } from "@/server/context";
|
|
2507
|
+
import { ensureStarterDatabase } from "./bootstrap";
|
|
2508
|
+
import { createRepositories } from "./repositories";
|
|
2509
|
+
import type * as schema from "./schema";
|
|
2510
|
+
|
|
2511
|
+
export const starterDatabaseProvider = createProvider<
|
|
2512
|
+
{ db: DbPort<typeof schema> },
|
|
2513
|
+
AppContext,
|
|
2514
|
+
AppServiceContextInput
|
|
2515
|
+
>()({
|
|
2516
|
+
name: "starter-database",
|
|
2517
|
+
|
|
2518
|
+
async setup({ ports, createServiceContext }) {
|
|
2519
|
+
const dbPort = ports.db;
|
|
2520
|
+
if (!dbPort) {
|
|
2521
|
+
throw new Error(
|
|
2522
|
+
"starterDatabaseProvider requires a db port. Register createDrizzleTursoProvider({ schema }) before it.",
|
|
2523
|
+
);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
const createBackgroundContext = () =>
|
|
2527
|
+
createServiceContext({
|
|
2528
|
+
actor: createSystemActor("starter-background"),
|
|
2529
|
+
});
|
|
2530
|
+
|
|
2531
|
+
const mailInstrumentation = createProviderInstrumentation(ports, {
|
|
2532
|
+
providerName: "memory-mailer",
|
|
2533
|
+
watcher: "mail",
|
|
2534
|
+
});
|
|
2535
|
+
const eventBus = createInMemoryEventBus({
|
|
2536
|
+
instrumentation: ports,
|
|
2537
|
+
onHandlerError(error: unknown, eventName: string) {
|
|
2538
|
+
console.error("Event handler failed", { error, eventName });
|
|
2539
|
+
},
|
|
2540
|
+
});
|
|
2541
|
+
const jobs = createInlineJobDispatcher<AppContext>({
|
|
2542
|
+
ctx: createBackgroundContext,
|
|
2543
|
+
});
|
|
2544
|
+
const notifications = createInlineNotificationDispatcher<AppContext>({
|
|
2545
|
+
ctx: createBackgroundContext,
|
|
2546
|
+
instrumentation: ports,
|
|
2547
|
+
});
|
|
2548
|
+
const mailer = createMemoryMailer({
|
|
2549
|
+
defaultFrom: "Beignet Starter <noreply@example.local>",
|
|
2550
|
+
onSend(delivery) {
|
|
2551
|
+
mailInstrumentation.custom({
|
|
2552
|
+
name: "mail.sent",
|
|
2553
|
+
label: "Mail sent",
|
|
2554
|
+
summary: delivery.message.subject,
|
|
2555
|
+
details: {
|
|
2556
|
+
to: delivery.message.to,
|
|
2557
|
+
subject: delivery.message.subject,
|
|
2558
|
+
id: delivery.id,
|
|
2559
|
+
},
|
|
2560
|
+
});
|
|
2561
|
+
},
|
|
2562
|
+
});
|
|
2563
|
+
const audit = createInstrumentedAuditLog({
|
|
2564
|
+
audit: createDrizzleAuditLog(dbPort.db),
|
|
2565
|
+
instrumentation: ports,
|
|
2566
|
+
});
|
|
2567
|
+
const repositories = createRepositories(dbPort.db);
|
|
2568
|
+
const idempotency = createDrizzleTursoIdempotencyPort(dbPort.db);
|
|
2569
|
+
const outbox = createDrizzleTursoOutboxPort(dbPort.db);
|
|
2570
|
+
const unregisterListeners = registerListeners(eventBus, todoListeners, {
|
|
2571
|
+
ctx: createBackgroundContext,
|
|
2572
|
+
onError(error, listener) {
|
|
2573
|
+
console.error("Event listener failed", {
|
|
2574
|
+
error,
|
|
2575
|
+
listenerName: listener.name,
|
|
2576
|
+
});
|
|
2577
|
+
},
|
|
2578
|
+
});
|
|
2579
|
+
|
|
2580
|
+
const providedPorts: Pick<
|
|
2581
|
+
AppPorts,
|
|
2582
|
+
| "audit"
|
|
2583
|
+
| "eventBus"
|
|
2584
|
+
| "idempotency"
|
|
2585
|
+
| "jobs"
|
|
2586
|
+
| "mailer"
|
|
2587
|
+
| "notifications"
|
|
2588
|
+
| "outbox"
|
|
2589
|
+
| "todoAttachments"
|
|
2590
|
+
| "todos"
|
|
2591
|
+
| "uow"
|
|
2592
|
+
> = {
|
|
2593
|
+
audit,
|
|
2594
|
+
...repositories,
|
|
2595
|
+
eventBus,
|
|
2596
|
+
idempotency,
|
|
2597
|
+
jobs,
|
|
2598
|
+
mailer,
|
|
2599
|
+
notifications,
|
|
2600
|
+
outbox,
|
|
2601
|
+
uow: createDrizzleTursoUnitOfWork({
|
|
2602
|
+
db: dbPort.db,
|
|
2603
|
+
createTransactionPorts: (tx) => {
|
|
2604
|
+
const txIdempotency = createDrizzleTursoIdempotencyPort(tx);
|
|
2605
|
+
const txOutbox = createDrizzleTursoOutboxPort(tx);
|
|
2606
|
+
|
|
2607
|
+
return {
|
|
2608
|
+
audit: createInstrumentedAuditLog({
|
|
2609
|
+
audit: createDrizzleAuditLog(tx),
|
|
2610
|
+
instrumentation: ports,
|
|
2611
|
+
}),
|
|
2612
|
+
...createRepositories(tx),
|
|
2613
|
+
events: createOutboxEventRecorder(txOutbox),
|
|
2614
|
+
idempotency: txIdempotency,
|
|
2615
|
+
jobs: createOutboxJobDispatcher(txOutbox),
|
|
2616
|
+
outbox: txOutbox,
|
|
2617
|
+
};
|
|
2618
|
+
},
|
|
2619
|
+
}),
|
|
2620
|
+
};
|
|
2621
|
+
|
|
2622
|
+
return {
|
|
2623
|
+
ports: providedPorts,
|
|
2624
|
+
async start() {
|
|
2625
|
+
await ensureStarterDatabase(dbPort.client);
|
|
2626
|
+
},
|
|
2627
|
+
stop() {
|
|
2628
|
+
unregisterListeners();
|
|
2629
|
+
},
|
|
2630
|
+
};
|
|
2631
|
+
},
|
|
2632
|
+
});
|
|
2633
|
+
`,
|
|
2634
|
+
productionContractsTodos: `import { defineContractGroup } from "@beignet/core/contracts";
|
|
1534
2635
|
import { z } from "zod";
|
|
1535
2636
|
import { errors } from "@/features/shared/errors";
|
|
1536
2637
|
import {
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
2638
|
+
CreateTodoInputSchema,
|
|
2639
|
+
GetTodoInputSchema,
|
|
2640
|
+
ListTodosInputSchema,
|
|
2641
|
+
ListTodosOutputSchema,
|
|
2642
|
+
TodoSchema,
|
|
2643
|
+
} from "@/features/todos/schemas";
|
|
1541
2644
|
|
|
1542
2645
|
const ErrorResponseSchema = z.object({
|
|
1543
2646
|
code: z.string(),
|
|
@@ -1545,7 +2648,7 @@ const ErrorResponseSchema = z.object({
|
|
|
1545
2648
|
requestId: z.string().optional(),
|
|
1546
2649
|
});
|
|
1547
2650
|
|
|
1548
|
-
const todos =
|
|
2651
|
+
const todos = defineContractGroup()
|
|
1549
2652
|
.namespace("todos")
|
|
1550
2653
|
.errors({ Unauthorized: errors.Unauthorized })
|
|
1551
2654
|
.responses({
|
|
@@ -1554,28 +2657,29 @@ const todos = createContractGroup()
|
|
|
1554
2657
|
|
|
1555
2658
|
export const listTodos = todos
|
|
1556
2659
|
.get("/api/todos")
|
|
1557
|
-
.query(
|
|
2660
|
+
.query(ListTodosInputSchema)
|
|
1558
2661
|
.responses({
|
|
1559
|
-
200:
|
|
2662
|
+
200: ListTodosOutputSchema,
|
|
1560
2663
|
});
|
|
1561
2664
|
|
|
1562
2665
|
export const createTodo = todos
|
|
1563
2666
|
.post("/api/todos")
|
|
1564
|
-
.body(
|
|
2667
|
+
.body(CreateTodoInputSchema)
|
|
1565
2668
|
.errors({ Forbidden: errors.Forbidden })
|
|
1566
2669
|
.responses({
|
|
1567
|
-
201:
|
|
2670
|
+
201: TodoSchema,
|
|
1568
2671
|
});
|
|
1569
2672
|
|
|
1570
2673
|
export const getTodo = todos
|
|
1571
2674
|
.get("/api/todos/:id")
|
|
1572
|
-
.pathParams(
|
|
2675
|
+
.pathParams(GetTodoInputSchema)
|
|
1573
2676
|
.errors({ TodoNotFound: errors.TodoNotFound })
|
|
1574
2677
|
.responses({
|
|
1575
|
-
200:
|
|
2678
|
+
200: TodoSchema,
|
|
1576
2679
|
});
|
|
1577
2680
|
`,
|
|
1578
|
-
productionTodoRoutes: `import {
|
|
2681
|
+
productionTodoRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2682
|
+
import { defineRouteGroup } from "@beignet/next";
|
|
1579
2683
|
import type { AppContext } from "@/app-context";
|
|
1580
2684
|
import { createTodo, getTodo, listTodos } from "@/features/todos/contracts";
|
|
1581
2685
|
import {
|
|
@@ -1587,31 +2691,14 @@ import {
|
|
|
1587
2691
|
export const todoRoutes = defineRouteGroup<AppContext>({
|
|
1588
2692
|
name: "todos",
|
|
1589
2693
|
routes: [
|
|
1590
|
-
{
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
status: 200,
|
|
1594
|
-
body: await listTodosUseCase.run({ ctx, input: query }),
|
|
1595
|
-
}),
|
|
1596
|
-
},
|
|
1597
|
-
{
|
|
1598
|
-
contract: createTodo,
|
|
1599
|
-
handle: async ({ ctx, body }) => ({
|
|
1600
|
-
status: 201,
|
|
1601
|
-
body: await createTodoUseCase.run({ ctx, input: body }),
|
|
1602
|
-
}),
|
|
1603
|
-
},
|
|
1604
|
-
{
|
|
1605
|
-
contract: getTodo,
|
|
1606
|
-
handle: async ({ ctx, path }) => ({
|
|
1607
|
-
status: 200,
|
|
1608
|
-
body: await getTodoUseCase.run({ ctx, input: path }),
|
|
1609
|
-
}),
|
|
1610
|
-
},
|
|
2694
|
+
{ contract: listTodos, useCase: listTodosUseCase },
|
|
2695
|
+
{ contract: createTodo, useCase: createTodoUseCase },
|
|
2696
|
+
{ contract: getTodo, useCase: getTodoUseCase },
|
|
1611
2697
|
],
|
|
1612
2698
|
});
|
|
1613
2699
|
`,
|
|
1614
|
-
productionServerRoutes: `import {
|
|
2700
|
+
productionServerRoutes: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2701
|
+
import { contractsFromRoutes, defineRoutes } from "@beignet/next";
|
|
1615
2702
|
import type { AppContext } from "@/app-context";
|
|
1616
2703
|
import { todoRoutes } from "@/features/todos/routes";
|
|
1617
2704
|
|
|
@@ -1620,45 +2707,86 @@ export const routes = defineRoutes<AppContext>([
|
|
|
1620
2707
|
]);
|
|
1621
2708
|
export const contracts = contractsFromRoutes(routes);
|
|
1622
2709
|
`,
|
|
1623
|
-
|
|
1624
|
-
import { createNextServer } from "@beignet/next";
|
|
2710
|
+
productionServerContext: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1625
2711
|
import {
|
|
2712
|
+
type ActivityActor,
|
|
1626
2713
|
createAnonymousActor,
|
|
2714
|
+
createServiceActor,
|
|
1627
2715
|
createTenant,
|
|
1628
2716
|
createUserActor,
|
|
1629
2717
|
} from "@beignet/core/ports";
|
|
1630
|
-
import
|
|
1631
|
-
import {
|
|
1632
|
-
import {
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
2718
|
+
import { defineServerContext } from "@beignet/core/server";
|
|
2719
|
+
import type { TraceContext } from "@beignet/core/tracing";
|
|
2720
|
+
import type { AppContext, AppRuntimePorts } from "@/app-context";
|
|
2721
|
+
|
|
2722
|
+
export type AppServiceContextInput =
|
|
2723
|
+
| {
|
|
2724
|
+
actor?: ActivityActor;
|
|
2725
|
+
tenantId?: string;
|
|
2726
|
+
}
|
|
2727
|
+
| undefined;
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* Context blueprint shared by the runtime server and route tests.
|
|
2731
|
+
*/
|
|
2732
|
+
export const appContext = defineServerContext<AppContext, AppRuntimePorts>()({
|
|
2733
|
+
gate: (ports) => ports.gate,
|
|
2734
|
+
request: async ({ req, ports, requestId, trace }) => {
|
|
1645
2735
|
const auth = await ports.auth.getSession(req);
|
|
1646
2736
|
const tenantId = req.headers.get("x-tenant-id") || undefined;
|
|
1647
|
-
|
|
1648
|
-
|
|
2737
|
+
|
|
2738
|
+
return {
|
|
2739
|
+
requestId,
|
|
1649
2740
|
actor: auth
|
|
1650
2741
|
? createUserActor(auth.user.id, { displayName: auth.user.name })
|
|
1651
2742
|
: createAnonymousActor(),
|
|
1652
2743
|
auth,
|
|
2744
|
+
...trace,
|
|
1653
2745
|
ports,
|
|
1654
2746
|
tenant: tenantId ? createTenant(tenantId) : undefined,
|
|
1655
2747
|
};
|
|
2748
|
+
},
|
|
2749
|
+
service: ({
|
|
2750
|
+
ports,
|
|
2751
|
+
input,
|
|
2752
|
+
requestId,
|
|
2753
|
+
trace,
|
|
2754
|
+
}: {
|
|
2755
|
+
ports: AppRuntimePorts;
|
|
2756
|
+
input: AppServiceContextInput;
|
|
2757
|
+
requestId: string;
|
|
2758
|
+
trace: TraceContext;
|
|
2759
|
+
}) => ({
|
|
2760
|
+
requestId,
|
|
2761
|
+
actor: input?.actor ?? createServiceActor("beignet-service"),
|
|
2762
|
+
auth: null,
|
|
2763
|
+
...trace,
|
|
2764
|
+
ports,
|
|
2765
|
+
tenant: createTenant(input?.tenantId ?? "tenant_default"),
|
|
2766
|
+
}),
|
|
2767
|
+
});
|
|
2768
|
+
`,
|
|
2769
|
+
productionServer: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
2770
|
+
import { createNextServer } from "@beignet/next";
|
|
2771
|
+
import { createIdempotencyHooks } from "@beignet/core/server";
|
|
2772
|
+
import type { AppContext } from "@/app-context";
|
|
2773
|
+
import { appPorts } from "@/infra/app-ports";
|
|
2774
|
+
import { env } from "@/lib/env";
|
|
2775
|
+
import { appContext } from "./context";
|
|
2776
|
+
import { providers } from "./providers";
|
|
2777
|
+
import { routes } from "./routes";
|
|
1656
2778
|
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
2779
|
+
export const server = await createNextServer({
|
|
2780
|
+
ports: appPorts,
|
|
2781
|
+
providers,
|
|
2782
|
+
providerConfig: {
|
|
2783
|
+
"drizzle-turso": {
|
|
2784
|
+
DB_URL: env.TURSO_DB_URL,
|
|
2785
|
+
DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
|
|
2786
|
+
},
|
|
1661
2787
|
},
|
|
2788
|
+
hooks: [createIdempotencyHooks<AppContext>()],
|
|
2789
|
+
context: appContext,
|
|
1662
2790
|
routes,
|
|
1663
2791
|
mapUnhandledError: ({ err, ctx }) => {
|
|
1664
2792
|
ctx?.ports.logger.error("Unhandled API error", {
|
|
@@ -1678,60 +2806,27 @@ export const server = await createNextServer({
|
|
|
1678
2806
|
},
|
|
1679
2807
|
});
|
|
1680
2808
|
`,
|
|
1681
|
-
productionServerWithDrizzleTurso: `import {
|
|
2809
|
+
productionServerWithDrizzleTurso: `import type { beignetServerOnly } from "@beignet/core/server-only";
|
|
1682
2810
|
import { createNextServer } from "@beignet/next";
|
|
1683
|
-
import {
|
|
1684
|
-
createAnonymousActor,
|
|
1685
|
-
createTenant,
|
|
1686
|
-
createUserActor,
|
|
1687
|
-
} from "@beignet/core/ports";
|
|
1688
|
-
import { createDrizzleTursoUnitOfWork } from "@beignet/provider-drizzle-turso";
|
|
2811
|
+
import { createIdempotencyHooks } from "@beignet/core/server";
|
|
1689
2812
|
import type { AppContext } from "@/app-context";
|
|
1690
2813
|
import { appPorts } from "@/infra/app-ports";
|
|
1691
|
-
import * as schema from "@/infra/db/schema";
|
|
1692
|
-
import { createRepositories } from "@/infra/db/repositories";
|
|
1693
2814
|
import { env } from "@/lib/env";
|
|
1694
|
-
import
|
|
2815
|
+
import { appContext } from "./context";
|
|
1695
2816
|
import { providers } from "./providers";
|
|
1696
2817
|
import { routes } from "./routes";
|
|
1697
2818
|
|
|
1698
2819
|
export const server = await createNextServer({
|
|
1699
2820
|
ports: appPorts,
|
|
1700
2821
|
providers,
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
createContext: async ({ req, ports }) => {
|
|
1707
|
-
const auth = await ports.auth.getSession(req);
|
|
1708
|
-
const tenantId = req.headers.get("x-tenant-id") || undefined;
|
|
1709
|
-
const repositories = createRepositories(ports.db.db);
|
|
1710
|
-
|
|
1711
|
-
const context = {
|
|
1712
|
-
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
|
|
1713
|
-
actor: auth
|
|
1714
|
-
? createUserActor(auth.user.id, { displayName: auth.user.name })
|
|
1715
|
-
: createAnonymousActor(),
|
|
1716
|
-
auth,
|
|
1717
|
-
ports: {
|
|
1718
|
-
...ports,
|
|
1719
|
-
...repositories,
|
|
1720
|
-
uow: createDrizzleTursoUnitOfWork<typeof schema, AppTransactionPorts>({
|
|
1721
|
-
db: ports.db.db,
|
|
1722
|
-
createTransactionPorts: (tx) => ({
|
|
1723
|
-
...createRepositories(tx),
|
|
1724
|
-
}),
|
|
1725
|
-
}),
|
|
1726
|
-
},
|
|
1727
|
-
tenant: tenantId ? createTenant(tenantId) : undefined,
|
|
1728
|
-
};
|
|
1729
|
-
|
|
1730
|
-
return {
|
|
1731
|
-
...context,
|
|
1732
|
-
gate: ports.gate.bind(context),
|
|
1733
|
-
};
|
|
2822
|
+
providerConfig: {
|
|
2823
|
+
"drizzle-turso": {
|
|
2824
|
+
DB_URL: env.TURSO_DB_URL,
|
|
2825
|
+
DB_AUTH_TOKEN: env.TURSO_DB_AUTH_TOKEN,
|
|
2826
|
+
},
|
|
1734
2827
|
},
|
|
2828
|
+
hooks: [createIdempotencyHooks<AppContext>()],
|
|
2829
|
+
context: appContext,
|
|
1735
2830
|
routes,
|
|
1736
2831
|
mapUnhandledError: ({ err, ctx }) => {
|
|
1737
2832
|
ctx?.ports.logger.error("Unhandled API error", {
|
|
@@ -1799,12 +2894,462 @@ export const GET = createOpenAPIHandler(server.contracts, {
|
|
|
1799
2894
|
title: "Beignet starter API",
|
|
1800
2895
|
version: "0.1.0",
|
|
1801
2896
|
});
|
|
2897
|
+
`,
|
|
2898
|
+
productionAuthRoute: `import { toNextJsHandler } from "better-auth/next-js";
|
|
2899
|
+
import { auth } from "@/lib/better-auth";
|
|
2900
|
+
|
|
2901
|
+
export const { GET, POST } = toNextJsHandler(auth);
|
|
2902
|
+
`,
|
|
2903
|
+
productionBetterAuth: `import { createClient } from "@libsql/client";
|
|
2904
|
+
import { betterAuth } from "better-auth";
|
|
2905
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
2906
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
2907
|
+
import * as schema from "@/infra/db/schema";
|
|
2908
|
+
import { ensureStarterDatabase } from "@/infra/db/bootstrap";
|
|
2909
|
+
import { env } from "@/lib/env";
|
|
2910
|
+
|
|
2911
|
+
const client = createClient({
|
|
2912
|
+
url: env.TURSO_DB_URL,
|
|
2913
|
+
authToken: env.TURSO_DB_AUTH_TOKEN,
|
|
2914
|
+
});
|
|
2915
|
+
|
|
2916
|
+
await ensureStarterDatabase(client);
|
|
2917
|
+
|
|
2918
|
+
const db = drizzle(client, { schema });
|
|
2919
|
+
|
|
2920
|
+
const trustedOrigins = [
|
|
2921
|
+
env.APP_URL,
|
|
2922
|
+
env.BETTER_AUTH_URL,
|
|
2923
|
+
...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",")
|
|
2924
|
+
.map((origin) => origin.trim())
|
|
2925
|
+
.filter(Boolean) ?? []),
|
|
2926
|
+
];
|
|
2927
|
+
|
|
2928
|
+
export const auth = betterAuth({
|
|
2929
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
2930
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
2931
|
+
trustedOrigins: [...new Set(trustedOrigins)],
|
|
2932
|
+
database: drizzleAdapter(db, {
|
|
2933
|
+
provider: "sqlite",
|
|
2934
|
+
schema,
|
|
2935
|
+
}),
|
|
2936
|
+
emailAndPassword: {
|
|
2937
|
+
enabled: true,
|
|
2938
|
+
},
|
|
2939
|
+
});
|
|
2940
|
+
`,
|
|
2941
|
+
productionDbSeed: `import { createClient } from "@libsql/client";
|
|
2942
|
+
import { ensureStarterDatabase } from "@/infra/db/bootstrap";
|
|
2943
|
+
import { env } from "@/lib/env";
|
|
2944
|
+
|
|
2945
|
+
const client = createClient({
|
|
2946
|
+
url: env.TURSO_DB_URL,
|
|
2947
|
+
authToken: env.TURSO_DB_AUTH_TOKEN,
|
|
2948
|
+
});
|
|
2949
|
+
|
|
2950
|
+
try {
|
|
2951
|
+
await ensureStarterDatabase(client, { seed: true });
|
|
2952
|
+
console.log("Database seeded.");
|
|
2953
|
+
} finally {
|
|
2954
|
+
client.close();
|
|
2955
|
+
}
|
|
2956
|
+
`,
|
|
2957
|
+
productionDbReset: `import { createClient } from "@libsql/client";
|
|
2958
|
+
import { resetStarterDatabase } from "@/infra/db/bootstrap";
|
|
2959
|
+
import { env } from "@/lib/env";
|
|
2960
|
+
|
|
2961
|
+
if (!env.TURSO_DB_URL.startsWith("file:") && process.env.BEIGNET_ALLOW_DATABASE_RESET !== "true") {
|
|
2962
|
+
throw new Error(
|
|
2963
|
+
"Refusing to reset a non-local database. Set BEIGNET_ALLOW_DATABASE_RESET=true if this is intentional.",
|
|
2964
|
+
);
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
const client = createClient({
|
|
2968
|
+
url: env.TURSO_DB_URL,
|
|
2969
|
+
authToken: env.TURSO_DB_AUTH_TOKEN,
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2972
|
+
try {
|
|
2973
|
+
await resetStarterDatabase(client);
|
|
2974
|
+
console.log("Database reset.");
|
|
2975
|
+
} finally {
|
|
2976
|
+
client.close();
|
|
2977
|
+
}
|
|
2978
|
+
`,
|
|
2979
|
+
productionDbTestDatabase: `import { existsSync, unlinkSync } from "node:fs";
|
|
2980
|
+
import { tmpdir } from "node:os";
|
|
2981
|
+
import { join } from "node:path";
|
|
2982
|
+
import type { OutboxPort } from "@beignet/core/outbox";
|
|
2983
|
+
import { createDrizzleTursoOutboxPort } from "@beignet/provider-drizzle-turso";
|
|
2984
|
+
import { type Client, createClient } from "@libsql/client";
|
|
2985
|
+
import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
|
|
2986
|
+
import { createDrizzleAuditLog } from "@/infra/audit/drizzle-audit-log";
|
|
2987
|
+
import { ensureStarterDatabase, resetStarterDatabase } from "./bootstrap";
|
|
2988
|
+
import { createRepositories } from "./repositories";
|
|
2989
|
+
import * as schema from "./schema";
|
|
2990
|
+
|
|
2991
|
+
export type TestDatabase = {
|
|
2992
|
+
\tclient: Client;
|
|
2993
|
+
\tdb: LibSQLDatabase<typeof schema>;
|
|
2994
|
+
\taudit: ReturnType<typeof createDrizzleAuditLog>;
|
|
2995
|
+
\toutbox: OutboxPort;
|
|
2996
|
+
\trepositories: ReturnType<typeof createRepositories>;
|
|
2997
|
+
\tpath: string;
|
|
2998
|
+
\treset(): Promise<void>;
|
|
2999
|
+
\tclose(): Promise<void>;
|
|
3000
|
+
};
|
|
3001
|
+
|
|
3002
|
+
export async function createTestDatabase(): Promise<TestDatabase> {
|
|
3003
|
+
\tconst path = join(tmpdir(), "beignet-test-" + crypto.randomUUID() + ".db");
|
|
3004
|
+
\tconst client = createClient({ url: "file:" + path });
|
|
3005
|
+
\tawait ensureStarterDatabase(client, { seed: false });
|
|
3006
|
+
|
|
3007
|
+
\tconst db = drizzle(client, { schema });
|
|
3008
|
+
|
|
3009
|
+
\treturn {
|
|
3010
|
+
\t\tclient,
|
|
3011
|
+
\t\tdb,
|
|
3012
|
+
\t\taudit: createDrizzleAuditLog(db),
|
|
3013
|
+
\t\toutbox: createDrizzleTursoOutboxPort(db),
|
|
3014
|
+
\t\trepositories: createRepositories(db),
|
|
3015
|
+
\t\tpath,
|
|
3016
|
+
\t\treset: async () => {
|
|
3017
|
+
\t\t\tawait resetStarterDatabase(client);
|
|
3018
|
+
\t\t},
|
|
3019
|
+
\t\tclose: async () => {
|
|
3020
|
+
\t\t\tclient.close();
|
|
3021
|
+
\t\t\tif (existsSync(path)) {
|
|
3022
|
+
\t\t\t\tunlinkSync(path);
|
|
3023
|
+
\t\t\t}
|
|
3024
|
+
\t\t},
|
|
3025
|
+
\t};
|
|
3026
|
+
}
|
|
3027
|
+
`,
|
|
3028
|
+
productionAuditHelper: `import type { AuditLogEntryInput } from "@beignet/core/ports";
|
|
3029
|
+
import type { AppContext } from "@/app-context";
|
|
3030
|
+
|
|
3031
|
+
export function auditEntry(
|
|
3032
|
+
ctx: AppContext,
|
|
3033
|
+
entry: Omit<
|
|
3034
|
+
AuditLogEntryInput,
|
|
3035
|
+
"actor" | "tenant" | "requestId" | "traceId"
|
|
3036
|
+
>,
|
|
3037
|
+
): AuditLogEntryInput {
|
|
3038
|
+
return {
|
|
3039
|
+
...entry,
|
|
3040
|
+
actor: ctx.actor,
|
|
3041
|
+
tenant: ctx.tenant,
|
|
3042
|
+
requestId: ctx.requestId,
|
|
3043
|
+
traceId: ctx.traceId,
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
`,
|
|
3047
|
+
productionTodoEvents: `import { defineEvent } from "@beignet/core/events";
|
|
3048
|
+
import { z } from "zod";
|
|
3049
|
+
|
|
3050
|
+
export const TodoCreated = defineEvent("todos.created", {
|
|
3051
|
+
payload: z.object({
|
|
3052
|
+
todoId: z.string().uuid(),
|
|
3053
|
+
title: z.string(),
|
|
3054
|
+
}),
|
|
3055
|
+
});
|
|
3056
|
+
|
|
3057
|
+
export const todoEvents = [TodoCreated] as const;
|
|
3058
|
+
`,
|
|
3059
|
+
productionTodoJobs: `import { retry } from "@beignet/core/jobs";
|
|
3060
|
+
import { z } from "zod";
|
|
3061
|
+
import { auditEntry } from "@/lib/audit";
|
|
3062
|
+
import { defineJob } from "@/lib/jobs";
|
|
3063
|
+
|
|
3064
|
+
export const LogTodoCreatedJob = defineJob("todos.log-created", {
|
|
3065
|
+
payload: z.object({
|
|
3066
|
+
todoId: z.string().uuid(),
|
|
3067
|
+
title: z.string(),
|
|
3068
|
+
}),
|
|
3069
|
+
retry: retry.exponential({
|
|
3070
|
+
attempts: 3,
|
|
3071
|
+
initialDelay: "1s",
|
|
3072
|
+
maxDelay: "1m",
|
|
3073
|
+
}),
|
|
3074
|
+
async handle({ ctx, payload }) {
|
|
3075
|
+
ctx.ports.logger.info("Todo created job handled", {
|
|
3076
|
+
todoId: payload.todoId,
|
|
3077
|
+
});
|
|
3078
|
+
await ctx.ports.audit.record(
|
|
3079
|
+
auditEntry(ctx, {
|
|
3080
|
+
action: "jobs.todos.log-created",
|
|
3081
|
+
resource: { type: "todo", id: payload.todoId, name: payload.title },
|
|
3082
|
+
metadata: { jobName: "todos.log-created" },
|
|
3083
|
+
}),
|
|
3084
|
+
);
|
|
3085
|
+
},
|
|
3086
|
+
});
|
|
3087
|
+
|
|
3088
|
+
export const todoJobs = [LogTodoCreatedJob] as const;
|
|
3089
|
+
`,
|
|
3090
|
+
productionTodoNotifications: `import { defineMailNotificationChannel } from "@beignet/core/notifications";
|
|
3091
|
+
import { z } from "zod";
|
|
3092
|
+
import { defineNotification } from "@/lib/notifications";
|
|
3093
|
+
|
|
3094
|
+
export const TodoCreatedNotification = defineNotification(
|
|
3095
|
+
"todos.created",
|
|
3096
|
+
{
|
|
3097
|
+
payload: z.object({
|
|
3098
|
+
todoId: z.string().uuid(),
|
|
3099
|
+
title: z.string(),
|
|
3100
|
+
}),
|
|
3101
|
+
channels: {
|
|
3102
|
+
email: defineMailNotificationChannel(({ payload }) => ({
|
|
3103
|
+
to: "ops@example.local",
|
|
3104
|
+
subject: \`Todo created: \${payload.title}\`,
|
|
3105
|
+
text: \`Todo \${payload.todoId} was created.\`,
|
|
3106
|
+
})),
|
|
3107
|
+
},
|
|
3108
|
+
},
|
|
3109
|
+
);
|
|
3110
|
+
`,
|
|
3111
|
+
productionTodoListeners: `import { TodoCreated } from "@/features/todos/domain/events";
|
|
3112
|
+
import { LogTodoCreatedJob } from "@/features/todos/jobs";
|
|
3113
|
+
import { TodoCreatedNotification } from "@/features/todos/notifications";
|
|
3114
|
+
import { auditEntry } from "@/lib/audit";
|
|
3115
|
+
import { defineListener } from "@/lib/listeners";
|
|
3116
|
+
|
|
3117
|
+
export const enqueueTodoCreatedWork = defineListener(TodoCreated, {
|
|
3118
|
+
name: "todos.enqueue-created-work",
|
|
3119
|
+
async handle({ ctx, payload }) {
|
|
3120
|
+
await ctx.ports.jobs.dispatch(LogTodoCreatedJob, payload);
|
|
3121
|
+
await ctx.ports.notifications.send(TodoCreatedNotification, payload);
|
|
3122
|
+
await ctx.ports.audit.record(
|
|
3123
|
+
auditEntry(ctx, {
|
|
3124
|
+
action: "listeners.todos.enqueue-created-work",
|
|
3125
|
+
resource: { type: "todo", id: payload.todoId, name: payload.title },
|
|
3126
|
+
metadata: {
|
|
3127
|
+
eventName: TodoCreated.name,
|
|
3128
|
+
jobName: LogTodoCreatedJob.name,
|
|
3129
|
+
notificationName: TodoCreatedNotification.name,
|
|
3130
|
+
},
|
|
3131
|
+
}),
|
|
3132
|
+
);
|
|
3133
|
+
},
|
|
3134
|
+
});
|
|
3135
|
+
|
|
3136
|
+
export const todoListeners = [enqueueTodoCreatedWork] as const;
|
|
3137
|
+
`,
|
|
3138
|
+
productionTodoSchedules: `import { normalizeOffsetPage } from "@beignet/core/pagination";
|
|
3139
|
+
import { z } from "zod";
|
|
3140
|
+
import { auditEntry } from "@/lib/audit";
|
|
3141
|
+
import { defineSchedule } from "@/lib/schedules";
|
|
3142
|
+
|
|
3143
|
+
export const LogDailyTodoSummarySchedule = defineSchedule(
|
|
3144
|
+
"todos.log-daily-summary",
|
|
3145
|
+
{
|
|
3146
|
+
cron: "0 9 * * *",
|
|
3147
|
+
timezone: "America/Chicago",
|
|
3148
|
+
payload: z.object({
|
|
3149
|
+
date: z.string(),
|
|
3150
|
+
}),
|
|
3151
|
+
createPayload({ run }) {
|
|
3152
|
+
const date = run.scheduledAt ?? run.triggeredAt;
|
|
3153
|
+
return { date: date.toISOString().slice(0, 10) };
|
|
3154
|
+
},
|
|
3155
|
+
async handle({ ctx, payload, run }) {
|
|
3156
|
+
const { page } = await ctx.ports.todos.list(
|
|
3157
|
+
normalizeOffsetPage(
|
|
3158
|
+
{ limit: 1, offset: 0 },
|
|
3159
|
+
{ defaultLimit: 1, maxLimit: 1 },
|
|
3160
|
+
),
|
|
3161
|
+
);
|
|
3162
|
+
ctx.ports.logger.info("Daily todo summary schedule handled", {
|
|
3163
|
+
date: payload.date,
|
|
3164
|
+
todoCount: page.total,
|
|
3165
|
+
});
|
|
3166
|
+
await ctx.ports.audit.record(
|
|
3167
|
+
auditEntry(ctx, {
|
|
3168
|
+
action: "schedules.todos.daily-summary",
|
|
3169
|
+
resource: {
|
|
3170
|
+
type: "schedule",
|
|
3171
|
+
id: "todos.log-daily-summary",
|
|
3172
|
+
name: "Daily todo summary",
|
|
3173
|
+
},
|
|
3174
|
+
metadata: {
|
|
3175
|
+
date: payload.date,
|
|
3176
|
+
todoCount: page.total,
|
|
3177
|
+
source: run.source ?? "inline",
|
|
3178
|
+
triggeredAt: run.triggeredAt.toISOString(),
|
|
3179
|
+
},
|
|
3180
|
+
}),
|
|
3181
|
+
);
|
|
3182
|
+
},
|
|
3183
|
+
},
|
|
3184
|
+
);
|
|
3185
|
+
|
|
3186
|
+
export const todoSchedules = [LogDailyTodoSummarySchedule] as const;
|
|
3187
|
+
`,
|
|
3188
|
+
productionSchedules: `import { createServiceActor } from "@beignet/core/ports";
|
|
3189
|
+
import type { AppContext } from "@/app-context";
|
|
3190
|
+
import { todoSchedules } from "@/features/todos/schedules";
|
|
3191
|
+
import { server } from "./index.js";
|
|
3192
|
+
|
|
3193
|
+
export const schedules = [...todoSchedules] as const;
|
|
3194
|
+
|
|
3195
|
+
export async function createScheduleContext(): Promise<AppContext> {
|
|
3196
|
+
return server.createServiceContext({
|
|
3197
|
+
actor: createServiceActor("beignet-schedule"),
|
|
3198
|
+
});
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
export async function stopScheduleContext(): Promise<void> {
|
|
3202
|
+
await server.stop();
|
|
3203
|
+
}
|
|
3204
|
+
`,
|
|
3205
|
+
productionTodoUploads: `import { defineUpload, defineUploads } from "@beignet/core/uploads";
|
|
3206
|
+
import { z } from "zod";
|
|
3207
|
+
import type { AppContext } from "@/app-context";
|
|
3208
|
+
import { auditEntry } from "@/lib/audit";
|
|
3209
|
+
|
|
3210
|
+
const todoAttachmentMetadataSchema = z.object({
|
|
3211
|
+
todoId: z.string().uuid(),
|
|
3212
|
+
});
|
|
3213
|
+
|
|
3214
|
+
export const TodoAttachmentUpload = defineUpload<
|
|
3215
|
+
"todos.attachment",
|
|
3216
|
+
typeof todoAttachmentMetadataSchema,
|
|
3217
|
+
AppContext,
|
|
3218
|
+
{ attachmentIds: string[] }
|
|
3219
|
+
>("todos.attachment", {
|
|
3220
|
+
metadata: todoAttachmentMetadataSchema,
|
|
3221
|
+
file: {
|
|
3222
|
+
contentTypes: ["application/pdf", "text/plain", "text/plain;charset=utf-8"],
|
|
3223
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
3224
|
+
maxFiles: 3,
|
|
3225
|
+
visibility: "private",
|
|
3226
|
+
cacheControl: "private, max-age=0",
|
|
3227
|
+
},
|
|
3228
|
+
authorize({ ctx }) {
|
|
3229
|
+
return ctx.actor.type === "user"
|
|
3230
|
+
? true
|
|
3231
|
+
: {
|
|
3232
|
+
allowed: false,
|
|
3233
|
+
reason: "You must be signed in to upload todo attachments.",
|
|
3234
|
+
};
|
|
3235
|
+
},
|
|
3236
|
+
key({ ctx, metadata, file, uploadId }) {
|
|
3237
|
+
const tenantId = ctx.tenant?.id ?? "tenant_default";
|
|
3238
|
+
const extension = file.name.includes(".")
|
|
3239
|
+
? file.name.split(".").pop()
|
|
3240
|
+
: undefined;
|
|
3241
|
+
const suffix = extension ? \`.\${extension}\` : "";
|
|
3242
|
+
return \`todos/\${tenantId}/\${metadata.todoId}/attachments/\${uploadId}\${suffix}\`;
|
|
3243
|
+
},
|
|
3244
|
+
storageMetadata({ ctx, metadata }) {
|
|
3245
|
+
return {
|
|
3246
|
+
tenantId: ctx.tenant?.id ?? "tenant_default",
|
|
3247
|
+
todoId: metadata.todoId,
|
|
3248
|
+
};
|
|
3249
|
+
},
|
|
3250
|
+
async onComplete({ ctx, metadata, files }) {
|
|
3251
|
+
const attachments = await Promise.all(
|
|
3252
|
+
files.map((file) =>
|
|
3253
|
+
ctx.ports.todoAttachments.create({
|
|
3254
|
+
id: file.uploadId,
|
|
3255
|
+
todoId: metadata.todoId,
|
|
3256
|
+
key: file.key,
|
|
3257
|
+
fileName: file.name,
|
|
3258
|
+
contentType: file.contentType,
|
|
3259
|
+
size: file.object.size,
|
|
3260
|
+
}),
|
|
3261
|
+
),
|
|
3262
|
+
);
|
|
3263
|
+
await ctx.ports.audit.record(
|
|
3264
|
+
auditEntry(ctx, {
|
|
3265
|
+
action: "todos.attachment.upload",
|
|
3266
|
+
resource: { type: "todo", id: metadata.todoId },
|
|
3267
|
+
metadata: {
|
|
3268
|
+
attachmentCount: attachments.length,
|
|
3269
|
+
keys: attachments.map((attachment) => attachment.key),
|
|
3270
|
+
},
|
|
3271
|
+
}),
|
|
3272
|
+
);
|
|
3273
|
+
|
|
3274
|
+
return {
|
|
3275
|
+
attachmentIds: attachments.map((attachment) => attachment.id),
|
|
3276
|
+
};
|
|
3277
|
+
},
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
export const todoUploads = defineUploads({
|
|
3281
|
+
todoAttachment: TodoAttachmentUpload,
|
|
3282
|
+
});
|
|
3283
|
+
`,
|
|
3284
|
+
productionOutbox: `import { defineOutboxRegistry } from "@beignet/core/outbox";
|
|
3285
|
+
import { createServiceActor } from "@beignet/core/ports";
|
|
3286
|
+
import type { AppContext } from "@/app-context";
|
|
3287
|
+
import { todoEvents } from "@/features/todos/domain/events";
|
|
3288
|
+
import { todoJobs } from "@/features/todos/jobs";
|
|
3289
|
+
import { server } from "./index.js";
|
|
3290
|
+
|
|
3291
|
+
export const outboxRegistry = defineOutboxRegistry({
|
|
3292
|
+
events: todoEvents,
|
|
3293
|
+
jobs: todoJobs,
|
|
3294
|
+
});
|
|
3295
|
+
|
|
3296
|
+
export async function createOutboxDrainContext(): Promise<AppContext> {
|
|
3297
|
+
return server.createServiceContext({
|
|
3298
|
+
actor: createServiceActor("beignet-outbox"),
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
export async function stopOutboxDrainContext(): Promise<void> {
|
|
3303
|
+
await server.stop();
|
|
3304
|
+
}
|
|
3305
|
+
`,
|
|
3306
|
+
productionOutboxDrainRoute: `import { createOutboxDrainRoute } from "@beignet/next";
|
|
3307
|
+
import { env } from "@/lib/env";
|
|
3308
|
+
import { server } from "@/server";
|
|
3309
|
+
import { outboxRegistry } from "@/server/outbox";
|
|
3310
|
+
|
|
3311
|
+
export const runtime = "nodejs";
|
|
3312
|
+
|
|
3313
|
+
export const { GET, POST } = createOutboxDrainRoute({
|
|
3314
|
+
server,
|
|
3315
|
+
registry: outboxRegistry,
|
|
3316
|
+
secret: env.CRON_SECRET,
|
|
3317
|
+
});
|
|
3318
|
+
`,
|
|
3319
|
+
productionScheduleRoute: `import { createScheduleRoute } from "@beignet/next";
|
|
3320
|
+
import { env } from "@/lib/env";
|
|
3321
|
+
import { server } from "@/server";
|
|
3322
|
+
import { schedules } from "@/server/schedules";
|
|
3323
|
+
|
|
3324
|
+
export const runtime = "nodejs";
|
|
3325
|
+
|
|
3326
|
+
export const { GET, POST } = createScheduleRoute({
|
|
3327
|
+
server,
|
|
3328
|
+
schedules,
|
|
3329
|
+
schedule: "todos.log-daily-summary",
|
|
3330
|
+
secret: env.CRON_SECRET,
|
|
3331
|
+
source: "starter-cron-route",
|
|
3332
|
+
});
|
|
3333
|
+
`,
|
|
3334
|
+
productionUploadsRoute: `import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
|
|
3335
|
+
import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
|
|
3336
|
+
import { createUploadRoute } from "@beignet/next";
|
|
3337
|
+
import type { AppContext } from "@/app-context";
|
|
3338
|
+
import { todoUploads } from "@/features/todos/uploads";
|
|
3339
|
+
import { server } from "@/server";
|
|
3340
|
+
|
|
3341
|
+
const uploadRouter = createUploadRouter<AppContext>({
|
|
3342
|
+
uploads: uploadsFromRegistry(todoUploads),
|
|
3343
|
+
ctx: () => server.createContextFromNext(),
|
|
3344
|
+
storage: server.ports.storage,
|
|
3345
|
+
instrumentation: resolveProviderInstrumentationPort(server.ports),
|
|
3346
|
+
});
|
|
3347
|
+
|
|
3348
|
+
export const { POST } = createUploadRoute(uploadRouter);
|
|
1802
3349
|
`,
|
|
1803
3350
|
};
|
|
1804
3351
|
function productionProviderPortImports(ctx) {
|
|
1805
3352
|
const imports = [];
|
|
1806
|
-
if (hasIntegration(ctx, "inngest"))
|
|
1807
|
-
imports.push("\tJobDispatcherPort,");
|
|
1808
3353
|
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
1809
3354
|
imports.push("\tRateLimitPort,");
|
|
1810
3355
|
}
|
|
@@ -1812,43 +3357,122 @@ function productionProviderPortImports(ctx) {
|
|
|
1812
3357
|
}
|
|
1813
3358
|
function productionProviderPortFields(ctx) {
|
|
1814
3359
|
const fields = [];
|
|
1815
|
-
if (hasIntegration(ctx, "inngest"))
|
|
1816
|
-
fields.push("\tjobs: JobDispatcherPort;");
|
|
1817
|
-
if (hasIntegration(ctx, "resend"))
|
|
1818
|
-
fields.push("\tmailer: MailerPort;");
|
|
1819
3360
|
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
1820
3361
|
fields.push("\trateLimit: RateLimitPort;");
|
|
1821
3362
|
}
|
|
1822
3363
|
return fields;
|
|
1823
3364
|
}
|
|
3365
|
+
function productionInfrastructurePortsWithDrizzleTurso(ctx) {
|
|
3366
|
+
const deferred = [
|
|
3367
|
+
'\t\t"audit",',
|
|
3368
|
+
hasIntegration(ctx, "better-auth") ? '\t\t"auth",' : undefined,
|
|
3369
|
+
'\t\t"eventBus",',
|
|
3370
|
+
'\t\t"idempotency",',
|
|
3371
|
+
'\t\t"jobs",',
|
|
3372
|
+
'\t\t"logger",',
|
|
3373
|
+
'\t\t"mailer",',
|
|
3374
|
+
'\t\t"notifications",',
|
|
3375
|
+
'\t\t"outbox",',
|
|
3376
|
+
hasIntegration(ctx, "upstash-rate-limit") ? '\t\t"rateLimit",' : undefined,
|
|
3377
|
+
'\t\t"storage",',
|
|
3378
|
+
'\t\t"todoAttachments",',
|
|
3379
|
+
'\t\t"todos",',
|
|
3380
|
+
'\t\t"uow",',
|
|
3381
|
+
]
|
|
3382
|
+
.filter((entry) => Boolean(entry))
|
|
3383
|
+
.sort();
|
|
3384
|
+
const bound = [
|
|
3385
|
+
hasIntegration(ctx, "better-auth")
|
|
3386
|
+
? undefined
|
|
3387
|
+
: "\t\tauth: createAnonymousAuth(),",
|
|
3388
|
+
"\t\tgate,",
|
|
3389
|
+
].filter((entry) => Boolean(entry));
|
|
3390
|
+
const imports = [
|
|
3391
|
+
'import { createGate, definePorts } from "@beignet/core/ports";',
|
|
3392
|
+
'import { todoPolicy } from "@/features/todos/policy";',
|
|
3393
|
+
'import { appError } from "@/features/shared/errors";',
|
|
3394
|
+
'import type { AppPorts } from "@/ports";',
|
|
3395
|
+
hasIntegration(ctx, "better-auth")
|
|
3396
|
+
? undefined
|
|
3397
|
+
: 'import { createAnonymousAuth } from "./auth/anonymous-auth";',
|
|
3398
|
+
].filter((entry) => Boolean(entry));
|
|
3399
|
+
return `${imports.join("\n")}
|
|
3400
|
+
|
|
3401
|
+
const gate = createGate({
|
|
3402
|
+
policies: [todoPolicy],
|
|
3403
|
+
onDeny(decision) {
|
|
3404
|
+
return appError("Forbidden", {
|
|
3405
|
+
message: decision.reason ?? "Forbidden",
|
|
3406
|
+
details: decision.details,
|
|
3407
|
+
});
|
|
3408
|
+
},
|
|
3409
|
+
});
|
|
3410
|
+
|
|
3411
|
+
/**
|
|
3412
|
+
* App ports the server boots with.
|
|
3413
|
+
*
|
|
3414
|
+
* Bound ports are app-owned. Every deferred key is contributed by a provider
|
|
3415
|
+
* in server/providers.ts during startup; createServer(...) fails boot if any
|
|
3416
|
+
* of them is still unbound after providers have started.
|
|
3417
|
+
*/
|
|
3418
|
+
export const appPorts = definePorts<AppPorts>()({
|
|
3419
|
+
bound: {
|
|
3420
|
+
${bound.join("\n")}
|
|
3421
|
+
},
|
|
3422
|
+
deferred: [
|
|
3423
|
+
${deferred.join("\n")}
|
|
3424
|
+
],
|
|
3425
|
+
});
|
|
3426
|
+
`;
|
|
3427
|
+
}
|
|
1824
3428
|
function productionPorts(ctx) {
|
|
1825
|
-
const mailImport =
|
|
1826
|
-
? 'import type { MailerPort } from "@beignet/core/mail";\n'
|
|
1827
|
-
: "";
|
|
3429
|
+
const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
|
|
1828
3430
|
const imports = [
|
|
3431
|
+
"\tAuditLogPort,",
|
|
1829
3432
|
"\tBoundGate,",
|
|
3433
|
+
"\tEventBusPort,",
|
|
1830
3434
|
"\tGatePort,",
|
|
3435
|
+
"\tJobDispatcherPort,",
|
|
1831
3436
|
"\tLoggerPort,",
|
|
1832
3437
|
...productionProviderPortImports(ctx),
|
|
3438
|
+
"\tStoragePort,",
|
|
1833
3439
|
"\tUnitOfWorkPort,",
|
|
1834
3440
|
];
|
|
1835
3441
|
const providerFields = productionProviderPortFields(ctx);
|
|
1836
|
-
return `import type {
|
|
3442
|
+
return `import type { IdempotencyPort } from "@beignet/core/idempotency";
|
|
3443
|
+
${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
|
|
3444
|
+
import type { OutboxPort } from "@beignet/core/outbox";
|
|
3445
|
+
import type {
|
|
1837
3446
|
${imports.join("\n")}
|
|
1838
3447
|
} from "@beignet/core/ports";
|
|
1839
|
-
|
|
3448
|
+
import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
|
|
1840
3449
|
import type { AuthPort } from "./auth";
|
|
1841
|
-
import type { TodoRepository } from "@/features/todos/ports";
|
|
3450
|
+
import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
|
|
1842
3451
|
|
|
1843
3452
|
export type AppTransactionPorts = {
|
|
3453
|
+
audit: AuditLogPort;
|
|
3454
|
+
events: import("@beignet/core/ports").BufferedDomainEventRecorder;
|
|
3455
|
+
idempotency: IdempotencyPort;
|
|
3456
|
+
jobs: JobDispatcherPort;
|
|
3457
|
+
outbox: OutboxPort;
|
|
3458
|
+
todoAttachments: TodoAttachmentRepository;
|
|
1844
3459
|
todos: TodoRepository;
|
|
1845
3460
|
};
|
|
1846
3461
|
|
|
1847
3462
|
export type AppGate = BoundGate<[typeof todoPolicy]>;
|
|
1848
3463
|
|
|
1849
3464
|
export type AppPorts = {
|
|
3465
|
+
audit: AuditLogPort;
|
|
1850
3466
|
auth: AuthPort;
|
|
3467
|
+
eventBus: EventBusPort;
|
|
1851
3468
|
gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
|
|
3469
|
+
idempotency: IdempotencyPort;
|
|
3470
|
+
jobs: JobDispatcherPort;
|
|
3471
|
+
mailer: MailerPort;
|
|
3472
|
+
notifications: NotificationPort;
|
|
3473
|
+
outbox: OutboxPort;
|
|
3474
|
+
storage: StoragePort;
|
|
3475
|
+
todoAttachments: TodoAttachmentRepository;
|
|
1852
3476
|
todos: TodoRepository;
|
|
1853
3477
|
logger: LoggerPort;
|
|
1854
3478
|
${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
|
|
@@ -1856,36 +3480,53 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
|
|
|
1856
3480
|
`;
|
|
1857
3481
|
}
|
|
1858
3482
|
function productionPortsWithDrizzleTurso(ctx) {
|
|
1859
|
-
const mailImport =
|
|
1860
|
-
? 'import type { MailerPort } from "@beignet/core/mail";\n'
|
|
1861
|
-
: "";
|
|
3483
|
+
const mailImport = 'import type { MailerPort } from "@beignet/core/mail";\n';
|
|
1862
3484
|
const imports = [
|
|
3485
|
+
"\tAuditLogPort,",
|
|
1863
3486
|
"\tBoundGate,",
|
|
3487
|
+
"\tEventBusPort,",
|
|
1864
3488
|
"\tGatePort,",
|
|
3489
|
+
"\tJobDispatcherPort,",
|
|
1865
3490
|
"\tLoggerPort,",
|
|
1866
3491
|
...productionProviderPortImports(ctx),
|
|
3492
|
+
"\tStoragePort,",
|
|
1867
3493
|
"\tUnitOfWorkPort,",
|
|
1868
3494
|
];
|
|
1869
3495
|
const providerFields = productionProviderPortFields(ctx);
|
|
1870
|
-
return `import type {
|
|
3496
|
+
return `import type { IdempotencyPort } from "@beignet/core/idempotency";
|
|
3497
|
+
${mailImport}import type { NotificationPort } from "@beignet/core/notifications";
|
|
3498
|
+
import type { OutboxPort } from "@beignet/core/outbox";
|
|
3499
|
+
import type {
|
|
1871
3500
|
${imports.join("\n")}
|
|
1872
3501
|
} from "@beignet/core/ports";
|
|
1873
|
-
${mailImport}import type { DbPort } from "@beignet/provider-drizzle-turso";
|
|
1874
|
-
import * as schema from "@/infra/db/schema";
|
|
1875
3502
|
import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
|
|
1876
3503
|
import type { AuthPort } from "./auth";
|
|
1877
|
-
import type { TodoRepository } from "@/features/todos/ports";
|
|
3504
|
+
import type { TodoAttachmentRepository, TodoRepository } from "@/features/todos/ports";
|
|
1878
3505
|
|
|
1879
3506
|
export type AppTransactionPorts = {
|
|
3507
|
+
audit: AuditLogPort;
|
|
3508
|
+
events: import("@beignet/core/ports").BufferedDomainEventRecorder;
|
|
3509
|
+
idempotency: IdempotencyPort;
|
|
3510
|
+
jobs: JobDispatcherPort;
|
|
3511
|
+
outbox: OutboxPort;
|
|
3512
|
+
todoAttachments: TodoAttachmentRepository;
|
|
1880
3513
|
todos: TodoRepository;
|
|
1881
3514
|
};
|
|
1882
3515
|
|
|
1883
3516
|
export type AppGate = BoundGate<[typeof todoPolicy]>;
|
|
1884
3517
|
|
|
1885
3518
|
export type AppPorts = {
|
|
3519
|
+
audit: AuditLogPort;
|
|
1886
3520
|
auth: AuthPort;
|
|
1887
|
-
|
|
3521
|
+
eventBus: EventBusPort;
|
|
1888
3522
|
gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
|
|
3523
|
+
idempotency: IdempotencyPort;
|
|
3524
|
+
jobs: JobDispatcherPort;
|
|
3525
|
+
mailer: MailerPort;
|
|
3526
|
+
notifications: NotificationPort;
|
|
3527
|
+
outbox: OutboxPort;
|
|
3528
|
+
storage: StoragePort;
|
|
3529
|
+
todoAttachments: TodoAttachmentRepository;
|
|
1889
3530
|
todos: TodoRepository;
|
|
1890
3531
|
logger: LoggerPort;
|
|
1891
3532
|
${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
|
|
@@ -1894,7 +3535,11 @@ ${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOf
|
|
|
1894
3535
|
}
|
|
1895
3536
|
function productionServerProviders(ctx) {
|
|
1896
3537
|
const imports = [
|
|
3538
|
+
'import type { beignetServerOnly } from "@beignet/core/server-only";',
|
|
1897
3539
|
'import { createDevtoolsProvider } from "@beignet/devtools";',
|
|
3540
|
+
hasIntegration(ctx, "better-auth")
|
|
3541
|
+
? 'import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";'
|
|
3542
|
+
: undefined,
|
|
1898
3543
|
hasDrizzleTurso(ctx)
|
|
1899
3544
|
? 'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";'
|
|
1900
3545
|
: undefined,
|
|
@@ -1912,15 +3557,26 @@ function productionServerProviders(ctx) {
|
|
|
1912
3557
|
hasDrizzleTurso(ctx)
|
|
1913
3558
|
? 'import * as schema from "@/infra/db/schema";'
|
|
1914
3559
|
: undefined,
|
|
3560
|
+
hasIntegration(ctx, "better-auth")
|
|
3561
|
+
? 'import { auth } from "@/lib/better-auth";'
|
|
3562
|
+
: undefined,
|
|
3563
|
+
'import { starterDatabaseProvider } from "@/infra/db/provider";',
|
|
3564
|
+
hasIntegration(ctx, "better-auth")
|
|
3565
|
+
? 'import type { AuthSessionMetadata, AuthUser } from "@/ports/auth";'
|
|
3566
|
+
: undefined,
|
|
1915
3567
|
].filter((line) => Boolean(line));
|
|
1916
3568
|
const declarations = hasDrizzleTurso(ctx)
|
|
1917
3569
|
? "\nconst drizzleTursoProvider = createDrizzleTursoProvider({ schema });\n"
|
|
1918
3570
|
: "";
|
|
1919
3571
|
const entries = [
|
|
1920
3572
|
"\tcreateDevtoolsProvider(),",
|
|
3573
|
+
hasIntegration(ctx, "better-auth")
|
|
3574
|
+
? "\tcreateAuthBetterAuthProvider<AuthUser, AuthSessionMetadata>(auth),"
|
|
3575
|
+
: undefined,
|
|
1921
3576
|
"\tlocalStorageProvider,",
|
|
1922
3577
|
"\tloggerPinoProvider,",
|
|
1923
3578
|
hasDrizzleTurso(ctx) ? "\tdrizzleTursoProvider," : undefined,
|
|
3579
|
+
"\tstarterDatabaseProvider,",
|
|
1924
3580
|
hasIntegration(ctx, "inngest") ? "\tinngestProvider," : undefined,
|
|
1925
3581
|
hasIntegration(ctx, "resend") ? "\tmailResendProvider," : undefined,
|
|
1926
3582
|
hasIntegration(ctx, "upstash-rate-limit")
|
|
@@ -1931,44 +3587,25 @@ function productionServerProviders(ctx) {
|
|
|
1931
3587
|
${declarations}
|
|
1932
3588
|
export const providers = [
|
|
1933
3589
|
${entries.join("\n")}
|
|
1934
|
-
];
|
|
3590
|
+
] as const;
|
|
1935
3591
|
`;
|
|
1936
3592
|
}
|
|
1937
|
-
function productionTodosTest(
|
|
1938
|
-
const portImports = [
|
|
1939
|
-
hasIntegration(ctx, "upstash-rate-limit")
|
|
1940
|
-
? "createMemoryRateLimiter"
|
|
1941
|
-
: undefined,
|
|
1942
|
-
"createMemoryStorage",
|
|
1943
|
-
"createNoopUnitOfWork",
|
|
1944
|
-
"createUserActor",
|
|
1945
|
-
].filter((item) => Boolean(item));
|
|
1946
|
-
const extraPorts = [
|
|
1947
|
-
hasDrizzleTurso(ctx)
|
|
1948
|
-
? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
|
|
1949
|
-
: undefined,
|
|
1950
|
-
hasIntegration(ctx, "inngest")
|
|
1951
|
-
? "\t\t\tjobs: { dispatch: async () => undefined },"
|
|
1952
|
-
: undefined,
|
|
1953
|
-
hasIntegration(ctx, "resend")
|
|
1954
|
-
? '\t\t\tmailer: { send: async () => ({ provider: "test" }) },'
|
|
1955
|
-
: undefined,
|
|
1956
|
-
hasIntegration(ctx, "upstash-rate-limit")
|
|
1957
|
-
? "\t\t\trateLimit: createMemoryRateLimiter(),"
|
|
1958
|
-
: undefined,
|
|
1959
|
-
].filter((entry) => Boolean(entry));
|
|
3593
|
+
function productionTodosTest(_ctx) {
|
|
1960
3594
|
return `import { describe, expect, it } from "bun:test";
|
|
1961
3595
|
import { createUseCaseTester } from "@beignet/core/application";
|
|
3596
|
+
import { offsetPageResult, type OffsetPage } from "@beignet/core/pagination";
|
|
1962
3597
|
import { createInMemoryDevtools } from "@beignet/devtools";
|
|
1963
|
-
import {
|
|
3598
|
+
import { createDomainEventRecorder } from "@beignet/core/ports";
|
|
3599
|
+
import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
|
|
3600
|
+
import { createTestUserActor } from "@beignet/core/ports/testing";
|
|
1964
3601
|
import type { AppContext } from "@/app-context";
|
|
1965
3602
|
import { appPorts } from "@/infra/app-ports";
|
|
3603
|
+
import type { AppTransactionPorts } from "@/ports";
|
|
1966
3604
|
import {
|
|
1967
3605
|
createTodoUseCase,
|
|
1968
3606
|
getTodoUseCase,
|
|
1969
3607
|
listTodosUseCase,
|
|
1970
3608
|
type CreateTodoInput,
|
|
1971
|
-
type ListTodosInput,
|
|
1972
3609
|
type Todo,
|
|
1973
3610
|
} from "../use-cases";
|
|
1974
3611
|
|
|
@@ -1976,15 +3613,16 @@ function createTestTodoRepository() {
|
|
|
1976
3613
|
const todos = new Map<string, Todo>();
|
|
1977
3614
|
|
|
1978
3615
|
return {
|
|
1979
|
-
async list(
|
|
3616
|
+
async list(page: OffsetPage) {
|
|
1980
3617
|
const allTodos = Array.from(todos.values()).sort((left, right) =>
|
|
1981
3618
|
left.createdAt.localeCompare(right.createdAt),
|
|
1982
3619
|
);
|
|
1983
3620
|
|
|
1984
|
-
return
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
3621
|
+
return offsetPageResult(
|
|
3622
|
+
allTodos.slice(page.offset, page.offset + page.limit),
|
|
3623
|
+
page,
|
|
3624
|
+
allTodos.length,
|
|
3625
|
+
);
|
|
1988
3626
|
},
|
|
1989
3627
|
async findById(id: string) {
|
|
1990
3628
|
return todos.get(id) ?? null;
|
|
@@ -2002,9 +3640,31 @@ function createTestTodoRepository() {
|
|
|
2002
3640
|
};
|
|
2003
3641
|
}
|
|
2004
3642
|
|
|
3643
|
+
function createTestTodoAttachmentRepository() {
|
|
3644
|
+
return {
|
|
3645
|
+
async create(input: {
|
|
3646
|
+
id: string;
|
|
3647
|
+
todoId: string;
|
|
3648
|
+
key: string;
|
|
3649
|
+
fileName: string;
|
|
3650
|
+
contentType: string;
|
|
3651
|
+
size: number;
|
|
3652
|
+
}) {
|
|
3653
|
+
return {
|
|
3654
|
+
...input,
|
|
3655
|
+
createdAt: new Date().toISOString(),
|
|
3656
|
+
};
|
|
3657
|
+
},
|
|
3658
|
+
async findMany() {
|
|
3659
|
+
return [];
|
|
3660
|
+
},
|
|
3661
|
+
};
|
|
3662
|
+
}
|
|
3663
|
+
|
|
2005
3664
|
describe("todos resource", () => {
|
|
2006
3665
|
it("creates, gets, and lists todos", async () => {
|
|
2007
3666
|
const todos = createTestTodoRepository();
|
|
3667
|
+
const todoAttachments = createTestTodoAttachmentRepository();
|
|
2008
3668
|
const auth = {
|
|
2009
3669
|
user: {
|
|
2010
3670
|
id: "user_test",
|
|
@@ -2013,23 +3673,31 @@ describe("todos resource", () => {
|
|
|
2013
3673
|
},
|
|
2014
3674
|
session: { id: "session_test" },
|
|
2015
3675
|
};
|
|
2016
|
-
const
|
|
2017
|
-
|
|
3676
|
+
const testFixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
|
|
3677
|
+
base: appPorts,
|
|
3678
|
+
overrides: {
|
|
3679
|
+
gate: appPorts.gate,
|
|
3680
|
+
todoAttachments,
|
|
3681
|
+
todos,
|
|
3682
|
+
devtools: createInMemoryDevtools(),
|
|
3683
|
+
},
|
|
3684
|
+
transaction: {
|
|
3685
|
+
ports: (ports) => ({
|
|
3686
|
+
...ports,
|
|
3687
|
+
events: createDomainEventRecorder(),
|
|
3688
|
+
todoAttachments,
|
|
3689
|
+
todos,
|
|
3690
|
+
}),
|
|
3691
|
+
},
|
|
2018
3692
|
});
|
|
2019
|
-
const
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
storage: createMemoryStorage(),
|
|
2025
|
-
};
|
|
2026
|
-
const tester = createUseCaseTester<AppContext>(() => ({
|
|
2027
|
-
requestId: "test-request",
|
|
2028
|
-
actor,
|
|
3693
|
+
const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
|
|
3694
|
+
ports: testFixture.ports,
|
|
3695
|
+
actor: createTestUserActor(auth.user.id, {
|
|
3696
|
+
displayName: auth.user.name,
|
|
3697
|
+
}),
|
|
2029
3698
|
auth,
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
}));
|
|
3699
|
+
});
|
|
3700
|
+
const tester = createUseCaseTester<AppContext>(createTestContext);
|
|
2033
3701
|
|
|
2034
3702
|
const ctx = await tester.ctx();
|
|
2035
3703
|
const created = await tester.run(
|
|
@@ -2046,8 +3714,8 @@ ${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUn
|
|
|
2046
3714
|
|
|
2047
3715
|
expect(created.title).toBe("First todo");
|
|
2048
3716
|
expect(found.id).toBe(created.id);
|
|
2049
|
-
expect(result.total).toBe(1);
|
|
2050
|
-
expect(result.
|
|
3717
|
+
expect(result.page.total).toBe(1);
|
|
3718
|
+
expect(result.items).toEqual([created]);
|
|
2051
3719
|
});
|
|
2052
3720
|
});
|
|
2053
3721
|
`;
|
|
@@ -2084,8 +3752,24 @@ function getProductionTemplateFiles(ctx) {
|
|
|
2084
3752
|
path: "app/api/openapi/route.ts",
|
|
2085
3753
|
content: files.productionOpenApiRoute,
|
|
2086
3754
|
},
|
|
2087
|
-
{
|
|
2088
|
-
|
|
3755
|
+
{
|
|
3756
|
+
path: "app/api/auth/[...all]/route.ts",
|
|
3757
|
+
content: files.productionAuthRoute,
|
|
3758
|
+
},
|
|
3759
|
+
{
|
|
3760
|
+
path: "app/api/cron/outbox/drain/route.ts",
|
|
3761
|
+
content: files.productionOutboxDrainRoute,
|
|
3762
|
+
},
|
|
3763
|
+
{
|
|
3764
|
+
path: "app/api/cron/todos/daily-summary/route.ts",
|
|
3765
|
+
content: files.productionScheduleRoute,
|
|
3766
|
+
},
|
|
3767
|
+
{
|
|
3768
|
+
path: "app/api/uploads/[uploadName]/[action]/route.ts",
|
|
3769
|
+
content: files.productionUploadsRoute,
|
|
3770
|
+
},
|
|
3771
|
+
{ path: "client/index.ts", content: clientIndex(ctx) },
|
|
3772
|
+
{ path: "client/forms.ts", content: files.formsClient },
|
|
2089
3773
|
{
|
|
2090
3774
|
path: "features/todos/contracts.ts",
|
|
2091
3775
|
content: files.productionContractsTodos,
|
|
@@ -2096,16 +3780,43 @@ function getProductionTemplateFiles(ctx) {
|
|
|
2096
3780
|
{
|
|
2097
3781
|
path: "infra/app-ports.ts",
|
|
2098
3782
|
content: usesDrizzleTurso
|
|
2099
|
-
?
|
|
3783
|
+
? productionInfrastructurePortsWithDrizzleTurso(ctx)
|
|
2100
3784
|
: files.productionInfrastructurePorts,
|
|
2101
3785
|
},
|
|
2102
3786
|
{ path: "lib/env.ts", content: files.productionEnv },
|
|
3787
|
+
{ path: "lib/audit.ts", content: files.productionAuditHelper },
|
|
2103
3788
|
{ path: "lib/auth.ts", content: files.productionAuthHelpers },
|
|
3789
|
+
{ path: "lib/better-auth.ts", content: files.productionBetterAuth },
|
|
2104
3790
|
{ path: "features/todos/policy.ts", content: files.productionTodoPolicy },
|
|
2105
3791
|
{
|
|
2106
|
-
path: "
|
|
2107
|
-
content: files.
|
|
3792
|
+
path: "features/todos/domain/events/index.ts",
|
|
3793
|
+
content: files.productionTodoEvents,
|
|
3794
|
+
},
|
|
3795
|
+
{ path: "features/todos/jobs/index.ts", content: files.productionTodoJobs },
|
|
3796
|
+
{
|
|
3797
|
+
path: "features/todos/listeners/index.ts",
|
|
3798
|
+
content: files.productionTodoListeners,
|
|
3799
|
+
},
|
|
3800
|
+
{
|
|
3801
|
+
path: "features/todos/notifications/index.ts",
|
|
3802
|
+
content: files.productionTodoNotifications,
|
|
3803
|
+
},
|
|
3804
|
+
{
|
|
3805
|
+
path: "features/todos/schedules/index.ts",
|
|
3806
|
+
content: files.productionTodoSchedules,
|
|
3807
|
+
},
|
|
3808
|
+
{
|
|
3809
|
+
path: "features/todos/uploads/index.ts",
|
|
3810
|
+
content: files.productionTodoUploads,
|
|
2108
3811
|
},
|
|
3812
|
+
...(!hasIntegration(ctx, "better-auth")
|
|
3813
|
+
? [
|
|
3814
|
+
{
|
|
3815
|
+
path: "infra/auth/anonymous-auth.ts",
|
|
3816
|
+
content: files.productionAnonymousAuth,
|
|
3817
|
+
},
|
|
3818
|
+
]
|
|
3819
|
+
: []),
|
|
2109
3820
|
{ path: "ports/auth.ts", content: files.productionAuthPort },
|
|
2110
3821
|
{
|
|
2111
3822
|
path: "ports/index.ts",
|
|
@@ -2119,7 +3830,7 @@ function getProductionTemplateFiles(ctx) {
|
|
|
2119
3830
|
},
|
|
2120
3831
|
{
|
|
2121
3832
|
path: "features/shared/errors.ts",
|
|
2122
|
-
content: files.
|
|
3833
|
+
content: files.productionSharedErrors,
|
|
2123
3834
|
},
|
|
2124
3835
|
{
|
|
2125
3836
|
path: "server/index.ts",
|
|
@@ -2127,12 +3838,22 @@ function getProductionTemplateFiles(ctx) {
|
|
|
2127
3838
|
? files.productionServerWithDrizzleTurso
|
|
2128
3839
|
: files.productionServer,
|
|
2129
3840
|
},
|
|
3841
|
+
{ path: "server/context.ts", content: files.productionServerContext },
|
|
2130
3842
|
{ path: "server/routes.ts", content: files.productionServerRoutes },
|
|
3843
|
+
{ path: "server/outbox.ts", content: files.productionOutbox },
|
|
3844
|
+
{ path: "server/schedules.ts", content: files.productionSchedules },
|
|
2131
3845
|
{
|
|
2132
3846
|
path: "server/providers.ts",
|
|
2133
3847
|
content: productionServerProviders(ctx),
|
|
2134
3848
|
},
|
|
2135
3849
|
{ path: "lib/use-case.ts", content: files.productionUseCaseBuilder },
|
|
3850
|
+
{ path: "lib/listeners.ts", content: files.productionListenersBuilder },
|
|
3851
|
+
{ path: "lib/jobs.ts", content: files.productionJobsBuilder },
|
|
3852
|
+
{ path: "lib/schedules.ts", content: files.productionSchedulesBuilder },
|
|
3853
|
+
{
|
|
3854
|
+
path: "lib/notifications.ts",
|
|
3855
|
+
content: files.productionNotificationsBuilder,
|
|
3856
|
+
},
|
|
2136
3857
|
{
|
|
2137
3858
|
path: "features/todos/use-cases/create-todo.ts",
|
|
2138
3859
|
content: files.productionCreateTodoUseCase,
|
|
@@ -2150,7 +3871,7 @@ function getProductionTemplateFiles(ctx) {
|
|
|
2150
3871
|
content: files.productionUseCasesIndex,
|
|
2151
3872
|
},
|
|
2152
3873
|
{
|
|
2153
|
-
path: "features/todos/
|
|
3874
|
+
path: "features/todos/schemas.ts",
|
|
2154
3875
|
content: files.productionTodoSchemas,
|
|
2155
3876
|
},
|
|
2156
3877
|
{
|
|
@@ -2161,6 +3882,24 @@ function getProductionTemplateFiles(ctx) {
|
|
|
2161
3882
|
];
|
|
2162
3883
|
if (usesDrizzleTurso) {
|
|
2163
3884
|
templateFiles.push({ path: "drizzle.config.ts", content: files.productionDrizzleConfig }, {
|
|
3885
|
+
path: "infra/audit/drizzle-audit-log.ts",
|
|
3886
|
+
content: files.productionDrizzleAuditLog,
|
|
3887
|
+
}, {
|
|
3888
|
+
path: "infra/db/bootstrap.ts",
|
|
3889
|
+
content: files.productionDbBootstrap,
|
|
3890
|
+
}, {
|
|
3891
|
+
path: "infra/db/seed.ts",
|
|
3892
|
+
content: files.productionDbSeed,
|
|
3893
|
+
}, {
|
|
3894
|
+
path: "infra/db/reset.ts",
|
|
3895
|
+
content: files.productionDbReset,
|
|
3896
|
+
}, {
|
|
3897
|
+
path: "infra/db/test-database.ts",
|
|
3898
|
+
content: files.productionDbTestDatabase,
|
|
3899
|
+
}, {
|
|
3900
|
+
path: "infra/db/provider.ts",
|
|
3901
|
+
content: files.productionDbProvider,
|
|
3902
|
+
}, {
|
|
2164
3903
|
path: "infra/db/schema/index.ts",
|
|
2165
3904
|
content: files.productionDbSchema,
|
|
2166
3905
|
}, {
|
|
@@ -2179,6 +3918,9 @@ function getProductionTemplateFiles(ctx) {
|
|
|
2179
3918
|
}
|
|
2180
3919
|
return templateFiles;
|
|
2181
3920
|
}
|
|
3921
|
+
/**
|
|
3922
|
+
* Render all template files for a new Beignet app.
|
|
3923
|
+
*/
|
|
2182
3924
|
export function getTemplateFiles(ctx) {
|
|
2183
3925
|
if (isStandardPreset(ctx)) {
|
|
2184
3926
|
return getProductionTemplateFiles(ctx);
|
|
@@ -2194,21 +3936,29 @@ export function getTemplateFiles(ctx) {
|
|
|
2194
3936
|
{ path: "app/layout.tsx", content: layout(ctx) },
|
|
2195
3937
|
{ path: "app/page.tsx", content: page(ctx) },
|
|
2196
3938
|
{ path: "app/api/[[...path]]/route.ts", content: files.apiCatchAllRoute },
|
|
3939
|
+
{ path: "app-context.ts", content: minimalAppContext(ctx) },
|
|
2197
3940
|
{ path: "features/todos/contracts.ts", content: files.contractsTodos },
|
|
2198
3941
|
{ path: "features/todos/routes.ts", content: files.todoRoutes },
|
|
2199
|
-
{ path: "ports/index.ts", content:
|
|
3942
|
+
{ path: "ports/index.ts", content: minimalPorts(ctx) },
|
|
2200
3943
|
{ path: "server/index.ts", content: server(ctx) },
|
|
3944
|
+
{ path: "server/context.ts", content: serverContext(ctx) },
|
|
2201
3945
|
{ path: "server/routes.ts", content: files.serverRoutes },
|
|
2202
|
-
{ path: "features/todos/use-cases.ts", content: files.useCasesTodos },
|
|
3946
|
+
{ path: "features/todos/use-cases/index.ts", content: files.useCasesTodos },
|
|
2203
3947
|
];
|
|
2204
3948
|
if (hasFeature(ctx, "client")) {
|
|
2205
3949
|
templateFiles.push({
|
|
2206
|
-
path: "client/
|
|
2207
|
-
content:
|
|
3950
|
+
path: "client/index.ts",
|
|
3951
|
+
content: clientIndex(ctx),
|
|
2208
3952
|
});
|
|
2209
3953
|
}
|
|
2210
3954
|
if (hasFeature(ctx, "react-query")) {
|
|
2211
|
-
templateFiles.push({ path: "app/providers.tsx", content: files.appProviders }, { path: "
|
|
3955
|
+
templateFiles.push({ path: "app/providers.tsx", content: files.appProviders }, { path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) });
|
|
3956
|
+
}
|
|
3957
|
+
if (hasFeature(ctx, "forms")) {
|
|
3958
|
+
templateFiles.push({
|
|
3959
|
+
path: "client/forms.ts",
|
|
3960
|
+
content: files.formsClient,
|
|
3961
|
+
});
|
|
2212
3962
|
}
|
|
2213
3963
|
if (hasFeature(ctx, "openapi")) {
|
|
2214
3964
|
templateFiles.push({
|
|
@@ -2216,14 +3966,23 @@ export function getTemplateFiles(ctx) {
|
|
|
2216
3966
|
content: files.apiOpenApiRoute,
|
|
2217
3967
|
});
|
|
2218
3968
|
}
|
|
2219
|
-
if (ctx
|
|
2220
|
-
templateFiles.push({
|
|
2221
|
-
path: "
|
|
2222
|
-
content:
|
|
3969
|
+
if (hasIntegration(ctx, "better-auth")) {
|
|
3970
|
+
templateFiles.push({ path: "lib/better-auth.ts", content: files.minimalBetterAuth }, {
|
|
3971
|
+
path: "app/api/auth/[...all]/route.ts",
|
|
3972
|
+
content: files.productionAuthRoute,
|
|
3973
|
+
});
|
|
3974
|
+
}
|
|
3975
|
+
if (hasDrizzleTurso(ctx)) {
|
|
3976
|
+
templateFiles.push({ path: "drizzle.config.ts", content: files.productionDrizzleConfig }, { path: "infra/db/provider.ts", content: files.minimalDbProvider }, { path: "infra/db/schema/index.ts", content: files.minimalDbSchema }, {
|
|
3977
|
+
path: "infra/todos/drizzle-todo-repository.ts",
|
|
3978
|
+
content: files.minimalDrizzleTodoRepository,
|
|
2223
3979
|
});
|
|
2224
3980
|
}
|
|
2225
3981
|
if (ctx.integrations.length > 0) {
|
|
2226
3982
|
templateFiles.push({
|
|
3983
|
+
path: "server/providers.ts",
|
|
3984
|
+
content: serverProviders(ctx),
|
|
3985
|
+
}, {
|
|
2227
3986
|
path: ".env.example",
|
|
2228
3987
|
content: envExample(ctx),
|
|
2229
3988
|
}, {
|