create-m5kdev 0.41.0 → 0.43.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.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/templates/minimal-app/.cursor/rules/frontend-component-guide.mdc +95 -95
  3. package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +103 -103
  4. package/templates/minimal-app/.cursor/rules/frontend-form-guide.mdc +80 -80
  5. package/templates/minimal-app/.cursor/rules/frontend-hook-guide.mdc +79 -79
  6. package/templates/minimal-app/.cursor/rules/frontend-i18n-guide.mdc +45 -45
  7. package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +60 -60
  8. package/templates/minimal-app/.cursor/rules/module-prompt-guide.mdc +214 -214
  9. package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +167 -167
  10. package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +53 -53
  11. package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +367 -367
  12. package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +71 -71
  13. package/templates/minimal-app/.github/workflows/check.yml.tpl +21 -0
  14. package/templates/minimal-app/AGENTS.md.tpl +22 -31
  15. package/templates/minimal-app/CLAUDE.md.tpl +17 -3
  16. package/templates/minimal-app/GLOSSARY.md.tpl +181 -0
  17. package/templates/minimal-app/README.md.tpl +14 -6
  18. package/templates/minimal-app/apps/.gitignore.tpl +13 -13
  19. package/templates/minimal-app/apps/e2e/package.json.tpl +23 -23
  20. package/templates/minimal-app/apps/e2e/servers.ts.tpl +22 -6
  21. package/templates/minimal-app/apps/email/package.json.tpl +34 -34
  22. package/templates/minimal-app/apps/expo/.gitignore.tpl +44 -44
  23. package/templates/minimal-app/apps/expo/.prettierignore +13 -13
  24. package/templates/minimal-app/apps/expo/.prettierrc.json.tpl +9 -9
  25. package/templates/minimal-app/apps/expo/CHANGELOG.md.tpl +156 -156
  26. package/templates/minimal-app/apps/expo/README.md.tpl +44 -44
  27. package/templates/minimal-app/apps/expo/expo-env.d.ts.tpl +3 -0
  28. package/templates/minimal-app/apps/expo/package.json.tpl +63 -63
  29. package/templates/minimal-app/apps/expo/uniwind-types.d.ts.tpl +10 -0
  30. package/templates/minimal-app/apps/landing/package.json.tpl +34 -34
  31. package/templates/minimal-app/apps/server/AGENTS.md.tpl +1 -1
  32. package/templates/minimal-app/apps/server/drizzle/0000_whole_echo.sql +366 -366
  33. package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +46 -46
  34. package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +3 -3
  35. package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +98 -98
  36. package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +1 -1
  37. package/templates/minimal-app/apps/server/drizzle/0006_lazy_blue_marvel.sql +158 -158
  38. package/templates/minimal-app/apps/server/drizzle/0007_abnormal_ultimo.sql +1 -1
  39. package/templates/minimal-app/apps/server/drizzle/0008_steady_nekra.sql +86 -86
  40. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2624 -2624
  41. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2670 -2670
  42. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2715 -2715
  43. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +2687 -2687
  44. package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2497 -2497
  45. package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2483 -2483
  46. package/templates/minimal-app/apps/server/drizzle/meta/0006_snapshot.json.tpl +3602 -3602
  47. package/templates/minimal-app/apps/server/drizzle/meta/0007_snapshot.json.tpl +3619 -3619
  48. package/templates/minimal-app/apps/server/drizzle/meta/0008_snapshot.json.tpl +3619 -3619
  49. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +76 -76
  50. package/templates/minimal-app/apps/server/drizzle-postgresql/0000_glorious_warhawk.sql +522 -0
  51. package/templates/minimal-app/apps/server/drizzle-postgresql/meta/0000_snapshot.json.tpl +3555 -0
  52. package/templates/minimal-app/apps/server/drizzle-postgresql/meta/_journal.json.tpl +13 -0
  53. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +1 -1
  54. package/templates/minimal-app/apps/server/mastra.ts.tpl +42 -0
  55. package/templates/minimal-app/apps/server/package.json.tpl +9 -4
  56. package/templates/minimal-app/apps/server/src/app.ts.tpl +21 -16
  57. package/templates/minimal-app/apps/server/src/drizzle.schema.ts.tpl +2 -0
  58. package/templates/minimal-app/apps/server/src/modules/conversation/conversation.mastra.ts.tpl +48 -0
  59. package/templates/minimal-app/apps/server/src/modules/conversation/conversation.persistence.ts.tpl +47 -0
  60. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +7 -6
  61. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +2 -2
  62. package/templates/minimal-app/apps/shared/.env.example.tpl +6 -3
  63. package/templates/minimal-app/apps/shared/.env.production.example.tpl +7 -2
  64. package/templates/minimal-app/apps/shared/.env.tpl +6 -3
  65. package/templates/minimal-app/apps/shared/Dockerfile.pgbouncer +6 -0
  66. package/templates/minimal-app/apps/shared/compose.yaml.tpl +27 -0
  67. package/templates/minimal-app/apps/shared/fly.toml.tpl +0 -4
  68. package/templates/minimal-app/apps/shared/package.json.tpl +27 -27
  69. package/templates/minimal-app/apps/shared/pgbouncer.ini +13 -0
  70. package/templates/minimal-app/apps/shared/pgbouncer.users +1 -0
  71. package/templates/minimal-app/apps/webapp/AGENTS.md.tpl +24 -24
  72. package/templates/minimal-app/apps/webapp/index.html.tpl +22 -22
  73. package/templates/minimal-app/apps/webapp/package.json.tpl +53 -53
  74. package/templates/minimal-app/apps/webapp/public/logo.svg.tpl +6 -6
  75. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +1 -1
  76. package/templates/minimal-app/biome.json.tpl +1 -0
  77. package/templates/minimal-app/docs/adr/.gitkeep +0 -0
  78. package/templates/minimal-app/docs/agents/domain.md.tpl +38 -0
  79. package/templates/minimal-app/docs/agents/issue-tracker.md.tpl +45 -0
  80. package/templates/minimal-app/docs/agents/triage-labels.md.tpl +15 -0
  81. package/templates/minimal-app/package.json.tpl +5 -0
  82. package/templates/minimal-app/pnpm-workspace.yaml.tpl +16 -13
  83. package/templates/minimal-app/template.manifest.json +21 -0
