One-Directional Relations
The single most important Lesan lesson ZiWound demonstrates โ across all 13 models โ is that relations are defined one-directional and Lesan maintains the reverse side automatically. You never store the same reference twice by hand.
The rule, in ZiWound's own termsโ
Every relation in the codebase picks one direction:
// WHO owns the relation โ declared on the model
// WHO is referenced โ gets the back-reference via relatedRelations
ZiWound has zero cases of two models both declaring the "same" relation to each other. Wherever it looks bidirectional, it's one declaration plus a generated back-reference.
Pattern 1 โ the owned back-list (with limit)โ
The workhorse pattern. One model declares the relation; the other model's relatedRelations creates the reverse array.
// report.ts โ declares it
warCriminals: {
schemaName: "warCriminal",
type: "multiple" as RelationDataType,
excludes: war_criminal_excludes,
relatedRelations: { reports: { type: "multiple" as RelationDataType, limit: 100 } },
},
// warCriminal.ts โ receives it
reports: {
schemaName: "report",
type: "multiple" as RelationDataType,
excludes: report_excludes,
relatedRelations: { warCriminals: { type: "multiple" as RelationDataType, limit: 100 } },
},
Both arrays exist in the database (report.warCriminals and warCriminal.reports), but only one was written by hand. When a report adds/removes a war criminal, proccessUpdateOrDeleteRelations syncs the embedded back-reference. The limit keeps the reverse array bounded (newest first).
Pattern 2 โ the true one-way (no reverse)โ
Some relations genuinely don't need a reverse side. Set relatedRelations: {}:
avatar: {
schemaName: "file",
type: "single" as RelationDataType,
optional: true,
excludes: file_excludes,
relatedRelations: {}, // a file doesn't list "avatar-of users"
},
Used for: user.avatar, user.national_card, document.documentFiles, location photos, and confirmation (which has no relations at all). The rule of thumb: one-directional when the back-list would never be queried.
Pattern 3 โ same target, different roleโ
ZiWound links the same schema in multiple named relations, each generating its own distinct back-list:
// report.ts
hostileCountries: { schemaName: "country", ... relatedRelations: { hostileReports: {...} } },
attackedCountries: { schemaName: "country", ... relatedRelations: { attackedReports: {...} } },
// user.ts, via report relations
reporter: { schemaName: "user", ... relatedRelations: { reports: {...} } }, // submitted
registrar: { schemaName: "user", ... relatedRelations: { registeredReports: {...} } }, // moderated
Two relations to the same model can even point at the same document โ a country can be both hostile and attacked โ without a collision, because each relation is a separate embedded list.
Pattern 4 โ the Ladderโ
country โ province โ city each points up one level:
// city.ts
province: { schemaName: "province", ... relatedRelations: { cities: {...} } },
country: { schemaName: "country", ... relatedRelations: { cities: {...} } },
The same city can appear in both its country's cities and its province's cities lists โ both generated from a single declaration per level.
Why ZiWound did it this wayโ
- No duplication to keep in sync โ Lesan's write pipeline handles both directions in one operation.
- Bounded payloads โ every back-list has a
limit; deep projection stays cheap. - Consistent excludes โ
user_excludes/location_excludes/report_excludestrim the same heavy fields from every embed, so a report'sreportersnapshot never bloats a response.
Next: The Authentication Chain.