cabloy 5.1.148 → 5.1.149
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/.cabloy-version +1 -1
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/repo-docs/.vitepress/config.mjs +4 -0
- package/repo-docs/backend/markdown-guide.md +1 -0
- package/repo-docs/backend/relations-guide.md +1 -0
- package/repo-docs/frontend/form-layout-guide.md +15 -1
- package/repo-docs/frontend/markdown-guide.md +1 -0
- package/repo-docs/fullstack/edition-collaboration-differences.md +1 -0
- package/repo-docs/fullstack/one-to-one-companion-resource-guide.md +447 -0
- package/repo-e2e/specs/cabloy-basic.spec.ts +176 -1
- package/vona/src/suite/a-training/modules/training-student/package.json +1 -0
- package/vona/src/suite/a-training/modules/training-student/src/.metadata/index.ts +177 -103
- package/vona/src/suite/a-training/modules/training-student/src/bean/meta.index.ts +1 -0
- package/vona/src/suite/a-training/modules/training-student/src/bean/meta.version.ts +19 -1
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +3 -0
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +3 -0
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentCreate.tsx +21 -2
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentSummary.tsx +7 -1
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentUpdate.tsx +21 -2
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentView.tsx +21 -2
- package/vona/src/suite/a-training/modules/training-student/src/entity/student.tsx +0 -3
- package/vona/src/suite/a-training/modules/training-student/src/entity/studentContent.tsx +36 -0
- package/vona/src/suite/a-training/modules/training-student/src/model/student.ts +6 -0
- package/vona/src/suite/a-training/modules/training-student/src/model/studentContent.ts +22 -0
- package/vona/src/suite/a-training/modules/training-student/src/service/student.ts +63 -6
- package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +117 -7
- package/zova/packages-zova/zova/package.json +2 -2
- package/zova/pnpm-lock.yaml +4 -0
- package/zova/src/suite/a-training/modules/training-student/package.json +3 -1
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +40 -40
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +358 -337
- package/zova/src/suite/a-training/modules/training-student/src/bean/tableCell.actionSummary.tsx +18 -7
- package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/formLayout.ts +13 -5
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
package/.cabloy-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.1.
|
|
1
|
+
5.1.149
|
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -125,6 +125,10 @@ const fullstackGroups = [
|
|
|
125
125
|
text: 'Frontend Metadata Back to Backend',
|
|
126
126
|
link: '/fullstack/frontend-metadata-to-backend',
|
|
127
127
|
},
|
|
128
|
+
{
|
|
129
|
+
text: 'One-to-One Companion Resource',
|
|
130
|
+
link: '/fullstack/one-to-one-companion-resource-guide',
|
|
131
|
+
},
|
|
128
132
|
{
|
|
129
133
|
text: 'Edition Collaboration Differences',
|
|
130
134
|
link: '/fullstack/edition-collaboration-differences',
|
|
@@ -7,6 +7,7 @@ Use this page together with:
|
|
|
7
7
|
- [Frontend Markdown Guide](/frontend/markdown-guide)
|
|
8
8
|
- [Validation Guide](/backend/validation-guide)
|
|
9
9
|
- [Serialization Guide](/backend/serialization-guide)
|
|
10
|
+
- [One-to-One Companion Resource Architecture](/fullstack/one-to-one-companion-resource-guide) when Markdown source and derived HTML belong to an aggregate-owned companion record.
|
|
10
11
|
|
|
11
12
|
## What `a-markdown` provides
|
|
12
13
|
|
|
@@ -239,6 +239,7 @@ Read this guide together with:
|
|
|
239
239
|
- [ORM Aggregate and Group Guide](/backend/orm-aggregate-group-guide)
|
|
240
240
|
- [Multi-Database and Datasource Guide](/backend/multi-database-datasource)
|
|
241
241
|
- [DTO Infer and Generation](/backend/dto-infer-generation)
|
|
242
|
+
- [One-to-One Companion Resource Architecture](/fullstack/one-to-one-companion-resource-guide) for aggregate-owned optional companion records, bounded projections, lifecycle ownership, and one-row-per-parent invariants.
|
|
242
243
|
|
|
243
244
|
## Implementation checks for model-relationship changes
|
|
244
245
|
|
|
@@ -225,6 +225,8 @@ Before rendering, `resolveFormLayout(...)` reconciles `formLayout` with the curr
|
|
|
225
225
|
|
|
226
226
|
Only schema properties with `rest.visible !== false` are eligible. When an eligible visible field is absent from `formLayout`, the resolver appends it as a root-level field after the declared nodes, in schema-property order.
|
|
227
227
|
|
|
228
|
+
A virtual relation field that uses `fieldSource` is represented at runtime by its nested source key. A declared relation name can therefore resolve to exactly one eligible source key with that name as its prefix; for example, `studentContentForm` resolves to `studentContentForm.descriptionMarkdown`. This preserves the DTO's business-level layout tree while rendering the actual nested form value. If more than one source key has that prefix, declare the exact source key instead.
|
|
229
|
+
|
|
228
230
|
If a field must not render, make it invisible in schema metadata. Leaving it out of `formLayout.children` is not enough.
|
|
229
231
|
|
|
230
232
|
### Invalid declarations and diagnostics
|
|
@@ -260,7 +262,7 @@ Current behavior boundaries:
|
|
|
260
262
|
|
|
261
263
|
## Complete entry-form example
|
|
262
264
|
|
|
263
|
-
The Student create DTO is the canonical complete example. It uses optional structural IDs, two tabs,
|
|
265
|
+
The Student create DTO is the canonical complete example. It uses optional structural IDs, two tabs, separate semantic groups for profile and companion content, a responsive profile section, and a nested-details field:
|
|
264
266
|
|
|
265
267
|
```tsx
|
|
266
268
|
ZovaRender.block('basic-pageentry:blockForm', {
|
|
@@ -290,6 +292,16 @@ ZovaRender.block('basic-pageentry:blockForm', {
|
|
|
290
292
|
},
|
|
291
293
|
],
|
|
292
294
|
},
|
|
295
|
+
{
|
|
296
|
+
type: 'group',
|
|
297
|
+
title: $locale('StudentContent'),
|
|
298
|
+
children: [
|
|
299
|
+
{
|
|
300
|
+
type: 'section',
|
|
301
|
+
children: [{ type: 'field', name: 'studentContentForm' }],
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
},
|
|
293
305
|
],
|
|
294
306
|
},
|
|
295
307
|
{
|
|
@@ -312,6 +324,8 @@ ZovaRender.block('basic-pageentry:blockForm', {
|
|
|
312
324
|
});
|
|
313
325
|
```
|
|
314
326
|
|
|
327
|
+
`studentContentForm` is one virtual nested-relation field in the structural tree. Its Markdown renderer owns the nested source-field UI, while the separate `StudentContent` group expresses that it is a distinct content area rather than part of the responsive profile Grid.
|
|
328
|
+
|
|
315
329
|
`trainingRecords` is one field in the structural tree. Its `basic-details:formFieldDetails` renderer owns the nested details UI; Form Layout does not recursively arrange the properties inside each detail record.
|
|
316
330
|
|
|
317
331
|
Student update and view DTOs use the same structural shape. The normal form scene controls readonly behavior, while the page-entry toolbar decides whether Submit, Back, or other actions are available.
|
|
@@ -17,6 +17,7 @@ Use this page together with:
|
|
|
17
17
|
- [SSR ClientOnly](/frontend/ssr-client-only)
|
|
18
18
|
- [Backend Markdown Guide](/backend/markdown-guide)
|
|
19
19
|
- [Image Guide](/frontend/image-guide)
|
|
20
|
+
- [One-to-One Companion Resource Architecture](/fullstack/one-to-one-companion-resource-guide) for the aggregate, projection, contract-loop, and edition-aware boundaries around Markdown companion content.
|
|
20
21
|
|
|
21
22
|
> [!TIP]
|
|
22
23
|
> **Choose the component by data contract**
|
|
@@ -66,3 +66,4 @@ That is exactly the kind of mistake good fullstack docs should prevent.
|
|
|
66
66
|
- If you want the shared bidirectional model first, continue with [Contract Loop Playbook](/fullstack/contract-loop-playbook).
|
|
67
67
|
- If your current task is the forward contract bridge, continue with [Backend OpenAPI to Frontend SDK](/fullstack/openapi-to-sdk).
|
|
68
68
|
- If your current task is the reverse metadata/resource bridge, continue with [Frontend Metadata Back to Backend](/fullstack/frontend-metadata-to-backend).
|
|
69
|
+
- If a parent owns an optional one-to-one content or extension record, continue with [One-to-One Companion Resource Architecture](/fullstack/one-to-one-companion-resource-guide).
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# One-to-One Companion Resource Architecture
|
|
2
|
+
|
|
3
|
+
<Badge type="tip" text="Common" />
|
|
4
|
+
|
|
5
|
+
This guide defines a reusable fullstack pattern for a resource that owns an optional, one-to-one companion record. It applies to both Cabloy Basic and Cabloy Start.
|
|
6
|
+
|
|
7
|
+
The pattern is useful for rich content, localized content, large or rarely used fields, derived projections, and data whose read or authorization boundary differs from the parent resource. It is not a requirement to split every optional field into another table.
|
|
8
|
+
|
|
9
|
+
## The short version
|
|
10
|
+
|
|
11
|
+
A companion resource should have these properties:
|
|
12
|
+
|
|
13
|
+
- the parent remains the aggregate owner;
|
|
14
|
+
- the companion stores the parent identity and is normally not an independent public CRUD aggregate;
|
|
15
|
+
- the parent model exposes a static `hasOne` relation;
|
|
16
|
+
- the companion model exposes the inverse `belongsTo` relation;
|
|
17
|
+
- read and edit consumers use deliberately bounded projections;
|
|
18
|
+
- source values and derived values have different authorities;
|
|
19
|
+
- the parent service owns creation, update, blank-value behavior, and deletion;
|
|
20
|
+
- the one-row-per-parent invariant is explicitly designed and tested;
|
|
21
|
+
- relation, DTO, and renderer changes follow the contract loop.
|
|
22
|
+
|
|
23
|
+
A typical shape is:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
Parent
|
|
27
|
+
└── ParentContent
|
|
28
|
+
├── parentId
|
|
29
|
+
├── sourceMarkdown
|
|
30
|
+
└── renderedHtml
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The names are illustrative. A companion does not have to contain Markdown; the same ownership and projection rules apply to settings, localized values, profile extensions, or other optional subresources.
|
|
34
|
+
|
|
35
|
+
## When to use a companion resource
|
|
36
|
+
|
|
37
|
+
Keep a field on the parent when it is short, always needed, has the same lifecycle and authorization as the parent, and does not require a separate read projection.
|
|
38
|
+
|
|
39
|
+
Consider a companion when one or more of the following is true:
|
|
40
|
+
|
|
41
|
+
- the data is optional or relatively large;
|
|
42
|
+
- most list queries do not need it;
|
|
43
|
+
- the data has a different public or authorization boundary;
|
|
44
|
+
- a server-derived projection is stored alongside an editable source;
|
|
45
|
+
- the data is likely to evolve independently from the parent’s ordinary fields;
|
|
46
|
+
- the data is naturally a separate schema or rendering concern.
|
|
47
|
+
|
|
48
|
+
Do not extract a companion merely to create a second CRUD endpoint. If the parent owns the data, a separate endpoint can accidentally create a second aggregate with unclear authorization, caching, and deletion semantics.
|
|
49
|
+
|
|
50
|
+
## Common-first and edition-aware design
|
|
51
|
+
|
|
52
|
+
The parent/companion architecture is shared by Basic and Start. Edition differences belong at the presentation and operational edges, not in the domain model.
|
|
53
|
+
|
|
54
|
+
Before copying an example, detect the active edition using the repository marker:
|
|
55
|
+
|
|
56
|
+
| Edition | Marker | UI baseline |
|
|
57
|
+
| ------------ | ------------------ | ---------------------- |
|
|
58
|
+
| Cabloy Basic | `__CABLOY_BASIC__` | DaisyUI + Tailwind CSS |
|
|
59
|
+
| Cabloy Start | `__CABLOY_START__` | Vuetify |
|
|
60
|
+
|
|
61
|
+
Explain the shared model once, then substitute edition-specific renderer IDs, package names, flavors, and generated paths. Do not silently apply Basic UI assumptions to Start.
|
|
62
|
+
|
|
63
|
+
For broader edition guidance, see [Editions Overview](/editions/overview) and [Edition Differences in Fullstack Collaboration](/fullstack/edition-collaboration-differences).
|
|
64
|
+
|
|
65
|
+
## Aggregate ownership
|
|
66
|
+
|
|
67
|
+
A companion table can have its own entity and model while remaining owned by the parent aggregate.
|
|
68
|
+
|
|
69
|
+
The parent service should normally be the authority for:
|
|
70
|
+
|
|
71
|
+
- creating the companion when parent creation includes companion data;
|
|
72
|
+
- updating or upserting companion data;
|
|
73
|
+
- deciding whether blank content means “no companion”;
|
|
74
|
+
- deleting or soft-deleting the companion when the parent is deleted;
|
|
75
|
+
- force-deleting the companion when the parent is force-deleted;
|
|
76
|
+
- repairing historical orphan rows through an explicit maintenance operation.
|
|
77
|
+
|
|
78
|
+
The companion may expose a `belongsTo` relation for navigation and query composition. That relation does not, by itself, make the companion an independently writable business resource.
|
|
79
|
+
|
|
80
|
+
If the domain genuinely requires independent companion lifecycle, document that as a separate aggregate decision. Define its authorization, API, cache ownership, deletion behavior, and consistency boundary explicitly rather than inferring them from the ORM relation.
|
|
81
|
+
|
|
82
|
+
## Persistence shape and invariants
|
|
83
|
+
|
|
84
|
+
A companion entity commonly contains the normal Vona basic fields plus a parent identity:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
export class EntityParentContent extends EntityBase {
|
|
88
|
+
@Api.field(v.tableIdentity(), ZovaRender.visible(false))
|
|
89
|
+
parentId: TableIdentity;
|
|
90
|
+
|
|
91
|
+
sourceMarkdown?: string;
|
|
92
|
+
renderedHtml?: string;
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The actual field decorators, validation, comments, and renderer metadata belong to the business module.
|
|
97
|
+
|
|
98
|
+
### Parent identity and indexing
|
|
99
|
+
|
|
100
|
+
Use the child’s `parentId` as the lookup key and add an ordinary index for the access path used by `hasOne` and service operations. In a shared-database multitenant deployment:
|
|
101
|
+
|
|
102
|
+
- use normal Vona model access so instance scoping remains active;
|
|
103
|
+
- distinguish the physical parent identity from the instance/tenant boundary;
|
|
104
|
+
- do not use a global database unique constraint casually to express tenant-scoped business uniqueness;
|
|
105
|
+
- keep lookup indexing separate from uniqueness enforcement.
|
|
106
|
+
|
|
107
|
+
### `hasOne` is not a physical uniqueness constraint
|
|
108
|
+
|
|
109
|
+
`hasOne` tells the ORM how to read a relation. It does not prevent the database from containing two child rows with the same `parentId`.
|
|
110
|
+
|
|
111
|
+
The one-row-per-parent invariant therefore needs an explicit strategy. Choose according to the consistency requirements:
|
|
112
|
+
|
|
113
|
+
- serialize writes through a transaction and a suitable row or application lock;
|
|
114
|
+
- use a database uniqueness constraint only when its tenant scope, dialect behavior, migration path, and error handling are intentionally designed;
|
|
115
|
+
- use an application-level uniqueness check only when the deployment and concurrency model make that guarantee sufficient;
|
|
116
|
+
- if the design intentionally accepts best-effort semantics, state that duplicate rows are possible and define a repair strategy.
|
|
117
|
+
|
|
118
|
+
A normal `get({ parentId })` followed by `insert(...)` is not enough under concurrent first writes: two callers can both observe no row. This race must be handled by the selected invariant strategy and covered by a test.
|
|
119
|
+
|
|
120
|
+
## Relation metadata
|
|
121
|
+
|
|
122
|
+
Declare stable business relations in model metadata. The parent commonly has two projections when consumers need different fields:
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
@Model({
|
|
126
|
+
entity: EntityParent,
|
|
127
|
+
relations: {
|
|
128
|
+
parentContent: $relation.hasOne('example:parentContent', 'parentId', {
|
|
129
|
+
columns: ['id', 'parentId', 'sourceMarkdown', 'renderedHtml'],
|
|
130
|
+
}),
|
|
131
|
+
parentContentForm: $relation.hasOne('example:parentContent', 'parentId', {
|
|
132
|
+
columns: ['id', 'parentId', 'sourceMarkdown'],
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
class ModelParent {}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The companion model declares the inverse relation:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
@Model({
|
|
143
|
+
entity: EntityParentContent,
|
|
144
|
+
relations: {
|
|
145
|
+
parent: $relation.belongsTo('example:parentContent', 'example:parent', 'parentId'),
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
class ModelParentContent {}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The names `parentContent` and `parentContentForm` are conventions, not framework keywords. Use names that make the projection and authority clear.
|
|
152
|
+
|
|
153
|
+
### Why separate projections matter
|
|
154
|
+
|
|
155
|
+
A full/read projection can include both source and derived values. An edit projection should normally include only the editable source. Positive `columns` selections make that boundary visible and reduce accidental contract widening.
|
|
156
|
+
|
|
157
|
+
A projection can be used for:
|
|
158
|
+
|
|
159
|
+
- an Admin form that edits `sourceMarkdown`;
|
|
160
|
+
- a public detail response that exposes only `renderedHtml`;
|
|
161
|
+
- an internal read that needs both values;
|
|
162
|
+
- a summary that deliberately returns a small compatibility facade.
|
|
163
|
+
|
|
164
|
+
Use `include` for declared static relations. Use `with` for relations that are genuinely dynamic or situational. Read [Relations Guide](/backend/relations-guide), [ORM Select Guide](/backend/orm-select-guide), and [ORM Mutation Guide](/backend/orm-mutation-guide) for the underlying ORM behavior.
|
|
165
|
+
|
|
166
|
+
Relation metadata changes can affect inferred DTOs, OpenAPI, generated metadata, and frontend consumers. Regenerate those artifacts from source after changing a relation.
|
|
167
|
+
|
|
168
|
+
## Markdown source and derived HTML
|
|
169
|
+
|
|
170
|
+
Markdown is a common example of a source/projection pair:
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
Admin Markdown field
|
|
174
|
+
→ nested source value in the DTO
|
|
175
|
+
→ parent service normalizes the source
|
|
176
|
+
→ backend Markdown bean renders and sanitizes HTML
|
|
177
|
+
→ source + derived HTML are persisted
|
|
178
|
+
→ Admin receives source
|
|
179
|
+
→ Web/public detail receives derived HTML
|
|
180
|
+
→ trusted HTML component displays the projection
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The authority boundary is strict:
|
|
184
|
+
|
|
185
|
+
- Markdown is the editable source of truth.
|
|
186
|
+
- HTML is a server-derived presentation projection.
|
|
187
|
+
- Every authoritative write path must derive HTML again.
|
|
188
|
+
- Client-submitted HTML must be ignored or overwritten.
|
|
189
|
+
- The trusted HTML display component is not a sanitizer.
|
|
190
|
+
- Public contracts should not expose editable Markdown unless that is intentional and authorized.
|
|
191
|
+
|
|
192
|
+
A service operation is conceptually:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
const markdown = sourceMarkdown?.trim();
|
|
196
|
+
|
|
197
|
+
if (!markdown) {
|
|
198
|
+
// The business policy decides whether to delete the companion row.
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const renderedHtml = this.bean.markdown.renderHtml(markdown);
|
|
203
|
+
await saveCompanion({
|
|
204
|
+
parentId,
|
|
205
|
+
sourceMarkdown: markdown,
|
|
206
|
+
renderedHtml,
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The backend Markdown module owns rendering and sanitization, not business persistence. See [Backend Markdown Guide](/backend/markdown-guide) and [Frontend Markdown Guide](/frontend/markdown-guide).
|
|
211
|
+
|
|
212
|
+
### Blank-content policy
|
|
213
|
+
|
|
214
|
+
`renderHtml('')` returning an empty string does not automatically mean that a database row should be deleted. The business module must choose and document its policy. Common choices are:
|
|
215
|
+
|
|
216
|
+
- delete the optional companion row;
|
|
217
|
+
- retain a row as an explicit empty state;
|
|
218
|
+
- reject blank input;
|
|
219
|
+
- retain the source while omitting a derived projection.
|
|
220
|
+
|
|
221
|
+
If blank means “absent,” normalize first, delete an existing row, and leave no new empty row behind. Test both the existing-row and no-existing-row cases.
|
|
222
|
+
|
|
223
|
+
## Parent service lifecycle
|
|
224
|
+
|
|
225
|
+
The service should separate parent fields from nested companion input before passing data to the parent model. A typical flow is:
|
|
226
|
+
|
|
227
|
+
### Create
|
|
228
|
+
|
|
229
|
+
1. Validate the parent and nested source contract.
|
|
230
|
+
2. Insert the parent.
|
|
231
|
+
3. If companion input is supplied, normalize and persist the companion.
|
|
232
|
+
4. Return the response projection expected by the action.
|
|
233
|
+
|
|
234
|
+
Whether steps 2 and 3 are atomic is a deliberate consistency choice described below.
|
|
235
|
+
|
|
236
|
+
### Update
|
|
237
|
+
|
|
238
|
+
1. Validate the parent and nested source contract.
|
|
239
|
+
2. Update parent fields.
|
|
240
|
+
3. If companion input is supplied, normalize it.
|
|
241
|
+
4. Delete the companion for the selected blank policy, or update/insert source and derived values.
|
|
242
|
+
5. Return a bounded response rather than an accidental full model object.
|
|
243
|
+
|
|
244
|
+
An update that does not include companion input should not silently erase existing companion content unless the API explicitly defines replacement semantics.
|
|
245
|
+
|
|
246
|
+
### Delete
|
|
247
|
+
|
|
248
|
+
Delete or soft-delete the companion before deleting the parent, unless a deliberately configured database cascade owns the same invariant. Force-delete must use the matching force-delete behavior for the child. Child-before-parent cleanup makes ownership explicit and prevents orphans when the ORM does not cascade the operation.
|
|
249
|
+
|
|
250
|
+
If historical orphans exist, clean them through a deliberate, auditable maintenance path. Do not assume that future parent deletion will repair rows whose parent is already gone.
|
|
251
|
+
|
|
252
|
+
## Consistency modes and concurrency
|
|
253
|
+
|
|
254
|
+
There are two valid implementation modes. The module must choose one rather than accidentally combining their guarantees.
|
|
255
|
+
|
|
256
|
+
### Simple non-transactional mode
|
|
257
|
+
|
|
258
|
+
Use this only when the domain accepts partial-failure semantics and low contention. The service may insert the parent and then persist the companion without a transaction. Document that a failure between those operations can leave a parent without its intended companion, and that concurrent first writes may require a repair strategy.
|
|
259
|
+
|
|
260
|
+
This mode is appropriate for a deliberately simple resource when those limitations are acceptable. It must not be described as atomic or duplicate-proof.
|
|
261
|
+
|
|
262
|
+
### Transactional and contention-safe mode
|
|
263
|
+
|
|
264
|
+
Use a transaction when parent and companion must commit or roll back together. `@Core.transaction()` uses `REQUIRED` by default: it starts a transaction when none exists and joins the current datasource transaction otherwise.
|
|
265
|
+
|
|
266
|
+
When competing writes can target the same companion:
|
|
267
|
+
|
|
268
|
+
- select an explicit serialization point, usually the parent row or existing companion row;
|
|
269
|
+
- call `getForUpdate()` or `getByIdForUpdate()` only inside an active transaction;
|
|
270
|
+
- handle the first-insert race when no companion row exists;
|
|
271
|
+
- use `@Core.retryable(...)` only with an explicit transient-error allowlist;
|
|
272
|
+
- place retry around the transaction boundary when the complete operation must be replayed;
|
|
273
|
+
- ensure the operation is replay-safe;
|
|
274
|
+
- never retry an external side effect as if it were a database mutation.
|
|
275
|
+
|
|
276
|
+
The essential race is:
|
|
277
|
+
|
|
278
|
+
```text
|
|
279
|
+
request A: no companion found ─┐
|
|
280
|
+
├─ both try to insert
|
|
281
|
+
request B: no companion found ─┘
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
A row lock on an existing child cannot protect a row that does not yet exist. Use parent serialization, a deliberate uniqueness/error-retry strategy, or another documented mechanism for the first insert. Add a test that begins with no companion row and proves the chosen invariant.
|
|
285
|
+
|
|
286
|
+
See [Transaction Guide](/backend/transaction-guide), [Field Indexes](/backend/field-indexes), and [Unit Testing](/backend/unit-testing).
|
|
287
|
+
|
|
288
|
+
## Cache coherence
|
|
289
|
+
|
|
290
|
+
If a parent query can include companion data, a companion mutation can make a warmed parent query stale. Configure the companion model’s cache dependency so child writes clear the affected parent model’s query cache when appropriate.
|
|
291
|
+
|
|
292
|
+
The dependency should be directional:
|
|
293
|
+
|
|
294
|
+
```text
|
|
295
|
+
companion mutation → parent query cache invalidation
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Keep the cache graph acyclic and avoid duplicate or reverse edges. Cache invalidation is not a lock, a uniqueness constraint, or a transaction. Continue to use normal Model mutation APIs so the framework’s cache behavior remains active. Read [Cache Guide](/backend/cache-guide) for general cache rules.
|
|
299
|
+
|
|
300
|
+
## DTO and contract design
|
|
301
|
+
|
|
302
|
+
The persistence shape does not dictate one universal API shape. Define projections by consumer authority:
|
|
303
|
+
|
|
304
|
+
| Consumer | Typical contract |
|
|
305
|
+
| ------------------- | ------------------------------------------------------------------ |
|
|
306
|
+
| Admin create/update | Nested editable source, such as `parentContentForm.sourceMarkdown` |
|
|
307
|
+
| Admin view | Nested source projection suitable for editing or review |
|
|
308
|
+
| Public detail | Derived HTML only, when publication is authorized |
|
|
309
|
+
| List/select | Usually no large companion content |
|
|
310
|
+
| Internal service | The smallest projection needed for the operation |
|
|
311
|
+
|
|
312
|
+
For schema-driven forms, the parent DTO can expose a nested relation and map the field to its source value:
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
const contentField = $makeMetadata(
|
|
316
|
+
ZovaRender.fieldSource('parentContentForm.sourceMarkdown'),
|
|
317
|
+
ZovaRender.field('<edition>-markdown:formFieldMarkdown'),
|
|
318
|
+
);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The exact renderer is edition-specific; the nested source contract is not. Use the DTO relation option type, `include`, and `dtoClass` according to the inferred DTO pattern. Do not add derived HTML to a mutation DTO merely because it exists in the table.
|
|
322
|
+
|
|
323
|
+
A DTO or OpenAPI declaration is not a substitute for runtime response review. If a service returns an already-built object with extra properties, narrowing the declaration alone may not remove those properties from JSON. Verify the actual action response as well as emitted schema metadata.
|
|
324
|
+
|
|
325
|
+
## Frontend and SSR boundaries
|
|
326
|
+
|
|
327
|
+
### Admin editing
|
|
328
|
+
|
|
329
|
+
The Admin form edits the source value through the active edition’s Markdown field renderer. It should submit Markdown, not browser-generated HTML. The browser-dependent editor and toolbar follow the active edition’s established client-only boundary.
|
|
330
|
+
|
|
331
|
+
### Web/public display
|
|
332
|
+
|
|
333
|
+
The Web page should receive the server-derived HTML from an intentionally public DTO and pass it to the active edition’s trusted Markdown HTML component. That component displays HTML; it does not replace backend sanitization.
|
|
334
|
+
|
|
335
|
+
### SSR
|
|
336
|
+
|
|
337
|
+
The editor is browser-dependent and normally renders a neutral SSR placeholder until mounted. The read-only HTML display path can be SSR-rendered because it consumes already-generated HTML. Keep server HTML and the hydration-time initial render equivalent, and defer browser-only editor state until the established mount boundary.
|
|
338
|
+
|
|
339
|
+
Do not infer the final browser theme or other browser-only state from an unauthenticated Web SSR request merely because the page contains Markdown content.
|
|
340
|
+
|
|
341
|
+
## Basic and Start substitutions
|
|
342
|
+
|
|
343
|
+
The architecture and backend Markdown bean are shared concepts. Verify concrete names in the active repository before copying commands or imports.
|
|
344
|
+
|
|
345
|
+
| Concern | Cabloy Basic | Cabloy Start |
|
|
346
|
+
| ------------------------- | ---------------------------------- | ---------------------------------- |
|
|
347
|
+
| Edition marker | `__CABLOY_BASIC__` | `__CABLOY_START__` |
|
|
348
|
+
| Form renderer | `basic-markdown:formFieldMarkdown` | `start-markdown:formFieldMarkdown` |
|
|
349
|
+
| HTML display renderer | `basic-markdown:markdownHtml` | `start-markdown:markdownHtml` |
|
|
350
|
+
| Frontend Markdown package | `zova-module-basic-markdown` | `zova-module-start-markdown` |
|
|
351
|
+
| UI baseline | DaisyUI + Tailwind CSS | Vuetify |
|
|
352
|
+
| Admin flavor family | `cabloyBasicAdmin` | `cabloyStartAdmin` |
|
|
353
|
+
| Web flavor family | `cabloyBasicWeb` | `cabloyStartWeb` |
|
|
354
|
+
|
|
355
|
+
The root command family may look similar, but the resolved flavor, module availability, generated output directory, and SSR baseline can differ. Detect the edition and inspect its `package.json`, CLI entrypoint, and build configuration before giving an operational command.
|
|
356
|
+
|
|
357
|
+
## Contract-loop implications
|
|
358
|
+
|
|
359
|
+
A companion relation is a fullstack contract change when it changes DTO shape, exposed fields, renderer metadata, or public projections.
|
|
360
|
+
|
|
361
|
+
Use the forward chain:
|
|
362
|
+
|
|
363
|
+
```text
|
|
364
|
+
Vona entity/model/DTO/controller source
|
|
365
|
+
→ backend metadata and OpenAPI
|
|
366
|
+
→ generated Zova SDK/schema/REST consumers
|
|
367
|
+
→ Admin/Web models and pages
|
|
368
|
+
→ SSR and REST verification
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The normal workflow is:
|
|
372
|
+
|
|
373
|
+
1. change backend contract truth first;
|
|
374
|
+
2. generate and inspect backend metadata/OpenAPI;
|
|
375
|
+
3. regenerate frontend consumers and schema metadata;
|
|
376
|
+
4. build the affected Admin or Web flavor as required;
|
|
377
|
+
5. synchronize consumers through the repository’s dependency workflow;
|
|
378
|
+
6. verify runtime responses and generated contracts;
|
|
379
|
+
7. never hand-edit generated metadata, SDK, API schema, REST, or installed dependency copies.
|
|
380
|
+
|
|
381
|
+
If the change starts in a frontend-owned renderer or metadata surface that backend consumers import, follow the reverse chain: run the relevant full flavor build first, then synchronize backend consumers. For the complete workflow, see [Contract Loop Playbook](/fullstack/contract-loop-playbook), [Backend OpenAPI to Frontend SDK](/fullstack/openapi-to-sdk), and [Frontend Metadata Back to Backend](/fullstack/frontend-metadata-to-backend).
|
|
382
|
+
|
|
383
|
+
## Testing checklist
|
|
384
|
+
|
|
385
|
+
A companion implementation should test the chosen semantics, not just that a relation can be loaded.
|
|
386
|
+
|
|
387
|
+
### Persistence and lifecycle
|
|
388
|
+
|
|
389
|
+
- [ ] A nonblank source creates exactly one logical companion.
|
|
390
|
+
- [ ] Updating source updates both source and derived projection.
|
|
391
|
+
- [ ] Updating an existing companion does not create a second row.
|
|
392
|
+
- [ ] Updating with blank or whitespace-only source follows the documented blank policy.
|
|
393
|
+
- [ ] Client-supplied derived HTML is ignored or overwritten.
|
|
394
|
+
- [ ] Parent deletion removes or soft-deletes the companion as documented.
|
|
395
|
+
- [ ] Force deletion applies the matching child behavior.
|
|
396
|
+
- [ ] Historical orphan handling has an explicit maintenance path if needed.
|
|
397
|
+
|
|
398
|
+
### Invariants and concurrency
|
|
399
|
+
|
|
400
|
+
- [ ] The one-row-per-parent strategy is documented.
|
|
401
|
+
- [ ] Concurrent updates leave a valid source/derived pair.
|
|
402
|
+
- [ ] Concurrent first insertion cannot silently violate the selected invariant, or the accepted limitation and repair path are tested.
|
|
403
|
+
- [ ] Transaction and retry boundaries are tested when those features are used.
|
|
404
|
+
|
|
405
|
+
### Contracts and consumers
|
|
406
|
+
|
|
407
|
+
- [ ] `hasOne` and inverse `belongsTo` metadata are emitted correctly.
|
|
408
|
+
- [ ] Admin forms expose the nested source field and the correct edition renderer.
|
|
409
|
+
- [ ] Mutation contracts do not accept derived HTML as an authority.
|
|
410
|
+
- [ ] Public responses expose only the intended projection.
|
|
411
|
+
- [ ] List/select responses do not load large companion content accidentally.
|
|
412
|
+
- [ ] Runtime JSON matches the DTO/OpenAPI declaration.
|
|
413
|
+
- [ ] Companion writes invalidate warmed parent queries when required.
|
|
414
|
+
- [ ] SSR output and hydration behavior are verified for applicable Web pages.
|
|
415
|
+
|
|
416
|
+
### Test data ownership
|
|
417
|
+
|
|
418
|
+
Persisted tests should own the identities they create, clean them in `finally`, and delete children before parents. Shared durable fixtures should come from the owning module’s seed path and be treated as read-only.
|
|
419
|
+
|
|
420
|
+
## Reference implementations and related reading
|
|
421
|
+
|
|
422
|
+
The most complete repository specimen is Commerce Catalog’s `Product` / `ProductContent` flow. In the active repository, inspect the corresponding paths:
|
|
423
|
+
|
|
424
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/product.tsx`
|
|
425
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/productContent.tsx`
|
|
426
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/src/model/product.ts`
|
|
427
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/src/model/productContent.ts`
|
|
428
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/src/service/product.ts`
|
|
429
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productCreate.tsx`
|
|
430
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productUpdate.tsx`
|
|
431
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productView.tsx`
|
|
432
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productPublicDetail.tsx`
|
|
433
|
+
- `vona/src/suite/a-commerce/modules/commerce-catalog/test/productContent.test.ts`
|
|
434
|
+
|
|
435
|
+
The Commerce implementation demonstrates the transaction, row-locking, derived HTML, projection, deletion, and contention-safe variant. A simpler companion-resource example may intentionally omit transactions when its documented consistency requirements allow that choice.
|
|
436
|
+
|
|
437
|
+
Read this guide together with:
|
|
438
|
+
|
|
439
|
+
- [Relations Guide](/backend/relations-guide)
|
|
440
|
+
- [Backend Markdown Guide](/backend/markdown-guide)
|
|
441
|
+
- [Frontend Markdown Guide](/frontend/markdown-guide)
|
|
442
|
+
- [DTO Guide](/backend/dto-guide)
|
|
443
|
+
- [DTO Infer and Generation](/backend/dto-infer-generation)
|
|
444
|
+
- [Transaction Guide](/backend/transaction-guide)
|
|
445
|
+
- [Cache Guide](/backend/cache-guide)
|
|
446
|
+
- [Contract Loop Playbook](/fullstack/contract-loop-playbook)
|
|
447
|
+
- [Edition Differences in Fullstack Collaboration](/fullstack/edition-collaboration-differences)
|