create-m5kdev 0.32.4 → 0.32.5

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.32.4",
3
+ "version": "0.32.5",
4
4
  "license": "GPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,52 +36,116 @@ type Schema = typeof schema;
36
36
  type Orm = LibSQLDatabase<Schema>;
37
37
  ```
38
38
 
39
- ## Built-in Base Methods — write nothing you get for free
40
-
41
- `BaseTableRepository` provides typed CRUD. Prefer these over hand-written queries:
42
-
43
- - `queryList(query, options)` — the generic list contract (`page`, `limit`,
44
- `sort`, `order`, `q`, `filters`) translated to Drizzle. Pass
45
- `globalSearchColumns` to enable `q`, `conditions` for extra scoping, and
46
- `showDeleted` to include soft-deleted rows (excluded by default when the
47
- table has `deletedAt`). Do NOT hand-write list/filter/count queries.
39
+ ## Prefer Built-in Base Methods (Strong Default)
40
+
41
+ `BaseTableRepository` provides typed CRUD helpers. **Always use these before
42
+ adding custom repository methods or raw Drizzle.** Services should call these
43
+ helpers directly in most cases.
44
+
45
+ - `queryList(query, options)` — filtered/sorted/paginated lists (`page`,
46
+ `limit`, `sort`, `order`, `q`, `filters`). Pass `globalSearchColumns` for
47
+ `q`, `conditions` for extra scoping, `showDeleted` to include soft-deleted
48
+ rows (excluded by default when the table has `deletedAt`).
49
+ - `queryFind(query, options)` — filtered/sorted single-row lookup (no
50
+ pagination/count). Same filter/`conditions` contract as `queryList`.
48
51
  - `findById(id)` / `findManyById(ids)` — primary-key lookups.
49
52
  - `create(data)` / `createMany(data)` — inserts.
50
53
  - `update(data)` / `updateMany(data)` — updates.
51
54
  - `softDeleteById(id)` / `deleteById(id)` — deletes.
52
55
 
53
56
  ```typescript
54
- // a full filtered/searched/paginated list — no custom SQL
57
+ // Preferred from the service — no custom repository list method
55
58
  this.repository.posts.queryList(input, {
56
59
  globalSearchColumns: ["title", "excerpt", "content"],
57
60
  });
58
61
  ```
59
62
 
63
+ ### Usually Do Not Add Custom List / Getter / Create / Update Methods
64
+
65
+ - **Lists and getters**: `queryList` / `queryFind` already expose read APIs
66
+ via `filters` (and optional `conditions`). Prefer calling them from the
67
+ service with the needed filters (or `.addFilters` on the procedure). Do not
68
+ add thin `listPublished`, `findBySlug`, `getMain`, etc. unless the same
69
+ filter set is reused heavily *and* belongs in the repository boundary.
70
+ - **Creates**: `create` is permissive — stamp defaults (`status`, `memberId`,
71
+ …) in the **service** and pass them to `create`. Do not add a custom
72
+ repository `create*` just to apply defaults.
73
+ - **Updates**: `update` accepts partial payloads — omit fields you do not
74
+ want to change. Do not add a custom repository `update*` just to restrict
75
+ columns or merge patches.
76
+ - Write raw `this.orm.select()...` or a custom method only for joins, CTEs,
77
+ multi-table writes, transactions, or predicates that cannot be expressed
78
+ with filters / `TableConditionBuilder`.
79
+
80
+ Filter shape: `{ columnId, type, method, value }` (`type`: `string` |
81
+ `number` | `date` | `boolean` | `enum` | `jsonArray`; common `method`s:
82
+ `equals`, `contains`, `oneOf`, `is_null`, …). Soft-delete exclusion is
83
+ applied automatically when the table has `deletedAt` (unless
84
+ `showDeleted: true`).
85
+
60
86
  ## Custom queries: the `this.query()` builder
61
87
 
62
- Single-purpose queries are declared with the builder, using DTO schemas for
63
- outputs. **Outputs are validated at runtime** — a find-style query that can
64
- return no row must use `output.found` (the optional row schema), or
65
- validation will fail on the legitimate "not found" case.
88
+ Use the builder only when a shared lookup truly belongs in the repository.
89
+ Wire DTO schemas for inputs/outputs. **Outputs are validated at runtime** —
90
+ a find-style query that can return no row must use `output.found` (the
91
+ optional row schema), or validation will fail on the legitimate "not found"
92
+ case.
93
+
94
+ Wrap `queryFind` / `queryList` with filters — never reimplement
95
+ select/where/limit:
66
96
 
67
97
  ```typescript
