create-m5kdev 0.19.2 → 0.20.0
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/package.json +3 -3
- package/templates/minimal-app/.cursor/rules/module-db-guide.mdc +61 -0
- package/templates/minimal-app/.cursor/rules/module-grants-guide.mdc +134 -0
- package/templates/minimal-app/.cursor/rules/module-module-guide.mdc +183 -0
- package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +193 -0
- package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +173 -0
- package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +240 -0
- package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +113 -0
- package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +2 -1
- package/templates/minimal-app/pnpm-workspace.yaml.tpl +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-m5kdev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"license": "GPL-3.0-only",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"@types/jest": "30.0.0",
|
|
24
24
|
"@types/node": "20.19.11",
|
|
25
25
|
"jest": "30.1.3",
|
|
26
|
-
"ts-jest": "29.4.
|
|
26
|
+
"ts-jest": "29.4.9",
|
|
27
27
|
"tsdown": "0.21.7",
|
|
28
|
-
"typescript": "
|
|
28
|
+
"typescript": "6.0.3"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "tsdown",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: apps/*/server/src/modules/**/*.db.ts
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Module DB File Style Guide
|
|
7
|
+
|
|
8
|
+
Conventions for database table definitions in app modules.
|
|
9
|
+
|
|
10
|
+
## Conventions
|
|
11
|
+
|
|
12
|
+
- Import enum-like constants from shared constants: `@appname/shared/modules/<module>/<module>.constants`.
|
|
13
|
+
- Import auth tables for foreign keys: `import { users, organizations, teams } from "@m5kdev/backend/modules/auth/auth.db"`.
|
|
14
|
+
- Define tables with Drizzle's `sqliteTable` aliased as `table`.
|
|
15
|
+
- Use `uuidv4` as the default for string IDs via `.$default(uuidv4)`.
|
|
16
|
+
- Represent booleans with `integer(..., { mode: "boolean" })` and `.default(false)`.
|
|
17
|
+
- Represent timestamps with `integer(..., { mode: "timestamp" })`.
|
|
18
|
+
- `createdAt`: `integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date())`
|
|
19
|
+
- `updatedAt`: `integer("updated_at", { mode: "timestamp" })`
|
|
20
|
+
- `deletedAt`: `integer("deleted_at", { mode: "timestamp" })`
|
|
21
|
+
- JSON columns: `text("col", { mode: "json" }).default([]).$type<string[]>()` or `.default({}).$type<Record<string, unknown>>()`.
|
|
22
|
+
- Use `references(() => otherTable.id, { onDelete: "cascade" })` for non-nullable FKs and `{ onDelete: "set null" }` for nullable FKs.
|
|
23
|
+
- Composite join tables define primary keys with `primaryKey({ columns: [t.aId, t.bId] })`.
|
|
24
|
+
- Table names are plural; column names are snake_case at DB level, camelCase in code.
|
|
25
|
+
- Inline union types are acceptable for simple status columns: `.$type<"draft" | "published">()`.
|
|
26
|
+
|
|
27
|
+
## File Location
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
apps/<app>/server/src/modules/<module>/<module>.db.ts
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Example
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { organizations, teams, users } from "@m5kdev/backend/modules/auth/auth.db";
|
|
37
|
+
import { integer, sqliteTable as table, text } from "drizzle-orm/sqlite-core";
|
|
38
|
+
import { v4 as uuidv4 } from "uuid";
|
|
39
|
+
|
|
40
|
+
export const posts = table("posts", {
|
|
41
|
+
id: text("id").primaryKey().$default(uuidv4),
|
|
42
|
+
authorUserId: text("author_user_id").references(() => users.id, {
|
|
43
|
+
onDelete: "set null",
|
|
44
|
+
}),
|
|
45
|
+
organizationId: text("organization_id").references(() => organizations.id, {
|
|
46
|
+
onDelete: "set null",
|
|
47
|
+
}),
|
|
48
|
+
teamId: text("team_id").references(() => teams.id, { onDelete: "set null" }),
|
|
49
|
+
title: text("title").notNull(),
|
|
50
|
+
slug: text("slug").notNull().unique(),
|
|
51
|
+
excerpt: text("excerpt"),
|
|
52
|
+
content: text("content").notNull(),
|
|
53
|
+
status: text("status").$type<"draft" | "published">().notNull().default("draft"),
|
|
54
|
+
publishedAt: integer("published_at", { mode: "timestamp" }),
|
|
55
|
+
createdAt: integer("created_at", { mode: "timestamp" })
|
|
56
|
+
.notNull()
|
|
57
|
+
.$default(() => new Date()),
|
|
58
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }),
|
|
59
|
+
deletedAt: integer("deleted_at", { mode: "timestamp" }),
|
|
60
|
+
});
|
|
61
|
+
```
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: apps/*/server/src/modules/**/*.grants.ts
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Module Grants and Permission Guide
|
|
7
|
+
|
|
8
|
+
Conventions for defining permission grants in app modules.
|
|
9
|
+
|
|
10
|
+
## File Location
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
apps/<app>/server/src/modules/<module>/<module>.grants.ts
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Core Rules
|
|
17
|
+
|
|
18
|
+
- Keep grants in `<module>.grants.ts`.
|
|
19
|
+
- Keep authorization checks in `<module>.service.ts` via the procedure builder's `.access()` step.
|
|
20
|
+
- Action names in `.access()` and `accessGuard`/`accessGuardAsync` must exactly match action names defined in grants.
|
|
21
|
+
- Prefer canonical action vocabulary: `read`, `write`, `delete`, `publish`.
|
|
22
|
+
|
|
23
|
+
## Grant Structure
|
|
24
|
+
|
|
25
|
+
App modules use flat `ResourceGrant[]` arrays imported from `@m5kdev/backend/modules/base/base.grants`:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import type { ResourceGrant } from "@m5kdev/backend/modules/base/base.grants";
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Each `ResourceGrant` has four fields:
|
|
32
|
+
|
|
33
|
+
| Field | Type | Description |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `action` | `string` | The permission action (`"read"`, `"write"`, `"delete"`, `"publish"`) |
|
|
36
|
+
| `level` | `"user" \| "team" \| "organization"` | The scope level at which the grant applies |
|
|
37
|
+
| `role` | `string` | The role that receives this grant (`"owner"`, `"admin"`, `"member"`) |
|
|
38
|
+
| `access` | `"all" \| "own"` | `"all"` grants access to any entity; `"own"` requires ownership match |
|
|
39
|
+
|
|
40
|
+
## Example
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import type { ResourceGrant } from "@m5kdev/backend/modules/base/base.grants";
|
|
44
|
+
|
|
45
|
+
export const postsGrants: ResourceGrant[] = [
|
|
46
|
+
{
|
|
47
|
+
action: "write",
|
|
48
|
+
level: "team",
|
|
49
|
+
role: "owner",
|
|
50
|
+
access: "own",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
action: "publish",
|
|
54
|
+
level: "team",
|
|
55
|
+
role: "owner",
|
|
56
|
+
access: "own",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
action: "delete",
|
|
60
|
+
level: "team",
|
|
61
|
+
role: "owner",
|
|
62
|
+
access: "own",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
action: "write",
|
|
66
|
+
level: "organization",
|
|
67
|
+
role: "owner",
|
|
68
|
+
access: "own",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
action: "publish",
|
|
72
|
+
level: "organization",
|
|
73
|
+
role: "owner",
|
|
74
|
+
access: "own",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
action: "delete",
|
|
78
|
+
level: "organization",
|
|
79
|
+
role: "owner",
|
|
80
|
+
access: "own",
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Wiring Grants to Services
|
|
86
|
+
|
|
87
|
+
Pass the grants array as the third argument to `BasePermissionService` in the module file:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
override services({ repositories }: ModuleServicesContext<Deps, Repositories>) {
|
|
91
|
+
return {
|
|
92
|
+
posts: new PostsService({ posts: repositories.posts }, {}, postsGrants),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Service Integration
|
|
98
|
+
|
|
99
|
+
Use `.access()` in procedure chains to enforce grants. The action string must match a grant's `action` field:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
readonly update = this.procedure<UpdateInput>("update")
|
|
103
|
+
.requireAuth()
|
|
104
|
+
.use("post", async ({ input }) => {
|
|
105
|
+
const current = await this.repository.posts.findById(input.id);
|
|
106
|
+
if (current.isErr()) return err(current.error);
|
|
107
|
+
if (!current.value || current.value.deletedAt) return this.error("NOT_FOUND");
|
|
108
|
+
return current.value;
|
|
109
|
+
})
|
|
110
|
+
.access({
|
|
111
|
+
action: "write",
|
|
112
|
+
entityStep: "post",
|
|
113
|
+
})
|
|
114
|
+
.handle(async ({ input }) => {
|
|
115
|
+
return this.repository.posts.update(input);
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The `.access()` step supports two entity resolution modes:
|
|
120
|
+
|
|
121
|
+
- `entityStep` — reads entity from a previous `.use()` or `.loadResource()` step.
|
|
122
|
+
- `entities` — inline function returning `{ userId?, organizationId?, teamId? }`.
|
|
123
|
+
|
|
124
|
+
## Service Choice
|
|
125
|
+
|
|
126
|
+
- Use `BasePermissionService` only when the module actually performs `.access()` checks.
|
|
127
|
+
- If a module has no permission enforcement, use `BaseService` instead.
|
|
128
|
+
|
|
129
|
+
## Anti-Patterns
|
|
130
|
+
|
|
131
|
+
- Grant declares `write` while service checks `update` — action names must match exactly.
|
|
132
|
+
- Defining grants but never enforcing them with `.access()` in service procedures.
|
|
133
|
+
- Doing permission checks in tRPC/router files instead of in the service layer.
|
|
134
|
+
- Using `BasePermissionService` when no grants or `.access()` calls exist.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: apps/*/server/src/modules/**/*.module.ts
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Module File Style Guide
|
|
7
|
+
|
|
8
|
+
A `*.module.ts` file is the composition unit for an app module. It wires repositories, services, and tRPC routers into a single class that the backend kernel orchestrates.
|
|
9
|
+
|
|
10
|
+
## File Location
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
apps/<app>/server/src/modules/<module>/<module>.module.ts
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Conventions
|
|
17
|
+
|
|
18
|
+
- Extend `BaseModule<Deps, Tables, Repositories, Services, Routers>` from `@m5kdev/backend/modules/base/base.module`.
|
|
19
|
+
- Declare local type aliases for each generic slot (`*ModuleDeps`, `*ModuleTables`, `*ModuleRepositories`, `*ModuleServices`, `*ModuleRouters`).
|
|
20
|
+
- Set `readonly id` to match the module directory name (e.g. `readonly id = "posts"`).
|
|
21
|
+
- Declare `dependsOn` as a `readonly` tuple with `as const`. App modules typically depend on framework modules like `AuthModule`.
|
|
22
|
+
- Override only the hooks the module actually needs; omit the rest.
|
|
23
|
+
- Keep hook bodies as pure wiring — no business logic.
|
|
24
|
+
- Use `createBackendRouterMap(namespace, router)` from `@m5kdev/backend/app` in the `trpc()` hook.
|
|
25
|
+
- Export a singleton instance at the bottom of the file.
|
|
26
|
+
|
|
27
|
+
## Generic Parameters
|
|
28
|
+
|
|
29
|
+
| Position | Name | Purpose | When unused |
|
|
30
|
+
|----------|------|---------|-------------|
|
|
31
|
+
| 1 | `Deps` | Map of module dependencies (`{ auth: AuthModule }`) | `never` |
|
|
32
|
+
| 2 | `Tables` | `typeof import("./<module>.db")` — DB table exports | `TableMap` |
|
|
33
|
+
| 3 | `Repositories` | Object map of repository instances | `{}` |
|
|
34
|
+
| 4 | `Services` | Object map of service instances | `{}` |
|
|
35
|
+
| 5 | `Routers` | Object map of tRPC routers keyed by namespace | `never` |
|
|
36
|
+
|
|
37
|
+
## Dependency Declaration
|
|
38
|
+
|
|
39
|
+
- `dependsOn` lists required modules by their `id`. The kernel initializes them first and provides their output via `ctx.deps`.
|
|
40
|
+
- App modules commonly depend on `"auth"` for the framework's `AuthModule`.
|
|
41
|
+
- Use `optionalDependsOn` when a dependency is not always registered.
|
|
42
|
+
|
|
43
|
+
## Lifecycle Hooks (Execution Order)
|
|
44
|
+
|
|
45
|
+
1. `repositories(ctx)` — Instantiate repositories. Context provides `db` (orm, schema, client), `deps`, `infra`.
|
|
46
|
+
2. `services(ctx)` — Instantiate services. Context adds `repositories` from step 1.
|
|
47
|
+
3. `trpc(ctx)` — Return router map fragment. Context adds `services` and `trpc` methods.
|
|
48
|
+
4. `express(ctx)` — Mount Express routes (optional).
|
|
49
|
+
5. `workflows(ctx)` — Register workflow handlers (optional).
|
|
50
|
+
6. `startup(ctx)` — Async hook after full app build (optional).
|
|
51
|
+
7. `shutdown(ctx)` — Async hook on teardown (optional).
|
|
52
|
+
|
|
53
|
+
## Example
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { createBackendRouterMap } from "@m5kdev/backend/app";
|
|
57
|
+
import type { AuthModule } from "@m5kdev/backend/modules/auth/auth.module";
|
|
58
|
+
import {
|
|
59
|
+
BaseModule,
|
|
60
|
+
type ModuleRepositoriesContext,
|
|
61
|
+
type ModuleServicesContext,
|
|
62
|
+
type ModuleTRPCContext,
|
|
63
|
+
} from "@m5kdev/backend/modules/base/base.module";
|
|
64
|
+
import type * as postsTables from "./posts.db";
|
|
65
|
+
import { postsGrants } from "./posts.grants";
|
|
66
|
+
import { PostsRepository } from "./posts.repository";
|
|
67
|
+
import { PostsService } from "./posts.service";
|
|
68
|
+
import { createPostsTRPC } from "./posts.trpc";
|
|
69
|
+
|
|
70
|
+
type PostsModuleDeps = { auth: AuthModule };
|
|
71
|
+
type PostsModuleTables = typeof postsTables;
|
|
72
|
+
type PostsModuleRepositories = {
|
|
73
|
+
posts: PostsRepository;
|
|
74
|
+
};
|
|
75
|
+
type PostsModuleServices = {
|
|
76
|
+
posts: PostsService;
|
|
77
|
+
};
|
|
78
|
+
type PostsModuleRouters = {
|
|
79
|
+
posts: ReturnType<typeof createPostsTRPC>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export class PostsModule extends BaseModule<
|
|
83
|
+
PostsModuleDeps,
|
|
84
|
+
PostsModuleTables,
|
|
85
|
+
PostsModuleRepositories,
|
|
86
|
+
PostsModuleServices,
|
|
87
|
+
PostsModuleRouters
|
|
88
|
+
> {
|
|
89
|
+
readonly id = "posts";
|
|
90
|
+
override readonly dependsOn = ["auth"] as const;
|
|
91
|
+
|
|
92
|
+
override repositories({ db }: ModuleRepositoriesContext<PostsModuleDeps, PostsModuleTables>) {
|
|
93
|
+
return {
|
|
94
|
+
posts: new PostsRepository({
|
|
95
|
+
orm: db.orm,
|
|
96
|
+
schema: db.schema,
|
|
97
|
+
table: db.schema.posts,
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override services({
|
|
103
|
+
repositories,
|
|
104
|
+
}: ModuleServicesContext<PostsModuleDeps, PostsModuleRepositories>) {
|
|
105
|
+
return {
|
|
106
|
+
posts: new PostsService({ posts: repositories.posts }, {}, postsGrants),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
override trpc({ trpc, services }: ModuleTRPCContext<PostsModuleDeps, PostsModuleServices>) {
|
|
111
|
+
return createBackendRouterMap("posts", createPostsTRPC(trpc, services.posts));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const postsModule = new PostsModule();
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Registration
|
|
119
|
+
|
|
120
|
+
Register the module in `apps/<app>/server/src/modules.ts`:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { defineBackendModules } from "@m5kdev/backend/app";
|
|
124
|
+
import { PostsModule } from "./modules/posts/posts.module";
|
|
125
|
+
|
|
126
|
+
export const postsBackendModule = new PostsModule();
|
|
127
|
+
|
|
128
|
+
export const backendModules = defineBackendModules([
|
|
129
|
+
emailBackendModule,
|
|
130
|
+
authBackendModule,
|
|
131
|
+
workflowBackendModule,
|
|
132
|
+
notificationBackendModule,
|
|
133
|
+
postsBackendModule,
|
|
134
|
+
] as const);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Then chain `.use(postsBackendModule)` in `apps/<app>/server/src/app.ts`.
|
|
138
|
+
|
|
139
|
+
## Accessing Module Output
|
|
140
|
+
|
|
141
|
+
After building, module repositories and services are re-exported from composition root files:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// apps/<app>/server/src/repository.ts
|
|
145
|
+
export const postsRepository = builtBackendApp.modules.posts.repositories.posts;
|
|
146
|
+
|
|
147
|
+
// apps/<app>/server/src/service.ts
|
|
148
|
+
export const postsService = builtBackendApp.modules.posts.services.posts;
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Patterns
|
|
152
|
+
|
|
153
|
+
### Services-Only Module (No DB)
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
export class UtilsModule extends BaseModule<
|
|
157
|
+
never, TableMap, {}, UtilsModuleServices, never
|
|
158
|
+
> {
|
|
159
|
+
readonly id = "utils";
|
|
160
|
+
|
|
161
|
+
override services() {
|
|
162
|
+
return { utils: new UtilsService() };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Module with Constructor Config
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
export class PaymentsModule extends BaseModule<...> {
|
|
171
|
+
readonly id = "payments";
|
|
172
|
+
|
|
173
|
+
constructor(private readonly config: { apiKey: string }) {
|
|
174
|
+
super();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
override services({ repositories }: ModuleServicesContext<...>) {
|
|
178
|
+
return {
|
|
179
|
+
payments: new PaymentsService(repositories, {}, this.config),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: apps/*/server/src/modules/**/*.repository.ts
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Module Repository File Style Guide
|
|
7
|
+
|
|
8
|
+
Conventions for repository classes in app modules.
|
|
9
|
+
|
|
10
|
+
## File Location
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
apps/<app>/server/src/modules/<module>/<module>.repository.ts
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Conventions
|
|
17
|
+
|
|
18
|
+
- Extend `BaseTableRepository<Orm, Schema, NestedRepos, Schema["table"]>` for table-bound modules.
|
|
19
|
+
- Use `BaseRepository` for non-table or multi-table repositories.
|
|
20
|
+
- Keep repositories data-focused: persistence, query composition, and lightweight mapping only.
|
|
21
|
+
- Do not import service classes into repositories.
|
|
22
|
+
- Use Drizzle operators (`and`, `eq`, `inArray`, `isNull`, `like`, etc.) and query helpers from base classes.
|
|
23
|
+
- When running inside `this.orm.transaction`, use `tx` consistently; avoid mixing `this.orm` calls in the same transaction flow.
|
|
24
|
+
- Leave orchestration, policy decisions, and side effects to services.
|
|
25
|
+
|
|
26
|
+
## Orm/Schema Type Pattern
|
|
27
|
+
|
|
28
|
+
App repositories define their schema and ORM types inline from local table imports:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
|
32
|
+
import { posts } from "./posts.db";
|
|
33
|
+
|
|
34
|
+
const schema = { posts };
|
|
35
|
+
type Schema = typeof schema;
|
|
36
|
+
type Orm = LibSQLDatabase<Schema>;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This is passed to `BaseTableRepository` generics and provided by the module's `repositories()` hook via `db.orm` and `db.schema`.
|
|
40
|
+
|
|
41
|
+
## Built-in Base Methods
|
|
42
|
+
|
|
43
|
+
`BaseTableRepository` provides typed CRUD helpers. Prefer them over hand-written queries when they fit:
|
|
44
|
+
|
|
45
|
+
- `queryList(query, options)` — filtered/sorted/paginated lists.
|
|
46
|
+
- `findById(id)` / `findManyById(ids)` — primary-key lookups.
|
|
47
|
+
- `create(data)` / `createMany(data)` — inserts.
|
|
48
|
+
- `update(data)` / `updateMany(data)` — updates.
|
|
49
|
+
- `softDeleteById(id)` / `deleteById(id)` — deletes.
|
|
50
|
+
|
|
51
|
+
Override only when custom joins, CTEs, or non-standard behavior is required.
|
|
52
|
+
|
|
53
|
+
## Error Handling
|
|
54
|
+
|
|
55
|
+
- Use `throwableQuery` to wrap individual Drizzle queries. It converts thrown database errors into `ServerResultAsync`.
|
|
56
|
+
- Do not wrap entire method bodies in `throwableAsync`. Keep `throwableQuery` scoped tightly around the database call so error-handling logic stays outside the wrapper.
|
|
57
|
+
- Chain results with `isErr()` checks and return `err(result.error)` to propagate failures.
|
|
58
|
+
|
|
59
|
+
## Schema Type Imports
|
|
60
|
+
|
|
61
|
+
Import schema types from the shared package for method signatures:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import type {
|
|
65
|
+
PostSchema,
|
|
66
|
+
PostsListInputSchema,
|
|
67
|
+
PostsListOutputSchema,
|
|
68
|
+
} from "@appname/shared/modules/posts/posts.schema";
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Example
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import type {
|
|
75
|
+
PostSchema,
|
|
76
|
+
PostsListInputSchema,
|
|
77
|
+
PostsListOutputSchema,
|
|
78
|
+
} from "@appname/shared/modules/posts/posts.schema";
|
|
79
|
+
import { BaseTableRepository } from "@m5kdev/backend/modules/base/base.repository";
|
|
80
|
+
import type { ServerResultAsync } from "@m5kdev/backend/utils/types";
|
|
81
|
+
import { and, asc, count, desc, eq, isNull, like, ne, or } from "drizzle-orm";
|
|
82
|
+
import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
|
83
|
+
import { err, ok } from "neverthrow";
|
|
84
|
+
import { posts } from "./posts.db";
|
|
85
|
+
|
|
86
|
+
const schema = { posts };
|
|
87
|
+
type Schema = typeof schema;
|
|
88
|
+
type Orm = LibSQLDatabase<Schema>;
|
|
89
|
+
|
|
90
|
+
export class PostsRepository extends BaseTableRepository<
|
|
91
|
+
Orm,
|
|
92
|
+
Schema,
|
|
93
|
+
Record<string, never>,
|
|
94
|
+
Schema["posts"]
|
|
95
|
+
> {
|
|
96
|
+
async list(input: PostsListInputSchema = {}): ServerResultAsync<PostsListOutputSchema> {
|
|
97
|
+
const page = input.page ?? 1;
|
|
98
|
+
const limit = input.limit ?? 6;
|
|
99
|
+
const search = input.search?.trim();
|
|
100
|
+
const conditions = [isNull(this.table.deletedAt)];
|
|
101
|
+
|
|
102
|
+
if (input.status) {
|
|
103
|
+
conditions.push(eq(this.table.status, input.status));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (search) {
|
|
107
|
+
const escapedSearch = search.replace(/[%_]/g, "\\$&");
|
|
108
|
+
const pattern = `%${escapedSearch}%`;
|
|
109
|
+
const searchCondition = or(
|
|
110
|
+
like(this.table.title, pattern),
|
|
111
|
+
like(this.table.slug, pattern),
|
|
112
|
+
like(this.table.excerpt, pattern),
|
|
113
|
+
like(this.table.content, pattern)
|
|
114
|
+
);
|
|
115
|
+
if (searchCondition) {
|
|
116
|
+
conditions.push(searchCondition);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const whereClause = and(...conditions);
|
|
121
|
+
const ordering =
|
|
122
|
+
input.sort === "title"
|
|
123
|
+
? input.order === "asc"
|
|
124
|
+
? asc(this.table.title)
|
|
125
|
+
: desc(this.table.title)
|
|
126
|
+
: desc(this.table.createdAt);
|
|
127
|
+
|
|
128
|
+
const rowsQuery = this.orm
|
|
129
|
+
.select()
|
|
130
|
+
.from(this.table)
|
|
131
|
+
.where(whereClause)
|
|
132
|
+
.orderBy(ordering, desc(this.table.createdAt))
|
|
133
|
+
.limit(limit)
|
|
134
|
+
.offset((page - 1) * limit);
|
|
135
|
+
|
|
136
|
+
const countQuery = this.orm.select({ count: count() }).from(this.table).where(whereClause);
|
|
137
|
+
|
|
138
|
+
const result = await this.throwableQuery(() => Promise.all([rowsQuery, countQuery]));
|
|
139
|
+
if (result.isErr()) return err(result.error);
|
|
140
|
+
const [rows, [totalRow]] = result.value;
|
|
141
|
+
|
|
142
|
+
return ok({
|
|
143
|
+
rows: rows as PostSchema[],
|
|
144
|
+
total: totalRow?.count ?? 0,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async findBySlug(slug: string, excludeId?: string) {
|
|
149
|
+
const whereClause = excludeId
|
|
150
|
+
? and(eq(this.table.slug, slug), ne(this.table.id, excludeId), isNull(this.table.deletedAt))
|
|
151
|
+
: and(eq(this.table.slug, slug), isNull(this.table.deletedAt));
|
|
152
|
+
|
|
153
|
+
const result = await this.throwableQuery(() =>
|
|
154
|
+
this.orm.select().from(this.table).where(whereClause).limit(1)
|
|
155
|
+
);
|
|
156
|
+
if (result.isErr()) return err(result.error);
|
|
157
|
+
const [row] = result.value;
|
|
158
|
+
return ok(row);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## When to Use `async` Methods vs Query Builder
|
|
164
|
+
|
|
165
|
+
Use regular `async` methods (as shown above) when:
|
|
166
|
+
|
|
167
|
+
- The method requires an optional `tx` parameter for transaction support.
|
|
168
|
+
- The method orchestrates multiple sequential queries with branching logic between them.
|
|
169
|
+
- The method wraps a `this.orm.transaction()` call.
|
|
170
|
+
|
|
171
|
+
Use `this.query()` builder for simpler single-query methods with schema-typed input/output:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
findBySlug = this.query("findBySlug")
|
|
175
|
+
.input(z.object({ slug: z.string() }))
|
|
176
|
+
.output(postSchema.nullable())
|
|
177
|
+
.handle(async ({ slug }) => {
|
|
178
|
+
const result = await this.throwableQuery(() =>
|
|
179
|
+
this.orm.select().from(this.table).where(eq(this.table.slug, slug)).limit(1)
|
|
180
|
+
);
|
|
181
|
+
if (result.isErr()) return err(result.error);
|
|
182
|
+
const [row] = result.value;
|
|
183
|
+
return row ?? null;
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Best Practices
|
|
188
|
+
|
|
189
|
+
1. Prefer built-in `BaseTableRepository` methods (`findById`, `create`, `update`, `softDeleteById`) over hand-written queries for standard CRUD.
|
|
190
|
+
2. Use `throwableQuery` around each individual Drizzle query, not around the whole method body.
|
|
191
|
+
3. Filter soft-deleted rows with `isNull(this.table.deletedAt)` in custom queries.
|
|
192
|
+
4. Accept optional `tx?: Orm` parameter in methods that participate in transactions.
|
|
193
|
+
5. Return `ServerResultAsync` consistently from all repository methods.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: apps/*/shared/src/modules/**/*.schema.ts
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Module Schema File Style Guide
|
|
7
|
+
|
|
8
|
+
Conventions for shared Zod schema files that define the contract between server and client.
|
|
9
|
+
|
|
10
|
+
## File Location
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
apps/<app>/shared/src/modules/<module>/<module>.schema.ts
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Companion constants file:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
apps/<app>/shared/src/modules/<module>/<module>.constants.ts
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Core Philosophy
|
|
23
|
+
|
|
24
|
+
- Define all Zod schemas and their inferred types in the shared package so both server and webapp can import them.
|
|
25
|
+
- Import constants (enum values, page sizes) from the companion `<module>.constants.ts` file.
|
|
26
|
+
- Extend `querySchema` from `@m5kdev/commons/modules/schemas/query.schema` for list inputs that need pagination, sorting, and filtering.
|
|
27
|
+
- Export both the schema and its inferred `type` for every schema.
|
|
28
|
+
|
|
29
|
+
## Naming Conventions
|
|
30
|
+
|
|
31
|
+
| Kind | Pattern | Example |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| Entity schema | `{entity}Schema` | `postSchema` |
|
|
34
|
+
| Entity type | `{Entity}Schema` | `PostSchema` |
|
|
35
|
+
| Enum schema | `{entity}{Field}Schema` | `postStatusSchema` |
|
|
36
|
+
| List input | `{entities}ListInputSchema` | `postsListInputSchema` |
|
|
37
|
+
| List output | `{entities}ListOutputSchema` | `postsListOutputSchema` |
|
|
38
|
+
| Operation input | `{entity}{Action}InputSchema` | `postCreateInputSchema` |
|
|
39
|
+
| Operation output | `{entity}{Action}OutputSchema` | `postCreateOutputSchema` |
|
|
40
|
+
|
|
41
|
+
## File Structure
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { querySchema } from "@m5kdev/commons/modules/schemas/query.schema";
|
|
45
|
+
import { z } from "zod";
|
|
46
|
+
import { STATUS_VALUES } from "./<module>.constants";
|
|
47
|
+
|
|
48
|
+
// 1. Enum schemas (from constants)
|
|
49
|
+
// 2. Main entity schema
|
|
50
|
+
// 3. List input/output schemas
|
|
51
|
+
// 4. Operation input/output schemas
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Section 1: Enum Schemas
|
|
55
|
+
|
|
56
|
+
Derive Zod enums from constant tuples defined in the constants file:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
export const postStatusSchema = z.enum(POST_STATUS_VALUES);
|
|
60
|
+
export type PostStatusSchema = z.infer<typeof postStatusSchema>;
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Section 2: Main Entity Schema
|
|
64
|
+
|
|
65
|
+
Define the full entity shape as a `z.object`. This is the canonical representation of the entity:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
export const postSchema = z.object({
|
|
69
|
+
id: z.string(),
|
|
70
|
+
authorUserId: z.string().nullish(),
|
|
71
|
+
organizationId: z.string().nullish(),
|
|
72
|
+
teamId: z.string().nullish(),
|
|
73
|
+
title: z.string(),
|
|
74
|
+
slug: z.string(),
|
|
75
|
+
excerpt: z.string().nullish(),
|
|
76
|
+
content: z.string(),
|
|
77
|
+
status: postStatusSchema,
|
|
78
|
+
publishedAt: z.date().nullish(),
|
|
79
|
+
createdAt: z.date(),
|
|
80
|
+
updatedAt: z.date().nullish(),
|
|
81
|
+
deletedAt: z.date().nullish(),
|
|
82
|
+
});
|
|
83
|
+
export type PostSchema = z.infer<typeof postSchema>;
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Section 3: List Input/Output Schemas
|
|
87
|
+
|
|
88
|
+
Extend `querySchema` for list inputs with module-specific filters. Define list output as `{ rows, total }`:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
export const postsListInputSchema = querySchema.extend({
|
|
92
|
+
search: z.string().optional(),
|
|
93
|
+
status: postStatusSchema.optional(),
|
|
94
|
+
});
|
|
95
|
+
export type PostsListInputSchema = z.infer<typeof postsListInputSchema>;
|
|
96
|
+
|
|
97
|
+
export const postsListOutputSchema = z.object({
|
|
98
|
+
rows: z.array(postSchema),
|
|
99
|
+
total: z.number(),
|
|
100
|
+
});
|
|
101
|
+
export type PostsListOutputSchema = z.infer<typeof postsListOutputSchema>;
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Section 4: Operation Input/Output Schemas
|
|
105
|
+
|
|
106
|
+
Define per-operation schemas. Output schemas can reuse the main entity schema or a subset:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
export const postCreateInputSchema = z.object({
|
|
110
|
+
title: z.string().min(1),
|
|
111
|
+
slug: z.string().optional(),
|
|
112
|
+
excerpt: z.string().optional(),
|
|
113
|
+
content: z.string().min(1),
|
|
114
|
+
});
|
|
115
|
+
export type PostCreateInputSchema = z.infer<typeof postCreateInputSchema>;
|
|
116
|
+
|
|
117
|
+
export const postCreateOutputSchema = postSchema;
|
|
118
|
+
export type PostCreateOutputSchema = z.infer<typeof postCreateOutputSchema>;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Patterns
|
|
122
|
+
|
|
123
|
+
### Reuse Entity Schema for Output
|
|
124
|
+
|
|
125
|
+
When the operation returns the full entity, alias the main schema:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
export const postUpdateOutputSchema = postSchema;
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Minimal Operation Schemas
|
|
132
|
+
|
|
133
|
+
For operations that take only an ID and return a subset:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
export const postSoftDeleteInputSchema = z.object({
|
|
137
|
+
id: z.string(),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const postSoftDeleteOutputSchema = z.object({
|
|
141
|
+
id: z.string(),
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Omitting Fields for Lighter Outputs
|
|
146
|
+
|
|
147
|
+
Use `.omit()` or `.pick()` on the main schema to create lighter variants:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
export const postSimpleSchema = postSchema.omit({
|
|
151
|
+
content: true,
|
|
152
|
+
deletedAt: true,
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Constants File Conventions
|
|
157
|
+
|
|
158
|
+
Keep enum values, filter presets, and page sizes in `<module>.constants.ts`:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
export const POST_STATUS_VALUES = ["draft", "published"] as const;
|
|
162
|
+
export const POST_FILTER_VALUES = ["all", ...POST_STATUS_VALUES] as const;
|
|
163
|
+
export const POSTS_PAGE_SIZE = 6;
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Best Practices
|
|
167
|
+
|
|
168
|
+
1. Always export both the Zod schema and its inferred type.
|
|
169
|
+
2. Use `.nullish()` for optional nullable fields, `.optional()` for optional-only.
|
|
170
|
+
3. Extend `querySchema` for list inputs rather than redefining pagination fields.
|
|
171
|
+
4. Reuse the main entity schema as operation output when the full entity is returned.
|
|
172
|
+
5. Keep validation rules (`.min()`, `.max()`, `.regex()`) in input schemas.
|
|
173
|
+
6. Keep schemas flat and avoid deep nesting when possible.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: apps/*/server/src/modules/**/*.service.ts
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Module Service File Style Guide
|
|
7
|
+
|
|
8
|
+
Conventions for service classes in app modules.
|
|
9
|
+
|
|
10
|
+
## File Location
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
apps/<app>/server/src/modules/<module>/<module>.service.ts
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Conventions
|
|
17
|
+
|
|
18
|
+
- Extend `BaseService<Repositories, Services>` or `BasePermissionService<Repositories, Services>` from `@m5kdev/backend/modules/base/base.service`.
|
|
19
|
+
- Declare repository and service dependencies as explicit named maps in generics.
|
|
20
|
+
- Keep orchestration and business logic in services; repositories handle persistence only.
|
|
21
|
+
- Implement service methods as procedures using `this.procedure("name")` builder chains.
|
|
22
|
+
- Propagate `ServerResultAsync` consistently and use `err(...)`/`ok(...)` composition.
|
|
23
|
+
|
|
24
|
+
## Schema Type Imports
|
|
25
|
+
|
|
26
|
+
Import types from the shared schema file for procedure type parameters:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import type {
|
|
30
|
+
PostCreateInputSchema,
|
|
31
|
+
PostCreateOutputSchema,
|
|
32
|
+
PostsListInputSchema,
|
|
33
|
+
PostsListOutputSchema,
|
|
34
|
+
} from "@appname/shared/modules/posts/posts.schema";
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Context Type
|
|
38
|
+
|
|
39
|
+
Define a `RequestContext` alias from the framework's `Context` type:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import type { Context } from "@m5kdev/backend/utils/trpc";
|
|
43
|
+
|
|
44
|
+
type RequestContext = Context;
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Pass this as the third generic to `BasePermissionService` or `BaseService`.
|
|
48
|
+
|
|
49
|
+
## Procedure Builder
|
|
50
|
+
|
|
51
|
+
Service methods are defined using a chainable procedure builder. Each procedure handles input typing, authentication, resource loading, access control, and the final handler.
|
|
52
|
+
|
|
53
|
+
### Builder Methods (in typical call order)
|
|
54
|
+
|
|
55
|
+
1. `.requireAuth(scope?)` — Enforces authentication. Scope: `"user"` (default), `"organization"`, `"team"`, `"admin"`.
|
|
56
|
+
2. `.use(name, step)` — Runs an arbitrary step and stores the result in `state`.
|
|
57
|
+
3. `.loadResource(name, loader)` — Loads a value and stores it in `state`; returns `NOT_FOUND` if falsy.
|
|
58
|
+
4. `.access(config)` — Permission check (only on `BasePermissionService`). Action must match a grant.
|
|
59
|
+
5. `.handle(handler)` — Terminal method; runs the handler with `{ input, ctx, state, repository, service, logger }`.
|
|
60
|
+
|
|
61
|
+
### Input Typing
|
|
62
|
+
|
|
63
|
+
Use the generic type parameter with shared schema types:
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
readonly create = this.procedure<PostCreateInputSchema>("create")
|
|
67
|
+
.requireAuth()
|
|
68
|
+
.handle(async ({ input, ctx }): ServerResultAsync<PostCreateOutputSchema> => {
|
|
69
|
+
// ...
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Procedure Patterns
|
|
74
|
+
|
|
75
|
+
### Simple List
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
readonly list = this.procedure<PostsListInputSchema>("list")
|
|
79
|
+
.requireAuth()
|
|
80
|
+
.handle(({ input }): ServerResultAsync<PostsListOutputSchema> => {
|
|
81
|
+
return this.repository.posts.list(input);
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Create with Business Logic
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
readonly create = this.procedure<PostCreateInputSchema>("create")
|
|
89
|
+
.requireAuth()
|
|
90
|
+
.access({
|
|
91
|
+
action: "write",
|
|
92
|
+
entities: ({ ctx }) => ({
|
|
93
|
+
organizationId: ctx.session.activeOrganizationId ?? null,
|
|
94
|
+
teamId: ctx.session.activeTeamId ?? null,
|
|
95
|
+
}),
|
|
96
|
+
})
|
|
97
|
+
.handle(async ({ input, ctx }): ServerResultAsync<PostCreateOutputSchema> => {
|
|
98
|
+
const uniqueSlug = await this.repository.posts.resolveUniqueSlug(
|
|
99
|
+
this.slugify(input.slug ?? input.title)
|
|
100
|
+
);
|
|
101
|
+
if (uniqueSlug.isErr()) return err(uniqueSlug.error);
|
|
102
|
+
|
|
103
|
+
return this.repository.posts.create({
|
|
104
|
+
authorUserId: ctx.user.id,
|
|
105
|
+
organizationId: ctx.session.activeOrganizationId ?? null,
|
|
106
|
+
teamId: ctx.session.activeTeamId ?? null,
|
|
107
|
+
title: input.title.trim(),
|
|
108
|
+
slug: uniqueSlug.value,
|
|
109
|
+
content: input.content.trim(),
|
|
110
|
+
status: "draft",
|
|
111
|
+
}) as ServerResultAsync<PostCreateOutputSchema>;
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Update with Resource Loading and Access Control
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
readonly update = this.procedure<PostUpdateInputSchema>("update")
|
|
119
|
+
.requireAuth()
|
|
120
|
+
.use("post", async ({ input }) => {
|
|
121
|
+
const current = await this.repository.posts.findById(input.id);
|
|
122
|
+
if (current.isErr()) return err(current.error);
|
|
123
|
+
if (!current.value || current.value.deletedAt) {
|
|
124
|
+
return this.error("NOT_FOUND", "Post not found");
|
|
125
|
+
}
|
|
126
|
+
return current.value;
|
|
127
|
+
})
|
|
128
|
+
.access({
|
|
129
|
+
action: "write",
|
|
130
|
+
entityStep: "post",
|
|
131
|
+
})
|
|
132
|
+
.handle(async ({ input }): ServerResultAsync<PostUpdateOutputSchema> => {
|
|
133
|
+
return this.repository.posts.update(input);
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Soft Delete
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
readonly softDelete = this.procedure<PostSoftDeleteInputSchema>("softDelete")
|
|
141
|
+
.requireAuth()
|
|
142
|
+
.use("post", async ({ input }) => {
|
|
143
|
+
const current = await this.repository.posts.findById(input.id);
|
|
144
|
+
if (current.isErr()) return err(current.error);
|
|
145
|
+
if (!current.value || current.value.deletedAt) {
|
|
146
|
+
return this.error("NOT_FOUND", "Post not found");
|
|
147
|
+
}
|
|
148
|
+
return current.value;
|
|
149
|
+
})
|
|
150
|
+
.access({
|
|
151
|
+
action: "delete",
|
|
152
|
+
entityStep: "post",
|
|
153
|
+
})
|
|
154
|
+
.handle(async ({ input }): ServerResultAsync<PostSoftDeleteOutputSchema> => {
|
|
155
|
+
const updated = await this.repository.posts.update({
|
|
156
|
+
id: input.id,
|
|
157
|
+
deletedAt: new Date(),
|
|
158
|
+
});
|
|
159
|
+
if (updated.isErr()) return err(updated.error);
|
|
160
|
+
return ok({ id: updated.value.id });
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Public (No Auth)
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
readonly getPublic = this.procedure<GetPublicInputSchema>("getPublic")
|
|
168
|
+
.handle(async ({ input }): ServerResultAsync<PostSchema> => {
|
|
169
|
+
return this.repository.posts.findPublished(input.slug);
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Access Control Modes
|
|
174
|
+
|
|
175
|
+
The `.access()` step supports two entity resolution strategies:
|
|
176
|
+
|
|
177
|
+
- **`entityStep`** — References a value stored by a prior `.use()` or `.loadResource()` step. The entity's `userId`, `organizationId`, and `teamId` are read automatically.
|
|
178
|
+
- **`entities`** — Inline function returning `{ userId?, organizationId?, teamId? }` for cases where the entity is not loaded into state.
|
|
179
|
+
|
|
180
|
+
## Permission and Grants
|
|
181
|
+
|
|
182
|
+
- If extending `BasePermissionService`, keep action names aligned with grants (`read`, `write`, `delete`, `publish`).
|
|
183
|
+
- Do not use undeclared action aliases (e.g., guard `"update"` when grants only define `"write"`).
|
|
184
|
+
- If a module has no `.access()` usage, prefer `BaseService` over `BasePermissionService`.
|
|
185
|
+
|
|
186
|
+
## Example Skeleton
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
import type {
|
|
190
|
+
PostCreateInputSchema,
|
|
191
|
+
PostCreateOutputSchema,
|
|
192
|
+
PostsListInputSchema,
|
|
193
|
+
PostsListOutputSchema,
|
|
194
|
+
} from "@appname/shared/modules/posts/posts.schema";
|
|
195
|
+
import { BasePermissionService } from "@m5kdev/backend/modules/base/base.service";
|
|
196
|
+
import type { ServerResultAsync } from "@m5kdev/backend/utils/types";
|
|
197
|
+
import type { Context } from "@m5kdev/backend/utils/trpc";
|
|
198
|
+
import { err, ok } from "neverthrow";
|
|
199
|
+
import type { PostsRepository } from "./posts.repository";
|
|
200
|
+
|
|
201
|
+
type RequestContext = Context;
|
|
202
|
+
|
|
203
|
+
export class PostsService extends BasePermissionService<
|
|
204
|
+
{ posts: PostsRepository },
|
|
205
|
+
Record<string, never>,
|
|
206
|
+
RequestContext
|
|
207
|
+
> {
|
|
208
|
+
readonly list = this.procedure<PostsListInputSchema>("list")
|
|
209
|
+
.requireAuth()
|
|
210
|
+
.handle(({ input }): ServerResultAsync<PostsListOutputSchema> => {
|
|
211
|
+
return this.repository.posts.list(input);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
readonly create = this.procedure<PostCreateInputSchema>("create")
|
|
215
|
+
.requireAuth()
|
|
216
|
+
.access({
|
|
217
|
+
action: "write",
|
|
218
|
+
entities: ({ ctx }) => ({
|
|
219
|
+
organizationId: ctx.session.activeOrganizationId ?? null,
|
|
220
|
+
teamId: ctx.session.activeTeamId ?? null,
|
|
221
|
+
}),
|
|
222
|
+
})
|
|
223
|
+
.handle(async ({ input, ctx }): ServerResultAsync<PostCreateOutputSchema> => {
|
|
224
|
+
return this.repository.posts.create({
|
|
225
|
+
authorUserId: ctx.user.id,
|
|
226
|
+
title: input.title.trim(),
|
|
227
|
+
content: input.content.trim(),
|
|
228
|
+
status: "draft",
|
|
229
|
+
}) as ServerResultAsync<PostCreateOutputSchema>;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Best Practices
|
|
235
|
+
|
|
236
|
+
1. Keep procedures as `readonly` class properties for consistent initialization.
|
|
237
|
+
2. Use `.use()` steps for resource loading with validation before `.access()`.
|
|
238
|
+
3. Return `this.error("NOT_FOUND")` or `this.error("FORBIDDEN")` for expected failures.
|
|
239
|
+
4. Keep private helper methods (slugify, excerpt generation) as regular class methods.
|
|
240
|
+
5. Propagate `err(...)` from repository results rather than throwing.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: apps/*/server/src/modules/**/*.trpc.ts
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Module tRPC File Style Guide
|
|
7
|
+
|
|
8
|
+
Conventions for tRPC router files in app modules.
|
|
9
|
+
|
|
10
|
+
## File Location
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
apps/<app>/server/src/modules/<module>/<module>.trpc.ts
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Conventions
|
|
17
|
+
|
|
18
|
+
- Export a factory function named `create<Module>TRPC`.
|
|
19
|
+
- Import input and output schemas from the shared schema file: `@appname/shared/modules/<module>/<module>.schema`.
|
|
20
|
+
- Use `handleTRPCResult` from `@m5kdev/backend/utils/trpc` to unwrap `Result` types.
|
|
21
|
+
- Separate queries and mutations; keep resolvers thin and delegate to the service.
|
|
22
|
+
- Always pass `ctx` to service methods as the second argument. The first argument (`input`) can be `undefined`.
|
|
23
|
+
- Use `z` inline only for simple ad-hoc inputs with primitives (`z.string()`, `z.number()`, `z.array()` with primitive value, `z.record()`); prefer shared schemas otherwise.
|
|
24
|
+
- Do not perform business logic or DB access directly in tRPC resolvers.
|
|
25
|
+
- To turn a schema into its array version, use `schema.array()` instead of `z.array(schema)`.
|
|
26
|
+
|
|
27
|
+
## Factory Pattern
|
|
28
|
+
|
|
29
|
+
The factory function receives `TRPCMethods` (destructured) and the service instance:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import {
|
|
33
|
+
postCreateInputSchema,
|
|
34
|
+
postCreateOutputSchema,
|
|
35
|
+
postsListInputSchema,
|
|
36
|
+
postsListOutputSchema,
|
|
37
|
+
postUpdateInputSchema,
|
|
38
|
+
postUpdateOutputSchema,
|
|
39
|
+
postPublishInputSchema,
|
|
40
|
+
postPublishOutputSchema,
|
|
41
|
+
postSoftDeleteInputSchema,
|
|
42
|
+
postSoftDeleteOutputSchema,
|
|
43
|
+
} from "@appname/shared/modules/posts/posts.schema";
|
|
44
|
+
import { handleTRPCResult, type TRPCMethods } from "@m5kdev/backend/utils/trpc";
|
|
45
|
+
import type { PostsService } from "./posts.service";
|
|
46
|
+
|
|
47
|
+
export function createPostsTRPC(
|
|
48
|
+
{ router, privateProcedure: procedure }: TRPCMethods,
|
|
49
|
+
postsService: PostsService
|
|
50
|
+
) {
|
|
51
|
+
return router({
|
|
52
|
+
list: procedure
|
|
53
|
+
.input(postsListInputSchema)
|
|
54
|
+
.output(postsListOutputSchema)
|
|
55
|
+
.query(async ({ ctx, input }) => handleTRPCResult(await postsService.list(input ?? {}, ctx))),
|
|
56
|
+
|
|
57
|
+
create: procedure
|
|
58
|
+
.input(postCreateInputSchema)
|
|
59
|
+
.output(postCreateOutputSchema)
|
|
60
|
+
.mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.create(input, ctx))),
|
|
61
|
+
|
|
62
|
+
update: procedure
|
|
63
|
+
.input(postUpdateInputSchema)
|
|
64
|
+
.output(postUpdateOutputSchema)
|
|
65
|
+
.mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.update(input, ctx))),
|
|
66
|
+
|
|
67
|
+
publish: procedure
|
|
68
|
+
.input(postPublishInputSchema)
|
|
69
|
+
.output(postPublishOutputSchema)
|
|
70
|
+
.mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.publish(input, ctx))),
|
|
71
|
+
|
|
72
|
+
softDelete: procedure
|
|
73
|
+
.input(postSoftDeleteInputSchema)
|
|
74
|
+
.output(postSoftDeleteOutputSchema)
|
|
75
|
+
.mutation(async ({ ctx, input }) =>
|
|
76
|
+
handleTRPCResult(await postsService.softDelete(input, ctx))
|
|
77
|
+
),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Available Procedure Types
|
|
83
|
+
|
|
84
|
+
Destructure from `TRPCMethods` based on auth requirements:
|
|
85
|
+
|
|
86
|
+
| Procedure | Auth | Use for |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `publicProcedure` | None | Public endpoints |
|
|
89
|
+
| `privateProcedure` | User session required | Most CRUD operations |
|
|
90
|
+
| `adminProcedure` | Admin role required | Admin-only operations |
|
|
91
|
+
| `organizationProcedure` | Organization context required | Org-scoped operations |
|
|
92
|
+
|
|
93
|
+
Alias `privateProcedure` as `procedure` for conciseness when most routes are authenticated.
|
|
94
|
+
|
|
95
|
+
## Wiring to Module
|
|
96
|
+
|
|
97
|
+
The factory is called in the module's `trpc()` hook:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
override trpc({ trpc, services }: ModuleTRPCContext<Deps, Services>) {
|
|
101
|
+
return createBackendRouterMap("posts", createPostsTRPC(trpc, services.posts));
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The namespace string (`"posts"`) becomes the tRPC path prefix (e.g., `trpc.posts.list`).
|
|
106
|
+
|
|
107
|
+
## Best Practices
|
|
108
|
+
|
|
109
|
+
1. Keep resolvers one-liners: `handleTRPCResult(await service.method(input, ctx))`.
|
|
110
|
+
2. Never access repositories or database directly in tRPC files.
|
|
111
|
+
3. Use shared schemas for `.input()` and `.output()` to maintain contract consistency.
|
|
112
|
+
4. For optional input, pass a default value: `input ?? {}`.
|
|
113
|
+
5. Use `.query()` for read operations and `.mutation()` for writes.
|
|
@@ -620,7 +620,7 @@ export function PostsRoute() {
|
|
|
620
620
|
}
|
|
621
621
|
}}
|
|
622
622
|
>
|
|
623
|
-
<Modal.Backdrop
|
|
623
|
+
<Modal.Backdrop>
|
|
624
624
|
<Modal.Container scroll="inside" size="lg" className="max-w-5xl">
|
|
625
625
|
<Modal.Dialog>
|
|
626
626
|
<form
|
|
@@ -718,6 +718,7 @@ export function PostsRoute() {
|
|
|
718
718
|
</form>
|
|
719
719
|
</Modal.Dialog>
|
|
720
720
|
</Modal.Container>
|
|
721
|
+
</Modal.Backdrop>
|
|
721
722
|
</Modal>
|
|
722
723
|
</div>
|
|
723
724
|
);
|