sonamu 0.10.7 → 0.10.9
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/decorators.d.ts.map +1 -1
- package/dist/api/decorators.js +5 -12
- package/dist/bin/cli.js +10 -253
- package/dist/database/base-model.d.ts +1 -0
- package/dist/database/base-model.d.ts.map +1 -1
- package/dist/database/base-model.js +14 -8
- package/dist/database/db.d.ts +2 -1
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +13 -3
- package/dist/database/puri-wrapper.d.ts.map +1 -1
- package/dist/database/puri-wrapper.js +4 -11
- package/dist/database/transaction-context.d.ts +8 -3
- package/dist/database/transaction-context.d.ts.map +1 -1
- package/dist/database/transaction-context.js +12 -7
- package/dist/migration/code-generation.js +2 -2
- package/dist/naite/naite-reporter.d.ts +8 -3
- package/dist/naite/naite-reporter.d.ts.map +1 -1
- package/dist/naite/naite-reporter.js +9 -4
- package/dist/ui-web/assets/{index-D0MHYbxl.js → index-CJf8uJYf.js} +47 -41
- package/dist/ui-web/assets/index-GMMIVGja.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/package.json +3 -3
- package/src/api/decorators.ts +11 -22
- package/src/bin/cli.ts +12 -375
- package/src/database/__tests__/transaction-scope.test.ts +227 -0
- package/src/database/base-model.ts +31 -6
- package/src/database/db.ts +17 -2
- package/src/database/puri-wrapper.ts +5 -22
- package/src/database/transaction-context.ts +14 -6
- package/src/migration/code-generation.ts +1 -1
- package/src/naite/naite-reporter.ts +8 -3
- package/dist/ui-web/assets/index-Dx_JX4aQ.css +0 -1
- package/src/skills/AGENTS.md +0 -108
- package/src/skills/sonamu/SKILL.md +0 -75
- package/src/skills/sonamu-ai-agents/SKILL.md +0 -205
- package/src/skills/sonamu-api/SKILL.md +0 -480
- package/src/skills/sonamu-auth/SKILL.md +0 -327
- package/src/skills/sonamu-auth/references/plugins.md +0 -310
- package/src/skills/sonamu-auth/references/user-id-migration-followups.md +0 -192
- package/src/skills/sonamu-auth/references/user-id-migration.md +0 -455
- package/src/skills/sonamu-config/SKILL.md +0 -203
- package/src/skills/sonamu-config/references/database.md +0 -452
- package/src/skills/sonamu-config/references/environments.md +0 -178
- package/src/skills/sonamu-config/references/server-options.md +0 -400
- package/src/skills/sonamu-entity/SKILL.md +0 -180
- package/src/skills/sonamu-entity/references/creation-workflow.md +0 -581
- package/src/skills/sonamu-entity/references/design-guides.md +0 -243
- package/src/skills/sonamu-entity/references/field-types.md +0 -170
- package/src/skills/sonamu-entity/references/relations-detail.md +0 -245
- package/src/skills/sonamu-entity/references/relations.md +0 -463
- package/src/skills/sonamu-entity/references/subset.md +0 -156
- package/src/skills/sonamu-fixture/SKILL.md +0 -180
- package/src/skills/sonamu-fixture/references/cli-usage.md +0 -439
- package/src/skills/sonamu-fixture/references/cone.md +0 -298
- package/src/skills/sonamu-frontend/SKILL.md +0 -142
- package/src/skills/sonamu-frontend/references/components.md +0 -323
- package/src/skills/sonamu-frontend/references/examples.md +0 -64
- package/src/skills/sonamu-frontend/references/hooks.md +0 -273
- package/src/skills/sonamu-frontend/references/runtime.md +0 -165
- package/src/skills/sonamu-frontend/references/scaffolding.md +0 -439
- package/src/skills/sonamu-i18n/SKILL.md +0 -287
- package/src/skills/sonamu-migration/SKILL.md +0 -316
- package/src/skills/sonamu-naite/SKILL.md +0 -266
- package/src/skills/sonamu-query/SKILL.md +0 -48
- package/src/skills/sonamu-query/references/model-patterns.md +0 -390
- package/src/skills/sonamu-query/references/model.md +0 -366
- package/src/skills/sonamu-query/references/puri.md +0 -413
- package/src/skills/sonamu-query/references/search.md +0 -238
- package/src/skills/sonamu-query/references/upsert.md +0 -324
- package/src/skills/sonamu-tasks/SKILL.md +0 -236
- package/src/skills/sonamu-testing/SKILL.md +0 -251
- package/src/skills/sonamu-testing/references/devrunner.md +0 -405
- package/src/skills/sonamu-testing/references/helpers.md +0 -185
- package/src/skills/sonamu-testing/references/patterns.md +0 -263
- package/src/skills/sonamu-testing/references/pitfalls.md +0 -588
- package/src/skills/sonamu-testing/references/quick-start.md +0 -285
- package/src/skills/sonamu-testing/references/type-safety.md +0 -172
- package/src/skills/sonamu-testing/references/writing-plan.md +0 -375
- package/src/skills/sonamu-vector/SKILL.md +0 -222
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
# Situation-Specific Design Guides
|
|
2
|
-
|
|
3
|
-
## Situation-Specific Guides
|
|
4
|
-
|
|
5
|
-
### Adding a string field
|
|
6
|
-
|
|
7
|
-
```json
|
|
8
|
-
{ "name": "title", "type": "string", "length": 255, "desc": "Title" }
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
- Omitting `length` → stored as `text` type (for long text)
|
|
12
|
-
|
|
13
|
-
### Adding an enum field
|
|
14
|
-
|
|
15
|
-
```json
|
|
16
|
-
// 1. Add to props
|
|
17
|
-
{ "name": "status", "type": "enum", "id": "ProductStatus", "desc": "Status" }
|
|
18
|
-
|
|
19
|
-
// 2. Define in enums (MUST — missing this causes errors)
|
|
20
|
-
"ProductStatus": { "draft": "Draft", "published": "Published", "archived": "Archived" }
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
### IMPORTANT: Always use enum for fixed-value fields
|
|
24
|
-
|
|
25
|
-
Defining a field with a fixed set of choices as `string` breaks DB integrity.
|
|
26
|
-
|
|
27
|
-
**Rule: "Can this value be freely entered from outside the code?"** No → enum, Yes → string
|
|
28
|
-
|
|
29
|
-
**Enum candidates**: strings that look like `faker.helpers.arrayElement([...])`, fields described as "one of the following / type / category", select boxes / radio buttons
|
|
30
|
-
|
|
31
|
-
```json
|
|
32
|
-
// WRONG: defined as string
|
|
33
|
-
{ "name": "budget_item", "type": "string", "desc": "Budget item" }
|
|
34
|
-
// CORRECT: defined as enum
|
|
35
|
-
{ "name": "budget_item", "type": "enum", "id": "BudgetItem", "desc": "Budget item" }
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
### Adding a nullable field
|
|
39
|
-
|
|
40
|
-
```json
|
|
41
|
-
{ "name": "deleted_at", "type": "date", "nullable": true, "desc": "Deleted at" }
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
**CRITICAL: Importance of the nullable attribute**
|
|
45
|
-
|
|
46
|
-
A field without `nullable: true` is treated as **required**.
|
|
47
|
-
|
|
48
|
-
Sonamu's `ubUpsert` uses PostgreSQL `ON CONFLICT ... DO UPDATE`, so **all required fields** must be included even on updates.
|
|
49
|
-
|
|
50
|
-
```json
|
|
51
|
-
// example
|
|
52
|
-
{
|
|
53
|
-
"props": [
|
|
54
|
-
{ "name": "title", "type": "string" }, // required (no nullable)
|
|
55
|
-
{ "name": "content", "type": "string" }, // required (no nullable)
|
|
56
|
-
{ "name": "category", "type": "string", "nullable": true } // optional
|
|
57
|
-
]
|
|
58
|
-
}
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
**Rules**:
|
|
62
|
-
|
|
63
|
-
- Do not add `nullable: true` to fields that are not optional
|
|
64
|
-
- Always specify `nullable: true` for optional fields
|
|
65
|
-
- Required fields must always have a value in tests and API calls
|
|
66
|
-
|
|
67
|
-
**Details:** see `sonamu-testing` and "CRITICAL: Required fields must be included" in `sonamu-query`
|
|
68
|
-
|
|
69
|
-
### Adding a JSON field
|
|
70
|
-
|
|
71
|
-
```json
|
|
72
|
-
{
|
|
73
|
-
"name": "metadata",
|
|
74
|
-
"type": "json",
|
|
75
|
-
"id": "ProductMetadata",
|
|
76
|
-
"desc": "Metadata"
|
|
77
|
-
}
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
- `id` is required (used as the type name)
|
|
81
|
-
- A separate TypeScript type definition is needed
|
|
82
|
-
|
|
83
|
-
### Adding a searchText field (for pg_trgm Fuzzy Search)
|
|
84
|
-
|
|
85
|
-
A dedicated prop type that consolidates multiple columns into a single generated column. Used with a GIN index.
|
|
86
|
-
|
|
87
|
-
```json
|
|
88
|
-
{
|
|
89
|
-
"props": [
|
|
90
|
-
{ "name": "title_ko", "type": "string" },
|
|
91
|
-
{ "name": "title_en", "type": "string" },
|
|
92
|
-
{ "name": "tags", "type": "string[]" },
|
|
93
|
-
{
|
|
94
|
-
"name": "search_text",
|
|
95
|
-
"type": "searchText",
|
|
96
|
-
"sourceColumns": [
|
|
97
|
-
{ "name": "title_ko" },
|
|
98
|
-
{ "name": "title_en", "caseInsensitive": true },
|
|
99
|
-
{ "name": "tags" }
|
|
100
|
-
]
|
|
101
|
-
}
|
|
102
|
-
],
|
|
103
|
-
"indexes": [
|
|
104
|
-
{
|
|
105
|
-
"name": "idx_items_search_text",
|
|
106
|
-
"type": "index",
|
|
107
|
-
"columns": [{ "name": "search_text", "opclass": "gin_trgm_ops" }],
|
|
108
|
-
"using": "gin"
|
|
109
|
-
}
|
|
110
|
-
]
|
|
111
|
-
}
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
SQL expressions per source column type:
|
|
115
|
-
|
|
116
|
-
| source type | caseInsensitive: true | caseInsensitive: false (default) |
|
|
117
|
-
| ---------------------------- | ----------------------------- | ------------------------------------ |
|
|
118
|
-
| `string` | `lower(COALESCE(col, ''))` | `COALESCE(col, '')` |
|
|
119
|
-
| `string[]` | `sonamu_text_array_agg(col)` | `sonamu_text_array_agg(col, false)` |
|
|
120
|
-
| `json` (z.array(z.string())) | `sonamu_jsonb_array_agg(col)` | `sonamu_jsonb_array_agg(col, false)` |
|
|
121
|
-
|
|
122
|
-
- If a `string[]` or `json(string[])` source is present, helper function DDL is automatically inserted in the migration
|
|
123
|
-
- `searchText` columns are generated columns and are excluded from SaveParams — INSERT/UPDATE is not allowed
|
|
124
|
-
- For query usage: see the "pg_trgm Fuzzy Search" section in `sonamu-query`
|
|
125
|
-
|
|
126
|
-
### Adding a unique constraint
|
|
127
|
-
|
|
128
|
-
```json
|
|
129
|
-
{
|
|
130
|
-
"name": "products_sku_unique",
|
|
131
|
-
"type": "unique",
|
|
132
|
-
"columns": [{ "name": "sku" }]
|
|
133
|
-
}
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### Composite unique constraint
|
|
137
|
-
|
|
138
|
-
```json
|
|
139
|
-
{
|
|
140
|
-
"name": "cart_items_unique",
|
|
141
|
-
"type": "unique",
|
|
142
|
-
"columns": [{ "name": "user_id" }, { "name": "product_id" }]
|
|
143
|
-
}
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
### Partial index (`where`)
|
|
147
|
-
|
|
148
|
-
`where` declares a PostgreSQL partial index predicate. Provide a raw SQL condition **without** the `WHERE` keyword; it is appended to the generated `CREATE INDEX`. Works for every index type (`index`, `unique`, `hnsw`, `ivfflat`, pgroonga).
|
|
149
|
-
|
|
150
|
-
```json
|
|
151
|
-
{
|
|
152
|
-
"name": "uniq_users_email_active",
|
|
153
|
-
"type": "unique",
|
|
154
|
-
"columns": [{ "name": "email" }],
|
|
155
|
-
"where": "deleted_at IS NULL"
|
|
156
|
-
}
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
→ `CREATE UNIQUE INDEX uniq_users_email_active ON users (email) WHERE deleted_at IS NULL;`
|
|
160
|
-
(Enforces email uniqueness only among non-deleted rows.)
|
|
161
|
-
|
|
162
|
-
### `nullsNotDistinct` (unique only)
|
|
163
|
-
|
|
164
|
-
By default PostgreSQL treats `NULL`s as distinct, so a unique index allows multiple `NULL` rows. Set `nullsNotDistinct: true` to emit `NULLS NOT DISTINCT`, treating `NULL`s as equal (at most one `NULL` allowed).
|
|
165
|
-
|
|
166
|
-
```json
|
|
167
|
-
{
|
|
168
|
-
"name": "uniq_accounts_external_id",
|
|
169
|
-
"type": "unique",
|
|
170
|
-
"columns": [{ "name": "external_id" }],
|
|
171
|
-
"nullsNotDistinct": true
|
|
172
|
-
}
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
→ `CREATE UNIQUE INDEX uniq_accounts_external_id ON accounts (external_id) NULLS NOT DISTINCT;`
|
|
176
|
-
|
|
177
|
-
### IMPORTANT: Use the actual DB column name in indexes
|
|
178
|
-
|
|
179
|
-
**The way FK columns are referenced differs between indexes and subsets. Do not confuse them.**
|
|
180
|
-
|
|
181
|
-
| Location | Format | Example |
|
|
182
|
-
| --------- | -------------------------- | ------------------------------------- |
|
|
183
|
-
| `indexes` | Actual DB column name | `role_id`, `user_id`, `department_id` |
|
|
184
|
-
| `subsets` | FieldExpr (relation.field) | `role.id`, `user.id`, `department.id` |
|
|
185
|
-
|
|
186
|
-
**DO NOT:**
|
|
187
|
-
|
|
188
|
-
```json
|
|
189
|
-
// Using FieldExpr in indexes → error
|
|
190
|
-
"indexes": [
|
|
191
|
-
{ "name": "ix_role", "type": "index", "columns": [{ "name": "role.id" }] }
|
|
192
|
-
]
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
**DO:**
|
|
196
|
-
|
|
197
|
-
```json
|
|
198
|
-
// indexes use actual DB column names
|
|
199
|
-
"indexes": [
|
|
200
|
-
{ "name": "ix_role_id", "type": "index", "columns": [{ "name": "role_id" }] }
|
|
201
|
-
]
|
|
202
|
-
|
|
203
|
-
// subsets use FieldExpr
|
|
204
|
-
"subsets": {
|
|
205
|
-
"A": ["id", "role.id", "role.name"]
|
|
206
|
-
}
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
### IMPORTANT: Unique constraints based on business rules
|
|
210
|
-
|
|
211
|
-
Not a technical decision — ask **"What if the same combination is inserted twice?"** → if it should error, use unique; if it should be allowed, use index only.
|
|
212
|
-
|
|
213
|
-
**Patterns that need composite unique**: per-year settings (`type, dept_id, year`), user-role mappings, per-year budgets (`project_id, year, budget_item`), likes/bookmarks (`user_id, entity_id`)
|
|
214
|
-
|
|
215
|
-
## Common Mistakes
|
|
216
|
-
|
|
217
|
-
| Mistake | Fix |
|
|
218
|
-
| ------------------------------------------------ | ------------------------------------------------------------------------------------- |
|
|
219
|
-
| Missing `id` prop | Recommended to add (needed by most Model logic) |
|
|
220
|
-
| Missing `created_at` prop | Recommended to add with `dbDefault: "CURRENT_TIMESTAMP"` |
|
|
221
|
-
| Missing `OrderBy` enum | Add `{EntityId}OrderBy` (needed for findMany sorting) |
|
|
222
|
-
| Missing `SearchField` enum | Add `{EntityId}SearchField` (needed for search) |
|
|
223
|
-
| enum prop `id` not defined in enums | Add definition to the enums section |
|
|
224
|
-
| Missing `id` on json prop | Add the `id` field |
|
|
225
|
-
| Using `"type": "text"` directly | `text` is invalid. Use `"type": "string"` without a length |
|
|
226
|
-
| Adding multiple values to `OrderBy` enum | **Default is `id-desc` only** (see below) |
|
|
227
|
-
| Defining fixed-choice fields as `string` | Convert to enum (check for fields with arrayElement-style fixtureGenerator) |
|
|
228
|
-
| Yearly/mapping tables without unique constraints | Add composite unique based on business rules |
|
|
229
|
-
| Using `number` type for integer fields | Use `integer` (use `numeric` only when decimal precision is needed) |
|
|
230
|
-
| Using `role.id` format in indexes | indexes use actual DB column name (`role_id`); only subsets use FieldExpr (`role.id`) |
|
|
231
|
-
|
|
232
|
-
## Resolving Entity Schema Validation Errors
|
|
233
|
-
|
|
234
|
-
**→ See `references/creation-workflow.md` PHASE 1** (missing index type, Subset FieldExpr, duplicate columns, Boolean dbDefault, etc.)
|
|
235
|
-
|
|
236
|
-
**Quick checklist:**
|
|
237
|
-
|
|
238
|
-
- [ ] Does every index have a `type` field? (`"index"` | `"unique"` | `"hnsw"` | `"ivfflat"`)
|
|
239
|
-
- [ ] Does the subset reference FK using `relation.id` format? (`user_id` ✗ → `user.id` ✓)
|
|
240
|
-
- [ ] No duplicate definition of BelongsToOne relation and FK column?
|
|
241
|
-
- [ ] Is Boolean `dbDefault` a string (`"true"` / `"false"`)? (0, 1 ✗)
|
|
242
|
-
- [ ] Are all fields included in Subset A?
|
|
243
|
-
- [ ] Do index columns use actual DB column names (`role_id`)? (FieldExpr `role.id` ✗)
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
# Field Types and Options
|
|
2
|
-
|
|
3
|
-
## Minimal Template
|
|
4
|
-
|
|
5
|
-
```json
|
|
6
|
-
{
|
|
7
|
-
"id": "Product",
|
|
8
|
-
"table": "products",
|
|
9
|
-
"title": "Product",
|
|
10
|
-
"props": [
|
|
11
|
-
{ "name": "id", "type": "integer", "desc": "ID" },
|
|
12
|
-
{
|
|
13
|
-
"name": "created_at",
|
|
14
|
-
"type": "date",
|
|
15
|
-
"dbDefault": "CURRENT_TIMESTAMP",
|
|
16
|
-
"desc": "Created at"
|
|
17
|
-
},
|
|
18
|
-
{ "name": "name", "type": "string", "length": 255, "desc": "Product name" }
|
|
19
|
-
],
|
|
20
|
-
"indexes": [],
|
|
21
|
-
"subsets": { "A": ["id", "name", "created_at"] },
|
|
22
|
-
"enums": {
|
|
23
|
-
"ProductOrderBy": { "id-desc": "Newest" },
|
|
24
|
-
"ProductSearchField": { "id": "ID", "name": "Name" }
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
## IMPORTANT: OrderBy Enum Generation Rule
|
|
31
|
-
|
|
32
|
-
**IMPORTANT: It is strongly recommended to use only `id-desc` during initial scaffolding.**
|
|
33
|
-
|
|
34
|
-
```json
|
|
35
|
-
// RECOMMENDED — for initial scaffolding
|
|
36
|
-
"ProductOrderBy": { "id-desc": "Newest" }
|
|
37
|
-
|
|
38
|
-
// AVOID — do not add these before scaffolding
|
|
39
|
-
"ProductOrderBy": { "id-desc": "Newest", "name-asc": "Name (A-Z)", "created_at-desc": "Newest first" }
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### Why only id-desc?
|
|
43
|
-
|
|
44
|
-
The model code generated by scaffolding handles only `id-desc` automatically. If the OrderBy enum has other values:
|
|
45
|
-
|
|
46
|
-
1. Scaffolding succeeds, but a type error occurs in the model's `exhaustive()` function
|
|
47
|
-
2. The developer must manually add cases to the model
|
|
48
|
-
3. If this is missed, a runtime error may occur
|
|
49
|
-
|
|
50
|
-
**This is not a technical constraint — it is scaffolding best practice.** Add complex OrderBy values after scaffolding is complete.
|
|
51
|
-
|
|
52
|
-
### When additional sort options are needed
|
|
53
|
-
|
|
54
|
-
When sort options are required later:
|
|
55
|
-
|
|
56
|
-
1. Add values to the OrderBy enum in entity.json
|
|
57
|
-
2. Add the corresponding cases to the orderBy branch in the model
|
|
58
|
-
|
|
59
|
-
```typescript
|
|
60
|
-
// model.ts — adding orderBy cases
|
|
61
|
-
if (params.orderBy === "id-desc") {
|
|
62
|
-
qb.orderBy("products.id", "desc");
|
|
63
|
-
} else if (params.orderBy === "name-asc") {
|
|
64
|
-
// added
|
|
65
|
-
qb.orderBy("products.name", "asc");
|
|
66
|
-
} else {
|
|
67
|
-
exhaustive(params.orderBy);
|
|
68
|
-
}
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
**Rule**: start with `id-desc` only → add others as needed after scaffolding
|
|
72
|
-
|
|
73
|
-
## IMPORTANT: integer vs number Type Selection
|
|
74
|
-
|
|
75
|
-
**CRITICAL: Always follow the criteria below when creating numeric fields. Wrong type choice causes unnecessary ALTER migrations.**
|
|
76
|
-
|
|
77
|
-
PostgreSQL mapping:
|
|
78
|
-
|
|
79
|
-
- `integer` → DB `integer` (whole numbers)
|
|
80
|
-
- `number` → DB `numeric(p,s)` (precise decimal numbers)
|
|
81
|
-
|
|
82
|
-
| Use case | Entity type | Example |
|
|
83
|
-
| --------------------------------------- | --------------------------------- | -------------------------------- |
|
|
84
|
-
| PK, FK, count, order, quantity | `integer` | id, user_id, order_num, quantity |
|
|
85
|
-
| Amount, ratio, values requiring decimal | `number` (+ `precision`, `scale`) | price, rate, weight, score |
|
|
86
|
-
|
|
87
|
-
**DO NOT:**
|
|
88
|
-
|
|
89
|
-
```json
|
|
90
|
-
{ "name": "order_num", "type": "number", "desc": "Sort order" }
|
|
91
|
-
{ "name": "quantity", "type": "number", "desc": "Quantity" }
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
**DO:**
|
|
95
|
-
|
|
96
|
-
```json
|
|
97
|
-
{ "name": "order_num", "type": "integer", "desc": "Sort order" }
|
|
98
|
-
{ "name": "quantity", "type": "integer", "desc": "Quantity" }
|
|
99
|
-
{ "name": "price", "type": "number", "precision": 12, "scale": 2, "desc": "Price" }
|
|
100
|
-
{ "name": "rate", "type": "number", "precision": 5, "scale": 2, "desc": "Rate" }
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
**Decision rule: "Does this value ever need a decimal point?"**
|
|
104
|
-
|
|
105
|
-
- No → `integer`
|
|
106
|
-
- Yes → `number` (always specify `precision` and `scale`)
|
|
107
|
-
|
|
108
|
-
## Common Options (CommonProp)
|
|
109
|
-
|
|
110
|
-
Options applicable to all prop types:
|
|
111
|
-
|
|
112
|
-
| Option | Type | Description |
|
|
113
|
-
| ---------- | ------- | --------------------------------------------------------- |
|
|
114
|
-
| `name` | string | Field name (required) |
|
|
115
|
-
| `desc` | string | Field description |
|
|
116
|
-
| `nullable` | boolean | Whether NULL is allowed (default: false) |
|
|
117
|
-
| `toFilter` | true | Register as a sonamuFilter filtering target. See model.md |
|
|
118
|
-
| `cone` | Cone | LLM-based fixture generation metadata. See cone.md |
|
|
119
|
-
|
|
120
|
-
## Required Options by Type
|
|
121
|
-
|
|
122
|
-
| Type | Required | Optional |
|
|
123
|
-
| ------------ | ------------ | ------------------------------------------------------------------ |
|
|
124
|
-
| `string` | — | `length` (text if omitted), `zodFormat` (email, uuid, etc.) |
|
|
125
|
-
| `integer` | — | — |
|
|
126
|
-
| `bigInteger` | — | — |
|
|
127
|
-
| `number` | — | `precision`, `scale`, `numberType` (real/double precision/numeric) |
|
|
128
|
-
| `numeric` | — | `precision`, `scale` |
|
|
129
|
-
| `enum` | `id` | `nullable`, `dbDefault`, `length` |
|
|
130
|
-
| `json` | `id` | `dbDefault: "{}"` |
|
|
131
|
-
| `date` | — | `dbDefault`, `precision` |
|
|
132
|
-
| `boolean` | — | `dbDefault: "false"` |
|
|
133
|
-
| `virtual` | `id` | `virtualType` (query/code, default: code) |
|
|
134
|
-
| `vector` | `dimensions` | — |
|
|
135
|
-
| `tsvector` | — | — |
|
|
136
|
-
|
|
137
|
-
## IMPORTANT: ENUM Type dbDefault Setting
|
|
138
|
-
|
|
139
|
-
When setting a default value on an ENUM field, the value must be wrapped in **escaped double quotes**.
|
|
140
|
-
|
|
141
|
-
### DO NOT - Incorrect Examples
|
|
142
|
-
|
|
143
|
-
```json
|
|
144
|
-
// Incorrect: no quotes — interpreted as a column reference in SQL, causes error
|
|
145
|
-
{ "name": "status", "type": "enum", "id": "ApprovalStatus", "dbDefault": "pending" }
|
|
146
|
-
// Error: cannot use column reference in DEFAULT expression
|
|
147
|
-
|
|
148
|
-
// Incorrect: single quotes — causes oxfmt format error
|
|
149
|
-
{ "name": "status", "type": "enum", "id": "ApprovalStatus", "dbDefault": "'pending'" }
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
### DO - Correct Example
|
|
153
|
-
|
|
154
|
-
```json
|
|
155
|
-
// Correct: escaped double quotes
|
|
156
|
-
{
|
|
157
|
-
"name": "status",
|
|
158
|
-
"type": "enum",
|
|
159
|
-
"id": "ApprovalStatus",
|
|
160
|
-
"dbDefault": "\"pending\"",
|
|
161
|
-
"desc": "Approval status"
|
|
162
|
-
}
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
### Generated SQL
|
|
166
|
-
|
|
167
|
-
```sql
|
|
168
|
-
-- Correct output
|
|
169
|
-
"status" text not null default 'pending'
|
|
170
|
-
```
|
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
# Relationship Details — FK Rules, Subsets, ManyToMany Types
|
|
2
|
-
|
|
3
|
-
## FK Reference Rules (FieldExpr)
|
|
4
|
-
|
|
5
|
-
When a BelongsToOne relationship is defined, a `{name}_id` column is automatically created. **In subsets, reference using the `{name}.id` form (FieldExpr)**, and **in indexes, use the actual DB column name (`{name}_id`)**.
|
|
6
|
-
|
|
7
|
-
### Where It Applies
|
|
8
|
-
|
|
9
|
-
| Location | Format | Example |
|
|
10
|
-
| -------- | ---------------------------- | --------------------- |
|
|
11
|
-
| subsets | FieldExpr (`relation.field`) | `"user.id"` |
|
|
12
|
-
| indexes | actual DB column name | `"user_id"` |
|
|
13
|
-
| unique | actual DB column name | `["user_id", "date"]` |
|
|
14
|
-
| search | FieldExpr (`relation.field`) | `"user.id"` |
|
|
15
|
-
|
|
16
|
-
### Example
|
|
17
|
-
|
|
18
|
-
```json
|
|
19
|
-
{
|
|
20
|
-
"id": "ApiLog",
|
|
21
|
-
"props": [{ "type": "relation", "name": "user", "with": "User", "relationType": "BelongsToOne" }],
|
|
22
|
-
"subsets": {
|
|
23
|
-
"A": ["id", "user.id", "api_path"] // use FieldExpr
|
|
24
|
-
},
|
|
25
|
-
"indexes": [
|
|
26
|
-
{
|
|
27
|
-
"type": "index",
|
|
28
|
-
"name": "api_logs_user_id_index",
|
|
29
|
-
"columns": [{ "name": "user_id" }]
|
|
30
|
-
}
|
|
31
|
-
]
|
|
32
|
-
}
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
### Error Message in Subsets
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
Error: ApiLog -- invalid FieldExpr 'user_id' (available props: id, created_at, ..., user)
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
If you see this error in subsets, change `user_id` → `user.id`. In indexes, `user_id` is the correct format.
|
|
42
|
-
|
|
43
|
-
> **Note:** For a detailed explanation of the difference in reference methods between indexes and subsets, see the "IMPORTANT: In indexes, use the actual DB column name for FK columns" section in `sonamu-entity`.
|
|
44
|
-
|
|
45
|
-
---
|
|
46
|
-
|
|
47
|
-
## Common Mistakes
|
|
48
|
-
|
|
49
|
-
| Mistake | Fix |
|
|
50
|
-
| --------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
51
|
-
| Using a separate `"relations": [...]` section | Define with `"type": "relation"` inside `props` |
|
|
52
|
-
| Directly defining `{name}_id` in BelongsToOne | Delete it (auto-generated) |
|
|
53
|
-
| Using `user_id` directly in Subset | Change to `user.id` form |
|
|
54
|
-
| Mismatch of FK intent in OneToOne | Explicitly set `hasJoinColumn: true` on the side holding the FK (optional, no FK if omitted) |
|
|
55
|
-
| Missing `joinColumn` in HasMany | Specify the FK column name in the related table |
|
|
56
|
-
| Missing `onUpdate/onDelete` in ManyToMany | Add as required |
|
|
57
|
-
| Inconsistent joinTable name | Consistent naming recommended (alphabetical order) |
|
|
58
|
-
| `nullable: false` in self-reference | Change to `nullable: true` |
|
|
59
|
-
|
|
60
|
-
## Using Relationships in Subsets
|
|
61
|
-
|
|
62
|
-
- See `references/subset.md`
|
|
63
|
-
|
|
64
|
-
```json
|
|
65
|
-
{
|
|
66
|
-
"subsets": {
|
|
67
|
-
"A": ["id", "title", "author.id", "author.username", "author.department.name"]
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
- Nesting is possible via dot notation
|
|
73
|
-
- JOIN is auto-generated
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## Type Definitions for ManyToMany Relationships
|
|
78
|
-
|
|
79
|
-
ManyToMany relationships are defined in Entity JSON, but SaveParams must pass join table data as an array.
|
|
80
|
-
|
|
81
|
-
Reference: sonamu/examples/miomock/api/src/application/project
|
|
82
|
-
|
|
83
|
-
### Handling ManyToMany in SaveParams
|
|
84
|
-
|
|
85
|
-
**Pattern: Use BaseSchema.partial().extend()**
|
|
86
|
-
|
|
87
|
-
```typescript
|
|
88
|
-
// project.types.ts (miomock example)
|
|
89
|
-
import { z } from "zod";
|
|
90
|
-
import { ProjectBaseSchema } from "../sonamu.generated";
|
|
91
|
-
|
|
92
|
-
export const ProjectSaveParams = ProjectBaseSchema.partial({
|
|
93
|
-
id: true,
|
|
94
|
-
created_at: true,
|
|
95
|
-
})
|
|
96
|
-
.extend({
|
|
97
|
-
employee_ids: z.array(z.number().int().positive()), // ManyToMany: employee
|
|
98
|
-
tag_ids: z.array(z.number().int().positive()), // ManyToMany: tags
|
|
99
|
-
})
|
|
100
|
-
.omit({
|
|
101
|
-
virtual_test: true, // remove virtual fields
|
|
102
|
-
virtual_query_test: true,
|
|
103
|
-
textsearchable_index_col: true, // remove generated fields
|
|
104
|
-
});
|
|
105
|
-
export type ProjectSaveParams = z.infer<typeof ProjectSaveParams>;
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
**Important:**
|
|
109
|
-
|
|
110
|
-
- Since BaseSchema does not have ManyToMany relation fields, add them with `.extend()`
|
|
111
|
-
- Field name should be in the `{relation_name}_ids` form (e.g. employee → employee_ids, tags → tag_ids)
|
|
112
|
-
- Type validation: `z.array(z.number().int().positive())` - only positive integers allowed
|
|
113
|
-
- Remove virtual/generated fields with `.omit()`
|
|
114
|
-
- Bidirectional ManyToMany is managed from one side only (Project only, not Employee)
|
|
115
|
-
|
|
116
|
-
### Handling in Model.save() (Recommended Pattern)
|
|
117
|
-
|
|
118
|
-
**Efficient pattern: Delete only changed entries with whereNotIn**
|
|
119
|
-
|
|
120
|
-
```typescript
|
|
121
|
-
// project.model.ts (miomock example)
|
|
122
|
-
async save(spa: ProjectSaveParams[]): Promise<number[]> {
|
|
123
|
-
const puri = this.getPuri("w");
|
|
124
|
-
|
|
125
|
-
// register
|
|
126
|
-
spa.forEach(({ employee_ids, tag_ids, ...sp }) => {
|
|
127
|
-
const project_id = puri.ubRegister("projects", sp);
|
|
128
|
-
|
|
129
|
-
employee_ids.forEach((employee_id) => {
|
|
130
|
-
puri.ubRegister("projects__employees", {
|
|
131
|
-
project_id,
|
|
132
|
-
employee_id,
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
tag_ids.forEach((tag_id) => {
|
|
137
|
-
puri.ubRegister("project_tags", {
|
|
138
|
-
project_id,
|
|
139
|
-
tag_id,
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
return puri.transaction(async (trx) => {
|
|
145
|
-
const ids = await trx.ubUpsert("projects");
|
|
146
|
-
const peIds = await trx.ubUpsert("projects__employees");
|
|
147
|
-
const ptIds = await trx.ubUpsert("project_tags");
|
|
148
|
-
|
|
149
|
-
// Key: delete only relationships not in the current request with whereNotIn (efficient)
|
|
150
|
-
await trx
|
|
151
|
-
.table("projects__employees")
|
|
152
|
-
.whereIn("project_id", ids)
|
|
153
|
-
.whereNotIn("id", peIds) // delete only those not in ubUpsert result
|
|
154
|
-
.delete();
|
|
155
|
-
|
|
156
|
-
await trx
|
|
157
|
-
.table("project_tags")
|
|
158
|
-
.whereIn("project_id", ids)
|
|
159
|
-
.whereNotIn("id", ptIds)
|
|
160
|
-
.delete();
|
|
161
|
-
|
|
162
|
-
return ids;
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
**Basic pattern: Delete all then re-register (simple but inefficient)**
|
|
168
|
-
|
|
169
|
-
```typescript
|
|
170
|
-
async save(spa: QuestionCollectionSaveParams[]): Promise<number[]> {
|
|
171
|
-
const wdb = this.getPuri("w");
|
|
172
|
-
|
|
173
|
-
const categoryIdsList: (number[] | undefined)[] = [];
|
|
174
|
-
spa.forEach((sp) => {
|
|
175
|
-
const { category_ids, ...collectionData } = sp as QuestionCollectionSaveParams;
|
|
176
|
-
categoryIdsList.push(category_ids);
|
|
177
|
-
wdb.ubRegister("question_collections", collectionData);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
return wdb.transaction(async (trx) => {
|
|
181
|
-
const ids = await trx.ubUpsert("question_collections");
|
|
182
|
-
|
|
183
|
-
// Delete all (inefficient but simple)
|
|
184
|
-
await trx
|
|
185
|
-
.table("question_collections__survey_categories")
|
|
186
|
-
.whereIn("question_collection_id", ids)
|
|
187
|
-
.delete();
|
|
188
|
-
|
|
189
|
-
// Register new relationships
|
|
190
|
-
ids.forEach((collectionId, index) => {
|
|
191
|
-
const categoryIds = categoryIdsList[index];
|
|
192
|
-
if (categoryIds && categoryIds.length > 0) {
|
|
193
|
-
categoryIds.forEach((categoryId) => {
|
|
194
|
-
trx.ubRegister("question_collections__survey_categories", {
|
|
195
|
-
question_collection_id: collectionId,
|
|
196
|
-
survey_category_id: categoryId,
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
await trx.ubUpsert("question_collections__survey_categories");
|
|
203
|
-
return ids;
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
### Notes on Update
|
|
209
|
-
|
|
210
|
-
When re-saving data queried in an update test, ManyToMany relationship fields must be provided again:
|
|
211
|
-
|
|
212
|
-
```typescript
|
|
213
|
-
// WRONG - saving without category_ids will delete all relationships
|
|
214
|
-
const { categories, ...collectionData } = collection;
|
|
215
|
-
await QuestionCollectionModel.save([{ ...collectionData, title: "Updated Title" }]);
|
|
216
|
-
|
|
217
|
-
// CORRECT - extract ids from categories and pass them
|
|
218
|
-
const { categories, ...collectionData } = collection;
|
|
219
|
-
const category_ids = categories?.map((c) => c.id) ?? [];
|
|
220
|
-
await QuestionCollectionModel.save([{ ...collectionData, category_ids, title: "Updated Title" }]);
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
### Managing Bidirectional ManyToMany
|
|
224
|
-
|
|
225
|
-
**Principle: Manage from one side only**
|
|
226
|
-
|
|
227
|
-
```typescript
|
|
228
|
-
// Project Entity: employee (ManyToMany)
|
|
229
|
-
// Employee Entity: projs (ManyToMany, same join table)
|
|
230
|
-
|
|
231
|
-
// project.types.ts - manages employee_ids
|
|
232
|
-
export const ProjectSaveParams = ProjectBaseSchema.extend({
|
|
233
|
-
employee_ids: z.array(z.number().int().positive()),
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
// employee.types.ts - does not manage proj_ids
|
|
237
|
-
export const EmployeeSaveParams = EmployeeBaseSchema.partial({ id: true, created_at: true });
|
|
238
|
-
// proj_ids is not added
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
**Reason:**
|
|
242
|
-
|
|
243
|
-
- Managing from both sides causes synchronization issues
|
|
244
|
-
- Managing from the primary Entity (Project) only is clearer
|
|
245
|
-
- When querying Employee, projs are automatically joined and returned
|