Skip to content

docs: add an entity design guide - #366

Merged
zantvoort merged 3 commits into
mainfrom
docs/entity-design
Aug 4, 2026
Merged

docs: add an entity design guide#366
zantvoort merged 3 commits into
mainfrom
docs/entity-design

Conversation

@zantvoort

@zantvoort zantvoort commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Two commits: the entity design guide, and a build change that stops llms-full.txt from 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 a Ref, 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:

  1. Where the Decision Lives sets out the trade. Storm answers the loading question in the entity class rather than at each query, so a read's cost is a property of the model and can be checked without running anything. The cost is that it applies uniformly, including to reads that did not need the data.
  2. Start by Not Optimizing is the default: model foreign keys as direct types and take the joins.
  3. What Actually Costs You separates the three costs. The join is nearly free; width is the real bill; growth you did not ask for is the one worth designing against, because adding a foreign key to a leaf table silently widens every entity that reaches it.
  4. What a Ref Does Not Cost shows that paths continue through a reference, in predicates and in select templates alike, so cutting an edge is a question of cost rather than capability.
  5. Where to Draw the Line gives the reasoning for hand-written entities, and How the Generator Decides gives the exact rule generation applies.

The guidance for this decision previously lived in three pages and had drifted: relationships.md advised keeping graphs shallow and using Ref for optional relationships, refs.md advised using Ref for 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.md demonstrated grouping by a foreign key with select(City.class, SelectMode.PK). Verified against H2, that form emits:

SELECT o.id, COUNT(*) FROM pet por INNER JOIN owner o ON por.owner_id = o.id GROUP BY por.owner_id

Naming the foreign key path instead emits:

SELECT por.owner_id, COUNT(*) FROM pet por GROUP BY por.owner_id

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 Ref exists to avoid.

Kotlin import guidance

storm-repository-kotlin listed entity, repository and insert from st.orm.repository as individual imports but omitted select, while nine files teach the select<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 FirPlaceholderProjectionImpl rather than reporting an unresolved reference. Verified against a dependency-free reproducer: 2.0.21 crashes, 2.1.0 and later report Unresolved 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.txt is derived entirely from docs/, and every reference to it points at https://orm.st/llms-full.txt rather 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 prebuild step of the site build, so it covers local builds, forks and CI alike, and the file is ignored rather than committed. This matches how website/static/api/ (generated Javadoc) is already handled, and it lets the dedicated workflow step go. Verified by deleting the file and running npm run build: it is regenerated and reaches the build output.

llms.txt stays tracked, since it is a hand-maintained index rather than generated output.

Notes

  • Docs, skills and build configuration. No source changes.
  • docs/ only; no versioned snapshot is touched, so this shows at /docs/next and reaches the live docs at the next release.

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.
Copilot AI lite review requested due to automatic review settings August 4, 2026 13:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md and link it from the docs index/sidebar and llms*.txt docs bundles.
  • Update relationship/refs/entity skill guidance to default to direct FK types and reach for Ref primarily 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 (notably select<...>).

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:
Comment thread docs/faq.md Outdated
Comment on lines +644 to +648
`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.
Copilot AI review requested due to automatic review settings August 4, 2026 13:36
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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”) with cost(T) (which includes columns(T)). Clarifying that the closure is the join graph produced by inlining, and that the 96-column budget applies to cost(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 includes columns(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 to cost(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.

Copilot AI review requested due to automatic review settings August 4, 2026 13:44
@zantvoort
zantvoort merged commit 17c59d2 into main Aug 4, 2026
8 checks passed
@zantvoort
zantvoort deleted the docs/entity-design branch August 4, 2026 13:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 includes columns(T) without clarifying how the 96-column limit is applied. As written, it’s easy to misread the budget as applying to cost(T) directly rather than cost(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 to cost(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

  • prebuild now hard-requires bash on 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) so npm run build works anywhere Node does.
    "prebuild": "bash scripts/generate-llms-full.sh",

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants