ProjectionHost
Authorized, tenant-scoped execution and delivery of versioned Fabric view projections.
ProjectionHost
@fabricorg/projection-host is the vertical-neutral query counterpart to the governed mutation Host.
It executes registered ViewContracts through one small interface:
interface ProjectionHost {
project<State = unknown>(query: ProjectionQuery): Promise<ProjectionResult<State>>;
}Behind that interface the Host authorizes the read, validates scope, selects a tenant-scoped snapshot, enforces freshness and offline rules, incrementally reads canonical events, replays the view, refuses unsafe checkpoints, and persists the new snapshot. Applications do not reproduce those rules in each GraphQL resolver, SDUI composer, API route, or agent tool.
Create a Host
import {
createProjectionHost,
PostgresProjectionEventSource,
PostgresProjectionSnapshotStore,
} from "@fabricorg/projection-host";
const projections = createProjectionHost({
views: module.views ?? [],
eventSource: new PostgresProjectionEventSource(sql),
snapshotStore: new PostgresProjectionSnapshotStore(sql),
authorize: async ({ actor, scope, view }) =>
policy.canRead(actor, scope, view)
? { allowed: true, decisionId: "opaque-decision-reference" }
: { allowed: false, reason: "read denied" },
onQuery: recordQueryEvidence,
});The PostgreSQL adapters use a narrow SQL client, read the canonical Platform Host event ledger with
tenant-scoped cursor constraints, and save snapshots with an atomic compare-and-swap update. The
included in-memory adapters exist for local execution and interface-level tests. Event adapters may
return events in any order; the Host applies the deterministic ordering and version rules from
replayView. If an adapter can no longer honor a snapshot cursor, it throws
ProjectionCursorUnavailableError and the Host performs a cold rebuild.
Snapshot records persist both the global cursor and subjectSequences, a per-subject high-water map.
Values use explicit subjectSequence when an event source supplies it; otherwise they preserve the
latest global ledger cursor seen for that subject without claiming contiguity. Incremental replay uses
those checkpoints to avoid reapplying an event already represented by durable state and detects gaps
only when subject-local continuity was explicitly declared. Legacy snapshots that
lack these checkpoints are rebuilt rather than trusted for incremental replay. An event source may
declare that its stream begins after a snapshot only when the snapshot has a durable event cursor; the
replay API fails closed if that cursor is absent.
Query contract
const result = await projections.project({
view: { name: "module/summary", version: "1" },
scope: { tenantId, spaceId, subjectType, subjectId },
actor: { id: principal.id, type: principal.type },
parameters: { region: "east" }, // required when the view declares parameterSchema
consistency: "current", // current | bounded-stale | offline
maximumAgeSeconds: 15, // may tighten, never relax, the view declaration
minimumCheckpoint: { eventSequence: writeEventSequence },
checkpointTimeoutMs: 2_000,
});Actor, tenant, and space values must be derived from authenticated server context. A query argument, path, host header, SDUI document, or cached record is not an authorization oracle. Authorization runs before either the snapshot store or event source is touched.
Views can declare a portable parameterSchema. ProjectionHost validates required parameters before
authorization, passes them to authorization and event-source adapters, and adds a canonical
SHA-256 parameter identity to the snapshot key. Parameter values are not persisted in snapshot
records or query audit records. The identity prevents two variants of one view from sharing state.
Missing, extra, or invalid parameters return ProjectionParameterValidationError.
minimumCheckpoint is a lower bound on the tenant/space event ledger. When the requested position
is newer than the cached projection, the event source must implement waitForCheckpoint; the
PostgreSQL source polls asset_events within checkpointTimeoutMs (five seconds by default).
Checkpoint polling starts only after authorization and always includes the requested tenant and
space. If the ledger does not reach the requested position in time, the Host throws
ProjectionCheckpointTimeoutError with code PROJECTION_CHECKPOINT_TIMEOUT rather than returning
data as if it were current. Checkpoint waits are not available with offline consistency.
onQuery receives an audit record for allowed and denied attempts. Denied records contain the reason
but no projection state. Successful calls return isolated state graphs, including calls coalesced by
the same in-process refresh, so one resolver cannot mutate another caller's result.
Consistency and offline behavior
| Mode | Behavior |
|---|---|
current | Resume from a compatible snapshot, read later events, replay, and save a new snapshot. |
bounded-stale | Return a snapshot only while it is inside the effective freshness bound; otherwise refresh it. |
offline | Never touch the event source. The view must declare offlineUsable: true, declare maximumAgeSeconds, and have a fresh snapshot. |
The effective freshness bound is the stricter of the view declaration and the caller request. A caller
cannot extend a view's declared lifetime. Snapshot keys always bind view name and version, tenant,
space, optional subject identity, and the canonical parameter identity. Views that change their state
shape should list compatible older snapshot versions and provide upgradeSnapshot(data, fromVersion);
the Host persists the upcast state at the current snapshot version and cold-rebuilds incompatible
snapshots.
Integrity failures
The mutation Host can retry work; ProjectionHost must not manufacture a plausible read model from an incomplete stream. It refuses to save or return a refreshed projection when replay reports:
- a missing explicitly declared subject sequence;
- an unsupported event schema version;
- an incomplete subject scope; or
- a cursor mismatch that the event-source adapter did not explicitly convert into a cold rebuild.
These failures use PROJECTION_INTEGRITY_FAILED. A snapshot-store record whose identity differs from
the requested tenant-scoped key fails with PROJECTION_SNAPSHOT_SCOPE_MISMATCH; it is never returned.
Concurrent refreshes for the same full snapshot key are coalesced within a Host instance. Across Host
instances, ProjectionSnapshotStore.save is a compare-and-swap operation: an adapter must return
conflict when its existing checkpoint no longer matches the Host's expected record, preventing a
stale writer from regressing a newer snapshot.
Generated GraphQL seam
@fabricorg/gen-graphql emits exact viewIds and a resolveProjection helper. Query resolvers pass
server-derived actor and scope context to that helper, which calls ProjectionHost.project. Generated
queries never access a projection table directly. Mutations continue to use
createGovernedActionHost().submitAction().
What this package does not own
ProjectionHost contains no layout, components, themes, renderer behavior, SDUI documents, commerce
assumptions, or other vertical vocabulary. A vertical defines view meaning and supplies adapters;
Platform owns the safe execution mechanics. Screen composition and delivery remain an experience-layer
responsibility outside @fabricorg/platform core.