docs: add an entity design guide - #366
Conversation
Covers the direct-type vs Ref decision in one place: the cost model, how to draw the line by hand, and the rule that schema-first generation applies. The guidance for this decision was spread across entities, relationships and refs, and had drifted into stating opposite defaults in different places. Also in this change: - The refs.md aggregation example names the foreign key path rather than selecting the referenced table's primary key. The old form joined that table, and on a nullable foreign key its INNER JOIN dropped the null group from the result. - The Kotlin skills import st.orm.repository with a wildcard. Enumerating the extensions individually is what left the reified select out. - The FAQ documents the Kotlin 2.0.x FirPlaceholderProjectionImpl crash, which reports an unresolved call as an internal compiler error and is fixed in Kotlin 2.1.0.
There was a problem hiding this comment.
Pull request overview
Adds a new “Entity Design” documentation page that centralizes Storm’s guidance for modeling foreign keys as direct entity types vs Ref<T>, and updates existing docs and AI skills to align with that guidance and link to the new page.
Changes:
- Add
docs/entity-design.mdand link it from the docs index/sidebar andllms*.txtdocs bundles. - Update relationship/refs/entity skill guidance to default to direct FK types and reach for
Refprimarily to control transitive graph growth (plus align the schema-to-entity generation rule write-up). - Adjust Kotlin skills import guidance to prefer
import st.orm.repository.*to avoid missing extension imports (notablyselect<...>).
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| website/static/skills/storm-repository-kotlin.md | Switches Kotlin repository skill import guidance to st.orm.repository.*. |
| website/static/skills/storm-query-kotlin.md | Adds st.orm.repository.* import guidance and Kotlin 2.0.x crash troubleshooting note. |
| website/static/skills/storm-entity-kotlin.md | Updates FK vs Ref guidance and generation posture in Kotlin entity skill. |
| website/static/skills/storm-entity-java.md | Updates FK vs Ref guidance and generation posture in Java entity skill. |
| website/static/skills/storm-entity-from-schema.md | Replaces “ask loading preference” with a deterministic FK modeling algorithm description. |
| website/static/llms.txt | Adds the new “Entity Design” link to the short LLM index. |
| website/static/llms-full.txt | Regenerates the full bundled docs output including the new page and related edits. |
| website/sidebars.ts | Adds entity-design to the docs sidebar ordering. |
| website/scripts/generate-llms-full.sh | Includes entity-design.md in the llms-full generation input list. |
| README.md | Adds “Entity Design” to the README docs table. |
| docs/relationships.md | Updates relationships guidance and links to Entity Design. |
| docs/refs.md | Updates aggregation example and adds links back to Entity Design guidance. |
| docs/index.md | Links the new Entity Design guide in the recommended reading order. |
| docs/faq.md | Adds FAQ entry about Kotlin 2.0.x crash and missing imports. |
| docs/entity-design.md | New guide page explaining the FK direct-type vs Ref decision and the generator’s rule. |
| docs/entities.md | Adds a short cross-link explaining transitive FK width and pointing to Entity Design. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| **Import both packages with wildcards: `st.orm.template.*` and `st.orm.repository.*`.** Repository operations are top-level extensions in `st.orm.repository`, so `import st.orm.template.*` alone does not bring them in. Naming them individually is how one ends up missing. | ||
|
|
||
| The custom-result form `select<Result, _, _> { ... }` is the one that punishes this hardest. Without its import the call falls back to the member `select(KClass, TemplateBuilder)`, which takes one type parameter instead of three, and on Kotlin 2.0.x the compiler crashes while reporting the mismatch rather than naming the missing import: |
| `import st.orm.template.*` does not bring them in, since they live in `st.orm.repository`. Import both packages with | ||
| wildcards rather than naming functions individually. Without it, a call such as | ||
| `select<UserSummary, _, _> { ... }` falls back to the member `select(KClass, TemplateBuilder)`, which takes one type | ||
| parameter rather than three, and nothing applies. Replacing the placeholders with explicit type arguments also turns | ||
| the crash back into a readable error on 2.0.x, which is a quick way to find the cause without upgrading. |
llms-full.txt is derived entirely from docs/, and every reference to it points at the served URL rather than the path in this repository. Keeping a copy under version control therefore buys nothing, and lets it fall behind: it had not been regenerated since 2026-07-29, so the next docs change to touch it carried several unrelated updates along with it. Generation now runs as the prebuild step of the site build, which covers local builds, forks and CI alike, and the file is ignored rather than tracked. This follows website/static/api/, the generated Javadoc, which is handled the same way. The dedicated workflow step is gone, since npm runs the script ahead of every build. llms.txt stays tracked: it is a hand-maintained index, not generated output.
The guidance said an unimported select<Result, _, _> call falls back to the member select(KClass, TemplateBuilder). That member takes two arguments and cannot be chosen by a call passing only a template lambda, so the description pointed at the wrong overload and would misdirect anyone diagnosing the crash. What actually happens is that no candidate applies: the extension is out of scope, and no select member matches a call passing a template lambda with three type arguments. That matches the diagnostic 2.1.0 produces for the same code, which reports an unresolved reference.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (2)
website/static/skills/storm-entity-from-schema.md:30
- This line defines the inline closure as a “set of tables”, but then defines
cost(T)/joins(T)in a way that counts per inlined edge (and can count the same table multiple times via different FK paths). It also doesn’t explicitly reconcile the 96-column budget (“excluding the table’s own columns”) withcost(T)(which includescolumns(T)). Clarifying that the closure is the join graph produced by inlining, and that the 96-column budget applies tocost(T) - columns(T), would avoid ambiguity in the deterministic rule.
The *inline closure* of a table is the set of tables reachable from it through direct-type FKs. With `columns(T)` as the table's own column count (counting inline record components, which live in the same table and cost no join), `cost(T) = columns(T) + Σ cost(X)` over every inlined edge from T, and `joins(T)` is the number of joins that closure produces.
docs/entity-design.md:207
- The definition of “inline closure” as a set of tables is inconsistent with the subsequent cost/join definitions (which effectively count tables/columns per join and can include the same table multiple times via different FK paths). It also isn’t explicit how the 96-column budget relates to
cost(T)(which includescolumns(T)), even though the budget text says it excludes the table’s own columns. Rewording this line to describe a join graph (counted per join) and to state that the 96-column budget applies tocost(T) - columns(T)would make the rule unambiguous.
A table's *inline closure* is the set of tables reachable from it through direct-type foreign keys. Define `columns(T)` as the table's own column count, counting [inline record](entities.md#embedded-components) components, which live in the same table and cost no join. Then `cost(T) = columns(T) + Σ cost(X)` across every edge from `T` that is inlined, and `joins(T)` is the number of joins that closure produces.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (3)
docs/entity-design.md:207
- The budget says the inline-closure column count excludes the root table’s own columns, but the
cost(T)definition includescolumns(T)without clarifying how the 96-column limit is applied. As written, it’s easy to misread the budget as applying tocost(T)directly rather thancost(T) - columns(T)(or an explicit closure-only metric).
A table's *inline closure* is the set of tables reachable from it through direct-type foreign keys. Define `columns(T)` as the table's own column count, counting [inline record](entities.md#embedded-components) components, which live in the same table and cost no join. Then `cost(T) = columns(T) + Σ cost(X)` across every edge from `T` that is inlined, and `joins(T)` is the number of joins that closure produces.
website/static/skills/storm-entity-from-schema.md:30
- This section says the 96-column budget excludes the table’s own columns, but then defines
cost(T) = columns(T) + Σ cost(X)without stating whether the budget applies tocost(T)or to the closure-only part (Σ cost(X)). That ambiguity makes it hard to apply the algorithm consistently.
Hold this invariant for EVERY table, treated as a potential root:
- its inline closure adds at most **10 joins**, and
- its inline closure adds at most **96 columns**, excluding the table's own columns.
The *inline closure* of a table is the set of tables reachable from it through direct-type FKs. With `columns(T)` as the table's own column count (counting inline record components, which live in the same table and cost no join), `cost(T) = columns(T) + Σ cost(X)` over every inlined edge from T, and `joins(T)` is the number of joins that closure produces.
website/package.json:8
prebuildnow hard-requiresbashon PATH. That’s fine on Linux CI, but it can break local builds on Windows (and some minimal environments) even though Docusaurus itself is cross-platform. Consider switching generation to a Node script (or otherwise making it portable) sonpm run buildworks anywhere Node does.
"prebuild": "bash scripts/generate-llms-full.sh",
Two commits: the entity design guide, and a build change that stops
llms-full.txtfrom drifting.Entity design guide
Adds
docs/entity-design.md, a single home for the decision every foreign key forces: model it as a direct type, which is joined on every read, or as aRef, which stores the key and leaves the join to the queries that ask for it.The page is deliberately ordered to argue against premature optimisation before it offers any rule:
The guidance for this decision previously lived in three pages and had drifted:
relationships.mdadvised keeping graphs shallow and usingReffor optional relationships,refs.mdadvised usingReffor optional relationships, and both entity skills said depth was the dimension to watch. Those now agree on one posture and link to the new page.Generation rule
The skill applies a deterministic rule instead of asking for a loading preference, so the same schema always yields the same entities. It is a guard rail rather than an optimisation pass, calibrated so ordinary schemas trip nothing and every foreign key comes out as a direct type.
For every table treated as a potential root, the inline closure adds at most 10 joins and 96 columns. Cycles are cut first, the graph is walked leaves-first, and each table's foreign keys are ranked (identifying keys, then non-null keys to foreign-key-free targets, then remaining non-null keys narrowest-first, then nullable keys) and inlined while the budget holds. Working bottom-up means a new foreign key on a leaf is cut at the leaf, leaving the entities above it unchanged. Existing entities that exceed the budget are reported, never rewritten.
Aggregation example fix
refs.mddemonstrated grouping by a foreign key withselect(City.class, SelectMode.PK). Verified against H2, that form emits:Naming the foreign key path instead emits:
It drops the join, and on a nullable foreign key it is also the correct form: the INNER JOIN above silently discards the null group. The section is about keeping aggregation reads lightweight, so the example was joining the very table the
Refexists to avoid.Kotlin import guidance
storm-repository-kotlinlistedentity,repositoryandinsertfromst.orm.repositoryas individual imports but omittedselect, while nine files teach theselect<Result, _, _>form that needs it. Both skills now import the package with a wildcard, which is what the working tests in this repo already do.Omitting that import crashes Kotlin 2.0.x with
Unexpected FirPlaceholderProjectionImplrather than reporting an unresolved reference. Verified against a dependency-free reproducer: 2.0.21 crashes, 2.1.0 and later reportUnresolved reference 'select'. The FAQ entry leads with the version fact and covers the other common trigger, a dependency compiled by Kotlin 2.2 or later, which a 2.0.x compiler cannot read and which surfaces as the same crash.llms-full.txt is no longer tracked
The second commit addresses something this change surfaced.
llms-full.txtis derived entirely fromdocs/, and every reference to it points athttps://orm.st/llms-full.txtrather than the repository path, so the tracked copy serves no consumer. It had not been regenerated since 2026-07-29, which is why a docs change of this size dragged several unrelated updates along with it.Generation now runs as the
prebuildstep of the site build, so it covers local builds, forks and CI alike, and the file is ignored rather than committed. This matches howwebsite/static/api/(generated Javadoc) is already handled, and it lets the dedicated workflow step go. Verified by deleting the file and runningnpm run build: it is regenerated and reaches the build output.llms.txtstays tracked, since it is a hand-maintained index rather than generated output.Notes
docs/only; no versioned snapshot is touched, so this shows at/docs/nextand reaches the live docs at the next release.