Engine and Resources
ViewEngine is the one object a host builds once, at application start, and shares across the application: it registers the definitions (resources), holds the store that keeps saved views, manages the open runtimes, and is the only entry point for the commands that open, create, save, rename and delete. There is one entry point because every write takes one path — so the default UI and a hand-built one behave the same, and every outcome that is not a success lands in the same three recovery actions (retryWrite, abandonWrite, resolveConflict).
Construct it; do not extend it. It is @sealed, and its base class EngineResources is how the file is split, not an extension point.
Guides: Getting Started with the View Engine (step 6 builds the engine and its resources); Fitting the View Engine into a Host (why one engine serves the whole application); View Engine Core Concepts (the views and runtimes the engine manages).
Building an engine
Each resource pairs a definition with the source of its data; every page over one source shares its queries, preferences and descriptors. MemoryViewStore forgets on reload, which is enough for development and tests; on a Wow service use WowViewStore instead.
import { MemoryViewStore, ViewEngine } from '@ahoo-wang/wow-view-engine';
import { browserRuntimeEnvironment } from '@ahoo-wang/wow-view-engine/react';
export const engine = new ViewEngine({
resources: [{ definition: ordersDefinition, source }],
store: new MemoryViewStore(),
// A failure is still shown where it happens; this copy is for your logs.
environment: browserRuntimeEnvironment({
onError: ({ kind, error }) => console.error(`[view-engine] ${kind}`, error),
}),
});Options
| Option | Role |
|---|---|
resources | Every resource the host registers: each definition, with the source of its data. A board's definition has no source — it queries nothing of its own |
store | The port that keeps saved views and preferences (persistence port) |
resolveOptions | Where the remote candidates of reference fields come from, by the same key |
kinds | The field kind registry; the built-in kinds when left out (definitions and field kinds) |
text | How the definitions' keys (text(key)) are said in the language the engine starts in, for a host with no Provider; under a ViewHost or MessagesProvider they are said in its language. Left out, a key reads as itself until a language is set |
limits | The host's budgets over DEFAULT_RUNTIME_LIMITS; what is left out keeps its default. The source budgets (maxPageSize, maxPageWindow, maxAnalysisRows, maxQueryFilterNodes, maxFilterValues) come from a source's descriptor, and one given here only lowers it — leave them out unless the engine should ask for less than the server admits |
environment | The only interface between a runtime and its host: clock, time zone, timers, page visibility, failure reports (RuntimeEnvironment) |
newId | Makes idempotency keys; overridden in tests to keep them readable |
onIssue | Findings with no caller to reject and nothing thrown behind them: a definition's admission, a list entry that was dropped, a change listener that threw. Failures — a query, a store call, an export, a render — go to environment.onError instead. Left out, a development build (NODE_ENV of development) writes them to the console, grouped by resource, each with how to fix it; any other build drops them |
Why two channels, onIssue and onError: the first is the host's declarations being wrong, fixed in code; the second is a failure met at run time, which the screen has already said in place — this copy is for monitoring.
export interface ViewEngineOptions {
environment?: RuntimeEnvironment;
kinds?: FieldKindRegistry;
limits?: Partial<RuntimeLimits>;
newId?(): string;
onIssue?(issue: Issue): void;
resolveOptions?(key: string): OptionSource;
resources: readonly ViewResource[];
store: ViewStore;
text?(key: string): string | undefined;
}ViewResource
One thing a host registers: a definition, and where its data comes from. A data definition's source is where its rows come from; a board has none.
export interface ViewResource {
definition: ViewDefinition;
source?: ViewSource;
}Data source
ViewSource is three of QueryApi's methods, so wow-client's snapshot query client satisfies it as it is; another backend, or a test, writes one by hand (a test uses /testing's memorySource).
- Cancellation is an
AbortControllerrather than theAbortSignalthe other ports take, because that is whatQueryApiaccepts, and this port exists so that a wow-client query client plugs in unchanged. The controller is the engine's: it aborts it when a newer request takes this one's place or the runtime is disposed. A source written by hand readsabortController?.signaland never aborts it. aggregateanswersRecordData[]: every value arrives asunknownand is read through a field's kind, rather than trusting a row type the caller asserts.describeis optional: the source's capability descriptor. The engine reads it once per source before the first view over it runs, narrows each definition to what it admits and takes its limits, and checks it again on its own schedule; asked with the version already held, an unchanged one answersnotModified. wow-client'sdescribeSnapshotordescribeEventStreamfits it as it is. Left out, a view runs on the definition and the default limits alone.
export interface ViewSource {
aggregate(query: AggregationQuery, attributes?: Record<string, unknown>, abortController?: AbortController): Promise<RecordData[]>;
cursor(query: CursorQuery, attributes?: Record<string, unknown>, abortController?: AbortController): Promise<CursorPage<RecordData>>;
describe?(previous?: string, attributes?: Record<string, unknown>, abortController?: AbortController): Promise<QueryDescriptorResult>;
paged(query: FilterPagedQuery, attributes?: Record<string, unknown>, abortController?: AbortController): Promise<PagedList<RecordData>>;
}A source over a Wow service is Storybook's integration walk-through's wowSource.ts: each of the four methods handed to the snapshot query client and the descriptor client.
Runtime environment
The only interface between a runtime and its host. Relative dates, the refresh timer, the "is anyone looking" question and the failures a host wants to hear about all pass through it, so the layers from model to store stay free of the DOM and of the system clock, and a test drives time without faking globals. In a browser use /react's browserRuntimeEnvironment(overrides?); without a screen, the root entry's defaultRuntimeEnvironment(overrides?).
timeZone: the IANA zone relative and preset dates are resolved in.onError: told once of every failure the engine meets doing its work — a query, a store call, an export, a render, a chart, a declared action's command. The failure is still answered on screen; this is the host's copy. Whatever it throws is dropped, and left out, nothing is logged anywhere.
export interface RuntimeEnvironment {
clearTimeout(handle: unknown): void;
now(): Date;
onError?(event: ViewErrorEvent): void;
setTimeout(callback: () => void, ms: number): unknown;
timeZone: string;
visibility: VisibilitySource;
}ViewEngine
The members a host uses most; the full signature is in the API report. The default UI already calls them for you; call them yourself when you build your own UI or write a test.
| Member | Role |
|---|---|
list(definitionId) | The system views declared in code first, then what the store holds. An id in the reserved namespace is dropped and reported: only a definition declares a system view. When the store fails, failed says why, and the declared system views are still in items |
open(instanceId, options?) | Opens a saved view, or a view declared in code without touching the store. options.scopeFilter is admitted with the config, in force from the first query |
create(definitionId, input) | An unsaved view, carrying a complete config (from defaultRecordConfig and its kin), executed at once. No permission is asked here: the first save asks |
save(runtime), saveAs(runtime, input) | The first save creates, later saves overwrite; both need a draft without errors. saveAs with scope: 'system' makes a stored system view, which needs editSystem |
rename, delete, changeAudience | Renaming carries no config, so a draft with errors does not block it. changeAudience needs the store's optional method of that name, or it is refused (view.changeAudience.unsupported) |
publishAsSystem(id) | "Publish as system view": copies the saved view — as saved, not an open draft — into a stored system view; needs editSystem |
retryWrite, abandonWrite, resolveConflict | The three recovery actions: replay under the original requestId; drop the outcome and keep the draft; reload the server's state, or overwrite against the revision the conflict reported (a new logical write, so a new requestId) |
close(runtime) | Closes one open view: disposes it and drops it from the registry. Calling only runtime.dispose() leaves the engine holding a dead runtime |
subscribe(listener) | Told whenever a write changes what a definition's list holds; answers the way to stop listening |
permissions(definitionId) | The permissions the store gives (ViewPermissions) |
setText(text, language?) | Changes how the definitions' keys are said, and in which language; ViewHost calls it for you |
export declare class ViewEngine extends EngineResources {
constructor(options: ViewEngineOptions);
abandonWrite(target: WriteTarget): void;
changeAudience(id: string, audience: ViewAudience): Promise<ViewInstance>;
close(runtime: ViewRuntime): void;
create<C extends ViewConfig>(definitionId: string, input: CreateInput<C>): RuntimeFor<C>;
definitionIssues(definitionId: string): Issue[];
delete(id: string): Promise<void>;
dispose(): void;
readonly environment: RuntimeEnvironment;
readonly kinds: FieldKindRegistry;
readonly limits: RuntimeLimits;
list(definitionId: string): Promise<ViewListing>;
open(instanceId: string, options?: OpenOptions): Promise<AnyViewRuntime>;
openRuntimes(): readonly ViewRuntime[];
pendingWrites(): ReadonlyMap<string, WriteState>;
permissions(definitionId: string): ViewPermissions;
preferences(definitionId: string): Promise<ViewPreferences>;
publishAsSystem(id: string): Promise<ViewInstance>;
rename(id: string, title: string): Promise<ViewInstance>;
reorder(definitionId: string, order: string[]): Promise<ViewPreferences>;
resolveConflict(target: WriteTarget, choice: ConflictChoice): Promise<ViewInstance | ViewPreferences | void>;
retryWrite(target: WriteTarget): Promise<ViewInstance | ViewPreferences | void>;
save(runtime: ViewRuntime): Promise<ViewInstance>;
saveAs(runtime: ViewRuntime, input: { title: string; scope: ViewScope }): Promise<ViewInstance>;
setAutoRun(definitionId: string, autoRun: boolean): Promise<ViewPreferences>;
setDefault(definitionId: string, instanceId: string | null): Promise<ViewPreferences>;
readonly store: ViewStore;
subscribe(listener: ViewChangeListener): () => void;
}What opening and creating take
scopeFilter: an outer condition in the view's own field names, admitted with the config — an order page scoped to one customer never lets an unscoped query leave, and never shows a row outside its scope.definitionId: the definition whose views the caller draws; an instance of another is refused before anything runs (view.open.other-definition).tab,filters,held: for a dashboard only — the tab it opens on, what its filters start at, the filters the host holds.
export interface OpenOptions {
definitionId?: string;
filters?: DashboardFilters | null;
held?: HeldFilters | null;
scopeFilter?: FilterTree | null;
tab?: string | null;
}export interface CreateInput<C extends ViewConfig> {
config: C;
scope: ViewAudience;
scopeFilter?: FilterTree | null;
title: string;
}CreateInput.scope is an audience only (personal or shared): a user creates for an audience, and only a definition declares a system view.
ViewListing
export interface ViewListing {
failed: Issue | null;
items: ViewInstanceSummary[];
}The full working version
- Storybook's integration walk-through wires an order object in the same steps; the bottom of the page is it running.
- Source files:
ordersEngine.ts,wowSource.ts; the engine itself issrc/runtime/viewEngine.ts.