@@ -1,367 +1,367 @@
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. Services consume the module
9
- DTO object (`postSchemas`) — they do not import hand-written request/response
10
- types. Input and output schemas are attached to each procedure.
11
-
12
- ## File Location
13
-
14
- ```
15
- apps/<app>/server/src/modules/<module>/<module>.service.ts
16
- ```
17
-
18
- ## Conventions
19
-
20
- - Always extend `BasePermissionService<Repositories, Services>`.
21
- - Declare repository and service dependencies as explicit named maps in generics.
22
- - Instantiate service classes in `apps/*/server/src/service.ts` (composition root), not inside module files.
23
- - Keep orchestration/business logic in service; repositories handle persistence only.
24
- - Implement service methods as `readonly` procedures using `this.procedure("name")` builder chains.
25
- - Propagate `ServerResultAsync` consistently and use `err(...)`/`ok(...)` composition.
26
- - Prefer helper methods for repeated workflows (prompt prep, query assembly, enrichment).
27
- - Define grants in `<module>.grants.ts` and wire them when composing the service.
28
- - **Strongly prefer repository built-ins** (`queryList`, `queryFind`, `findById`, `create`, `update`, `deleteById`, …) over custom repository methods.
29
-
30
- ## Prefer Repository Built-ins
31
-
32
- Call `BaseTableRepository` helpers from the service. Do **not** invent repository wrappers for ordinary CRUD, lists, or lookups.
33
-
34
- - **Reads**: use `queryList` / `queryFind` with `filters` (and `addContextFilter` when org/member scoped). Usually no custom `list*` / `get*` / `findBy*` repository methods.
35
- - **Creates**: `create` is permissive — stamp defaults and actor fields in the service (`memberId`, `organizationId`, `status: "draft"`, …) and pass the full row payload. No custom `create*` repo method for defaults.
36
- - **Updates**: `update` accepts partial fields — only pass columns that should change. No custom `update*` repo method to omit fields or merge patches.
37
- - Add a custom repository method only for joins, CTEs, multi-table writes, transactions, or predicates that cannot be expressed with filters / `TableConditionBuilder`.
38
-
39
- ```typescript
40
- // Preferred: built-in + filters / stamped defaults in the service
41
- readonly listPublished = this.procedure("listPublished")
42
- .input(postSchemas.input.list)
43
- .output(postSchemas.output.list)
44
- .requireAuth("organization")
45
- .addContextFilter(["organization"])
46
- .addFilters(() => [
47
- {
48
- columnId: "status",
49
- type: "enum",
50
- method: "equals",
51
- value: "published",
52
- },
53
- ])
54
- .access({ action: "read" })
55
- .handle(({ input }) =>
56
- this.repository.posts.queryList(input, {
57
- globalSearchColumns: ["title", "excerpt", "content"],
58
- })
59
- );
60
-
61
- readonly create = this.procedure("create")
62
- .input(postSchemas.input.create)
63
- .output(postSchemas.output.single)
64
- .requireAuth("organization")
65
- .access({ action: "write" })
66
- .handle(async ({ input, ctx }) => {
67
- return this.repository.posts.create({
68
- ...input,
69
- memberId: ctx.actor.memberId,
70
- organizationId: ctx.session.activeOrganizationId,
71
- status: "draft",
72
- });
73
- });
74
-
75
- readonly update = this.procedure("update")
76
- .input(postSchemas.input.update)
77
- .output(postSchemas.output.single)
78
- .requireAuth("organization")
79
- .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
80
- .access({ action: "write", entityStep: "post" })
81
- .handle(async ({ input }) => {
82
- // Partial update — only fields present on input are written
83
- return this.repository.posts.update(input);
84
- });
85
- ```
86
-
87
- ## Avoid Trivial Service Delegation
88
-
89
- - Do not add a service method whose only behavior is calling another service.
90
- - Call the owning service directly when no business rule, authorization, validation, orchestration, transaction boundary, or meaningful domain translation is added.
91
- - Renaming the dependency method, repackaging arguments, constructing a prompt, or forwarding actor/context data alone does not justify a wrapper.
92
- - A delegating method is justified only when the current service genuinely owns the use case and adds behavior or protects a boundary callers must not bypass.
93
- - If removing the method and calling the dependency directly would lose nothing, do not add the method.
94
-
95
- ## Procedure Builder
96
-
97
- Each procedure declares its schemas from the DTO, then auth, resource loading,
98
- access control, and the handler:
99
-
100
- 1. `.input(schema)` -- Accepts a Zod schema; infers `TInput` so you don't need `this.procedure<Type>(...)`. No runtime validation by default; pass `true` as second arg to enable it.
101
- 2. `.output(schema)` -- Accepts a Zod schema; constrains the handler return type so you don't need explicit return annotations. No runtime validation by default; pass `true` as second arg to enable it.
102
- 3. `.requireAuth(scope?)` -- Enforces authentication. Scope can be `"user"` (default), `"organization" (recommended)`, `"team"`, or `"admin"`.
103
- 4. `.loadResource(name, loader)` -- Loads a value and stores it in `state`; returns `NOT_FOUND` if falsy.
104
- 5. `.use(name, step)` -- Runs an arbitrary step and stores the result in `state`.
105
- 6. `.addContextFilter(scopes?)` -- Applies context-based query filtering (requires auth).
106
- 7. `.addFilters(resolve)` -- Appends query filters to the current input (`({ input, ctx, state, ... }) => QueryFilter | QueryFilter[]`).
107
- 8. `.access(config)` -- Permission check via grants. Arrays are soft-filtered to allowed rows (empty OK); single entities return `FORBIDDEN` when denied. Filtered arrays are written back to `entityStep` state.
108
- 9. `.handle(handler)` -- Terminal method; runs the handler with `{ input, ctx, state, repository, service, logger }`.
109
-
110
- ### Input / Output Typing
111
-
112
- Prefer `.input(schema)` with Zod schemas from the module's `.dto.ts`:
113
-
114
- ```typescript
115
- // Preferred: schema-driven
116
- readonly create = this.procedure("create")
117
- .input(postSchemas.input.create)
118
- .output(postSchemas.output.single)
119
- .requireAuth("organization")
120
- .access({ action: "write" })
121
- .handle(async ({ input, ctx }) => {
122
- return this.repository.posts.create({
123
- ...input,
124
- userId: ctx.actor.userId,
125
- memberId: ctx.actor.memberId,
126
- });
127
- });
128
-
129
- // Acceptable for simple primitives with no matching schema
130
- readonly getCount = this.procedure("getCount")
131
- .requireAuth("organization")
132
- .access({ action: "read" })
133
- .handle(async ({ ctx }): ServerResultAsync<number> => {
134
- return this.repository.posts.countByUser(ctx.actor.userId);
135
- });
136
- ```
137
-
138
- When no Zod schema exists (simple primitives, inline objects), you can still use the generic: `this.procedure<string>("name")`.
139
-
140
- ## Procedure Patterns
141
-
142
- ### Simple CRUD
143
-
144
- ```typescript
145
- readonly list = this.procedure("list")
146
- .input(postSchemas.input.list)
147
- .output(postSchemas.output.list)
148
- .requireAuth("organization")
149
- .addContextFilter(["organization"])
150
- .loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
151
- .access({
152
- action: "read",
153
- entityStep: "posts",
154
- })
155
- .handle(({ state }) => state.posts);
156
-
157
- readonly create = this.procedure("create")
158
- .input(postSchemas.input.create)
159
- .output(postSchemas.output.single)
160
- .requireAuth("organization")
161
- .access({ action: "write" })
162
- .handle(async ({ input, ctx }) => {
163
- return this.repository.posts.create({
164
- ...input,
165
- userId: ctx.actor.userId,
166
- memberId: ctx.actor.memberId,
167
- });
168
- });
169
- ```
170
-
171
- ### With Resource Loading and Access Control (recommended)
172
-
173
- ```typescript
174
- readonly update = this.procedure("update")
175
- .input(postSchemas.input.update)
176
- .output(postSchemas.output.single)
177
- .requireAuth("organization")
178
- .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
179
- .access({
180
- action: "write",
181
- entityStep: "post",
182
- })
183
- .handle(async ({ input }) => {
184
- return this.repository.posts.update(input);
185
- });
186
- ```
187
-
188
- Use grants with `"org"` (org admin/owner) or `"own"` (member) so `findById` cannot update cross-org rows. Do not use `"all"` for organization-scoped roles.
189
-
190
- ### Org-scoped list with access soft-filter
191
-
192
- ```typescript
193
- readonly list = this.procedure("list")
194
- .input(postSchemas.input.list)
195
- .output(postSchemas.output.list)
196
- .requireAuth("organization")
197
- .addContextFilter(["organization"])
198
- .loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
199
- .access({
200
- action: "read",
201
- entityStep: "posts",
202
- })
203
- .handle(({ state }) => state.posts);
204
- ```
205
-
206
- - Default list scoping: `addContextFilter(["organization"])`.
207
- - When grants are member-own only, prefer `addContextFilter(["member", "organization"])` so the query is already narrowed.
208
- - `.access` on an **array** soft-filters to allowed rows (may be `[]`); it does not 403 the whole list.
209
- - Filtered results are written back to `state[entityStep]` (here `state.posts`).
210
-
211
- ### With Context Filter (Query-Based Lists)
212
-
213
- ```typescript
214
- readonly list = this.procedure("list")
215
- .output(postSchemas.output.list)
216
- .requireAuth("organization")
217
- .addContextFilter(["member", "organization"])
218
- .access({ action: "read" })
219
- .handle(({ input }) => {
220
- return this.repository.posts.queryList(input, {
221
- globalSearchColumns: ["title", "excerpt", "content"],
222
- });
223
- });
224
- ```
225
-
226
- ### Create with business logic
227
-
228
- ```typescript
229
- readonly create = this.procedure("create")
230
- .requireAuth("organization")
231
- .input(postSchemas.input.create)
232
- .output(postSchemas.output.single)
233
- .access({ action: "write" })
234
- .handle(async ({ input, ctx }) => {
235
- const uniqueSlug = await this.repository.posts.resolveUniqueSlug({
236
- candidate: slugify(input.slug ?? input.title),
237
- });
238
- if (uniqueSlug.isErr()) return err(uniqueSlug.error);
239
-
240
- return this.repository.posts.create({
241
- // Org-scoped assets: stamp memberId (+ organizationId). Personal resources: stamp userId.
242
- memberId: ctx.actor.memberId,
243
- organizationId: ctx.actor.organizationId,
244
- title: input.title.trim(),
245
- slug: uniqueSlug.value,
246
- content: input.content.trim(),
247
- status: "draft",
248
- });
249
- });
250
- ```
251
-
252
- ### Soft delete
253
-
254
- ```typescript
255
- readonly softDelete = this.procedure("softDelete")
256
- .input(postSchemas.input.delete)
257
- .output(postSchemas.output.uuid)
258
- .requireAuth("organization")
259
- .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
260
- .access({ action: "delete", entityStep: "post" })
261
- .handle(async ({ input }) => {
262
- const updated = await this.repository.posts.update({
263
- id: input.id,
264
- deletedAt: new Date(),
265
- });
266
- if (updated.isErr()) return err(updated.error);
267
- return ok({ id: updated.value.id });
268
- });
269
- ```
270
-
271
- ### Public (No Auth)
272
-
273
- ```typescript
274
- readonly validateCode = this.procedure("validateCode")
275
- .input(postSchemas.input.validate)
276
- .output(postSchemas.output.status)
277
- .handle(async ({ input }) => {
278
- return this.repository.posts.validateCode(input);
279
- });
280
- ```
281
-
282
- ## Permission and Grants
283
-
284
- - Keep all guarded action names aligned with grants (`read`, `write`, `delete`, `publish`, etc.).
285
- - Do not use undeclared action aliases (e.g., guard `"update"` when grants only define `"write"`).
286
- - Prefer `.access({ action, entityStep? })` over ad-hoc ownership checks in `.use`.
287
- - For org-scoped assets, stamp and authorize with `memberId` (`ctx.actor.memberId`). Keep `userId` for authentication and personal resources (billing, devices, OAuth). Prefer the column name `memberId` over aliases like `authorMemberId`.
288
- - Access modes: `"all"` (global), `"org"` (same organization), `"own"` (ownership), `"none"` (explicit deny). Prefer `"org"` for org owner/admin grants.
289
-
290
- ## Access Control Modes
291
-
292
- - **`entityStep`** — references a value stored by `.loadResource()`/`.use()`;
293
- the entity's `userId`, `memberId`, `organizationId`, and `teamId` are read automatically.
294
- Arrays are soft-filtered; singles return `FORBIDDEN` when denied. Filtered arrays are written back to the step state.
295
- - **`entities`** — inline function returning `{ userId?, memberId?, organizationId?, teamId? }`
296
- when the entity is not loaded into state.
297
-
298
- ## Example Skeleton
299
-
300
- ```typescript
301
- import { BasePermissionService } from "@m5kdev/backend/base/base.service";
302
- import { postSchemas } from "./posts.dto";
303
- import type { PostsRepository } from "./posts.repository";
304
-
305
- export class PostsService extends BasePermissionService<
306
- { posts: PostsRepository },
307
- Record<string, never>
308
- > {
309
- readonly list = this.procedure("list")
310
- .input(postSchemas.input.list)
311
- .output(postSchemas.output.list)
312
- .requireAuth("organization")
313
- .addContextFilter(["organization"])
314
- .loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
315
- .access({
316
- action: "read",
317
- entityStep: "posts",
318
- })
319
- .handle(({ state }) => state.posts);
320
-
321
- readonly create = this.procedure("create")
322
- .input(postSchemas.input.create)
323
- .output(postSchemas.output.single)
324
- .requireAuth("organization")
325
- .access({ action: "write" })
326
- .handle(async ({ input, ctx }) => {
327
- return this.repository.posts.create({
328
- ...input,
329
- userId: ctx.actor.userId,
330
- memberId: ctx.actor.memberId,
331
- });
332
- });
333
-
334
- readonly update = this.procedure("update")
335
- .input(postSchemas.input.update)
336
- .output(postSchemas.output.single)
337
- .requireAuth("organization")
338
- .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
339
- .access({
340
- action: "write",
341
- entityStep: "post",
342
- })
343
- .handle(async ({ input }) => {
344
- return this.repository.posts.update(input);
345
- });
346
-
347
- readonly delete = this.procedure("delete")
348
- .input(postSchemas.input.delete)
349
- .requireAuth("organization")
350
- .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
351
- .access({
352
- action: "delete",
353
- entityStep: "post",
354
- })
355
- .handle(async ({ input }) => {
356
- return this.repository.posts.deleteById(input.id);
357
- });
358
- }
359
- ```
360
-
361
- ## Best Practices
362
-
363
- 1. Keep procedures as `readonly` class properties.
364
- 2. `.loadResource()` before `.access({ entityStep })` — it 404s on missing rows for free.
365
- 3. Return `this.error("NOT_FOUND")` / `this.error("FORBIDDEN")` for expected failures; reserve 5xx codes for server faults (they report to Sentry).
366
- 4. Keep pure helpers (slugify, excerpt) in `<module>.utils.ts`.
367
- 5. Propagate `err(...)` from repository results rather than throwing.
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. Services consume the module
9
+ DTO object (`postSchemas`) — they do not import hand-written request/response
10
+ types. Input and output schemas are attached to each procedure.
11
+
12
+ ## File Location
13
+
14
+ ```
15
+ apps/<app>/server/src/modules/<module>/<module>.service.ts
16
+ ```
17
+
18
+ ## Conventions
19
+
20
+ - Always extend `BasePermissionService<Repositories, Services>`.
21
+ - Declare repository and service dependencies as explicit named maps in generics.
22
+ - Instantiate service classes in `apps/*/server/src/service.ts` (composition root), not inside module files.
23
+ - Keep orchestration/business logic in service; repositories handle persistence only.
24
+ - Implement service methods as `readonly` procedures using `this.procedure("name")` builder chains.
25
+ - Propagate `ServerResultAsync` consistently and use `err(...)`/`ok(...)` composition.
26
+ - Prefer helper methods for repeated workflows (prompt prep, query assembly, enrichment).
27
+ - Define grants in `<module>.grants.ts` and wire them when composing the service.
28
+ - **Strongly prefer repository built-ins** (`queryList`, `queryFind`, `findById`, `create`, `update`, `deleteById`, …) over custom repository methods.
29
+
30
+ ## Prefer Repository Built-ins
31
+
32
+ Call `BaseTableRepository` helpers from the service. Do **not** invent repository wrappers for ordinary CRUD, lists, or lookups.
33
+
34
+ - **Reads**: use `queryList` / `queryFind` with `filters` (and `addContextFilter` when org/member scoped). Usually no custom `list*` / `get*` / `findBy*` repository methods.
35
+ - **Creates**: `create` is permissive — stamp defaults and actor fields in the service (`memberId`, `organizationId`, `status: "draft"`, …) and pass the full row payload. No custom `create*` repo method for defaults.
36
+ - **Updates**: `update` accepts partial fields — only pass columns that should change. No custom `update*` repo method to omit fields or merge patches.
37
+ - Add a custom repository method only for joins, CTEs, multi-table writes, transactions, or predicates that cannot be expressed with filters / `TableConditionBuilder`.
38
+
39
+ ```typescript
40
+ // Preferred: built-in + filters / stamped defaults in the service
41
+ readonly listPublished = this.procedure("listPublished")
42
+ .input(postSchemas.input.list)
43
+ .output(postSchemas.output.list)
44
+ .requireAuth("organization")
45
+ .addContextFilter(["organization"])
46
+ .addFilters(() => [
47
+ {
48
+ columnId: "status",
49
+ type: "enum",
50
+ method: "equals",
51
+ value: "published",
52
+ },
53
+ ])
54
+ .access({ action: "read" })
55
+ .handle(({ input }) =>
56
+ this.repository.posts.queryList(input, {
57
+ globalSearchColumns: ["title", "excerpt", "content"],
58
+ })
59
+ );
60
+
61
+ readonly create = this.procedure("create")
62
+ .input(postSchemas.input.create)
63
+ .output(postSchemas.output.single)
64
+ .requireAuth("organization")
65
+ .access({ action: "write" })
66
+ .handle(async ({ input, ctx }) => {
67
+ return this.repository.posts.create({
68
+ ...input,
69
+ memberId: ctx.actor.memberId,
70
+ organizationId: ctx.session.activeOrganizationId,
71
+ status: "draft",
72
+ });
73
+ });
74
+
75
+ readonly update = this.procedure("update")
76
+ .input(postSchemas.input.update)
77
+ .output(postSchemas.output.single)
78
+ .requireAuth("organization")
79
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
80
+ .access({ action: "write", entityStep: "post" })
81
+ .handle(async ({ input }) => {
82
+ // Partial update — only fields present on input are written
83
+ return this.repository.posts.update(input);
84
+ });
85
+ ```
86
+
87
+ ## Avoid Trivial Service Delegation
88
+
89
+ - Do not add a service method whose only behavior is calling another service.
90
+ - Call the owning service directly when no business rule, authorization, validation, orchestration, transaction boundary, or meaningful domain translation is added.
91
+ - Renaming the dependency method, repackaging arguments, constructing a prompt, or forwarding actor/context data alone does not justify a wrapper.
92
+ - A delegating method is justified only when the current service genuinely owns the use case and adds behavior or protects a boundary callers must not bypass.
93
+ - If removing the method and calling the dependency directly would lose nothing, do not add the method.
94
+
95
+ ## Procedure Builder
96
+
97
+ Each procedure declares its schemas from the DTO, then auth, resource loading,
98
+ access control, and the handler:
99
+
100
+ 1. `.input(schema)` -- Accepts a Zod schema; infers `TInput` so you don't need `this.procedure<Type>(...)`. No runtime validation by default; pass `true` as second arg to enable it.
101
+ 2. `.output(schema)` -- Accepts a Zod schema; constrains the handler return type so you don't need explicit return annotations. No runtime validation by default; pass `true` as second arg to enable it.
102
+ 3. `.requireAuth(scope?)` -- Enforces authentication. Scope can be `"user"` (default), `"organization" (recommended)`, `"team"`, or `"admin"`.
103
+ 4. `.loadResource(name, loader)` -- Loads a value and stores it in `state`; returns `NOT_FOUND` if falsy.
104
+ 5. `.use(name, step)` -- Runs an arbitrary step and stores the result in `state`.
105
+ 6. `.addContextFilter(scopes?)` -- Applies context-based query filtering (requires auth).
106
+ 7. `.addFilters(resolve)` -- Appends query filters to the current input (`({ input, ctx, state, ... }) => QueryFilter | QueryFilter[]`).
107
+ 8. `.access(config)` -- Permission check via grants. Arrays are soft-filtered to allowed rows (empty OK); single entities return `FORBIDDEN` when denied. Filtered arrays are written back to `entityStep` state.
108
+ 9. `.handle(handler)` -- Terminal method; runs the handler with `{ input, ctx, state, repository, service, logger }`.
109
+
110
+ ### Input / Output Typing
111
+
112
+ Prefer `.input(schema)` with Zod schemas from the module's `.dto.ts`:
113
+
114
+ ```typescript
115
+ // Preferred: schema-driven
116
+ readonly create = this.procedure("create")
117
+ .input(postSchemas.input.create)
118
+ .output(postSchemas.output.single)
119
+ .requireAuth("organization")
120
+ .access({ action: "write" })
121
+ .handle(async ({ input, ctx }) => {
122
+ return this.repository.posts.create({
123
+ ...input,
124
+ userId: ctx.actor.userId,
125
+ memberId: ctx.actor.memberId,
126
+ });
127
+ });
128
+
129
+ // Acceptable for simple primitives with no matching schema
130
+ readonly getCount = this.procedure("getCount")
131
+ .requireAuth("organization")
132
+ .access({ action: "read" })
133
+ .handle(async ({ ctx }): ServerResultAsync<number> => {
134
+ return this.repository.posts.countByUser(ctx.actor.userId);
135
+ });
136
+ ```
137
+
138
+ When no Zod schema exists (simple primitives, inline objects), you can still use the generic: `this.procedure<string>("name")`.
139
+
140
+ ## Procedure Patterns
141
+
142
+ ### Simple CRUD
143
+
144
+ ```typescript
145
+ readonly list = this.procedure("list")
146
+ .input(postSchemas.input.list)
147
+ .output(postSchemas.output.list)
148
+ .requireAuth("organization")
149
+ .addContextFilter(["organization"])
150
+ .loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
151
+ .access({
152
+ action: "read",
153
+ entityStep: "posts",
154
+ })
155
+ .handle(({ state }) => state.posts);
156
+
157
+ readonly create = this.procedure("create")
158
+ .input(postSchemas.input.create)
159
+ .output(postSchemas.output.single)
160
+ .requireAuth("organization")
161
+ .access({ action: "write" })
162
+ .handle(async ({ input, ctx }) => {
163
+ return this.repository.posts.create({
164
+ ...input,
165
+ userId: ctx.actor.userId,
166
+ memberId: ctx.actor.memberId,
167
+ });
168
+ });
169
+ ```
170
+
171
+ ### With Resource Loading and Access Control (recommended)
172
+
173
+ ```typescript
174
+ readonly update = this.procedure("update")
175
+ .input(postSchemas.input.update)
176
+ .output(postSchemas.output.single)
177
+ .requireAuth("organization")
178
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
179
+ .access({
180
+ action: "write",
181
+ entityStep: "post",
182
+ })
183
+ .handle(async ({ input }) => {
184
+ return this.repository.posts.update(input);
185
+ });
186
+ ```
187
+
188
+ Use grants with `"org"` (org admin/owner) or `"own"` (member) so `findById` cannot update cross-org rows. Do not use `"all"` for organization-scoped roles.
189
+
190
+ ### Org-scoped list with access soft-filter
191
+
192
+ ```typescript
193
+ readonly list = this.procedure("list")
194
+ .input(postSchemas.input.list)
195
+ .output(postSchemas.output.list)
196
+ .requireAuth("organization")
197
+ .addContextFilter(["organization"])
198
+ .loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
199
+ .access({
200
+ action: "read",
201
+ entityStep: "posts",
202
+ })
203
+ .handle(({ state }) => state.posts);
204
+ ```
205
+
206
+ - Default list scoping: `addContextFilter(["organization"])`.
207
+ - When grants are member-own only, prefer `addContextFilter(["member", "organization"])` so the query is already narrowed.
208
+ - `.access` on an **array** soft-filters to allowed rows (may be `[]`); it does not 403 the whole list.
209
+ - Filtered results are written back to `state[entityStep]` (here `state.posts`).
210
+
211
+ ### With Context Filter (Query-Based Lists)
212
+
213
+ ```typescript
214
+ readonly list = this.procedure("list")
215
+ .output(postSchemas.output.list)
216
+ .requireAuth("organization")
217
+ .addContextFilter(["member", "organization"])
218
+ .access({ action: "read" })
219
+ .handle(({ input }) => {
220
+ return this.repository.posts.queryList(input, {
221
+ globalSearchColumns: ["title", "excerpt", "content"],
222
+ });
223
+ });
224
+ ```
225
+
226
+ ### Create with business logic
227
+
228
+ ```typescript
229
+ readonly create = this.procedure("create")
230
+ .requireAuth("organization")
231
+ .input(postSchemas.input.create)
232
+ .output(postSchemas.output.single)
233
+ .access({ action: "write" })
234
+ .handle(async ({ input, ctx }) => {
235
+ const uniqueSlug = await this.repository.posts.resolveUniqueSlug({
236
+ candidate: slugify(input.slug ?? input.title),
237
+ });
238
+ if (uniqueSlug.isErr()) return err(uniqueSlug.error);
239
+
240
+ return this.repository.posts.create({
241
+ // Org-scoped assets: stamp memberId (+ organizationId). Personal resources: stamp userId.
242
+ memberId: ctx.actor.memberId,
243
+ organizationId: ctx.actor.organizationId,
244
+ title: input.title.trim(),
245
+ slug: uniqueSlug.value,
246
+ content: input.content.trim(),
247
+ status: "draft",
248
+ });
249
+ });
250
+ ```
251
+
252
+ ### Soft delete
253
+
254
+ ```typescript
255
+ readonly softDelete = this.procedure("softDelete")
256
+ .input(postSchemas.input.delete)
257
+ .output(postSchemas.output.uuid)
258
+ .requireAuth("organization")
259
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
260
+ .access({ action: "delete", entityStep: "post" })
261
+ .handle(async ({ input }) => {
262
+ const updated = await this.repository.posts.update({
263
+ id: input.id,
264
+ deletedAt: new Date(),
265
+ });
266
+ if (updated.isErr()) return err(updated.error);
267
+ return ok({ id: updated.value.id });
268
+ });
269
+ ```
270
+
271
+ ### Public (No Auth)
272
+
273
+ ```typescript
274
+ readonly validateCode = this.procedure("validateCode")
275
+ .input(postSchemas.input.validate)
276
+ .output(postSchemas.output.status)
277
+ .handle(async ({ input }) => {
278
+ return this.repository.posts.validateCode(input);
279
+ });
280
+ ```
281
+
282
+ ## Permission and Grants
283
+
284
+ - Keep all guarded action names aligned with grants (`read`, `write`, `delete`, `publish`, etc.).
285
+ - Do not use undeclared action aliases (e.g., guard `"update"` when grants only define `"write"`).
286
+ - Prefer `.access({ action, entityStep? })` over ad-hoc ownership checks in `.use`.
287
+ - For org-scoped assets, stamp and authorize with `memberId` (`ctx.actor.memberId`). Keep `userId` for authentication and personal resources (billing, devices, OAuth). Prefer the column name `memberId` over aliases like `authorMemberId`.
288
+ - Access modes: `"all"` (global), `"org"` (same organization), `"own"` (ownership), `"none"` (explicit deny). Prefer `"org"` for org owner/admin grants.
289
+
290
+ ## Access Control Modes
291
+
292
+ - **`entityStep`** — references a value stored by `.loadResource()`/`.use()`;
293
+ the entity's `userId`, `memberId`, `organizationId`, and `teamId` are read automatically.
294
+ Arrays are soft-filtered; singles return `FORBIDDEN` when denied. Filtered arrays are written back to the step state.
295
+ - **`entities`** — inline function returning `{ userId?, memberId?, organizationId?, teamId? }`
296
+ when the entity is not loaded into state.
297
+
298
+ ## Example Skeleton
299
+
300
+ ```typescript
301
+ import { BasePermissionService } from "@m5kdev/backend/base/base.service";
302
+ import { postSchemas } from "./posts.dto";
303
+ import type { PostsRepository } from "./posts.repository";
304
+
305
+ export class PostsService extends BasePermissionService<
306
+ { posts: PostsRepository },
307
+ Record<string, never>
308
+ > {
309
+ readonly list = this.procedure("list")
310
+ .input(postSchemas.input.list)
311
+ .output(postSchemas.output.list)
312
+ .requireAuth("organization")
313
+ .addContextFilter(["organization"])
314
+ .loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
315
+ .access({
316
+ action: "read",
317
+ entityStep: "posts",
318
+ })
319
+ .handle(({ state }) => state.posts);
320
+
321
+ readonly create = this.procedure("create")
322
+ .input(postSchemas.input.create)
323
+ .output(postSchemas.output.single)
324
+ .requireAuth("organization")
325
+ .access({ action: "write" })
326
+ .handle(async ({ input, ctx }) => {
327
+ return this.repository.posts.create({
328
+ ...input,
329
+ userId: ctx.actor.userId,
330
+ memberId: ctx.actor.memberId,
331
+ });
332
+ });
333
+
334
+ readonly update = this.procedure("update")
335
+ .input(postSchemas.input.update)
336
+ .output(postSchemas.output.single)
337
+ .requireAuth("organization")
338
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
339
+ .access({
340
+ action: "write",
341
+ entityStep: "post",
342
+ })
343
+ .handle(async ({ input }) => {
344
+ return this.repository.posts.update(input);
345
+ });
346
+
347
+ readonly delete = this.procedure("delete")
348
+ .input(postSchemas.input.delete)
349
+ .requireAuth("organization")
350
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
351
+ .access({
352
+ action: "delete",
353
+ entityStep: "post",
354
+ })
355
+ .handle(async ({ input }) => {
356
+ return this.repository.posts.deleteById(input.id);
357
+ });
358
+ }
359
+ ```
360
+
361
+ ## Best Practices
362
+
363
+ 1. Keep procedures as `readonly` class properties.
364
+ 2. `.loadResource()` before `.access({ entityStep })` — it 404s on missing rows for free.
365
+ 3. Return `this.error("NOT_FOUND")` / `this.error("FORBIDDEN")` for expected failures; reserve 5xx codes for server faults (they report to Sentry).
366
+ 4. Keep pure helpers (slugify, excerpt) in `<module>.utils.ts`.
367
+ 5. Propagate `err(...)` from repository results rather than throwing.