create-m5kdev 0.19.3 → 0.20.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-m5kdev",
3
- "version": "0.19.3",
3
+ "version": "0.20.1",
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.4",
26
+ "ts-jest": "29.4.9",
27
27
  "tsdown": "0.21.7",
28
- "typescript": "5.9.2"
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
  );
@@ -49,7 +49,7 @@ catalog:
49
49
  tsx: 4.19.2
50
50
  turbo: 2.5.6
51
51
  tw-animate-css: 1.3.6
52
- typescript: 5.9.2
52
+ typescript: 6.0.3
53
53
  uuid: 11.0.5
54
54
  vite: 7.0.4
55
55
  vite-plugin-i18next-loader: 3.1.2