sonamu 0.8.24 → 0.8.26
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/dist/api/__tests__/config.test.js +189 -0
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +7 -2
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +14 -10
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +2 -1
- package/dist/auth/knex-adapter.d.ts +23 -0
- package/dist/auth/knex-adapter.d.ts.map +1 -0
- package/dist/auth/knex-adapter.js +163 -0
- package/dist/auth/plugins/wrappers/admin.d.ts +2 -2
- package/dist/bin/__tests__/ts-loader-register.test.js +45 -0
- package/dist/bin/cli.js +47 -9
- package/dist/bin/ts-loader-register.js +3 -29
- package/dist/bin/ts-loader-registration.d.ts +2 -0
- package/dist/bin/ts-loader-registration.d.ts.map +1 -0
- package/dist/bin/ts-loader-registration.js +42 -0
- package/dist/cone/cone-generator.js +3 -3
- package/dist/database/puri-subset.test-d.js +9 -1
- package/dist/database/puri-subset.types.d.ts +1 -1
- package/dist/database/puri-subset.types.d.ts.map +1 -1
- package/dist/database/puri-subset.types.js +1 -1
- package/dist/testing/fixture-generator.js +5 -5
- package/dist/ui/ai-client.js +2 -2
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +14 -14
- package/dist/ui/cdd-service.d.ts +15 -18
- package/dist/ui/cdd-service.d.ts.map +1 -1
- package/dist/ui/cdd-service.js +246 -222
- package/dist/ui/cdd-types.d.ts +41 -68
- package/dist/ui/cdd-types.d.ts.map +1 -1
- package/dist/ui/cdd-types.js +2 -2
- package/dist/ui-web/assets/index-CKo0Z2Iu.css +1 -0
- package/dist/ui-web/assets/{index-CxiydzeC.js → index-DK-2aacv.js} +83 -83
- package/dist/ui-web/index.html +2 -2
- package/package.json +6 -2
- package/src/api/__tests__/config.test.ts +225 -0
- package/src/api/config.ts +10 -4
- package/src/api/sonamu.ts +16 -13
- package/src/auth/index.ts +1 -0
- package/src/auth/knex-adapter.ts +208 -0
- package/src/bin/__tests__/ts-loader-register.test.ts +62 -0
- package/src/bin/cli.ts +52 -9
- package/src/bin/ts-loader-register.ts +2 -32
- package/src/bin/ts-loader-registration.ts +55 -0
- package/src/cone/cone-generator.ts +2 -2
- package/src/database/puri-subset.test-d.ts +102 -0
- package/src/database/puri-subset.types.ts +1 -1
- package/src/skills/commands/sonamu-skills.md +20 -0
- package/src/skills/sonamu/SKILL.md +179 -137
- package/src/skills/sonamu/ai-agents.md +69 -69
- package/src/skills/sonamu/api.md +147 -147
- package/src/skills/sonamu/auth-migration.md +220 -220
- package/src/skills/sonamu/auth-plugins.md +83 -83
- package/src/skills/sonamu/auth.md +106 -106
- package/src/skills/sonamu/cdd.md +65 -200
- package/src/skills/sonamu/cone.md +138 -138
- package/src/skills/sonamu/config.md +191 -191
- package/src/skills/sonamu/create-sonamu.md +66 -66
- package/src/skills/sonamu/database.md +158 -158
- package/src/skills/sonamu/entity-basic.md +292 -293
- package/src/skills/sonamu/entity-relations.md +246 -246
- package/src/skills/sonamu/entity-validation-checklist.md +124 -124
- package/src/skills/sonamu/fixture-cli.md +231 -231
- package/src/skills/sonamu/framework-change.md +37 -37
- package/src/skills/sonamu/frontend.md +223 -223
- package/src/skills/sonamu/i18n.md +82 -82
- package/src/skills/sonamu/migration.md +77 -77
- package/src/skills/sonamu/model.md +222 -222
- package/src/skills/sonamu/naite.md +86 -86
- package/src/skills/sonamu/project-init.md +228 -228
- package/src/skills/sonamu/puri.md +122 -122
- package/src/skills/sonamu/scaffolding.md +154 -154
- package/src/skills/sonamu/skill-contribution.md +124 -124
- package/src/skills/sonamu/subset.md +46 -46
- package/src/skills/sonamu/tasks.md +82 -82
- package/src/skills/sonamu/testing-devrunner.md +147 -147
- package/src/skills/sonamu/testing.md +673 -673
- package/src/skills/sonamu/upsert.md +79 -79
- package/src/skills/sonamu/vector.md +67 -67
- package/src/testing/fixture-generator.ts +4 -4
- package/src/ui/ai-client.ts +1 -1
- package/src/ui/api.ts +18 -17
- package/src/ui/cdd-service.ts +264 -254
- package/src/ui/cdd-types.ts +40 -75
- package/dist/ui-web/assets/index-BrQKU3j9.css +0 -1
- package/src/skills/sonamu/workflow.md +0 -317
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sonamu-model
|
|
3
|
-
description: Sonamu Model
|
|
3
|
+
description: Writing Sonamu Model classes. BaseModelClass inheritance, CRUD method patterns, business logic, executeSubsetQuery options. Use when implementing Model classes with business logic.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Model
|
|
6
|
+
# Model Class
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
- `sonamu/examples/miomock/api/src/application/project/project.model.ts` - ManyToMany save
|
|
10
|
-
- `sonamu/examples/miomock/api/src/application/employee/employee.model.ts` -
|
|
11
|
-
- `sonamu/examples/miomock/api/src/application/project/project.model.test.ts` -
|
|
8
|
+
**Reference working code:**
|
|
9
|
+
- `sonamu/examples/miomock/api/src/application/project/project.model.ts` - ManyToMany save implementation
|
|
10
|
+
- `sonamu/examples/miomock/api/src/application/employee/employee.model.ts` - basic CRUD pattern
|
|
11
|
+
- `sonamu/examples/miomock/api/src/application/project/project.model.test.ts` - test examples
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Basic Structure
|
|
14
14
|
|
|
15
15
|
```typescript
|
|
16
16
|
import { api, BaseModelClass, ListResult, NotFoundException } from "sonamu";
|
|
@@ -32,18 +32,18 @@ class UserModelClass extends BaseModelClass<
|
|
|
32
32
|
export const UserModel = new UserModelClass();
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
## CRUD
|
|
35
|
+
## CRUD Pattern
|
|
36
36
|
|
|
37
|
-
Sonamu Model
|
|
37
|
+
Sonamu Model provides the following basic methods:
|
|
38
38
|
|
|
39
|
-
|
|
|
39
|
+
| Method | Purpose | Notes |
|
|
40
40
|
|--------|------|------|
|
|
41
|
-
| `findById` |
|
|
42
|
-
| `findMany` |
|
|
43
|
-
| `save` |
|
|
44
|
-
| `del` |
|
|
41
|
+
| `findById` | Retrieve single record | |
|
|
42
|
+
| `findMany` | Retrieve list | |
|
|
43
|
+
| `save` | Create/update | upsert behavior |
|
|
44
|
+
| `del` | Delete | Note: not `delete` |
|
|
45
45
|
|
|
46
|
-
**JavaScript
|
|
46
|
+
**Avoiding JavaScript reserved words:** `delete` is a JS reserved word, so it is named `del`. While TypeScript allows `delete` as a method name without a compile error, it can cause runtime issues, so Sonamu uses `del`.
|
|
47
47
|
|
|
48
48
|
### findById
|
|
49
49
|
|
|
@@ -101,55 +101,55 @@ async del(ids: number[]): Promise<number> {
|
|
|
101
101
|
}
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
## BaseModel
|
|
104
|
+
## BaseModel Methods
|
|
105
105
|
|
|
106
|
-
|
|
|
106
|
+
| Method | Description |
|
|
107
107
|
|--------|------|
|
|
108
|
-
| `getPuri("r")` |
|
|
109
|
-
| `getPuri("w")` |
|
|
110
|
-
| `getSubsetQueries(subset)` | Subset
|
|
111
|
-
| `executeSubsetQuery(options)` |
|
|
112
|
-
| `createEnhancers(enhancers)` | Enhancer
|
|
108
|
+
| `getPuri("r")` | Read query builder |
|
|
109
|
+
| `getPuri("w")` | Write query builder |
|
|
110
|
+
| `getSubsetQueries(subset)` | Subset query builder (returns `{ qb, onSubset }`) |
|
|
111
|
+
| `executeSubsetQuery(options)` | Execute subset query |
|
|
112
|
+
| `createEnhancers(enhancers)` | Enhancer object creation helper (type inference) |
|
|
113
113
|
|
|
114
114
|
## getSubsetQueries
|
|
115
115
|
|
|
116
116
|
```typescript
|
|
117
117
|
const { qb, onSubset } = this.getSubsetQueries(subset);
|
|
118
118
|
|
|
119
|
-
// qb:
|
|
119
|
+
// qb: query builder for adding conditions
|
|
120
120
|
qb.where("users.status", "active");
|
|
121
121
|
|
|
122
|
-
// onSubset:
|
|
123
|
-
const typedQb = onSubset("A"); //
|
|
122
|
+
// onSubset: when you need the type for a specific subset
|
|
123
|
+
const typedQb = onSubset("A"); // infers as subset A's type
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
-
## executeSubsetQuery
|
|
126
|
+
## executeSubsetQuery Options
|
|
127
127
|
|
|
128
128
|
```typescript
|
|
129
129
|
return this.executeSubsetQuery({
|
|
130
|
-
subset, //
|
|
131
|
-
qb, //
|
|
132
|
-
params, // ListParams (num, page, queryMode, sonamuFilter
|
|
133
|
-
debug: true, //
|
|
134
|
-
optimizeCountQuery: true, // COUNT
|
|
135
|
-
enhancers, // Enhancer
|
|
130
|
+
subset, // subset key
|
|
131
|
+
qb, // query builder
|
|
132
|
+
params, // ListParams (num, page, queryMode, sonamuFilter, etc.)
|
|
133
|
+
debug: true, // print query log (default: false)
|
|
134
|
+
optimizeCountQuery: true, // COUNT query optimization - removes unnecessary LEFT JOINs (default: false)
|
|
135
|
+
enhancers, // Enhancer function object (optional)
|
|
136
136
|
});
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
-
> **CRITICAL:
|
|
139
|
+
> **CRITICAL: Do not directly mutate the object returned by `executeSubsetQuery()`.**
|
|
140
140
|
>
|
|
141
|
-
> `result.rows = result.rows.map(...)`
|
|
142
|
-
> `total`
|
|
141
|
+
> Replacing rows via `result.rows = result.rows.map(...)` or `(result as any).rows = ...`
|
|
142
|
+
> will break the `total` count and cause pagination to malfunction.
|
|
143
143
|
>
|
|
144
|
-
>
|
|
144
|
+
> Use the `enhancers` pattern for virtual fields that require additional computation:
|
|
145
145
|
>
|
|
146
146
|
> ```typescript
|
|
147
|
-
> // WRONG — pagination
|
|
147
|
+
> // WRONG — breaks pagination
|
|
148
148
|
> const result = await this.executeSubsetQuery({ subset, qb, params });
|
|
149
149
|
> (result as any).rows = result.rows.map((row) => ({ ...row, extra: "value" }));
|
|
150
150
|
> return result as any;
|
|
151
151
|
>
|
|
152
|
-
> // CORRECT — enhancers
|
|
152
|
+
> // CORRECT — enhancers pattern
|
|
153
153
|
> const enhancers = this.createEnhancers({
|
|
154
154
|
> A: (row) => ({ ...row, extra: "value" }),
|
|
155
155
|
> });
|
|
@@ -158,45 +158,45 @@ return this.executeSubsetQuery({
|
|
|
158
158
|
|
|
159
159
|
### queryMode
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
Pass queryMode in params to control the return value:
|
|
162
162
|
|
|
163
163
|
```typescript
|
|
164
|
-
//
|
|
164
|
+
// List only (skip COUNT query) - performance optimization
|
|
165
165
|
const { rows } = await this.findMany(subset, { ...params, queryMode: "list" });
|
|
166
166
|
|
|
167
|
-
//
|
|
167
|
+
// Count only (skip list)
|
|
168
168
|
const { total } = await this.findMany(subset, { ...params, queryMode: "count" });
|
|
169
169
|
|
|
170
|
-
//
|
|
170
|
+
// Both (default)
|
|
171
171
|
const { rows, total } = await this.findMany(subset, { ...params, queryMode: "both" });
|
|
172
172
|
```
|
|
173
173
|
|
|
174
174
|
### sonamuFilter (FilterQuery)
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
Automatically apply filter conditions via params.sonamuFilter:
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
**Prerequisite:** The corresponding prop in entity.json must have `"toFilter": true` set. Fields without this setting are excluded from filtering.
|
|
179
179
|
|
|
180
180
|
```typescript
|
|
181
|
-
//
|
|
181
|
+
// Filter passed from the client
|
|
182
182
|
const params = {
|
|
183
183
|
num: 10,
|
|
184
184
|
page: 1,
|
|
185
185
|
sonamuFilter: {
|
|
186
|
-
status: "active", // eq (
|
|
186
|
+
status: "active", // eq (default)
|
|
187
187
|
age: { gte: 18 }, // >=
|
|
188
188
|
role: { in: ["admin", "user"] },
|
|
189
189
|
email: { contains: "@test" }, // LIKE %...%
|
|
190
190
|
}
|
|
191
191
|
};
|
|
192
192
|
|
|
193
|
-
//
|
|
193
|
+
// Automatically applied in the Model
|
|
194
194
|
return this.executeSubsetQuery({ subset, qb, params });
|
|
195
195
|
```
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
**Allowed operators by type:**
|
|
198
198
|
|
|
199
|
-
|
|
|
199
|
+
| Type | Operators |
|
|
200
200
|
|------|--------|
|
|
201
201
|
| `string` | eq, ne, contains, startsWith, endsWith, in, notIn, isNull, isNotNull |
|
|
202
202
|
| `integer` | eq, ne, gt, gte, lt, lte, in, notIn, between, isNull, isNotNull |
|
|
@@ -206,11 +206,11 @@ return this.executeSubsetQuery({ subset, qb, params });
|
|
|
206
206
|
| `enum` | eq, ne, in, notIn, isNull, isNotNull |
|
|
207
207
|
| `json` | isNull, isNotNull |
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
**Operator examples:**
|
|
210
210
|
|
|
211
|
-
|
|
|
211
|
+
| Operator | SQL | Example |
|
|
212
212
|
|--------|-----|------|
|
|
213
|
-
| `eq` (
|
|
213
|
+
| `eq` (default) | `=` | `{ status: "active" }` |
|
|
214
214
|
| `ne` | `!=` | `{ status: { ne: "deleted" } }` |
|
|
215
215
|
| `gt`, `gte` | `>`, `>=` | `{ age: { gte: 18 } }` |
|
|
216
216
|
| `lt`, `lte` | `<`, `<=` | `{ price: { lte: 1000 } }` |
|
|
@@ -219,32 +219,32 @@ return this.executeSubsetQuery({ subset, qb, params });
|
|
|
219
219
|
| `startsWith` | `LIKE ...%` | `{ code: { startsWith: "A" } }` |
|
|
220
220
|
| `endsWith` | `LIKE %...` | `{ ext: { endsWith: ".pdf" } }` |
|
|
221
221
|
| `isNull`, `isNotNull` | `IS NULL` | `{ deleted_at: { isNull: true } }` |
|
|
222
|
-
| `before`, `after` | `<`, `>` (
|
|
222
|
+
| `before`, `after` | `<`, `>` (date) | `{ created_at: { after: "2024-01-01" } }` |
|
|
223
223
|
| `between` | `BETWEEN` | `{ price: { between: [100, 500] } }` |
|
|
224
224
|
|
|
225
|
-
|
|
225
|
+
**Type definition (`ApplySonamuFilter`):**
|
|
226
226
|
|
|
227
227
|
```typescript
|
|
228
228
|
import type { ApplySonamuFilter } from "sonamu";
|
|
229
229
|
|
|
230
|
-
//
|
|
230
|
+
// Define sonamuFilter type in ListParams
|
|
231
231
|
type ProjectListParams = {
|
|
232
232
|
num: number;
|
|
233
233
|
page: number;
|
|
234
234
|
sonamuFilter?: ApplySonamuFilter<
|
|
235
|
-
ProjectSubsetA, //
|
|
236
|
-
"id" | "created_at", //
|
|
237
|
-
"budget" //
|
|
235
|
+
ProjectSubsetA, // entity type
|
|
236
|
+
"id" | "created_at", // fields to exclude (TOmitKeys)
|
|
237
|
+
"budget" // fields to treat as numeric (TNumericKeys)
|
|
238
238
|
>;
|
|
239
239
|
};
|
|
240
240
|
```
|
|
241
241
|
|
|
242
242
|
## Enhancers
|
|
243
243
|
|
|
244
|
-
virtual
|
|
244
|
+
Post-query processing for virtual field computation and similar needs:
|
|
245
245
|
|
|
246
246
|
```typescript
|
|
247
|
-
//
|
|
247
|
+
// Define enhancer
|
|
248
248
|
const enhancers = this.createEnhancers({
|
|
249
249
|
A: async (row) => ({
|
|
250
250
|
...row,
|
|
@@ -256,11 +256,11 @@ const enhancers = this.createEnhancers({
|
|
|
256
256
|
}),
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
-
// executeSubsetQuery
|
|
259
|
+
// Use in executeSubsetQuery
|
|
260
260
|
return this.executeSubsetQuery({ subset, qb, params, enhancers });
|
|
261
261
|
```
|
|
262
262
|
|
|
263
|
-
## Types
|
|
263
|
+
## Types File
|
|
264
264
|
|
|
265
265
|
```typescript
|
|
266
266
|
// user.types.ts
|
|
@@ -270,7 +270,7 @@ import { UserOrderBy, UserSearchField, UserBaseSchema, UserBaseListParams } from
|
|
|
270
270
|
export const UserListParams = UserBaseListParams;
|
|
271
271
|
export type UserListParams = z.infer<typeof UserListParams>;
|
|
272
272
|
|
|
273
|
-
//
|
|
273
|
+
// Basic pattern: partial from BaseSchema
|
|
274
274
|
export const UserSaveParams = UserBaseSchema.partial({
|
|
275
275
|
id: true,
|
|
276
276
|
created_at: true,
|
|
@@ -278,9 +278,9 @@ export const UserSaveParams = UserBaseSchema.partial({
|
|
|
278
278
|
export type UserSaveParams = z.infer<typeof UserSaveParams>;
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
-
### SaveParams
|
|
281
|
+
### SaveParams Patterns
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
**Basic pattern (no relations):**
|
|
284
284
|
```typescript
|
|
285
285
|
import { UserBaseSchema, UserBaseListParams } from "../sonamu.generated";
|
|
286
286
|
|
|
@@ -294,9 +294,9 @@ export const UserSaveParams = UserBaseSchema.partial({
|
|
|
294
294
|
export type UserSaveParams = z.infer<typeof UserSaveParams>;
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
-
**ManyToMany relation
|
|
297
|
+
**If a ManyToMany relation exists:**
|
|
298
298
|
```typescript
|
|
299
|
-
// ManyToMany
|
|
299
|
+
// ManyToMany relation: add {relation_name}_ids array
|
|
300
300
|
export const ProjectSaveParams = ProjectBaseSchema.partial({
|
|
301
301
|
id: true,
|
|
302
302
|
created_at: true,
|
|
@@ -306,49 +306,49 @@ export const ProjectSaveParams = ProjectBaseSchema.partial({
|
|
|
306
306
|
tag_ids: z.array(z.number().int().positive()),
|
|
307
307
|
})
|
|
308
308
|
.omit({
|
|
309
|
-
// virtual
|
|
309
|
+
// omit virtual fields, system-generated fields, etc.
|
|
310
310
|
virtual_test: true,
|
|
311
311
|
});
|
|
312
312
|
export type ProjectSaveParams = z.infer<typeof ProjectSaveParams>;
|
|
313
313
|
```
|
|
314
314
|
|
|
315
|
-
**
|
|
315
|
+
**Handling nullable fields in BelongsToOne relations:**
|
|
316
316
|
```typescript
|
|
317
|
-
//
|
|
317
|
+
// Nullable relations are automatically optional, so no extra partial is needed
|
|
318
318
|
export const ResponseSaveParams = ResponseBaseSchema.partial({
|
|
319
319
|
id: true,
|
|
320
320
|
created_at: true,
|
|
321
|
-
updated_at: true, // timestamp
|
|
321
|
+
updated_at: true, // also make timestamp fields partial
|
|
322
322
|
});
|
|
323
323
|
export type ResponseSaveParams = z.infer<typeof ResponseSaveParams>;
|
|
324
324
|
```
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
- `sonamu/examples/miomock/api/src/application/project/project.types.ts` - ManyToMany SaveParams
|
|
328
|
-
- `sonamu/examples/miomock/api/src/application/employee/employee.types.ts` - BelongsToOne SaveParams
|
|
326
|
+
**Reference working code:**
|
|
327
|
+
- `sonamu/examples/miomock/api/src/application/project/project.types.ts` - ManyToMany SaveParams example
|
|
328
|
+
- `sonamu/examples/miomock/api/src/application/employee/employee.types.ts` - BelongsToOne SaveParams example
|
|
329
329
|
|
|
330
|
-
###
|
|
330
|
+
### Handling Relations in the Model
|
|
331
331
|
|
|
332
|
-
**
|
|
332
|
+
**Removing relation objects on update:**
|
|
333
333
|
```typescript
|
|
334
|
-
//
|
|
334
|
+
// Pattern used in tests for updates
|
|
335
335
|
const original = await UserModel.findById("A", userId);
|
|
336
336
|
|
|
337
|
-
//
|
|
337
|
+
// Remove relation object and extract FK only
|
|
338
338
|
const { institution, ...userData } = original;
|
|
339
339
|
|
|
340
340
|
await UserModel.save([
|
|
341
341
|
{
|
|
342
342
|
...userData,
|
|
343
|
-
institution_id: institution?.id ?? null, //
|
|
344
|
-
name: "
|
|
343
|
+
institution_id: institution?.id ?? null, // explicitly add FK
|
|
344
|
+
name: "Updated Name",
|
|
345
345
|
},
|
|
346
346
|
]);
|
|
347
347
|
```
|
|
348
348
|
|
|
349
|
-
**ManyToMany save
|
|
349
|
+
**ManyToMany save:**
|
|
350
350
|
```typescript
|
|
351
|
-
// ManyToMany
|
|
351
|
+
// ManyToMany is passed as an _ids array
|
|
352
352
|
await ProjectModel.save([
|
|
353
353
|
{
|
|
354
354
|
id: projectId,
|
|
@@ -359,11 +359,11 @@ await ProjectModel.save([
|
|
|
359
359
|
]);
|
|
360
360
|
```
|
|
361
361
|
|
|
362
|
-
|
|
363
|
-
- `sonamu/examples/miomock/api/src/application/project/project.model.ts` - ManyToMany save
|
|
364
|
-
- `sonamu/examples/miomock/api/src/application/project/project.model.test.ts` - Save
|
|
362
|
+
**Reference working code:**
|
|
363
|
+
- `sonamu/examples/miomock/api/src/application/project/project.model.ts` - ManyToMany save implementation
|
|
364
|
+
- `sonamu/examples/miomock/api/src/application/project/project.model.test.ts` - Save test example
|
|
365
365
|
|
|
366
|
-
##
|
|
366
|
+
## Transactions
|
|
367
367
|
|
|
368
368
|
```typescript
|
|
369
369
|
await this.getPuri("w").transaction(async (trx) => {
|
|
@@ -372,57 +372,57 @@ await this.getPuri("w").transaction(async (trx) => {
|
|
|
372
372
|
});
|
|
373
373
|
```
|
|
374
374
|
|
|
375
|
-
##
|
|
375
|
+
## Validation Patterns
|
|
376
376
|
|
|
377
|
-
###
|
|
377
|
+
### Step-by-step Validation
|
|
378
378
|
|
|
379
|
-
|
|
379
|
+
A pattern for validating business rules step by step:
|
|
380
380
|
|
|
381
381
|
```typescript
|
|
382
382
|
async enroll(courseId: number, userId: number): Promise<Enrollment> {
|
|
383
|
-
// 1
|
|
383
|
+
// Step 1: Duplicate check
|
|
384
384
|
const existing = await this.findOne("A", {
|
|
385
385
|
course_id: courseId,
|
|
386
386
|
user_id: userId,
|
|
387
387
|
});
|
|
388
388
|
|
|
389
389
|
if (existing) {
|
|
390
|
-
throw new Error("
|
|
390
|
+
throw new Error("Already enrolled in this course");
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
-
// 2
|
|
393
|
+
// Step 2: Capacity check
|
|
394
394
|
const course = await CourseModel.findById("A", courseId);
|
|
395
395
|
const { total } = await this.findMany({ course_id: courseId });
|
|
396
396
|
|
|
397
397
|
if (total >= course.max_students) {
|
|
398
|
-
throw new Error("
|
|
398
|
+
throw new Error("The course is full");
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
-
// 3
|
|
401
|
+
// Step 3: Execute
|
|
402
402
|
const [id] = await this.save([{ course_id: courseId, user_id: userId }]);
|
|
403
403
|
return this.findById("A", id);
|
|
404
404
|
}
|
|
405
405
|
```
|
|
406
406
|
|
|
407
|
-
###
|
|
407
|
+
### Conditional Validation
|
|
408
408
|
|
|
409
|
-
|
|
409
|
+
Perform different validations depending on conditions:
|
|
410
410
|
|
|
411
411
|
```typescript
|
|
412
412
|
async save(spa: TaskSaveParams[]): Promise<number[]> {
|
|
413
413
|
for (const sp of spa) {
|
|
414
|
-
//
|
|
414
|
+
// completion date is required only when status is completed
|
|
415
415
|
if (sp.status === "completed" && !sp.completed_at) {
|
|
416
|
-
throw new Error("
|
|
416
|
+
throw new Error("A completion date is required for completed status");
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
-
//
|
|
419
|
+
// Check amount range only when budget is present
|
|
420
420
|
if (sp.budget !== null && sp.budget < 0) {
|
|
421
|
-
throw new Error("
|
|
421
|
+
throw new Error("Budget must be 0 or greater");
|
|
422
422
|
}
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
-
//
|
|
425
|
+
// Save after validation passes
|
|
426
426
|
const wdb = this.getPuri("w");
|
|
427
427
|
spa.forEach((sp) => wdb.ubRegister("tasks", sp));
|
|
428
428
|
|
|
@@ -432,28 +432,28 @@ async save(spa: TaskSaveParams[]): Promise<number[]> {
|
|
|
432
432
|
}
|
|
433
433
|
```
|
|
434
434
|
|
|
435
|
-
###
|
|
435
|
+
### Validating Against Related Data
|
|
436
436
|
|
|
437
|
-
|
|
437
|
+
Validate relationships with other tables:
|
|
438
438
|
|
|
439
439
|
```typescript
|
|
440
440
|
async save(spa: ResponseSaveParams[]): Promise<number[]> {
|
|
441
441
|
for (const sp of spa) {
|
|
442
|
-
//
|
|
442
|
+
// Check if the survey is still open
|
|
443
443
|
const collection = await CollectionModel.findById("A", sp.collection_id);
|
|
444
444
|
|
|
445
445
|
if (collection.status === "closed") {
|
|
446
|
-
throw new Error("
|
|
446
|
+
throw new Error("This survey has already ended");
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
-
//
|
|
449
|
+
// Check response period
|
|
450
450
|
const now = new Date();
|
|
451
451
|
if (now < collection.begin_date || now > collection.end_date) {
|
|
452
|
-
throw new Error("
|
|
452
|
+
throw new Error("This is not within the response period");
|
|
453
453
|
}
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
-
//
|
|
456
|
+
// Save after validation passes
|
|
457
457
|
const wdb = this.getPuri("w");
|
|
458
458
|
spa.forEach((sp) => wdb.ubRegister("responses", sp));
|
|
459
459
|
|
|
@@ -463,32 +463,32 @@ async save(spa: ResponseSaveParams[]): Promise<number[]> {
|
|
|
463
463
|
}
|
|
464
464
|
```
|
|
465
465
|
|
|
466
|
-
|
|
467
|
-
-
|
|
468
|
-
-
|
|
469
|
-
-
|
|
466
|
+
**Key points:**
|
|
467
|
+
- Clear error messages when validation fails
|
|
468
|
+
- Only save after all validations pass
|
|
469
|
+
- Enforce business rules through code
|
|
470
470
|
|
|
471
471
|
---
|
|
472
472
|
|
|
473
473
|
## IMPORTANT: Verify orderBy After Scaffolding
|
|
474
474
|
|
|
475
|
-
###
|
|
475
|
+
### Problem
|
|
476
476
|
|
|
477
|
-
Sonamu UI
|
|
477
|
+
When scaffolding is run from Sonamu UI, the model file is **regenerated**, leaving only the default value (`id-desc`) and losing any custom orderBy cases.
|
|
478
478
|
|
|
479
479
|
```
|
|
480
|
-
|
|
480
|
+
Error: Argument of type 'xxx-asc' is not assignable to parameter of type 'never'
|
|
481
481
|
```
|
|
482
482
|
|
|
483
|
-
###
|
|
483
|
+
### Fix
|
|
484
484
|
|
|
485
|
-
|
|
485
|
+
After scaffolding, you must exhaustively handle **all orderBy enum cases** from entity.json in the model file.
|
|
486
486
|
|
|
487
487
|
```typescript
|
|
488
|
-
// entity.json
|
|
489
|
-
{ "TaskOrderBy": { "id-desc": "ID
|
|
488
|
+
// entity.json orderBy enum
|
|
489
|
+
{ "TaskOrderBy": { "id-desc": "ID Latest", "created_at-desc": "By Date", "title-asc": "By Title" } }
|
|
490
490
|
|
|
491
|
-
// model -
|
|
491
|
+
// model - must verify/add after scaffolding
|
|
492
492
|
if (params.orderBy) {
|
|
493
493
|
if (params.orderBy === "id-desc") {
|
|
494
494
|
qb.orderBy("tasks.id", "desc");
|
|
@@ -497,26 +497,26 @@ if (params.orderBy) {
|
|
|
497
497
|
} else if (params.orderBy === "title-asc") {
|
|
498
498
|
qb.orderBy("tasks.title", "asc");
|
|
499
499
|
} else {
|
|
500
|
-
exhaustive(params.orderBy); //
|
|
500
|
+
exhaustive(params.orderBy); // compile error if any case is missing
|
|
501
501
|
}
|
|
502
502
|
}
|
|
503
503
|
```
|
|
504
504
|
|
|
505
|
-
###
|
|
505
|
+
### Checklist
|
|
506
506
|
|
|
507
|
-
-
|
|
508
|
-
-
|
|
509
|
-
-
|
|
507
|
+
- Verify orderBy cases in model after scaffolding
|
|
508
|
+
- Confirm they match the orderBy enum in entity.json
|
|
509
|
+
- Also check other custom logic such as search cases and enhancers
|
|
510
510
|
|
|
511
511
|
---
|
|
512
512
|
|
|
513
|
-
##
|
|
513
|
+
## Code Quality and Consistency
|
|
514
514
|
|
|
515
|
-
### DRY
|
|
515
|
+
### DRY principle: use this.modelName
|
|
516
516
|
|
|
517
|
-
|
|
517
|
+
Use `this.modelName` instead of hardcoding the model name in error messages.
|
|
518
518
|
|
|
519
|
-
**BAD:
|
|
519
|
+
**BAD: hardcoded model name**
|
|
520
520
|
```typescript
|
|
521
521
|
// department.model.ts
|
|
522
522
|
if (!rows[0]) {
|
|
@@ -529,206 +529,206 @@ if (!rows[0]) {
|
|
|
529
529
|
}
|
|
530
530
|
```
|
|
531
531
|
|
|
532
|
-
**GOOD: this.modelName
|
|
532
|
+
**GOOD: use this.modelName**
|
|
533
533
|
```typescript
|
|
534
|
-
//
|
|
534
|
+
// Common to all Models
|
|
535
535
|
if (!rows[0]) {
|
|
536
536
|
throw new NotFoundException(SD("notFound")(this.modelName, id));
|
|
537
537
|
}
|
|
538
538
|
```
|
|
539
539
|
|
|
540
|
-
|
|
541
|
-
-
|
|
542
|
-
-
|
|
543
|
-
-
|
|
540
|
+
**Benefits:**
|
|
541
|
+
- Prevents copy-paste mistakes: no need to update the model name when copying from another model
|
|
542
|
+
- Consistency: all models use the same pattern
|
|
543
|
+
- Maintainability: changing modelName in the constructor automatically reflects in all error messages
|
|
544
544
|
|
|
545
|
-
###
|
|
545
|
+
### Consistent i18n Key Usage
|
|
546
546
|
|
|
547
|
-
|
|
547
|
+
Use the same i18n keys consistently for the same purpose across the entire project.
|
|
548
548
|
|
|
549
|
-
**BAD:
|
|
549
|
+
**BAD: duplicate i18n keys**
|
|
550
550
|
```typescript
|
|
551
|
-
//
|
|
551
|
+
// Different keys used across models
|
|
552
552
|
throw new NotFoundException(SD("error.entityNotFound")(this.modelName, id));
|
|
553
553
|
throw new NotFoundException(SD("error.notFound")(this.modelName, id));
|
|
554
554
|
throw new NotFoundException(SD("notFound")(this.modelName, id));
|
|
555
555
|
|
|
556
|
-
//
|
|
556
|
+
// Search field error
|
|
557
557
|
throw new BadRequestException(SD("error.unknownSearchField")(params.search));
|
|
558
558
|
throw new BadRequestException(SD("error.invalidSearchField")(params.search));
|
|
559
559
|
```
|
|
560
560
|
|
|
561
|
-
**GOOD:
|
|
561
|
+
**GOOD: use standard i18n keys**
|
|
562
562
|
```typescript
|
|
563
|
-
// Entity
|
|
563
|
+
// Entity lookup failure - short and clear
|
|
564
564
|
throw new NotFoundException(SD("notFound")(this.modelName, id));
|
|
565
565
|
|
|
566
|
-
//
|
|
566
|
+
// Search field error - search namespace
|
|
567
567
|
throw new BadRequestException(SD("search.invalidField")(params.search));
|
|
568
568
|
```
|
|
569
569
|
|
|
570
|
-
|
|
571
|
-
|
|
|
570
|
+
**Recommended i18n key patterns:**
|
|
571
|
+
| Situation | i18n key | Used in |
|
|
572
572
|
|------|---------|--------|
|
|
573
|
-
| Entity
|
|
574
|
-
|
|
|
575
|
-
|
|
|
576
|
-
|
|
|
577
|
-
|
|
|
573
|
+
| Entity lookup failure | `notFound` | findById |
|
|
574
|
+
| Invalid search field | `search.invalidField` | findMany search |
|
|
575
|
+
| Missing required field | `validation.required` | save validation |
|
|
576
|
+
| Unauthorized | `error.forbidden` | guards failure |
|
|
577
|
+
| Login required | `error.loginRequired` | Context.user null |
|
|
578
578
|
|
|
579
|
-
###
|
|
579
|
+
### Bulk Refactoring Strategy
|
|
580
580
|
|
|
581
|
-
|
|
581
|
+
When consistently modifying multiple model files, use sed for automation:
|
|
582
582
|
|
|
583
|
-
**1
|
|
583
|
+
**Step 1: Confirm pattern**
|
|
584
584
|
```bash
|
|
585
|
-
#
|
|
585
|
+
# Find files to modify
|
|
586
586
|
grep -r 'SD("error.entityNotFound")' packages/api/src/application/*/
|
|
587
587
|
```
|
|
588
588
|
|
|
589
|
-
**2
|
|
589
|
+
**Step 2: Validate changes (dry-run)**
|
|
590
590
|
```bash
|
|
591
|
-
#
|
|
591
|
+
# Preview changes before applying
|
|
592
592
|
sed -n 's/SD("error.entityNotFound")(\(.*\), id)/SD("notFound")(this.modelName, id)/p' file.ts
|
|
593
593
|
```
|
|
594
594
|
|
|
595
|
-
**3
|
|
595
|
+
**Step 3: Apply in bulk**
|
|
596
596
|
```bash
|
|
597
|
-
#
|
|
597
|
+
# Modify all model files
|
|
598
598
|
find packages/api/src/application -name "*.model.ts" -exec sed -i '' \
|
|
599
599
|
's/SD("error.entityNotFound")(\(.*\), id)/SD("notFound")(this.modelName, id)/g' {} \;
|
|
600
600
|
```
|
|
601
601
|
|
|
602
|
-
**4
|
|
602
|
+
**Step 4: Validate with build**
|
|
603
603
|
```bash
|
|
604
|
-
# TypeScript
|
|
604
|
+
# TypeScript type check
|
|
605
605
|
pnpm typecheck
|
|
606
606
|
|
|
607
|
-
#
|
|
607
|
+
# Full build
|
|
608
608
|
pnpm build
|
|
609
609
|
```
|
|
610
610
|
|
|
611
|
-
|
|
612
|
-
-
|
|
613
|
-
- dry-run
|
|
614
|
-
-
|
|
615
|
-
-
|
|
611
|
+
**Cautions:**
|
|
612
|
+
- Always run after a git commit (to allow rollback)
|
|
613
|
+
- Confirm changes with dry-run first
|
|
614
|
+
- Check for type errors with build
|
|
615
|
+
- Verify behavior with tests
|
|
616
616
|
|
|
617
|
-
###
|
|
617
|
+
### Type Check Patterns
|
|
618
618
|
|
|
619
619
|
**satisfies vs as const:**
|
|
620
620
|
|
|
621
621
|
```typescript
|
|
622
|
-
// BAD:
|
|
622
|
+
// BAD: bypasses type checking with type assertion
|
|
623
623
|
const params = {
|
|
624
624
|
num: 24,
|
|
625
625
|
page: 1,
|
|
626
626
|
search: "id" as const,
|
|
627
|
-
orderBy: "wrong-value" as const, //
|
|
627
|
+
orderBy: "wrong-value" as const, // error not detected
|
|
628
628
|
...rawParams,
|
|
629
629
|
} as RoleListParams;
|
|
630
630
|
|
|
631
|
-
// GOOD:
|
|
631
|
+
// GOOD: compile-time validation with satisfies
|
|
632
632
|
const params = {
|
|
633
633
|
num: 24,
|
|
634
634
|
page: 1,
|
|
635
635
|
search: "id" as const,
|
|
636
|
-
orderBy: "wrong-value" as const, //
|
|
636
|
+
orderBy: "wrong-value" as const, // compile error!
|
|
637
637
|
...rawParams,
|
|
638
638
|
} satisfies RoleListParams;
|
|
639
639
|
```
|
|
640
640
|
|
|
641
|
-
|
|
642
|
-
-
|
|
643
|
-
-
|
|
641
|
+
**Recommended usage locations:**
|
|
642
|
+
- Default values for params in findMany
|
|
643
|
+
- Complex object literals (where type checking is important)
|
|
644
644
|
|
|
645
|
-
### IMPORTANT: ListParams / findMany / SearchField
|
|
645
|
+
### IMPORTANT: ListParams / findMany / SearchField Synchronization
|
|
646
646
|
|
|
647
|
-
|
|
647
|
+
The following three must always remain consistent. If any one is out of sync, the feature either exists as a declaration only with no behavior, or a runtime error will occur.
|
|
648
648
|
|
|
649
|
-
1. `
|
|
650
|
-
2. `types.ts
|
|
651
|
-
3.
|
|
649
|
+
1. `SearchField` enum values in `entity.json`
|
|
650
|
+
2. `ListParams` field definitions in `types.ts`
|
|
651
|
+
3. Filter/search handling code in `findMany` in `model.ts`
|
|
652
652
|
|
|
653
|
-
|
|
654
|
-
- [ ]
|
|
655
|
-
- [ ]
|
|
656
|
-
- [ ]
|
|
653
|
+
**Checklist:**
|
|
654
|
+
- [ ] Are all values declared in SearchField implemented in findMany?
|
|
655
|
+
- [ ] If any filter branch is commented out, either remove it or implement it
|
|
656
|
+
- [ ] Are "filter by ~", "search by ~" features from requirements reflected in ListParams?
|
|
657
657
|
|
|
658
|
-
|
|
659
|
-
(
|
|
658
|
+
**In particular, entities with an approval workflow must always add a status filter.**
|
|
659
|
+
(Clicking count by stage → filter to show only that list is a commonly required pattern)
|
|
660
660
|
|
|
661
661
|
```typescript
|
|
662
|
-
// types.ts -
|
|
662
|
+
// types.ts - approval workflow entity example
|
|
663
663
|
export const AchievementListParams = AchievementBaseListParams.extend({
|
|
664
664
|
status: z.nativeEnum(AchievementStatus).optional(),
|
|
665
665
|
achievement_type: z.nativeEnum(AchievementType).optional(),
|
|
666
666
|
submitter_id: z.string().optional(),
|
|
667
667
|
});
|
|
668
668
|
|
|
669
|
-
// model.ts -
|
|
669
|
+
// model.ts - corresponding filter implementation
|
|
670
670
|
if (params.status) qb.where("achievements.status", params.status);
|
|
671
671
|
if (params.achievement_type) qb.where("achievements.achievement_type", params.achievement_type);
|
|
672
672
|
if (params.submitter_id) qb.where("achievements.submitter_id", params.submitter_id);
|
|
673
673
|
```
|
|
674
674
|
|
|
675
|
-
**DO NOT -
|
|
675
|
+
**DO NOT - declaration/implementation mismatch:**
|
|
676
676
|
```typescript
|
|
677
|
-
//
|
|
678
|
-
// model.ts
|
|
677
|
+
// SearchField "title" declared in entity.json
|
|
678
|
+
// model.ts only handles "id" case, "title" is commented out
|
|
679
679
|
if (params.search === "id") {
|
|
680
680
|
// ...
|
|
681
681
|
} /* else if (params.search === "title") {
|
|
682
|
-
// TODO:
|
|
682
|
+
// TODO: not implemented
|
|
683
683
|
} */
|
|
684
684
|
```
|
|
685
685
|
|
|
686
|
-
###
|
|
686
|
+
### Code Review Checklist
|
|
687
687
|
|
|
688
|
-
|
|
689
|
-
- [ ] `this.modelName`
|
|
690
|
-
- [ ]
|
|
691
|
-
- [ ] satisfies
|
|
692
|
-
- [ ]
|
|
693
|
-
- [ ]
|
|
694
|
-
- [ ] ManyToMany relation
|
|
695
|
-
- [ ] `@upload`
|
|
696
|
-
- [ ] SearchField enum
|
|
697
|
-
- [ ]
|
|
688
|
+
When writing a new Model:
|
|
689
|
+
- [ ] Use `this.modelName` (no hardcoding)
|
|
690
|
+
- [ ] Use standard i18n keys (`notFound`, `search.invalidField`)
|
|
691
|
+
- [ ] Use the `satisfies` keyword (type safety)
|
|
692
|
+
- [ ] Do not unnecessarily specify the debug option
|
|
693
|
+
- [ ] Exhaustively handle all orderBy cases
|
|
694
|
+
- [ ] If a ManyToMany relation exists, add _ids array to SaveParams
|
|
695
|
+
- [ ] Does the `@upload` method have `@api` on it? (`@upload` is used standalone; using both together causes a build error)
|
|
696
|
+
- [ ] Do the SearchField enum and findMany implementation match?
|
|
697
|
+
- [ ] For entities with approval workflows, are status/type filters present in both ListParams and findMany?
|
|
698
698
|
|
|
699
|
-
|
|
700
|
-
- [ ]
|
|
701
|
-
- [ ]
|
|
702
|
-
- [ ]
|
|
703
|
-
- [ ]
|
|
704
|
-
- [ ] dry-run
|
|
705
|
-
- [ ]
|
|
706
|
-
- [ ] pnpm test
|
|
707
|
-
- [ ] `any`
|
|
699
|
+
When bulk-modifying 20+ Models:
|
|
700
|
+
- [ ] Compare patterns with reference code like miomock
|
|
701
|
+
- [ ] Prioritize inconsistent patterns
|
|
702
|
+
- [ ] Write an automation script using sed or similar
|
|
703
|
+
- [ ] Commit to git before making changes
|
|
704
|
+
- [ ] Validate changes with dry-run
|
|
705
|
+
- [ ] Check for type errors with pnpm typecheck
|
|
706
|
+
- [ ] Verify behavior with pnpm test
|
|
707
|
+
- [ ] Check for `any` type usage (prohibited)
|
|
708
708
|
|
|
709
|
-
### any
|
|
709
|
+
### Prohibition on any type
|
|
710
710
|
|
|
711
|
-
`any`
|
|
711
|
+
The `any` type neutralizes TypeScript's type safety and must **never be used**.
|
|
712
712
|
|
|
713
|
-
**BAD: any
|
|
713
|
+
**BAD: using any**
|
|
714
714
|
```typescript
|
|
715
715
|
const { category_ids, ...data } = sp as any;
|
|
716
716
|
function process(input: any) { ... }
|
|
717
717
|
```
|
|
718
718
|
|
|
719
|
-
**GOOD:
|
|
719
|
+
**GOOD: use precise types or unknown**
|
|
720
720
|
```typescript
|
|
721
|
-
//
|
|
721
|
+
// Destructure with a precise type
|
|
722
722
|
const { category_ids, ...data } = sp as QuestionCollectionSaveParams;
|
|
723
723
|
|
|
724
|
-
//
|
|
724
|
+
// Use unknown when the type is not known (instead of any)
|
|
725
725
|
function process(input: unknown) {
|
|
726
726
|
if (typeof input === "string") { ... }
|
|
727
727
|
}
|
|
728
728
|
```
|
|
729
729
|
|
|
730
|
-
|
|
731
|
-
- `any
|
|
732
|
-
-
|
|
733
|
-
-
|
|
734
|
-
- `eslint-disable @typescript-eslint/no-explicit-any`
|
|
730
|
+
**Rules:**
|
|
731
|
+
- `any` is prohibited
|
|
732
|
+
- When the type is unknown, use `unknown` and narrow with a type guard
|
|
733
|
+
- When a type assertion is needed during destructuring, specify the exact type name (`as ConcreteType`)
|
|
734
|
+
- Suppression comments like `eslint-disable @typescript-eslint/no-explicit-any` are also prohibited
|