Skip to content

Repository files navigation

English · Русский

$mol + GraphQL codegen: Relay's fragment way, without React

You have a GraphQL API on one side and a $mol UI on the other. This repo shows the shortest path between them: a component describes the data it needs in a .graphql file next to its code, a code generator turns that file into a typed function, and the component calls it and renders the result. Every component declares its own data (the fragment idea, borrowed from Relay), so one request fetches exactly what the page needs, and $mol re-renders when the data changes. New to $mol and GraphQL? Start with the walkthrough below: it builds the whole picture on the demo's real files.

In expert terms: a copy-paste-able starter wiring $mol components to a GraphQL API with full end-to-end typing and Relay-style fragments: declared per component, spread by name, masked for everyone else, zero imports, no changes to the $mol/mam builder.

Live demo: https://trip2g.github.io/mol_graphql/ (runs entirely in the browser: the $demo_pages entry links the in-browser GraphQL mock into the bundle alongside the app, no server; likes reset on reload).

docker-compose up --build
# open http://localhost:8080  (mock GraphQL API is on http://localhost:4000/graphql)

You get a page that greets the viewer (plain typed query), lists notes (parent query composing a child's fragment), and lets you like a note (typed mutation + reactive refetch), typed from the schema down to the component code.

Walkthrough: from a query to a rendered page

Two facts are enough to read this section:

  • A GraphQL query is a plain-text list of the fields you want; the server answers with JSON of exactly those fields. A fragment is a named, reusable piece of such a list.
  • A $mol component is a folder with two files: *.view.tree declares what is on screen, *.view.ts adds behavior in TypeScript. A property declared in the tree can be computed or overridden in the .ts.

Step 1: one query, one component

The app greets the current user. Its data need lives in app/viewer.graphql, a plain file next to the component:

query {
	viewer {
		name
		pinned_note {
			...demo_note_card_note
		}
	}
}

Only name matters for now; pinned_note { ...demo_note_card_note } reuses another component's fragment, see step 2.

The codegen watches this file and generates app/viewer.graphql.ts next to it: a typed function $demo_app_viewer, named after the file path. The component just calls it:

// app/app.view.ts
@ $mol_mem
greeting() {
	return `Reading list of ${$demo_app_viewer().viewer.name}`
}

No import, no fetch plumbing: $demo_app_viewer() performs the request, and .viewer.name is typed from the schema, so a typo in a field name is a compile error. @ $mol_mem memoizes the property and makes it reactive: $mol suspends the computation while the response is in flight and replays it when the data arrives; when the data later changes, everything that read it re-renders.

The tree file puts the text on screen:

$demo_app $mol_page
	title \$mol × GraphQL fragments
	body /
		<= Greeting $mol_paragraph
			title <= greeting \

That is the whole loop: a .graphql file describes the data, a generated typed function fetches it, a one-line property renders it.

Step 2: a catalog and its cards, tied by a ref

The page also shows a list of note cards: the card (note/card) is the item, the app is the catalog. Each declares only what it itself needs.

The card states its data needs as a fragment in note/card/note.graphql:

fragment demo_note_card_note on Note {
	id
	title
	body
	likes
	author {
		name
	}
}

The card's tree declares its layout and one input, note_ref:

$demo_note_card $mol_view
	note_ref null $demo_note_card_note_ref
	sub /
		<= Title $mol_paragraph
			title <= note_title \
		<= Body $mol_paragraph
			title <= note_body \

note_ref is not the data but an opaque reference (the $demo_note_card_note_ref type is also generated from the fragment file). The card turns it into fields with a generated unmask helper:

// note/card/card.view.ts
@ $mol_mem
note() {
	return $demo_note_card_note_unmask_not_null(this.note_ref())
}

note_title() {
	return this.note().title
}

like_title() {
	return `♥ ${this.note().likes}`
}

The card never fetches anything: whoever shows a card hands it a ref, and only the card can open it.

Now the catalog side. The app's page query in app/notes.graphql spreads the card's fragment by name:

query DemoAppNotes {
	notes {
		id
		...demo_note_card_note
	}
}

One network request fetches the list ids plus every field every card asked for. The app itself owns only id: reading notes()[0].title in app code is a compile error, because the card's fields are masked from everyone else.

The app renders one card per note and passes each card its ref, in the tree:

		<= Note_list $mol_list
			rows <= note_rows /
	Note_card* $demo_note_card
		note_ref <= card_ref* null

and in the behavior file:

// app/app.view.ts
@ $mol_mem
notes() {
	return $demo_app_notes().notes
}

@ $mol_mem
note_rows() {
	return this.notes().map(note => this.Note_card(note.id))
}

card_ref(id: string): $demo_note_card_note_ref {
	return this.notes().find(note => note.id === id)!
}

Follow the ref: the query result comes out of $demo_app_notes() typed and masked, note_rows() makes a Note_card per id, the tree binding note_ref <= card_ref* hands each card its own masked item, and the card's note() unmasks it. The pinned_note { ...demo_note_card_note } from step 1 is the same card fragment, reused for the pinned note.

Step 3: a mutation that refreshes the page by itself

The like button changes data on the server. The mutation is again a plain file next to the component, note/card/like.graphql:

mutation DemoNoteCardLike($id: ID!) {
	note_like(id: $id) {
		id
		likes
	}
}

The codegen turns it into the typed function $demo_note_card_like, and the card calls it from its click handler:

// note/card/card.view.ts
like(next?: Event) {
	// calling this auto-reloads all queries on the page
	$demo_note_card_like({ id: this.note().id })
}

That comment is the whole cache story (the default revalidation: 'all' convention, detailed below): after any mutation every query on the page refetches, so everything derived from the data updates with no manual cache work.

Step 4: what you see, and why it is the shortest path

The page renders the greeting, the pinned-note line, and one card per note with its title, body, author, and a like button with the live count. Click like: the typed mutation fires, the page queries refetch, and every count on screen updates.

The payoff: the catalog fetches exactly what its cards need in a single request; each card unmasks only its own fields; a mutation refreshes the page by convention; $mol re-renders whatever read the changed data.

The rest of this page is reference: the codegen, what it generates, tuning the refetch scope, testing.

The idea in one paragraph

Each component module co-locates its GraphQL operations as separate .graphql files. A watch codegen (graphql-codegen + ~150 lines of custom preset/plugin) generates a <name>.graphql.ts next to each: a thin, fully-typed wrapper in the global namespace $. Two builders meet through a file seam: graphql-codegen writes .graphql.ts, the $mol builder (mam) compiles it as ordinary module TypeScript, exactly like .view.tree-view.tree/*.d.ts in $mol itself. The generated symbol $<module>_<opname> appears in namespace $ with zero imports.

This repo is the demo package of a mam workspace (the repo root mounts at demo/), so a repo path app/... is workspace path demo/app/... and the generated symbols carry the $demo_ prefix.

.graphql source generated .graphql.ts exported symbol
app/notes.graphql app/notes.graphql.ts $demo_app_notes(): demo_app_notesQuery
app/viewer.graphql app/viewer.graphql.ts $demo_app_viewer(): demo_app_viewerQuery
note/card/note.graphql note/card/note.graphql.ts $demo_note_card_note + $demo_note_card_note_ref + $demo_note_card_note_unmask(ref) + $demo_note_card_note_unmask_not_null(ref)
note/card/like.graphql note/card/like.graphql.ts $demo_note_card_like(vars): demo_note_card_likeMutation

The result/variables types are baked in by the generator, so there is no reliance on byte-for-byte string-literal matching.

Operations are auto-named from the file location

Write query { ... } (anonymous) or query AnyName { ... }: the codegen rewrites the operation name to the path-derived canonical, the generated symbol without the leading $. For note/card/like.graphql the symbol is $demo_note_card_like, so the document sent is mutation demo_note_card_like(...): the function you call, the operation name the server sees, and the file path match 1:1.

For free: server logs and APM always get a meaningful operation name, and the usual graphql-codegen "anonymous operation" error is gone (app/viewer.graphql is anonymous on purpose). Fragments are NOT renamed: they are spread by name across files, so a rewrite would break the spread sites; the codegen only prints a non-blocking warning when a fragment's name deviates from the same path-derived canonical. This repo names its fragments canonically (note/card/note.graphql declares demo_note_card_note).

Fragments: Relay's model, $mol's style

This reproduces the Relay fragment rendering model without React and without its normalized store:

  • A component declares its data needs as a named fragment in its own .graphql file:

    # note/card/note.graphql
    fragment demo_note_card_note on Note {
      id
      title
      body
      likes
      author { name }
    }
  • Fragments are global, spread by unique name. Any operation (or another fragment) can spread ...demo_note_card_note, regardless of the component tree. Fragment names follow the same path-derived canonical (note/card/note.graphql -> demo_note_card_note), so the file path, which encodes component and prop, makes them globally unique. At codegen time the fragment definitions are merged (transitively) into every operation that spreads them, producing one network request with no runtime document registry:

    # app/notes.graphql
    query DemoAppNotes {
      notes {
        id
        ...demo_note_card_note
      }
    }
  • Masking. The parent physically receives the fragment's data, but its TYPE hides it. $demo_app_notes().notes[0] is typed as { id: string } & { ' $fragmentRefs'?: { demo_note_card_noteFragment } }. Reading .title in the parent is a compile error:

    error TS2339: Property 'title' does not exist on type
    '{ __typename?: "Note"; id: string; } & { ' $fragmentRefs'?: ... }'
    
  • Unmask accessor instead of useFragment. Relay's useFragment is really an identity cast, not a hook, so in $mol it is a plain generated function used inside a reactive $mol_mem property:

    // note/card/card.view.ts
    export class $demo_note_card extends $.$demo_note_card {
    
        @ $mol_mem
        note() {
            // checked unmask: the parent always binds a real ref (see below)
            return $demo_note_card_note_unmask_not_null(this.note_ref())
        }
    
        note_title() { return this.note().title }
    }

Typing the ref in the .view.tree: $<name>_ref

Alongside the fragment type, each fragment file generates a bare-name alias for its opaque ref:

// note/card/note.graphql.ts (generated)
export type $demo_note_card_note = demo_note_card_noteFragment
export type $demo_note_card_note_ref = $demo_graphql_ref<$demo_note_card_note>

The alias lets the ref type be named by a bare $name in .view.tree (a tree property cannot carry a generic), so the card types its input right in the tree, with no .view.ts override:

$demo_note_card $mol_view
	note_ref null $demo_note_card_note_ref

mol properties are always nullable by default, so this generates note_ref(): $demo_note_card_note_ref | null in the tree .d.ts. The parent binds the ref as before (note_ref <= card_ref* in app/app.view.tree).

Two unmask helpers: nullability is preserved, never erased

Unmask keeps the schema's nullability. Each fragment generates two accessors; the choice follows the schema:

  • $<name>_unmask(ref) preserves the ref's nullability: a non-null ref yields the fragment, a Ref | null yields Frag | null, so the compiler forces the null branch. Use it for anything the schema can null: a nullable field, a null list element. In the demo User.pinned_note is nullable and the app shows a fallback:

    // app/app.view.ts
    @ $mol_mem
    pinned() {
        // Ref | null in, Frag | null out
        return $demo_note_card_note_unmask($demo_app_viewer().viewer.pinned_note)
    }
    
    pinned_title() {
        const note = this.pinned()
        if (!note) return 'No pinned note' // the compiler forces this branch
        return `Pinned: ${note.title} (♥ ${note.likes})`
    }
  • $<name>_unmask_not_null(ref) is the checked accessor for values that are non-null by schema but arrive through a nullable seam (a tree-typed property is | null by default). On a null ref it throws a clear error naming the fragment. The safe replacement for TS !, which has no runtime check:

    // note/card/card.view.ts: list elements are non-null in the schema
    @ $mol_mem
    note() {
        return $demo_note_card_note_unmask_not_null(this.note_ref())
    }

Rule of thumb: nullable by schema, use unmask and handle the null; guaranteed present, use _unmask_not_null, never !.

Convention: a mutation refetches every query on the page

Invalidation is one reactive marker (graphql/index.ts): every query subscribes to a generation counter, every mutation bumps it, so all $mol_mem-oized queries on the current page re-run.

This fires some duplicate and overlapping requests, deliberately: a redundant fetch is cheaper than a UX bug from a forgotten invalidation callback. Optimize only when a real cost shows up.

Pass { revalidate: false } to $demo_graphql_request to opt one query out of refetching, or one mutation out of triggering a refetch. On the backend, persisted queries make the repeated reads cheap. The request layer and the unmask path stay small and swappable, so smarter per-fragment reactivity via $mol_mem can land later without touching generated code.

Live: each note card shows a renders counter, plus a revalidate:false static panel. Like any note: every card's counter ticks together while the static panel stays put (card.view.ts, app.view.ts).

$mol re-renders only what changed

$mol_mem deep-compares each result ($mol_compare_deep), so the page does not re-render a subtree whose data is unchanged, even when a mutation refetched it over the network. The redundant refetch costs a network roundtrip; the redundant re-render never happens.

The demo makes this visible: every render region flashes on re-render, like "highlight updates" in React DevTools. Like a note: the like region flashes while the author region (author_name() in card.view.ts) stays calm.

Choosing the refetch scope: the revalidation codegen mode

How much a mutation refetches is a compile-time switch. One line in the plugin config (codegen/graphqlgen.js) selects the invalidation metadata baked into every generated wrapper:

config: { molRuntime: '$demo_graphql', revalidation: 'all' } // 'all' | 'by_typenames' | 'disable'
  • all (the default, what this demo ships): the convention above, one universal marker.
  • by_typenames: the codegen walks each operation against the schema and bakes the resulting object-type set into the wrapper. A query subscribes to a per-type marker for each type it reads (reads: ['Note', 'User']); a mutation bumps the markers of the types in its response (writes: ['Note']). A like (writes Note) refetches the notes list (reads Note) but not a viewer query (reads only User), no manual { revalidate: false } needed. The read set comes from the schema walk, not runtime data, so a query whose list came back empty still refetches when the first item appears (urql's document cache, deriving the sets from runtime __typenames, misses that case).
  • disable: wrappers never subscribe and never bump. Queries fetch once and recompute only when their own inputs change.

{ revalidate: false } keeps working in every mode and opts a single call out. Operations without metadata (hand-written $demo_graphql_request calls) degrade to the universal marker in any mode, so mixing them with generated wrappers stays safe.

Enable by_typenames when your backend guarantees that every mutation returns, in its own payload, all the object types it affects: then the computed write set equals the real one, per-type invalidation is exact, and @touches is not needed. A mutation whose payload contains no object type at all (a delete returning Boolean) falls back to the universal marker automatically: unknown effect, assume everything. The dangerous case is a mutation that returns one type but also changes another, like a like that also bumps a per-user counter. Either include the changed type in the payload or declare it:

mutation DemoFixtureTouch($id: ID!) @touches(types: ["User"]) {
  note_like(id: $id) { id likes }
}

The codegen unions the declared types into writes and strips the directive from the sent document. The caveat: nothing forces the author of a side-effectful mutation to remember that declaration, and a forgotten @touches means silently stale data, which is exactly the "forgotten invalidation callback" the convention exists to prevent. Hence the safe default stays 'all'.

The other two modes are proven by tests: graphql/index.test.ts drives wrappers generated in by_typenames and disable mode (graphql/fixture/) against a mock transport and asserts the selective refetch, the empty-list case, @touches, and the no-metadata degradation.

Subscriptions over SSE

Subscriptions are codegen-TYPED from the schema, same as queries and mutations, but their RUNTIME is a separate raw SSE host, the same split trip2g uses: a query is one request returning one value through the sync fiber transport, while a subscription is a long-lived stream pushing many values. Forcing a stream through the request seam would give it the wrong shape, so only the TYPE comes from the codegen (codegen/molplugin.js); the stream lives in a small hand-written runtime (graphql/subscription/subscription.ts).

The subscription document is a .graphql file next to the component that consumes it (note/live/note_liked.graphql), and the generated wrapper (note/live/note_liked.graphql.ts) returns the raw host with the schema-derived result type baked in:

export function $demo_note_live_note_liked(): $demo_graphql_subscription_host<demo_note_live_note_likedSubscription> {
	return $demo_graphql_subscription(`subscription demo_note_live_note_liked {
  note_liked { id title likes }
}`, undefined) as $demo_graphql_subscription_host<demo_note_live_note_likedSubscription>
}

The consumer (note/live/live.view.ts) calls the wrapper and reads .data()?.note_liked fully typed, so a schema change breaks the build instead of silently drifting past a hand-written type:

@ $mol_mem
subscription() {
	return $demo_note_live_note_liked()
}

$demo_graphql_subscription<Data>(query, variables) returns a shared reactive host per document: .data() is a $mol_mem holding the latest event payload (typed Data | null), .error() the latest failure, .opened() the connection state. The Data generic is a compile-time claim only, pinned by the generated wrapper; the runtime is identical for every caller. Reading .data() spins the stream up; the host reconnects after a delay when the stream drops, and aborts the connection when nothing on the page renders the data anymore.

The transport is SSE: the default connect seam POSTs the document to the GraphQL endpoint with Accept: text/event-stream (the graphql-sse "distinct connections" protocol) and parses event: next frames. The server side is a plain async iterator in server/mock.mjs: the like mutation broadcasts the changed note to every live note_liked stream, and graphql-yoga turns the iterator into SSE natively, so curl -N against the endpoint shows the raw events.

The consumer is the watcher idiom from trip2g: the live panel binds a watcher property in its view.tree body, so rendering subscribes it and every SSE event re-runs the $mol_mem. The watcher defers its side effect out of the computation with setTimeout: it calls $demo_graphql_revalidate(), a manual bump of the universal invalidation markers (graphql/index.ts), so every page query refetches. A like made in ANOTHER browser tab therefore updates the note cards live: the subscription event drives the same revalidation convention as a local mutation.

The connect seam is swappable exactly like the request transport: the Pages build has no server, so the in-browser mock (graphql/mock/mock.ts) swaps $demo_graphql_subscription_connect for a local emitter. The mock like mutation broadcasts the changed note to every open "stream" on a macrotask, like a real network push would arrive, so the live panel works with zero network on GitHub Pages too.

Tests (note/live/live.view.test.ts) swap the connect seam for a stub that captures the events sink, then push subscription events by hand: the same global-swap-restore discipline as the transport mock.

Where to look (reading path)

In order, from a .graphql file to a running component:

  1. A component's own operations: app/notes.graphql and note/card/note.graphql. Plain files next to the component.
  2. The codegen that types them: codegen/molplugin.js: renameOperations rewrites the operation name to the path-derived canonical; operationCode merges spread fragments into the sent string and emits the typed wrapper; fragmentCode emits the fragment type, the ref alias and both unmask helpers; escapeDollars is the $-escape fix. codegen/preset.js wires one output per file.
  3. The generated output: app/notes.graphql.ts (masked query with the fragment merged in) and note/card/note.graphql.ts (fragment type + ref alias + unmask helpers).
  4. The runtime: graphql/index.ts: the request layer and refetch convention, the invalidation markers, the opaque ref type.
  5. A component consuming a fragment: note/card/card.view.ts: note() unmasks the ref; renders() is the counter that ticks on every refetch.
  6. The opt-out in action: app/app.view.ts: viewer_static() passes { revalidate: false }, so its counter never moves.

Project layout

The repo is a mam package in the canonical hyoo-ru shape: no vendored builder bootstrap, just module files. A mam workspace (cloned from hyoo-ru/mam, which provides mam.ts/tsconfig.json and the namespace→repo map for mol/node) mounts this repo at demo/. That is what hyoo-ru/mam_build does in CI with package: 'demo'.

codegen/
  graphqlgen.js         graphql-codegen config (checked-in SDL, no introspection)
  preset.js             one output per .graphql file + shared schema types
  molplugin.js          typed wrappers + fragment unmask helpers, in namespace $
server/
  schema.graphql        the SDL: single source of truth for server AND codegen
  index.mjs             graphql-yoga mock server with in-memory data
graphql/index.ts        runtime: request fn, error, reactive marker, ref type
graphql/subscription/   raw SSE subscription runtime: reactive host + connect seam
graphql/mock/           $demo_graphql_mock: in-browser mock; linking it swaps the transport
graphql/schema.graphql.ts   (generated) shared scalar/enum/input types
app/                    $demo_app: page, plain query + fragment-composing query
pages/                  $demo_pages: thin Pages entry, links the app + the mock
note/card/              $demo_note_card: fragment + unmask + typed mutation
note/live/              $demo_note_live: live panel over the note_liked subscription
package.json            DEV TOOL only: codegen + mock server (not part of the build)

Commands

Local dev happens inside a mam workspace:

git clone https://github.com/hyoo-ru/mam.git mam-ws && cd mam-ws
git clone https://github.com/trip2g/mol_graphql.git demo
npm install
command (from the workspace root) what
npm start mam dev server on :9080 (/demo/app/); run the mock server alongside
npx mam demo/app one-shot production build into demo/app/-/ (type-checks the bundle, runs tests)
npx mam demo/pages build the serverless Pages entry into demo/pages/-/
command (from demo/, this repo) what
docker-compose up --build mock GraphQL API on :4000 + built app on :8080
npm install && npm run codegen regenerate all *.graphql.ts
npm run codegen:watch regenerate on every .graphql/schema change
npm run server run the mock GraphQL server locally

The dev loop is npm run codegen:watch + npm run server (both in demo/) + npm start (workspace root) in three terminals. The mam builder picks up regenerated .graphql.ts like any source change.

Build & deploy (GitHub Pages)

deploy.yml is the canonical hyoo-ru pipeline: hyoo-ru/mam_build@master2 assembles the workspace (clones hyoo-ru/mam + deps, mounts this repo as package: 'demo'), builds demo/app and demo/pages, runs every *.test.ts; then the demo/pages/- folder is published to Pages. The default GITHUB_TOKEN is enough: everything mam_build clones is public. The deployed site is $demo_pages (pages/pages.ts): a thin entry that renders the app and explicitly links $demo_graphql_mock (graphql/mock/mock.ts). That link is the whole transport choice: the mock module's body swaps the transport seam for a sync in-browser mock answering each operation from the same dataset as the mock server, while demo/app, which never references the mock, keeps the server transport. Keep the dataset in sync with server/mock.mjs by hand.

How to copy this into your project

  1. Take codegen/ as-is; point schema at your SDL file and documents at your UI tree; set molRuntime to your prefix (e.g. $myapp_graphql).
  2. Port graphql/index.ts under your prefix (request fn, error, ref type, invalidation marker), or swap the body of *_request for your transport.
  3. Write .graphql files next to your components: one operation or fragment per file; file path defines the generated symbol (a/b/c.graphql$a_b_c) and the operation name (a_b_c, whatever you wrote in the file, even nothing); name fragments by their file path too (same a_b_c rule), which is globally unique automatically; the codegen warns if you deviate.
  4. npm run codegen:watch next to your mam dev server. Generated *.graphql.ts can be committed (this repo does) so the app builds without running codegen first.

Gotchas (learned the hard way)

  • $mol module paths are literal: $demo_note_card must live at demo/note/card/ in the workspace, i.e. note/card/ in this repo. Underscore = directory separator, always. (The builder resolves dependency FQNs by exact path segments; hence the mount point demo, not mol_graphql: no underscores in path segments.)
  • The $mol dep scanner reads $-tokens everywhere, including string literals and doc-comments; emitted non-module $ is escaped as \u0024. Full story in The $ dependency scanner below.
  • The generated wrapper for an operation that spreads fragments carries a /** Spreads fragments: $demo_note_card_note */ doc-comment: a real dependency edge for the builder (nothing else would link the fragment's module into the bundle, fragments being independent of the view hierarchy).
  • The app calls http://localhost:4000/graphql (see $demo_graphql_endpoint, override it for other setups). CORS is open on the mock server.

The $ dependency scanner

The $mol builder does not parse code to find a module's dependencies. It scans the source TEXT for $-prefixed names and resolves each one to a file - everywhere, including string literals and doc-comments. This repo leans on that deliberately: the generated /** Spreads fragments: $demo_note_card_note */ doc-comment is a real dependency edge that links the fragment's module into the bundle. The flip side: every stray $-token in source text is a build input, intended or not.

Escape emitted $ that is not a module, as \u0024

GraphQL query variables ($id) and fragment-masking keys (' $fragmentRefs', ' $fragmentName') carry a $ that is not a $mol module; the scanner would resolve phantom modules and fail the build. So the codegen writes every such $ as \u0024: escapeDollars for type output, escapeTemplate for the embedded query strings, both in codegen/molplugin.js. Real module references the codegen DOES want as dependencies ($demo_note_card_note) stay unescaped on purpose. If you hand-write such non-module tokens in a module .ts, escape them the same way (this repo's graphql/index.ts does, for the masking keys).

Prior art avoided the phantom modules with empty stub directories named after the phantom tokens (fragment/, id/, ...). Escaping is the canonical fix: no stub dirs, and it survives new field and variable names automatically.

\u0024 is safe - and the server never sees it

\u0024 is a compile-time unicode escape: TS and JS decode it to U+0024 = $ when the string literal is parsed, so the generated types and the RUNTIME query strings are identical in meaning to writing $. The escape exists only in the raw source text (six ASCII characters, no $ byte) to hide the token from the textual scanner. At runtime the query string carries a real $: the Like mutation is sent as mutation demo_note_card_like($id: ID!), a real $ in the POST body, and the server receives normal GraphQL, never \u0024.

The comment-token trap (a real bug this repo hit)

The same rule bites hand-written modules. A comment in graphql/index.ts once named the in-browser mock module by its live symbol. Because graphql/index.ts is a real dependency of the app, that one comment token pulled the whole in-browser mock into the production app bundle: the app rendered mock data and never called the server. The fix is NOT to escape (a comment is prose, not code that needs the character) but to DROP the $: the comment now names the mock in words, "the mock module (graphql/mock/mock.ts)". The flip side is what the Pages entry does on purpose: $demo_pages_transport = $demo_graphql_mock is a real code reference, so the mock links into THAT bundle by explicit choice.

The rule

The scanner treats every $-token in source text as a dependency. Two tools: escape (\u0024) tokens that must keep the character but are not modules; name modules you do not want linked without the $ (in words, by file path).

Testing query/mutation components

$mol tests live next to the code: card.view.test.ts, app.view.test.ts. They use $mol_test + $mol_assert, and every mam build compiles them into demo/app/-/node.test.js and runs them; a failing test fails the build. Run just the bundle with node --enable-source-maps demo/app/-/node.test.js from the workspace root.

GraphQL components are tested with NO server by mocking the transport seam. $demo_graphql_transport is a namespace export let, so a test reassigns it to a mock that counts calls per operation and answers from a tiny in-memory store, then restores it in finally:

// app/app.view.test.ts (sketch)
const { calls, transport } = graphql_mock()          // per-operation call counter + in-memory store
with_transport(transport, () => {
	const app = $demo_app.make({ $ })
	$mol_assert_equal(app.greeting(), 'Reading list of Ada Lovelace')
	$mol_assert_equal(calls.demo_app_notes, 1)
	// mutate, then re-read, then assert the page queries refetched
})

What the tests prove:

  • Fragment unmask (card.view.test.ts): a fragment ref unmasks into the typed fields and the card renders them; a null ref stays null through unmask and makes unmask_not_null throw with the fragment's name. No network.
  • The nullable field (app.view.test.ts): a present pinned_note renders the pinned panel, a null one renders the fallback text.
  • The refetch convention (app.view.test.ts): after a like mutation, the page queries are re-requested (demo_app_notes and demo_app_viewer call counts go 1 → 2) and the data changes.
  • The re-render gate (app.view.test.ts): across a like, the like region's render probe advances while the author region's stays put, in the liked card and in the untouched one: $mol_compare_deep stops the propagation at the unchanged memos.
  • The opt-out (app.view.test.ts): a { revalidate: false } query is fetched exactly once across a mutation, and a { revalidate: false } mutation leaves the page queries put.

Worth knowing:

  • The mock transport is synchronous (same contract as the Pages mock), so cases are plain sync functions. Reading a $mol_mem in a test just computes it.
  • Refetch is lazy: a mutation marks the page-query cells stale but nothing recomputes until the next read, so assert query counts AFTER re-reading.
  • $mol_mem skips deep-equal recomputes, so a stateful mock (likes actually increment) is needed for a re-render to be observable.
  • The per-case isolated $ cannot intercept the transport (free functions read the global $), so the mock is a global swap restored in finally; leak-proof because $mol runs cases sequentially.

What's mocked / simplified

  • The GraphQL server is graphql-yoga with fixed in-memory data (likes actually increment server-side).
  • No persisted queries, no normalized cache (see above). Subscriptions are codegen-typed like queries, but the stream runtime is the raw SSE host, not the request seam.
  • type-check evidence: the mam build itself type-checks the exact bundle program (its audit fails the build on any TS error). A whole-workspace tsc -p . also drags in mol's unbuilt demo modules and is noisy; the bundle audit is the real gate.

Further reading

The fragment model here follows Relay's, without its runtime store.

About

$mol + GraphQL codegen starter: Relay-style fragments (named spread + masking) in $mol, .graphql→.graphql.ts, mock server, docker-compose up

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages