Skip to the content.

Consuming a model

Section 7 of the architecture overview names four packages under this one heading: model-data, reflection-client, codegen, and authoring. They form a single pipeline — read a compiled model at runtime with no compiler in the bundle, generate a typed TypeScript surface over it at build time, and, when a project needs to write, stage new instances and serialise them back to .todl. This page walks the pipeline end to end, with real types and file paths from @pragmatic-tech-ai/todl v0.36.x.

The one thing to hold onto throughout: everything here reads the manifest and reflection layer described in section 6, never the compiler’s live Repository. That is what makes it safe to ship in a browser bundle — no parser, no loader, no validator, just a packed binary (or its JSON form) and a thin typed lens over it.

model-data: browser-safe access with no compiler dependency

src/model-data/ is the runtime data layer. Its center is ModelDataSource (src/model-data/model-data-source.ts), an abstract typed façade over a single package’s reflection graph. It is not a document parser — it wraps a FrozenGraph and a GraphQuery (from src/domain/ and src/graph-api/), both built from a Manifest the class loads via Manifest.load(resolved.manifest). The compiler proper never enters this path.

A source materialises in one of two ways:

Two connectors ship today, both trivial by design (the interesting work is elsewhere): BundledModelDataConnector (bundled-model-data-connector.ts) wraps a document that was embedded into the build artifact at compile time — no I/O, Prepare just resolves it — and DocumentModelDataConnector (document-model-data-connector.ts) wraps a document handed in at runtime (for example, one fetched over the network by the host application before construction). Both exist so ModelDataSource itself never has to know whether its data came from a bundle, a fetch, or a test fixture; a consumer that needs real I/O and authentication implements IModelDataConnector itself, resolving credentials from the IServiceProvider passed to Prepare.

ModelRegistry (model-registry.ts) is a small service-keyed lookup — ServiceKey<ModelRegistry> — that indexes many ModelDataSource instances by model id and designates one as the root (SetRoot/RootModel/Root). Its PrepareAll(services) is the startup fan-out: it awaits every registered source’s Prepare before any synchronous accessor is used, so a multi-model application (a meta-model shard plus several library shards) comes up as one coordinated unit rather than each model racing to load independently.

GenericModelDataSource (generic-model-data-source.ts) is the one concrete, non-generated subclass in the tree — a thin constructor over ModelDataSource(connector) with modelName set, and nothing else. It is what the built-in model browser reads (§11’s “legacy path”): it only needs ConceptNames() and Instances(concept), both already public on the base class, so it adds no typed accessors. Every other consumer works through a generated subclass instead (see codegen below), which is ModelDataSource directly subclassed rather than composed — codegen does not use GenericModelDataSource as a base.

A gotcha worth flagging: the TodlDocument type that flows through this whole layer is imported from compiler-services/emit/json.js, which can read as though model-data depends on the compiler. It only depends on the shape — the { nodes, edges } interchange format — not on any of the compiler’s parse/load/validate code. Nothing in src/model-data/ imports api.ts, the parser, or the loader; the manifest and FrozenGraph are all it touches at runtime.

reflection-client: the typed read lens and identity-mapped references

src/reflection-client/ReflectedEntity (reflected-entity.ts) is the runtime analog of the compiler-side EntityBase — a typed read lens over one reflected instance or taxonomy term. It exposes exactly three protected primitives that generated subclasses build typed getters on top of:

protected field(name: string): Scalar | undefined
protected ref(member: string): ReflectedEntity | undefined
protected refs(member: string): readonly ReflectedEntity[]

field reads a scalar value from an EntityReader. ref/refs resolve reference members by asking the reader for target ids and then asking an EntityHost — implemented by ModelDataSource — to turn each id back into a ReflectedEntity. That host is where identity mapping happens: ModelDataSource keeps a private entities: Map<string, ReflectedEntity> and its entityFor/entity methods always return the cached instance for a given id rather than constructing a fresh wrapper. The practical consequence: if two different entities both reference the same target, === holds between the two ref() results — you can safely use entities as map keys or compare them by reference, the way you would compiled objects in an ORM’s identity map.

Two EntityReader implementations feed ReflectedEntity. MirrorReader wraps an InstanceMirror from the reflection layer (src/manifest/reflection/) and is the ordinary case — one instance in the heap. Its targets(member) reads this.mirror.node.refs?.[member] directly rather than going through getRelationships(); the comment in the source explains why: both true relationships and concept-typed reference fields flatten into node.refs in the reflected node, and only some of them are declared via getRelationships(), so node.refs is the one source that is uniform across both origins. TermReader wraps a TermInfo — a taxonomy term — and answers field() by looking up the term’s fixed value for that field (term.getFixedValue(field)); targets() is stubbed to return nothing, since term ref-fixing is explicitly out of scope for this reader today.

