| name | shalom |
| description | Use when building or modifying Dart/Flutter apps that use the Shalom GraphQL client: annotated @Query/@Mutation/@Subscription/@Fragment classes, ShalomRuntimeClient setup, network links, normalized cache reads/writes, optimistic mutation updates, and mutation-driven list updates without refetching. |
Shalom
Use this skill when acting as an app developer consuming Shalom. Prefer Shalom's generated Dart/Flutter APIs over hand-written GraphQL plumbing. Do not edit generated __graphql__ files directly; change annotations/schema/config and regenerate.
Paradigm
shalom contains a "smart" runtime that automagically updates widgets if you use it correctly
shalom is declarative meaning that as a rule of thumb we don't need services nor state management solutions for graphql stuff.
in shalom every widget should request only what it needs.
in shalom we do less on the ui and more on the server. so if so far you have done sorting on the ui, you should (mostly) now delegate that to the server because usually list nodes would use fragments which are not readable (declaratively) by the list view builder.
if you still need to do sorting on the ui, make sure your lists are not huge an you'd prob better off without @Fragment widgets.
Mental Model
Shalom has four user-facing layers:
- A Rust runtime under the Dart API that parses registered GraphQL documents, executes requests through your Dart link, normalizes responses, tracks cache observers, and rolls back optimistic writes.
package:shalom, which exposes ShalomRuntimeClient, response types, GraphQLLink, HTTP/WebSocket links, CacheProxy, Maybe, Some, and None.
package:shalom_flutter, which exposes ShalomProvider/ShalomScope, generated widget integration, observable scopes, and the debug panel.
package:shalom_annotations, which marks user classes with @Query, @Mutation, @Subscription, and @Fragment for code generation.
Normal app flow:
- Initialize the Flutter Rust bridge before creating the client.
- Create a
GraphQLLink (HttpLink, WebSocketLink, or a custom link).
- Create
ShalomRuntimeClient with the generated schema SDL.
- Call generated
registerShalomDefinitions(client).
- Put the client above your widgets with the generated
ShalomProvider or ShalomInheritedWidget.
- Use generated query/subscription/fragment widgets for reactive UI.
- Use generated mutation classes for imperative writes.
Example client setup:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await ShalomRuntimeClient.initFlutterRustBridge();
final client = ShalomRuntimeClient.create(
schemaSdl: kSchemaSdl,
link: HttpLink(transportLayer: transport, url: graphqlUrl),
);
registerShalomDefinitions(client);
runApp(ShalomProvider(client: client, child: const MyApp()));
}
Runtime cache APIs are async because they cross the Flutter/Rust bridge. In generated mutation update callbacks, mark the callback async and await generated readFrom, cache.readOperation, cache.writeOperation, cache.evictOperation, ref.readFrom, cache.readFragment, and cache.writeFragment calls.
Normalized Cache
Shalom normalizes GraphQL responses into root records (ROOT_QUERY, ROOT_MUTATION, ROOT_SUBSCRIPTION) and entity records such as Album:123. Queries, subscriptions, and fragments generated from annotations are registered with @observe, so the runtime knows which cache refs they watch.
Cache writes notify active observers:
- Query/subscription widgets re-emit when any watched cache key changes.
- Fragment widgets/scopes re-emit when their target entity or nested watched refs change.
- Mutation responses are normalized automatically and update shared entities.
- List membership is not inferred unless the mutation response includes the parent/list field. For create/delete/add/remove list changes, update the relevant cached query or fragment yourself.
For network errors and GraphQL errors, handle all response variants:
switch (response) {
case GraphQLData(data: final data):
// use data
case GraphQLError():
// GraphQL-level errors
case LinkExceptionResponse():
// transport/network errors
}
Queries And Subscriptions
Annotate a widget class with partial SDL. The generated operation is named after the Dart class.
@Query(r"""
($query: String!, $offset: Int!, $limit: Int!) {
searchGifs(query: $query, offset: $offset, limit: $limit) {
items {
title
url
previewUrl
}
hasNextPage
}
}
""")
class AlbumGifSearch extends $AlbumGifSearch {
const AlbumGifSearch({super.key, required super.variables});
@override
Widget buildLoading(BuildContext context) => const CircularProgressIndicator();
@override
Widget buildError(BuildContext context, Object error) => Text('$error');
@override
Widget buildData(BuildContext context, AlbumGifSearchData data) => ...;
}
Generated query/subscription APIs usually include:
$ClassName, a widget base with buildLoading, buildError, and buildData.
ClassNameData, nested result classes, and ClassNameVariables when variables exist.
ClassNameObservable.observe(client) for lower-level observation.
ClassNameData.readFrom(cache) and ClassNameData.evictFrom(cache, {variables}) for mutation update callbacks.
executionPolicy, defaulting to ExecutionPolicyInput.cacheFirst.
retryDelay and autoRefetch for resilience — see Retry And Auto-Refetch.
Execution Policy
Generated query and subscription widgets accept executionPolicy.
Default to ExecutionPolicyInput.cacheFirst: it emits complete cached data
immediately when available, then stays subscribed to later cache/network updates.
Use ExecutionPolicyInput.networkFirst only when the UI must wait for a fresh
server result, such as explicit refresh flows or server-authorized screens.
AlbumsPage(
executionPolicy: .networkFirst,
)
If cacheFirst cannot satisfy all selected refs/fields, it waits for the
network result. Changing executionPolicy resubscribes the widget.
Avoid ShalomScope.of(context).request(...).first for reads you want to keep in cache, including one-off operations like submit-to-search. The runtime's GC only keeps a root operation field (and entities only reachable through it) alive while some active subscription still references it; a one-shot request(...) never registers as an active subscriber, so the written data becomes eligible for eviction the moment any other cache activity triggers garbage collection. For a one-off read whose result should stay live for as long as the UI cares about it, mount the generated @Query widget conditionally instead:
if (query.isNotEmpty)
AlbumGifSearch(
variables: AlbumGifSearchVariables(query: query, offset: 0, limit: 20),
),
The widget keeps an active observer for as long as it's in the tree, so GC won't evict its data, and it naturally unmounts (and becomes eligible for collection) when the search ends.
@Subscription uses the same widget shape as @Query; the link must support streaming operation results.
Retry And Auto-Refetch
Generated query/subscription widgets (and ShalomRuntimeClient.request) accept two independent, orthogonal knobs for resilience:
retryDelay (a RetryDelay) — if the link reports a transport error (e.g. a dropped connection), the error is always emitted on the stream immediately, and if retryDelay resolves to a delay, the whole operation is re-issued after it. Defaults to RetryDelay.inherit(), which uses the runtime's global default (runtimeConfig(defaultRetryDelay: ...)); RetryDelay.disabled() turns it off for one call; RetryDelay.after(duration) overrides the delay per call. A GraphQL-level error is terminal and never retried — only transport errors are. ShalomRuntimeClient.mutate defaults to RetryDelay.disabled() since blindly re-sending a mutation after a network blip can duplicate a side effect; opt in explicitly if the mutation is known to be idempotent.
autoRefetch (a Duration?) — independent of any error, re-issues the operation on a plain timer as long as it's still observed. Only meaningful for queries (subscriptions stay open on their own; polling a mutation on a timer doesn't make sense). null (the default) means no polling.
AlbumsPage(
retryDelay: RetryDelay.after(const Duration(seconds: 2)),
autoRefetch: const Duration(seconds: 30),
)
Both stop as soon as the widget/stream is disposed/cancelled — cancellation is checked before each retry/refetch fires, so there's no risk of a stray request landing after the caller stopped listening.
Naming Rules
Shalom requires every generated GraphQL definition name to be globally unique across the app.
This includes: - @Query / @Mutation / @Subscription / @Fragment class names.
Do not reuse the same Dart class name for another Shalom operation or fragment, even if the GraphQL selection is different or lives in another file. Generated operation, fragment, data, variables, ref, and scope APIs are derived from these names, so duplicate names cause generation/runtime registration conflicts. Prefer explicit feature-prefixed names when needed.
Imperative APIs In Flutter
Imperative API's for reads are generally discouraged in Flutter, as they can lead to inconsistent state.
Default to declarative generated widgets/scopes for reads:
- Use
@Query / @Subscription widgets for screen-level data.
- Use
XScope such as AlbumWidgetScope when the screen already has an entity id/ref and needs reactive fragment data.
- Keep local UI-only state, such as text input, selected tabs, filtering, and local search result lists, in normal Flutter state.
Use imperative Shalom APIs for event-driven work:
- Mutations triggered by button taps, form submits, swipe actions, etc.
- Optimistic updates, via generated
executeOptimistic.
- Mutations that must add/remove/reorder cached list items, via
executeWithCacheUpdate.
- UI side effects after an operation, such as
Navigator.pop, snack bars, loading flags, and error messages.
The common Flutter shape is: declarative scope around the reactive entity, imperative handlers inside the state object.
class _AlbumDetailPageState extends State<_AlbumDetailPage> {
Future<void> _addGif(SearchGif gif) async {
final client = ShalomScope.of(context);
final response = await AddGifToAlbumMutation(client).executeWithCacheUpdate(
albumId: widget.albumId,
title: gif.title,
url: gif.url,
previewUrl: gif.previewUrl != null ? Some(gif.previewUrl) : const None(),
update: (cache, data) async {
final current = await AlbumWidgetRef.fromId(widget.albumId).readFrom(cache);
if (current == null) return;
await cache.writeFragment(
data: AlbumWidgetData(
id: current.id,
name: current.name,
tag: current.tag,
gifs: [
...current.gifs,
AlbumWidget_gifs(
id: data.addGifToAlbum.id,
title: data.addGifToAlbum.title,
url: data.addGifToAlbum.url,
),
],
),
);
},
);
if (response is! GraphQLData && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to add GIF')),
);
}
}
@override
Widget build(BuildContext context) {
return AlbumWidgetScope(
ref: AlbumWidgetRef.fromId(widget.albumId),
loadingBuilder: (_) => const CircularProgressIndicator(),
errorBuilder: (context, error) => Text('$error'),
builder: (context, album) {
final filtered = _filteredLocal(album);
return ListView(
children: [
for (final gif in filtered)
ListTile(
title: Text(gif.title),
trailing: IconButton(
icon: const Icon(Icons.add),
onPressed: () => _addGif(gif),
),
),
],
);
},
);
}
}
Avoid starting imperative client.request(...) calls directly inside build. Trigger them from handlers, lifecycle methods with cancellation, or generated widgets/scopes. Reserve imperative client.request(...).first for reads whose result you don't need GC to retain (for example, a throwaway check before a mutation); if the result should stay observable in the cache, mount the generated @Query widget instead, since an unsubscribed one-shot read is eligible for eviction as soon as GC next runs. For long-lived manual subscriptions, keep and cancel the StreamSubscription.
Mutations
@Mutation classes are imperative. They extend a generated base that takes a ShalomRuntimeClient.
@Mutation(r"""
($name: String!) {
createAlbum(name: $name) {
id
name
tag
gifs { ...AlbumGif }
}
}
""")
class CreateAlbumMutation extends $CreateAlbumMutation {
const CreateAlbumMutation(super.client);
}
Generated mutation APIs include:
execute(...): run the mutation and normalize the response.
executeWithCacheUpdate(..., update: (CacheProxy cache, Data data) async { ... }): run the mutation, then await update only for successful GraphQLData.
executeOptimistic(optimisticFactory, rollbackWhen: ..., ...): write a predicted mutation payload before the network response, then return an OptimisticMutationResponse.
Mutation selection rules:
- Include
id on object results that should merge with normalized entities.
- Include every field needed by active fragments/widgets that will read the returned entity.
- Use fragment spreads in mutation SDL when you want generated mutation result classes to implement the same fragment interface as the UI.
- Remember that normalizing a mutation response updates matching entity records but does not automatically add/remove refs from parent lists.
Fragments
Use @Fragment for reusable UI over one normalized entity, especially child widgets that should update independently from parent queries.
@Fragment(r"""
on Album {
id
name
tag
gifs { ...AlbumGif }
}
""")
class AlbumWidget extends $AlbumWidget {
const AlbumWidget({super.key, required super.ref});
@override
Widget buildData(BuildContext context, AlbumWidgetData album) => ...;
}
Generated fragment APIs include:
FragmentNameRef.fromId(id) and FragmentNameRef.fromEntityKey(key).
ref.readFrom(cache) and ref.observe(client).
FragmentNameData implements FragmentInterface.
FragmentNameData.entityKey(id).
$FragmentName widget and FragmentNameScope.
Parent operations should spread fragments and pass refs:
@Query(r"""
{
albums {
...AlbumWidget
}
}
""")
class AlbumsPage extends $AlbumsPage {
@override
Widget buildData(BuildContext context, AlbumsPageData data) {
return ListView.builder(
itemCount: data.albums.length,
itemBuilder: (context, i) => AlbumWidget(ref: data.albums[i]),
);
}
}
When a page already has an id, build the ref directly:
AlbumWidgetScope(
ref: AlbumWidgetRef.fromId(albumId),
builder: (context, album) => ...,
)
Fragment Scope Shortcuts
Every generated fragment has two useful shortcuts:
XRef: a typed cache pointer for the fragment, for example AlbumWidgetRef.fromId(albumId).
XScope: a lightweight builder widget for observing that ref without creating a separate X extends $X widget.
Use XScope when a screen already has the entity id/ref and just needs reactive fragment data:
AlbumWidgetScope(
ref: AlbumWidgetRef.fromId(widget.albumId),
loadingBuilder: (_) => const CircularProgressIndicator(),
errorBuilder: (context, error) => Text('$error'),
builder: (context, album) {
return Text(album.name);
},
)
Use the ref shortcuts inside cache update callbacks:
final ref = AlbumWidgetRef.fromId(albumId);
final current = await ref.readFrom(cache);
if (current == null) return;
await cache.writeFragment(
data: AlbumWidgetData(
id: current.id,
name: current.name,
tag: current.tag,
gifs: current.gifs,
),
);
ref.readFrom(cache) is shorthand for cache.readFragment(...) with the generated fragment name, entity key, and decoder. ref.observe(client) is shorthand for client.subscribeToFragment(...) with the generated decoder.
Fragment guidelines:
- Include
id for entity fragments you will manually address in cache updates.
- Pass refs to child fragment widgets instead of passing full data when the child should stay reactive.
- Fragment spreads are flattened into generated selections. If a fragment spreads another fragment, the generated concrete class implements the spread fragment interface.
- Nested object selections inside fragments are generated in the fragment file.
- Union/interface selections become sealed classes resolved by
__typename.
Optimistic Updates
Use generated executeOptimistic when the optimistic state can be represented as the mutation response shape.
final result = await RenameAlbumMutation(client).executeOptimistic(
(vars) => RenameAlbumMutationData(
renameAlbum: RenameAlbumMutation_renameAlbum(
id: vars.id,
name: vars.name,
),
),
id: albumId,
name: nextName,
rollbackWhen: (data) => data.renameAlbum == null,
);
What it does:
- Writes
optimisticFactory(vars).toJson() through client.writeOptimistic.
- Normalizes the predicted payload and notifies observers immediately.
- Runs the real mutation.
- Calls
rollbackWhen only for successful GraphQLData.
- Returns
OptimisticMutationResponse with response, wasRolledBack, and idempotent rollback().
Important details:
- The optimistic payload must exactly match the mutation response shape.
- Include ids and any fields watched by active fragments/widgets.
GraphQLError and LinkExceptionResponse are response values, not thrown exceptions. Call await result.rollback() yourself when those should undo the optimistic write.
- Thrown exceptions during the mutation path are rolled back by the generated helper.
executeOptimistic does not run an executeWithCacheUpdate list callback. For optimistic list membership, prefer a mutation response that includes the parent/list field, or implement a manual rollback path for the extra list write.
OptimisticMutationResponse.rollback() is async; await it when manually rolling back GraphQLError or LinkExceptionResponse results.
Updating Lists After Mutations
Use executeWithCacheUpdate when a mutation creates, deletes, adds, removes, or reorders items and the server response does not refetch the parent list. The mutation response is normalized before the callback runs.
Root query list add:
await CreateAlbumMutation(client).executeWithCacheUpdate(
name: name,
update: (cache, data) async {
final current = await AlbumsPageData.readFrom(cache);
if (current == null) return;
await cache.writeOperation(
data: AlbumsPageData(
albums: [
...current.albums,
AlbumWidgetRef.fromId(data.createAlbum.id),
],
),
);
},
);
Entity child list add:
await AddGifToAlbumMutation(client).executeWithCacheUpdate(
albumId: albumId,
title: gif.title,
url: gif.url,
previewUrl: gif.previewUrl != null ? Some(gif.previewUrl) : const None(),
update: (cache, data) async {
final current = await AlbumWidgetRef.fromId(albumId).readFrom(cache);
if (current == null) return;
if (current.gifs.any((g) => g.url == data.addGifToAlbum.url)) return;
await cache.writeFragment(
data: AlbumWidgetData(
id: current.id,
name: current.name,
tag: current.tag,
gifs: [
...current.gifs,
AlbumWidget_gifs(
id: data.addGifToAlbum.id,
title: data.addGifToAlbum.title,
url: data.addGifToAlbum.url,
),
],
),
);
},
);
Entity child list remove:
await RemoveGifFromAlbumMutation(client).executeWithCacheUpdate(
albumId: albumId,
gifId: gifId,
update: (cache, data) async {
if (data.removeGifFromAlbum != null) return;
final current = await cache.readFragment<AlbumWidgetData>(
fragmentName: 'AlbumWidget',
entityKey: AlbumWidgetData.entityKey(albumId),
decoder: AlbumWidgetData.fromCache,
);
if (current == null) return;
await cache.writeFragment(
data: AlbumWidgetData(
id: current.id,
name: current.name,
tag: current.tag,
gifs: current.gifs.where((g) => g.id != gifId).toList(),
),
);
},
);
List update rules:
- Use
cache.writeOperation for root operation data.
- Use
cache.writeFragment for one normalized entity's fragment data.
- Preserve all required fields from the existing cached value when constructing replacement data.
readFrom, readOperation, and readFragment can return null when data is absent or incomplete.
- Guard duplicate inserts with stable ids or unique fields.
- Only write generated refs such as
AlbumWidgetRef.fromId(id) after the mutation response includes enough fields to satisfy that fragment.
- For queries with variables, pass the same variables to
readOperation/writeOperation/evictOperation; argument values are part of the normalized field key.
- Use
cache.evictOperation(name: ..., variables: ...) (or the generated ClassNameData.evictFrom(cache, variables: ...) shortcut) to drop a cached operation's root entry entirely instead of overwriting it — for example when a mutation invalidates a query rather than producing data to merge. It only unlinks the operation's root field(s); referenced entities are reclaimed by the next GC sweep if nothing else keeps them reachable, and it's a no-op (false) when nothing matched.