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
|
@@ -36,52 +36,116 @@ type Schema = typeof schema;
|
|
|
36
36
|
type Orm = LibSQLDatabase<Schema>;
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
## Built-in Base Methods
|
|
40
|
-
|
|
41
|
-
`BaseTableRepository` provides typed CRUD.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
`
|
|
47
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
63
|
-
outputs. **Outputs are validated at runtime** —
|
|
64
|
-
return no row must use `output.found` (the
|
|
65
|
-
validation will fail on the legitimate "not found"
|
|
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()
|
|
103
|
+
.input(z.object({ slug: z.string() }))
|
|
73
104
|
.output(postSchemas.output.found) // NOT output.single — may return undefined
|
|
74
|
-
.handle(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
95
|
-
database errors into `ServerResultAsync`. Do not wrap whole method
|
|
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
|
-
-
|
|
98
|
-
|
|
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
|
|
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
|
-
-
|
|
20
|
+
- Always extend `BasePermissionService<Repositories, Services>`.
|
|
22
21
|
- Declare repository and service dependencies as explicit named maps in generics.
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
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)`
|
|
41
|
-
2. `.
|
|
42
|
-
3. `.
|
|
43
|
-
4. `.
|
|
44
|
-
5. `.
|
|
45
|
-
6. `.
|
|
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
|
-
###
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
.
|
|
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
|
|
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
|
-
###
|
|
252
|
+
### Soft delete
|
|
97
253
|
|
|
98
254
|
```typescript
|
|
99
|
-
readonly
|
|
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: "
|
|
105
|
-
.handle(async ({ input
|
|
106
|
-
|
|
260
|
+
.access({ action: "delete", entityStep: "post" })
|
|
261
|
+
.handle(async ({ input }) => {
|
|
262
|
+
const updated = await this.repository.posts.update({
|
|
107
263
|
id: input.id,
|
|
108
|
-
|
|
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
|
-
###
|
|
271
|
+
### Public (No Auth)
|
|
115
272
|
|
|
116
273
|
```typescript
|
|
117
|
-
readonly
|
|
118
|
-
.input(postSchemas.input.
|
|
119
|
-
.output(postSchemas.output.
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
9
|
-
"@m5kdev/commons": 0.32.
|
|
10
|
-
"@m5kdev/config": 0.32.
|
|
11
|
-
"@m5kdev/email": 0.32.
|
|
12
|
-
"@m5kdev/frontend": 0.32.
|
|
13
|
-
"@m5kdev/web-ui": 0.32.
|
|
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
|