68
98
  import { z } from "zod";
69
99
  import { postSchemas } from "./posts.dto";
70
100
 
101
+ // Shared domain lookup used from multiple services/jobs — still no hand-written SQL
71
102
  findBySlug = this.query("findBySlug")
72
- .input(z.object({ slug: z.string(), excludeId: z.string().optional() }))
103
+ .input(z.object({ slug: z.string() }))
73
104
  .output(postSchemas.output.found) // NOT output.single — may return undefined
74
- .handle(async ({ slug, excludeId }) => {
75
- const whereClause = excludeId
76
- ? and(eq(this.table.slug, slug), ne(this.table.id, excludeId), isNull(this.table.deletedAt))
77
- : and(eq(this.table.slug, slug), isNull(this.table.deletedAt));
105
+ .handle(({ slug }) =>
106
+ this.queryFind({
107
+ filters: [
108
+ { columnId: "slug", type: "string", method: "equals", value: slug },
109
+ ],
110
+ })
111
+ );
112
+ ```
113
+
114
+ ### Extend With `conditions` When Filters Alone Are Not Enough
78
115
 
79
- const result = await this.throwableQuery(() =>
80
- this.orm.select().from(this.table).where(whereClause).limit(1)
116
+ Pass a `TableConditionBuilder` via `options.conditions` for extra predicates
117
+ (optional excludes, `ne`, `inArray`, …) while still reusing soft-delete and
118
+ column picking from `queryFind` / `queryList`:
119
+
120
+ ```typescript
121
+ import { ne } from "drizzle-orm";
122
+ import { z } from "zod";
123
+ import { postSchemas } from "./posts.dto";
124
+
125
+ // Prefer calling queryFind with conditions from the service when one-off;
126
+ // repository wrapper only if shared across callers.
127
+ findBySlug = this.query("findBySlug")
128
+ .input(
129
+ z.object({
130
+ slug: z.string(),
131
+ excludeId: z.string().optional(),
132
+ })
133
+ )
134
+ .output(postSchemas.output.found)
135
+ .handle(({ slug, excludeId }) => {
136
+ const conditions = this.getConditionBuilder(this.table);
137
+ if (excludeId) {
138
+ conditions.push(ne(this.table.id, excludeId));
139
+ }
140
+
141
+ return this.queryFind(
142
+ {
143
+ filters: [
144
+ { columnId: "slug", type: "string", method: "equals", value: slug },
145
+ ],
146
+ },
147
+ { conditions }
81
148
  );
82
- if (result.isErr()) return err(result.error);
83
- const [row] = result.value;
84
- return ok(row);
85
149
  });
86
150
  ```
87
151
 
@@ -91,8 +155,13 @@ multiple queries with branching between them.
91
155
 
92
156
  ## Error Handling
93
157
 
94
- - Use `throwableQuery` tightly around each Drizzle call — it converts thrown
95
- database errors into `ServerResultAsync`. Do not wrap whole method bodies.
158
+ - Use `throwableQuery` tightly around each **raw** Drizzle call — it converts
159
+ thrown database errors into `ServerResultAsync`. Do not wrap whole method
160
+ bodies.
161
+ - Built-in helpers (`queryFind`, `queryList`, `findById`, `create`, …)
162
+ already use `throwableQuery` internally — do not wrap their return values
163
+ again.
96
164
  - Chain results with `isErr()` checks and `err(result.error)`.
97
- - Filter soft-deleted rows with `isNull(this.table.deletedAt)` in custom
98
- queries (`queryList` does this automatically).
165
+ - Soft-deleted rows are excluded automatically by `queryList` / `queryFind`
166
+ when the table has `deletedAt` (unless `showDeleted: true`). Only add
167
+ `isNull(this.table.deletedAt)` in raw custom queries.
@@ -7,8 +7,7 @@ alwaysApply: false
7
7
 
8
8
  Conventions for service classes in app modules. Services consume the module
9
9
  DTO object (`postSchemas`) — they do not import hand-written request/response
10
- types. Input and output schemas are attached to each procedure and validated
11
- at runtime.
10
+ types. Input and output schemas are attached to each procedure.
12
11
 
13
12
  ## File Location
14
13
 
@@ -18,11 +17,72 @@ apps/<app>/server/src/modules/<module>/<module>.service.ts
18
17
 
19
18
  ## Conventions
20
19
 
21
- - Extend `BaseService<Repositories, Services>` or `BasePermissionService<Repositories, Services, Context>` from `@m5kdev/backend/modules/base/base.service`.
20
+ - Always extend `BasePermissionService<Repositories, Services>`.
22
21
  - Declare repository and service dependencies as explicit named maps in generics.
23
- - Keep orchestration and business logic in services; repositories handle persistence only.
24
- - Implement service methods as `readonly` procedures using the `this.procedure("name")` builder.
25
- - Propagate `ServerResultAsync` consistently with `err(...)`/`ok(...)` composition.
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
+ ```
26
86
 
27
87
  ## Avoid Trivial Service Delegation
28
88
 
@@ -37,16 +97,49 @@ apps/<app>/server/src/modules/<module>/<module>.service.ts
37
97
  Each procedure declares its schemas from the DTO, then auth, resource loading,
38
98
  access control, and the handler:
39
99
 
40
- 1. `.input(schema)` / `.output(schema)` — DTO schemas; both are validated at runtime.
41
- 2. `.requireAuth(scope?)` — `"user"` (default), `"organization"`, `"team"`, `"admin"`.
42
- 3. `.loadResource(name, loader)` — loads a value into `state`; returns `NOT_FOUND` when falsy.
43
- 4. `.use(name, step)` — arbitrary step storing its result in `state`.
44
- 5. `.access(config)` — permission check (`BasePermissionService` only); action must match a grant.
45
- 6. `.handle(handler)` — terminal; receives `{ input, ctx, state }`.
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")`.
46
139
 
47
140
  ## Procedure Patterns
48
141
 
49
- ### List — org context filter + optional access soft-filter
142
+ ### Simple CRUD
50
143
 
51
144
  ```typescript
52
145
  readonly list = this.procedure("list")
@@ -54,18 +147,81 @@ readonly list = this.procedure("list")
54
147
  .output(postSchemas.output.list)
55
148
  .requireAuth("organization")
56
149
  .addContextFilter(["organization"])
57
- .loadResource("posts", ({ input }) =>
58
- this.repository.posts.queryList(input, {
59
- globalSearchColumns: ["title", "excerpt", "content"],
60
- })
61
- )
62
- .access({ action: "read", entityStep: "posts" })
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
+ })
63
203
  .handle(({ state }) => state.posts);
64
204
  ```
65
205
 
66
206
  - Default list scoping: `addContextFilter(["organization"])`.
67
- - When grants are member-own only, prefer `addContextFilter(["member", "organization"])`.
68
- - `.access` on arrays soft-filters allowed rows (empty OK) and writes back to `state.posts`.
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
+ ```
69
225
 
70
226
  ### Create with business logic
71
227
 
@@ -93,40 +249,44 @@ readonly create = this.procedure("create")
93
249
  });
94
250
  ```
95
251
 
96
- ### Update with resource loading and access control
252
+ ### Soft delete
97
253
 
98
254
  ```typescript
99
- readonly update = this.procedure("update")
255
+ readonly softDelete = this.procedure("softDelete")
256
+ .input(postSchemas.input.delete)
257
+ .output(postSchemas.output.uuid)
100
258
  .requireAuth("organization")
101
- .input(postSchemas.input.update)
102
- .output(postSchemas.output.single)
103
259
  .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
104
- .access({ action: "write", entityStep: "post" })
105
- .handle(async ({ input, state }) => {
106
- return this.repository.posts.update({
260
+ .access({ action: "delete", entityStep: "post" })
261
+ .handle(async ({ input }) => {
262
+ const updated = await this.repository.posts.update({
107
263
  id: input.id,
108
- title: input.title ? input.title.trim() : state.post.title,
109
- content: (input.content?.trim() ?? state.post.content).trim(),
264
+ deletedAt: new Date(),
110
265
  });
266
+ if (updated.isErr()) return err(updated.error);
267
+ return ok({ id: updated.value.id });
111
268
  });
112
269
  ```
113
270
 
114
- ### Soft delete
271
+ ### Public (No Auth)
115
272
 
116
273
  ```typescript
117
- readonly softDelete = this.procedure("softDelete")
118
- .input(postSchemas.input.delete)
119
- .output(postSchemas.output.uuid)
120
- .requireAuth()
121
- .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
122
- .access({ action: "delete", entityStep: "post" })
274
+ readonly validateCode = this.procedure("validateCode")
275
+ .input(postSchemas.input.validate)
276
+ .output(postSchemas.output.status)
123
277
  .handle(async ({ input }) => {
124
- const updated = await this.repository.posts.update({ id: input.id, deletedAt: new Date() });
125
- if (updated.isErr()) return err(updated.error);
126
- return ok({ id: updated.value.id });
278
+ return this.repository.posts.validateCode(input);
127
279
  });
128
280
  ```
129
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
+
130
290
  ## Access Control Modes
131
291
 
132
292
  - **`entityStep`** — references a value stored by `.loadResource()`/`.use()`;
@@ -135,13 +295,68 @@ readonly softDelete = this.procedure("softDelete")
135
295
  - **`entities`** — inline function returning `{ userId?, memberId?, organizationId?, teamId? }`
136
296
  when the entity is not loaded into state.
137
297
 
138
- ## Permissions and Grants
298
+ ## Example Skeleton
299
+
300
+ ```typescript
301
+ import { BasePermissionService } from "@m5kdev/backend/modules/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
+ });
139
346
 
140
- - Keep action names aligned with grants (`read`, `write`, `delete`, `publish`).
141
- - Do not use undeclared aliases (e.g. guarding `"update"` when grants define `"write"`).
142
- - If a module never uses `.access()`, extend `BaseService` instead of `BasePermissionService`.
143
- - Access modes: `"all"` (global), `"org"` (same organization), `"own"` (ownership), `"none"` (explicit deny).
144
- - Prefer `"org"` for org owner/admin grants so `findById` updates cannot cross organizations.
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
+ ```
145
360
 
146
361
  ## Best Practices
147
362
 
@@ -5,12 +5,12 @@ catalog:
5
5
  "@heroui/react": 3.0.3
6
6
  "@heroui/styles": 3.0.3
7
7
  "@libsql/client": 0.17.4
8
- "@m5kdev/backend": 0.32.4
9
- "@m5kdev/commons": 0.32.4
10
- "@m5kdev/config": 0.32.4
11
- "@m5kdev/email": 0.32.4
12
- "@m5kdev/frontend": 0.32.4
13
- "@m5kdev/web-ui": 0.32.4
8
+ "@m5kdev/backend": 0.32.5
9
+ "@m5kdev/commons": 0.32.5
10
+ "@m5kdev/config": 0.32.5
11
+ "@m5kdev/email": 0.32.5
12
+ "@m5kdev/frontend": 0.32.5
13
+ "@m5kdev/web-ui": 0.32.5
14
14
  "@playwright/test": 1.57.0
15
15
  "@react-email/components": 1.0.1
16
16
  "@tailwindcss/vite": 4.1.11