Skip to content

Query Cache

vyuh_query_cache is the client-side data-consistency layer for Blueprint applications. It sits between a fixed Blueprint protocol client and the Studio UI. The Blueprint remains the source of entity relationships; the cache derives invalidation impact rather than asking each product to maintain a hand-written dependency table.

Read lifecycle

Every read has a QueryDescriptor with two parts:

  • a canonical QueryCacheKey containing the entity, operation, query inputs, and cache partition;
  • a set of QueryDependency entries naming entity families or exact records read by the query.

The partition must include every value that can change what the caller is allowed to see: actor, tenant, site, locale, and the permission/policy fingerprint when applicable. This prevents a value loaded in one security or scope context from being reused in another.

For cacheFirst reads the cache returns a fresh value immediately. A retained but stale value can be returned while one background refresh runs. Concurrent reads for the same canonical key share that in-flight request. refresh, cacheOnly, and networkOnly modes cover explicit refresh, offline reads, and uncached operations.

Mutation and invalidation lifecycle

After a successful action, the writer announces a BlueprintMutation. When the cache is created, it traverses the effective Blueprint graph and compiles the affected entity set for every entity type into a BlueprintInvalidationIndex. The mutation hot path is therefore a map lookup, not a graph traversal. The default depth is one relationship hop; applications may choose zero or the complete connected component.

This is intentionally type-safe but conservative:

  • an exact mutated record invalidates exact reads of that record and entity family reads such as lists, counts, and aggregates;
  • related entity families are invalidated because relationship projections can include the changed data;
  • unrelated entity families remain warm;
  • a generation guard prevents an older in-flight response from repopulating a key after its mutation.

The fixed action protocol already returns ActionRecord.results. Every entityState result announces only its own entity. The client then expands the impact through the compiled index. An integration may alternatively return an authoritative mutation envelope when one request changes several entities:

json
{
  "mutations": [
    {
      "entity_type": "mfg.equipment",
      "entity_id": "equipment-1",
      "type": "update",
      "values": {"site_id": "site-1"},
      "metadata": {"action": "move_equipment"}
    }
  ]
}

If that envelope is absent, CachedBlueprintProtocolClient announces the direct action entity as a safe fallback. There are no product-specific cache invalidation lists.

Relationships across modules

Relationship targets use stable qualified entity types. Cross-module validity is explicit: the source module depends on the target module, and the target module exports the entity.

dart
const Relationship(
  name: 'assigned_group',
  targetEntity: 'iam.user_group',
  kind: RelationshipKind.belongsTo,
  field: ActivityFields.assignedToGroupId,
);

final elogModule = Module(
  name: 'elog',
  dependsOn: ['iam'],
  // ...
);

final iamModule = Module(
  name: 'iam',
  exports: [EntityExport('user'), EntityExport('user_group')],
  // ...
);

Physical relationships use the default RelationshipStorage.foreignKey and lower to Postgres constraints. Polymorphic and association-derived links use logical relationships. They remain first-class graph edges but emit no column or constraint:

dart
const Relationship(
  name: 'assignee_user',
  targetEntity: 'iam.user',
  kind: RelationshipKind.belongsTo,
  field: RoleAssignmentFields.assigneeId,
  storage: RelationshipStorage.logical,
  discriminatorField: 'assignee_type',
  discriminatorValue: 'user',
);

A role can also declare direct logical manyToMany edges to iam.user and iam.user_group. This expresses the domain impact explicitly, allowing a role mutation to invalidate those entity families in one hop without teaching the role writer anything about cache invalidation.

Blueprint UI consumption

vyuh_blueprint_ui decorates the fixed protocol client once the effective app Blueprint is loaded. Collection reads then use the cache automatically, while successful action execution emits invalidations back into AppBuilder. The visible collection refreshes immediately; hidden collections are invalidated without causing a background network storm.

dart
runBlueprintApp(
  queryCachePolicy: const QueryCachePolicy(
    freshFor: Duration(seconds: 45),
    retainFor: Duration(hours: 8),
    maxEntries: 750,
  ),
  relationshipInvalidationDepth: 1,
  queryCachePersistence: browserOrDevicePersistence,
);

The cache partition is assembled from stable application, deployment, actor, tenant, and site ids after actor-first bootstrap. Display labels are excluded: they can change and can be shared by distinct security scopes. A production authorization integration should also add a stable permission/policy fingerprint whenever the same actor and scope can receive different projections.

Offline boundary

The core package deliberately defines only QueryCachePersistence. IndexedDB, SQLite, or another local database belongs in a platform adapter. The same retention rules validate restored values, so adding an adapter does not fork the read or invalidation model. Mutation queues, conflict resolution, optimistic writes, and server reconciliation are the next offline layer; they should not be hidden inside the read cache.

Observability

VyuhQueryCache.events exposes hits, misses, fetches, refreshes, invalidations, and errors. invalidations exposes the mutation, affected entity types, and removed keys. Studio diagnostics can consume these streams without coupling the core package to Flutter.

Blue is the Vyuh Blueprint documentation surface.