ModelDataSource.instancesOf(concept) and .termsOf(taxonomy) are the two places these readers get constructed — instances wrap MirrorReader, terms wrap TermReader — and both route through the same entityFor cache, so a term and an instance never collide in identity even though they share the ReflectedEntity base.

codegen: turning a compiled model into deterministic typed TypeScript

src/codegen/read-client.ts’s generateReadClient(repo, options) is the generator. It reflects a resolved Repository — concepts, taxonomies, effectiveSchema — and emits one .ts source file containing:

Everything is sorted by id before emission, so the output is deterministic byte-for-byte across runs — useful for diffing generated code in review, and required for the build’s clobber-guard behaviour described in section 10. Here is a trimmed excerpt from the read-client test fixture (src/codegen/tests/fixtures/tech-catalog.generated.ts), showing the shape for a technology concept that has a scalar label, a many-valued reference availableIn, and an optional reference billing:

export class TechCatalog extends ModelDataSource {
  constructor(connector?: IModelDataConnector) {
    super(connector);
    this.modelName = "tech-catalog";
    this.modelVersion = "0.0.0";
  }

  static fromJSON(doc: TodlDocument): TechCatalog {
    const client = new TechCatalog();
    client.loadDocument(doc);
    return client;
  }

  protected override createEntity(reader: EntityReader): ReflectedEntity {
    switch (reader.concept) {
      case "billing": return new Billing(this, reader);
      case "location": return new Location(this, reader);
      case "technology": return new Technology(this, reader);
      default: return super.createEntity(reader);
    }
  }

  get technologies(): readonly Technology[] {
    return this.instancesOf("technology") as readonly Technology[];
  }
}

export class Technology extends ReflectedEntity {
  get label(): string { return this.field("label") as string; }
  get availableIn(): readonly Location[] { return this.refs("availableIn") as readonly Location[]; }
  get billing(): Billing | undefined { return this.ref("billing") as Billing | undefined; }
}

naming.ts supplies the identifier machinery: pascalCase/camelCase split an id on hyphens, underscores, and case boundaries (so both kebab-case and already-C-like identifiers normalise the same way), pluralize is a small deterministic heuristic (-y → -ies, s|x|z|ch|sh → +es, else +s), and allocateNames maps a list of ids through a transform and throws on any collision — generateReadClient calls it once up front across every concept name specifically to fail generation early rather than emit two classes with the same name.

Why the naming contract matters

The generated collection getter name is pluralize(camelCase(conceptId)) — for concept technology that is technologies; for a taxonomy, the collection getter uses the taxonomy’s represented concept (repo.represents(t)[0]) rather than the taxonomy id itself. This is called out in the architecture overview as load-bearing, because it is a naming heuristic, not a type, that any markup or view-model binding to those collections must match. DtoGenerator regenerates generated/model.ts on every reference change, so its collection getters always track the current concept set — but a src/app.mu or src/main.ts that binds $<collection> is scaffolded once (WritePolicy.WriteOnce) and then owned by the developer, never regenerated. Rename a concept later and the DTO’s accessor renames with it, while any hand-written $<old-name> binding keeps pointing at the old one and silently binds to nothing, since mural does not error on an unresolved binding path by default.

The scaffolded default UI sidesteps this deliberately: rather than bind a ListBox per concept, src/app.mu’s view-model exposes a ConceptSummary getter reading model.ConceptNames().length — a demonstration that reads the model through its reflection API without depending on any one concept’s collection name (see Project content generators). The naming contract becomes load-bearing the moment a developer writes real markup that binds $technologies and friends — which is exactly when they are editing src/, with the DTO’s current accessor names in front of them.

model-package.ts’s ModelPackageGenerator.Generate wraps generateReadClient into a larger, runnable single file: it reuses the generated DTO class verbatim (omitHeader: true), embeds each model’s document shard as a JSON.parse(...)-initialised constant, and emits an AppRegistry.Create(): ModelRegistry factory that registers one DTO instance per shard behind a BundledModelDataConnector and calls SetRoot on the application’s root model id. This is the multi-model analog of the single-model fromJSON path above, and it is what a compiled application package actually ships as its data-access surface.

authoring: the write path back to source

