Skip to content

Startup and configuration

function

declare function startup(options: StartupOptions): Promise<Root>;

Initializes QLive by

Parameters

options

interface

interface StartupOptions

QLive startup options.

views?: ViewModules;

The application’s view modules, as produced by import.meta.glob().

Vite resolves import.meta.glob() at build time, relative to the file it appears in, and only accepts literal patterns – so the call has to happen in the application, which knows where its views live, and the resulting map is handed to QLive here:

await startup({views: import.meta.glob("./app/**\/*.tsx"), ... })

Without the eager option the map holds loader functions, so a view module is only fetched once loadView() actually asks for it.

Optional: an entry point that renders one fixed page – a login page, say – resolves no routes and has nothing to register.

init?: (config: QLiveConfig) => Promise<void>;

The init function can be used to modify system behavior after the initialization is done but before a view is rendered.

Parameters

config Initialized config
render?: FunctionComponent<{
config: QLiveConfig;

In case there are no views defined, the user can give a render function to render the first components after initialization. The component is rendered in strictMode if that is set to true.

root?: string | FunctionComponent<any>;

Defines the behavior for visiting the root URL (e.g. /app). If it is a string, we internally redirect to the view with that name. Otherwise, it has to be a function component.

strictMode?: boolean;

Whether to wrap the views in React.StrictMode. Default is true

fixture?: QLiveFixture;

Runs the application on the given fixture instead of on the data a server sends, see initFixture(). Takes the place of the bootstrap the page would otherwise read or fetch; everything else is the same.

function

declare function noSchema(): void;

Declares that this entry point needs no domain schema.

Call it at the top level of an entry module. The call does nothing at runtime and exists to be seen by the build’s track-usage analysis, which the server reads: a path whose module declares this gets the reduced bootstrap – context path, CSRF token and injections, with an empty schema and empty meta data in place of the domain. On a large domain that is the difference between shipping the whole introspection result and shipping none of it.

Only for entry points that issue no queries and render no application view: a login page, an error page, a public landing page. Anything that resolves a route or reads an injection with a query needs the schema and will fail on its first type lookup without it.

function

declare function initFixture(fixture: QLiveFixture): Promise<QLiveConfig>;

Initializes QLive from the given fixture, the way init() does from the bootstrap a server sends, and runs it on that fixture from then on.

A view running on a fixture runs unchanged. Its injections come out of the fixture, and its query documents answer update() in the browser: they filter, sort and page the rows the fixture holds, as localDocument() does. Nothing else goes anywhere – graphql() rejects, so nothing is written, and subscribeToTopic() subscribes to nothing, so nothing is pushed.

This is for whatever renders a view without startup(): a Storybook decorator, a component test, an island on a static page. startup({fixture}) runs a whole application on one.

initFixture(fixture)
createRoot(element).render(<FooList/>)

The config and the injections are module state: initializing another fixture replaces the injections of the last. To run several views on fixtures on one page, add each with addFixture(), or render each in a FixtureScope.

Parameters

fixture the fixture, e.g. one recorded from a running application in dev mode

Returns the initialized config

function

declare function addFixture(fixture: QLiveFixture): Promise<QLiveConfig>;

Adds the given fixture to those the page runs on, for several views on fixtures on one page: a docs page with several demos, a canvas with several stories. FixtureScope calls it; this is for code that sets up a page without React.

The fixture’s injections go in next to those already loaded. Injection ids carry the route of their view, so the fixtures of different views don’t clash. Where QLive isn’t initialized yet, this initializes it as initFixture() does. Otherwise the fixture has to fit the page:

  • The page has to run on fixtures. graphql() and subscribeToTopic() ask isFixture(), which holds for the whole page, so a page can’t be live in one place and on a fixture in another.
  • Its schema has to have the same types, or its data would convert by the wrong one without any sign of it. A reduced config, one without the schema, fits any; where one fixture has the full config and another the reduced one, the full one is kept.
  • An injection id already loaded has to come with the same data. Two fixtures for one route can’t share a page.

The authentication, CSRF token, locale, translations and layouts stay those of the fixture that initialized the config.

Parameters

fixture the fixture

Returns the config the page runs with

function

declare function FixtureScope({ fixture, children }: FixtureScopeProps): JSX.Element;

Runs the view below on the given fixture, at the fixture’s route. For whatever renders a view without startup(): an island on a static page, a Storybook story, a component test.

<FixtureScope fixture={sortingFixture}>
<Sorting/>
</FixtureScope>
<FixtureScope fixture={filtersFixture}>
<Filters/>
</FixtureScope>

Any number of scopes can share a page, one per route: the fixture goes in next to the others with addFixture(), and the view reads its injections by the route a ViewRoute gives it. A fixture that doesn’t fit the page – another application’s, another recording for a route already there, a page running on a server – throws while rendering, for the nearest ErrorBoundary to show.

Two scopes with the same fixture share its documents: sort the view in one, and the other sorts with it.

type

type FixtureScopeProps = {
/**
* The fixture the view below runs on, e.g. one recorded from a running application in dev mode
*/
fixture: QLiveFixture;
children: ReactNode;
};

function

declare function isFixture(): boolean;

Returns true if QLive runs on a fixture, see initFixture().

A view runs the same either way and normally has no reason to ask. Where it shows something only a server makes work – a save button, say – this is how it can tell.

function

declare function config(): QLiveConfig;

type

type QLiveConfig = {
/**
* Relative path of the QLive server
*/
contextPath: string;
/**
* GraphQL schema for this application
*/
schema: GraphQLSchema;
/**
* Domain meta information for the schema.
* Contents vary with MetadataProvider configuration. QLive comes with `maxPageSize` and query configuration by type
* which is transmitted here.
*/
meta: DomainMeta;
/**
* Every row of every catalog type, by type name, in the wire format; read them with catalogRows(). Empty in a
* reduced config, and absent from a fixture recorded before there were catalogs.
*/
catalogs?: Record<string, Record<string, unknown>[]>;
csrfToken?: CSRFToken;
/**
* Who is looking at this page. Not part of the config the server renders -- that one is per module and
* shared by everyone it is served to -- but put here on arrival, the way the CSRF token is, because
* this is where an application looks things up.
*/
authentication?: Authentication;
/**
* Language tag of the locale the page is served in, `"en"` or `"de-CH"`: the one of the locales the server
* offers that the browser's language settings match best. Put here on arrival, like the authentication.
*/
locale?: string;
/**
* Cached set of the names of QueryDocument<T> equivalent types in the application
*/
queryDocumentTypes?: Set<string>;
/**
* Cached lookup for GraphQL types by name
*/
typesByName?: Map<string, GraphQLType>;
/**
* The component rendered in place of a view QLive could not produce -- a path no view answers, a view
* module that failed to load.
*
* The one member of this config an application writes rather than reads. An initialized config always
* carries one, so a caller can render it without a fallback of its own; assign your own in startup()'s
* init hook, which runs after the config is initialized and before anything is rendered.
*
* await startup({
* views: import.meta.glob("./app/**\/*.tsx"),
* init: async config => {
* config.errorView = MyErrorPage
* }
* })
*/
errorView?: ComponentType<ErrorViewProps>;
/**
* What i18n() looks its tags up in: a tag, `"Filter {0}"` or `"Foo.name"`, to its translation, which holds the
* same placeholders. A tag without one renders as itself in brackets. The server sends the translations into the
* page's locale with the bootstrap, and they are put here on arrival. An application changing or adding to them
* does so in startup()'s init hook, like it does errorView.
*
* config.translations = {...config.translations, "Filter {0}": "{0} filtern"}
*/
translations?: Record<string, string>;
/**
* Widgets and decorators for the form fields the application doesn't name one for, matched by where the field is
* and what it is, see WidgetOverride. Written by the application like errorView, in startup()'s init hook.
*
* config.widgets = [{fieldType: "Money", widget: MoneyInput}]
*/
widgets?: WidgetOverride[];
/**
* The named layouts, which `<Form layout="name"/>` renders (namedLayout()). The server sends its own with the
* bootstrap (its LayoutProviders), and they are put here on arrival, an empty map where there are none. The
* application's join them in one namespace: added, changed or replaced in startup()'s init hook, like the
* translations.
*
* config.layouts = {...config.layouts, fooCompact: {domainType: "Foo", nodes: [{field: "name"}]}}
*/
layouts?: Record<string, Layout>;
};

type

type QLiveBoostrap = {
/**
* System config
*/
config: QLiveConfig | null;
/**
* CSRF-Token needed to POST stuff
*/
csrfToken: CSRFToken;
/**
* The current user
*/
authentication: Authentication;
/**
* Language tag of the locale the page is served in, the one of the locales the application offers that the
* browser asked for. Absent from a fixture recorded before there were translations, like `translations`.
*/
locale?: string;
/**
* Tag to translation in that locale, which init() puts into the config.
*/
translations?: Record<string, string>;
/**
* Layout name to layout, which init() puts into the config. Absent from a page without the schema, and from a
* fixture recorded before there were layouts.
*/
layouts?: Record<string, Layout>;
/**
* Injection data
*/
data: {
[key: string]: InjectionSource;
};
};

Entry-point boostrap data.

type

type QLiveFixture = QLiveBoostrap & {
/**
* What the fixture holds and where it came from, for people: the recorder gives the time, location, login and
* rows it recorded; a fixture written by hand or generated says what it is made of.
*/
description: string;
/**
* Route of the view the fixture was recorded on, as routeOf() gives it: "grid/sorting". The injection ids in
* `data` start with it, and FixtureScope renders its view at it. A route rather than the location path, which
* only resolves under the base of the application it was recorded in.
*/
route: string;
};

Bootstrap data a view runs on without a server, see initFixture().

A bootstrap like any other but for its query document injections, which hold every row instead of a page of them: rows is the whole result, rowCount its length, and config the one the view injected with, so the view opens on the page it opens on live.

type

type Authentication = {
/**
* Login name of the current user
*/
login: string;
/**
* Roles of the current user.
*/
roles: string[];
/**
* Id of the current user (Usually pointing to the app_user table)
*/
id: string;
};

Who the page is being served to, as AppAuthentication describes them on the Java side.

Anonymous is an answer and not an absence: a page served to nobody in particular carries the anonymous login, its role and its fixed id, so a reader never has to handle “not logged in” as a missing value.

type

type CSRFToken = {
/**
* Name of the hidden input field carrying the token inside an HTML form POST
*/
param: string;
/**
* HTTP header that contains the token value for a fetch POST or similar.
*/
header: string;
/**
* CSRF token value
*/
value: string;
};

Cross-Site Request-Forging protection token handling.

interface

interface DomainMeta

Domain meta information from the server.

Declared as an interface, not a type alias, so that an application can extend it. The server-side io.github.qlivedev.qlive.graphql.meta.DomainMeta is an open map that every MetadataProvider bean adds its own addenda to, and declaration merging is the client-side equivalent: name the addenda your providers write once and they are typed at every place the application reads config().meta.

declare module "@qlivedev/qlive-ts" {
interface DomainMeta {
myAddendum: MyAddendumInfo[]
}
}
types: {
[typeName: string]: DomainTypeMeta;

Contains type names mapped to TypeMeta

genericTypes: GenericTypeInfo[];
relations: RelationInfo[];
manyToMany: ManyToManyInfo[];

interface

interface DomainTypeMeta

Meta data for a single GraphQL object type.

fields?: {
[fieldName: string]: DomainFieldMeta;

Field meta data by field name. Absent if no provider wrote field meta data for this type.

meta?: DomainTypeMetaProps;

Type meta data. Absent if no provider wrote type meta data for this type.

interface

interface DomainTypeMetaProps

Type-level meta data properties, written server-side with DomainTypeMeta#setMeta.

Extend by declaration merging, see DomainMeta.

nameFields?: string[];

Names of the fields naming an instance of the type to a user, most significant first. Written by QLive’s NameFieldProvider.

A grid column of a relation and pick() show a row by these, so under vite dev a query selecting the type without all of them logs a warning the first time it is used. Mutations are not checked.

uniqueKeys?: UniqueKeyInfo[];

The unique constraints of a table-backed type, the primary key first. Written by QLive’s UniqueKeyProvider from the jOOQ tables; unique indexes that are not constraints are not included.

queryConfig?: QueryConfigDelta;

What querying rows of this type looks like when nothing says otherwise: a page size, a sort order, a condition every query of the type carries. A delta, so a type naming only a page size leaves the rest at the defaults a query config has anyway.

Written server-side by QLive’s QueryConfigMetadataProvider, which an application registers as a MetadataProvider bean if it wants type-level defaults at all – absent everywhere in one that does not.

The server applies this to the queries a view injects with useInjection(), whose parameters are static and have no config to update. Nothing on the client applies it: read it where you build a query config from scratch and want it to start where the injected ones start.

maxPageSize?: number;

The largest page any query over rows of this type comes back with. Absent where the type sets no limit, which is also what a page size of 0 asks for.

Written server-side by QLive’s QueryConfigMetadataProvider and enforced there: a config asking for a larger page – or for all rows – is held to this, and the config that comes back on the document says so. Read it to keep a page size control from offering what the server will not give, not to enforce anything.

catalog?: boolean;

true for a catalog type: a closed set of rows, constant while the application runs, which the bootstrap ships whole (see catalogRows()). A picker filters them in the browser rather than asking the server. Written by QLive’s CatalogMetadataProvider.

merge?: MergeTypeMeta;

What this type declares about merging it: whether a conflict comes back to the view to resolve, which fields never count as one, and whether a non-overlapping change may merge silently.

Written server-side by QLive’s MergeMetadataProvider, which an application registers as a MetadataProvider bean if it declares any of this – absent everywhere in one that does not, and on every type that declared nothing.

Whether a type takes part in merging at all is not in here: that is the type having a “version” field, which both ends derive from the schema. Read it through the functions in merge/meta rather than off the map, so that “declared nothing” and “no such type” answer the same way.

interface

interface UniqueKeyInfo

One unique constraint of a table-backed domain type.

name: string;

Name of the constraint in the database.

fields: string[];

Fields of the type the constraint covers, in constraint order.

primary: boolean;

Whether this is the primary key.

nullable: boolean;

Whether any of the fields is nullable. The database lets any number of rows hold NULL in a unique constraint, so such a constraint does not make rows distinct – a sort covering it can still have ties.

interface

interface DomainFieldMeta

Field-level meta data properties, written server-side with DomainTypeMeta#setFieldMeta.

Extend by declaration merging, see DomainMeta.

computed?: boolean;

true if the field is a QLiveComputed-annotated property. Written by QLive’s ComputedMetadataProvider.

editable?: boolean;

false if a form shows the field without editing it, see defaultFieldMode(). Written by an application for columns only the server writes, such as created and modified stamps. A primary key, a computed field and the version field are not edited without it.

maxLength?: number;

The most characters a string field holds – or elements a list field, or entries a map field. Written by QLive’s SizeMetadataProvider from the field’s @Size(max = n), which jOOQ generates from the column length, so a table-backed field of limited length has it. Absent where nothing limits the field.

minLength?: number;

The fewest characters a string field holds – or elements a list field, or entries a map field. Written by QLive’s SizeMetadataProvider from the field’s @Size(min = n). jOOQ never generates a minimum, so only a field that was given one by hand has it.

precision?: number;

The most significant digits a BigDecimal or BigInteger field holds, for a BigDecimal on both sides of the point together. Written by QLive’s DecimalMetadataProvider from the field’s @Column(precision = p), which jOOQ generates for a numeric(p, s) column. Absent for an unconstrained numeric, and on every field that is neither.

scale?: number;

The digits a BigDecimal field holds after the point. Written by QLive’s DecimalMetadataProvider from the field’s @Column(scale = s), wherever it writes a precision – 0 then means the field holds integers. Never on a BigInteger, whose scale is 0 by definition.

type

type GenericTypeInfo = {
/**
* GraphQL Type name
*/
type: string;
/**
* TypeParameters as GraphQL type names
*/
typeParameters: string[];
/**
* Full-qualified Java class name of the original generic class. Mostly useful as constant in the TS world
*/
genericType: "io.github.qlivedev.qlive.model.QueryDocument" | string;
};

Meta-information about types that are generic types on the Java side.

For example, io.github.qlivedev.qlive.model.QueryDocument is the generic Java class for query documents. In the GraphQL world, the same type is called FooDocument with Foo replacing the generic T.

type

type RelationInfo = {
/**
* Right side type of the relation
*/
targetType: string;
/**
* Generated GraphQL field of the left / source side of the relation
*/
leftSideObjectName?: string;
/**
* Generated GraphQL field of the right / target side of the relation
*/
rightSideObjectName?: string;
/**
* SourceField enum. Controlled the GraphQL fields on the left side
*/
sourceField: SourceField;
/**
* Target field enum. Controlled the GraphQL fields on the right side
*/
targetField: TargetField;
/**
* Left side type of the relation
*/
sourceType: string;
/**
* Meta tags
*/
metaTags: string[];
/**
* Name of the relation
*/
id?: string;
/**
* Fields on the left side table forming the foreign key
*/
sourceFields: string[];
/**
* Fields on the right side table the foreign key is pointing to
*/
targetFields: [string];
};

Describe a relation within the domain

type

type ManyToManyInfo = {
/**
* Domain type of the link table, e.g. "BarLink"
*/
linkType: string;
/**
* End the declaration's first link column points at
*/
left: ManyToManyEnd;
/**
* End the declaration's second link column points at
*/
right: ManyToManyEnd;
/**
* Whether the fields of both ends can be written, i.e. whether a link row can be inserted from its two link
* columns alone
*/
writable: boolean;
};

A many-to-many declared with configureManyToMany() or withManyToMany(): a link type whose rows each associate a row of one end with a row of the other.

type

type ManyToManyEnd = {
/**
* Domain type of this end, e.g. "Bar"
*/
type: string;
/**
* Field of the link type pointing at this end, e.g. "barId"
*/
linkField: string;
/**
* Field on this end's type listing the rows on the other end, e.g. "bazes". Absent where the declaration
* gave this end none.
*/
field?: string;
};

One end of a declared many-to-many: its type, the field of the link type pointing at it, and the field it gets listing the rows on the other end.

type

type SourceField =
/**
* Ignore field for source type.
*/
"NONE" |
/**
* Define a scalar GraphQL field for the key itself (e.g. fooId : string)
*/
"SCALAR" |
/**
* Define an embedded object for the target
*/
"OBJECT" |
/**
* Define a field for the key iteself *and* define an embedded object.
*
* This is useful in situations where you want the embedded object in some cases, but in others you
* want to save one level of querying because all you need is the target id.
*/
"OBJECT_AND_SCALAR";

Source field configuration for a relation. Defines what fields to add for the relation on the side where the foreign key is.

type

type TargetField =
/**
* Do nothing on target side.
*/
"NONE" |
/**
* Assume the foreign key to represent a one-to-one relationship and embed a single object as back reference.
*/
"ONE" |
/** Assume the foreign key to represent a many-to-one relationship and embed a list of back references.*/
"MANY";

Target field configuration for a relation. Defines what fields should be added for the relation to the side the foreign key points to.

function

declare function i18n(tag: string, ...args: string[]): string;

Translates the given tag, filling in the given arguments.

The tag is the static text, the arguments what varies: i18n("Filter {0}", label). The translation comes from config().translations, which the server fills for the locale the page is served in, and has the same placeholders, {0} for the first argument, {1} for the second and so on, as in Java’s MessageFormat. A tag without a translation renders as itself in brackets, its arguments appended: [Filter {0}: Name], so a missing translation shows, and a tag with placeholders doesn’t read well with the arguments filled in.

The analysis records the tag of every call, which is what the server offers translations of. It records only a tag that is static: a string literal, not a variable holding one, so write the tag out at the call. The one exception is a tag named after the domain – a type, Type.field, Enum.VALUE – which the server offers for every type with a table whether a call names it or not, and which QLive builds from the names it renders.

Parameters

tag translation tag
args non-static arguments

function

declare function findRoot(id?: string): HTMLElement;

Find the root container in a QLive template.

Parameters

id id of element to find, default is “root”, used by QLive for the root container