Skip to content

5.1 Two link shapes (same engine)

  • id-match (§3.1) — anchor is a real domain node; keyField and recordKeyField name a shared value (email/domain). The fetched record links to the real node whose property matches.
  • bridge (§3.2) — anchor is an external-identity node (GitHubIdentity, HubSpotOwner). Declared with a resolve: chain instead of a plain keyField.

What a keyField may name. Either a property the anchor type declares, or a field one of the anchor type’s OWN joins names as its recordKeyField. Anything else is a load problem reported against the realm declaring the join.

The second case is how a foreign key is traversed in REVERSE, and it is sound rather than lenient. A type may keep an FK out of its properties — a visible id is one a generator returns instead of following the link — while its own forward join already declares that its records carry that column. The reverse hop keyed on it traverses because materialization stores that column on the node whether or not the type declares it: the stored-property allowlist is the declared set PLUS the join’s recordKeyField, the producer’s projections and computed fields, and the echoed key. So the column is on the anchor by the time the reverse hop reads it, and refusing the shape reported a problem on joins that work.

The check applies where the anchor type is declared in the same world. A join whose anchor is a type this validation does not hold — a realm keying on another realm’s type — is not checked here, because the properties that would answer live in the other realm’s declaration.

A keyField naming a list-valued property yields one key per element, not one key for the whole list. A trial’s collaborators or interventions names several organizations or drugs, and each is looked up in its own right; blank and duplicate elements are dropped, so two anchors naming the same value fetch it once. A scalar property is unaffected — it yields exactly one key, as always.

The same holds for a composite producerKeyFields: a list-valued member expands to one composite per element, and a member with no value keeps its slot so the composite’s arity is fixed.

A type name may be declared by more than one realm, and the declarations merge: every declaration’s virtualJoins accumulate onto the one type, while its shape — properties, including the identity: true merge key — may live in any one of them. This is how a domain realm contributes its own edge into a type another realm owns: it re-declares the name with only virtualJoins, and those joins converge on the owning realm’s identity. Identical join declarations collapse, so a re-declared file never double-fetches; and the requirement that a type reachable by more than one join carry an identity property is judged over the merged type, not each declaration alone.

Identity-less rows. A virtual type may declare NO identity property — the natural shape for rows unique only as a combination (a per-(petition, constituency) signature count, a per-(place, month) tally). Such rows materialize under an engine-minted deterministic id: identical rows still merge, distinct rows never collide, re-fetching is idempotent. A type that DOES declare an identity and ships a record without it keeps the old behaviour — the record is skipped, because that is a data error, not a modelling choice.

Identity is per owner for a user-owned type. Source ids are global, so two users’ records of the same source can share an identity by construction. A fetched record of a type whose instances belong to one user converges only with THAT user’s node of the same identity — a user’s own stored node is still preferred over a second copy — and never binds, reads or re-owns another user’s. A shared, reference or public type converges on identity alone, so a fetched record still lands on the one stored node everyone shares.