src/authoring/ModelDraft (model-draft.ts) is the mutable counterpart to the read-only surfaces above: a delta-based overlay over frozen bases. The source of truth is an own TodlDocument ({ nodes, edges }) that mutators edit directly; the combined working Repository (bases ∪ own) is derived on demand and cached, invalidated on every mutation. A draft opens over a set of compiled base repositories — ModelDraft.on(bases, { namespace }) for a blank draft, or ModelDraft.fromSource/fromSources to reopen an existing .todl model (single- or multi-file) as an editable overlay, recompiling it against the bases and stripping the base nodes and the synthesized model container back out so only the genuinely “own” instances remain in the overlay.

The write entry point is add(descriptor: InstanceDescriptor):

export interface InstanceDescriptor {
  concept: string;
  id: string;
  scalars?: ReadonlyMap<string, Scalar>;
  refs?: ReadonlyMap<string, readonly NodeId[]>;
}

add is fail-fast: before it stages anything, it walks every reference target in descriptor.refs and throws if any target id is not already known (base or own) — no dangling reference is ever staged, even transiently. Note the comment at emitAuthoringConstructor in read-client.ts: this is exactly the record codegen’s own per-concept authoring methods build and return. That earlier technology(id, fields) example above has a sibling on the generated TechCatalog class — a technology(id, fields): InstanceDescriptor method with the same shape, one required/optional parameter per scalar field and per reference member, readonly Technology[] for the many-valued reference and a bare Billing for the optional one. A caller typically writes draft.add(dto.technology("svc-1", { label: "...", billing: someBillingEntity })) — the codegen-emitted constructor and ModelDraft.add are two ends of the same contract, so a change to a concept’s schema updates both sides together the next time codegen runs.

One documented gap: many-valued scalar fields (for example an identifier[]) are not authorable today. Scalar and InstanceDescriptor.scalars are single-valued (Map<string, Scalar>), so emitAuthoringConstructor deliberately skips emitting a constructor parameter or assignment for any such field — there is nowhere to stage an array scalar without widening the core Scalar type, which is called out as a deferred decision rather than an oversight. Reading a many-valued scalar back (via the generated entity’s getter) is unaffected; only writing one through ModelDraft is not yet supported.

Once staged, toTodl() serialises the overlay delta back to .todl model source (emitModelTodl from compiler-services/emit/todl.ts, with bindings derived by deriveBindings), and toTodlByFile() does the same per home file for the multi-file case — each own node’s “home” is recorded either explicitly (the home parameter to create) or, for a draft reopened via fromSources, from the loader’s own provenance map, so a model spread across several .todl files round-trips node-for-node back to the file it came from rather than collapsing to one file. TodlFileStore (src/authoring/file-store.ts) is the thin save/load seam over an injected FileIO ({ read(): Promise<string>; write(content): Promise<void> }) — TODL owns only the interface; the concrete node:fs or Plexus IStorage adapter is a host concern, keeping the authoring package itself environment-agnostic, matching the same seam discipline the storage abstraction elsewhere in the codebase uses.

Upstream and downstream

Upstream, this whole layer sits on top of manifest and reflection: ModelDataSource.loadDocument bridges a TodlDocument into a PackageManifestBridge-resolved manifest, calls Manifest.load, and reads through FrozenGraph/GraphQuery — the same packed, read-only view that Manifest.reflect(node) and TypeInfo/InstanceMirror expose elsewhere. Nothing in model-data, reflection-client, or the generated codegen output re-parses or re-validates .todl text; that work already happened when the model was compiled and emitted (emit/manifest.ts, emit/json.ts).

Downstream, this is the layer the runnable app and the build system are built on. DtoGenerator (see Project content generators) reflects the project’s full compiled closure and runs generateReadClient to write generated/model.ts into the project ahead of any build; the html-bundle build (section 10) only requires that file be present and never writes it itself. The model-data generator writes generated/data.ts, which calls <Dto>.fromJSON(window.__TODL_APP__) — the exact synchronous fromJSON path described above — to export the model the build’s sandbox entry.ts imports and hands to TodlAppBootstrap.Mount(app, model). Any markup or view-model that binds that DTO’s collection getters does so by the naming contract discussed above. authoring’s ModelDraft is the one piece of this section that is not part of that generated-app path at all — it backs interactive editing tools (project authoring UIs, agent-driven model construction) that write .todl source, not the runtime read path a shipped application uses.


← Back to the Architecture overview

See also: Manifest and reflection · The build system · Project content generators · The runnable app