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.
Files changed (79) hide show
  1. package/dist/api/decorators.d.ts.map +1 -1
  2. package/dist/api/decorators.js +5 -12
  3. package/dist/bin/cli.js +10 -253
  4. package/dist/database/base-model.d.ts +1 -0
  5. package/dist/database/base-model.d.ts.map +1 -1
  6. package/dist/database/base-model.js +14 -8
  7. package/dist/database/db.d.ts +2 -1
  8. package/dist/database/db.d.ts.map +1 -1
  9. package/dist/database/db.js +13 -3
  10. package/dist/database/puri-wrapper.d.ts.map +1 -1
  11. package/dist/database/puri-wrapper.js +4 -11
  12. package/dist/database/transaction-context.d.ts +8 -3
  13. package/dist/database/transaction-context.d.ts.map +1 -1
  14. package/dist/database/transaction-context.js +12 -7
  15. package/dist/migration/code-generation.js +2 -2
  16. package/dist/naite/naite-reporter.d.ts +8 -3
  17. package/dist/naite/naite-reporter.d.ts.map +1 -1
  18. package/dist/naite/naite-reporter.js +9 -4
  19. package/dist/ui-web/assets/{index-D0MHYbxl.js → index-CJf8uJYf.js} +47 -41
  20. package/dist/ui-web/assets/index-GMMIVGja.css +1 -0
  21. package/dist/ui-web/index.html +2 -2
  22. package/package.json +3 -3
  23. package/src/api/decorators.ts +11 -22
  24. package/src/bin/cli.ts +12 -375
  25. package/src/database/__tests__/transaction-scope.test.ts +227 -0
  26. package/src/database/base-model.ts +31 -6
  27. package/src/database/db.ts +17 -2
  28. package/src/database/puri-wrapper.ts +5 -22
  29. package/src/database/transaction-context.ts +14 -6
  30. package/src/migration/code-generation.ts +1 -1
  31. package/src/naite/naite-reporter.ts +8 -3
  32. package/dist/ui-web/assets/index-Dx_JX4aQ.css +0 -1
  33. package/src/skills/AGENTS.md +0 -108
  34. package/src/skills/sonamu/SKILL.md +0 -75
  35. package/src/skills/sonamu-ai-agents/SKILL.md +0 -205
  36. package/src/skills/sonamu-api/SKILL.md +0 -480
  37. package/src/skills/sonamu-auth/SKILL.md +0 -327
  38. package/src/skills/sonamu-auth/references/plugins.md +0 -310
  39. package/src/skills/sonamu-auth/references/user-id-migration-followups.md +0 -192
  40. package/src/skills/sonamu-auth/references/user-id-migration.md +0 -455
  41. package/src/skills/sonamu-config/SKILL.md +0 -203
  42. package/src/skills/sonamu-config/references/database.md +0 -452
  43. package/src/skills/sonamu-config/references/environments.md +0 -178
  44. package/src/skills/sonamu-config/references/server-options.md +0 -400
  45. package/src/skills/sonamu-entity/SKILL.md +0 -180
  46. package/src/skills/sonamu-entity/references/creation-workflow.md +0 -581
  47. package/src/skills/sonamu-entity/references/design-guides.md +0 -243
  48. package/src/skills/sonamu-entity/references/field-types.md +0 -170
  49. package/src/skills/sonamu-entity/references/relations-detail.md +0 -245
  50. package/src/skills/sonamu-entity/references/relations.md +0 -463
  51. package/src/skills/sonamu-entity/references/subset.md +0 -156
  52. package/src/skills/sonamu-fixture/SKILL.md +0 -180
  53. package/src/skills/sonamu-fixture/references/cli-usage.md +0 -439
  54. package/src/skills/sonamu-fixture/references/cone.md +0 -298
  55. package/src/skills/sonamu-frontend/SKILL.md +0 -142
  56. package/src/skills/sonamu-frontend/references/components.md +0 -323
  57. package/src/skills/sonamu-frontend/references/examples.md +0 -64
  58. package/src/skills/sonamu-frontend/references/hooks.md +0 -273
  59. package/src/skills/sonamu-frontend/references/runtime.md +0 -165
  60. package/src/skills/sonamu-frontend/references/scaffolding.md +0 -439
  61. package/src/skills/sonamu-i18n/SKILL.md +0 -287
  62. package/src/skills/sonamu-migration/SKILL.md +0 -316
  63. package/src/skills/sonamu-naite/SKILL.md +0 -266
  64. package/src/skills/sonamu-query/SKILL.md +0 -48
  65. package/src/skills/sonamu-query/references/model-patterns.md +0 -390
  66. package/src/skills/sonamu-query/references/model.md +0 -366
  67. package/src/skills/sonamu-query/references/puri.md +0 -413
  68. package/src/skills/sonamu-query/references/search.md +0 -238
  69. package/src/skills/sonamu-query/references/upsert.md +0 -324
  70. package/src/skills/sonamu-tasks/SKILL.md +0 -236
  71. package/src/skills/sonamu-testing/SKILL.md +0 -251
  72. package/src/skills/sonamu-testing/references/devrunner.md +0 -405
  73. package/src/skills/sonamu-testing/references/helpers.md +0 -185
  74. package/src/skills/sonamu-testing/references/patterns.md +0 -263
  75. package/src/skills/sonamu-testing/references/pitfalls.md +0 -588
  76. package/src/skills/sonamu-testing/references/quick-start.md +0 -285
  77. package/src/skills/sonamu-testing/references/type-safety.md +0 -172
  78. package/src/skills/sonamu-testing/references/writing-plan.md +0 -375
  79. 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