Working sets
WorkingSet
Section titled “WorkingSet”class
class WorkingSetThe rows an application is editing, the changes it has made to them, and the one call that writes them.
A working set is a store like a query document, read through useWorkingSet(): it is mutated in place and hands out a fresh snapshot whenever it changes. Nothing in here is React, which is what lets a form library – or a form written by hand – drive it.
The rows come from queries the application already ran. register() takes the document, walks it, and remembers what every row looked like and which version it was read at; edit() hands out a draft that records what changes; merge() writes the lot in one transaction.
WorkingSet.constructor
Section titled “WorkingSet.constructor”constructor(options?: WorkingSetOptions);WorkingSet.register
Section titled “WorkingSet.register”register(document: DocumentOrSnapshot): void;Registers every row of the given query document, and the rows below them.
What it takes from a row is the version it was read at – the base every write of it is held to – and a copy of its scalar values, which is what a change is a change against. Anything with an id is an entity, whatever type it is and however deep it sits, so registering the document a view renders registers everything that view can edit.
A row of a versioned type that came without its version is registered like any other and refuses the write that would need one, naming the query that read it. Registering walks everything a query selected, most of which a view only displays, so the query that reads a lookup table for a dropdown is not the place to insist – and the field somebody does try to change still fails long before a merge could lose an update.
Registering a document again takes the rows it holds now, after a page turn say. Rows new to the working set are a change like any other and reach the subscribers, which is what moves a watch on to them – also where edit() met them first and bound them without telling anyone.
Parameters
document |
query document, or the snapshot a view holds of one |
WorkingSet.edit
Section titled “WorkingSet.edit”edit<T extends object>(row: T): T;Returns the draft of the given row: the same row with every change made to it so far, and writes to it recorded rather than applied.
const bar = ws.edit(row)bar.name = "New name"bar.bazes = [...bar.bazes, baz]A many-to-many field is set like any other field and means something else: it says which rows this one is associated with, and the merge turns the difference into associations gained and lost.
One draft per row, so two components editing the same row edit the same draft. A draft is not the
row – draft !== row – and it is read rather than kept: hold the row, call this on every render.
Handing a draft back in returns it unchanged, so calling this twice is free.
Parameters
row |
row of a registered document, or a draft of one |
Throws if the row belongs to no entity of this working set
WorkingSet.create
Section titled “WorkingSet.create”create<T extends object>(type: string, values?: Partial<T>): T;Adds a row that does not exist yet and returns its draft.
The id is generated here rather than by the database, so that new rows can refer to each other before the server has seen any of them – a new Bar and a new Baz associated with it go over in one merge.
Parameters
type |
GraphQL type name |
values |
field values the row starts with |
Returns the draft of the new row
WorkingSet.created
Section titled “WorkingSet.created”created<T extends object = any>(type: string): T[];The drafts of the rows of the given type that were created here and not saved yet, oldest first.
What a list shows above the rows its query returned: a created row is in no query result until a merge wrote it.
Parameters
type |
GraphQL type name |
WorkingSet.delete
Section titled “WorkingSet.delete”delete(row: object): void;Marks the given row for deletion. A row that was only ever created here is dropped instead: there is nothing to delete, and nothing to tell the server about.
Parameters
row |
row of a registered document, or a draft of one |
WorkingSet.raw
Section titled “WorkingSet.raw”raw<T extends object>(row: T): T;Returns the current values of the given draft as a plain object – what the row would look like with every change applied.
The way out of the working set, for anything that wants a value rather than a draft: a component that keeps its own copy, a payload for something else, a comparison.
Parameters
row |
row of a registered document, or a draft of one |
WorkingSet.accessor
Section titled “WorkingSet.accessor”accessor(row: object): MergeAccessor;The merge state of one row: what has happened to each of its fields, and the way to a decision about one two writes changed.
The plain call under useMerge(), for anything that is not a React component – a form library binding to it, a test, a headless check of whether anything is left to decide.
Parameters
row |
row of a registered document, or a draft of one |
Throws if the row belongs to no entity of this working set
WorkingSet.view
Section titled “WorkingSet.view”get view(): MergeView;Which values a draft read returns.
WorkingSet.setView
Section titled “WorkingSet.setView”setView: (view: MergeView) => void;Switches every draft of this working set over to another view at once.
The form does not change and no input has to know: a read goes through the draft, so this re-renders the same form against the user’s own values, against what is in the database, or against the two folded together.
Parameters
view |
which values to return |
WorkingSet.held
Section titled “WorkingSet.held”held(): HeldRows[];What this working set is holding: its rows by type, and the fields it registered of them.
The fields are what the queries selected, which is the closest thing to “what the form binds” that a working set can know – it hands out drafts and is never told which of their fields an input was put on. What it holds moves as rows are registered, created and dropped, so a caller that turned this into a standing subscription re-reads it whenever the working set changes.
Returns one entry per type held, in no particular order
WorkingSet.storedState
Section titled “WorkingSet.storedState”storedState(state: StoredState): void;Takes what somebody else’s write left in the database.
The fields it names become the values a “stored” read returns and the ones a merged read takes where the user has no opinion of their own; a field both writes changed becomes a conflict for the user to decide. A row this working set does not hold is not an error – with push, most of what arrives is about rows nobody here is editing.
merge() calls this for every conflict that came back, which is today’s only caller. It is public because the second one is a push message and nothing about it would differ.
Parameters
state |
the row, the version it stands at, and the fields that changed |
WorkingSet.dirty
Section titled “WorkingSet.dirty”get dirty(): boolean;true while the working set holds anything unsaved.
WorkingSet.undo
Section titled “WorkingSet.undo”undo: () => void;Takes every change back, leaving the rows as they were registered. Conflicts go with them, and so do the decisions taken about them: they are all about a write that no longer exists.
What stays is what the working set was told about the database – the fields somebody else changed are still changed, and a form still shows them as such. That is knowledge rather than unsaved work, and throwing it away would only mean showing the user values that are no longer there.
WorkingSet.clear
Section titled “WorkingSet.clear”clear: () => void;Drops everything, the registered documents included. What undo() is to the changes, this is to the whole working set – after it, nothing is being edited.
WorkingSet.merge
Section titled “WorkingSet.merge”merge: () => Promise<MergeResult>;Writes everything the working set holds: every change, association and deletion in one transaction, or none of them.
Done, and the changes are gone and the registered documents run their query again – a version left standing in a document that stayed on screen would fail the next edit, so refreshing is part of a merge that landed rather than the application’s chore.
Not done, and nothing was written. The conflicts say which rows stood in the way and, where the type and this working set both allow it, both values per field. The user’s changes are all still here, and every conflicted row now stands against the version that is in the database – so saving again writes the user’s values over the other ones. That second save is deliberately theirs to make: a working set never re-sends by itself.
Returns what came of it
WorkingSet.subscribe
Section titled “WorkingSet.subscribe”subscribe: (fn: () => void) => () => void;WorkingSet.getSnapshot
Section titled “WorkingSet.getSnapshot”getSnapshot: () => WorkingSetSnapshot;WorkingSetOptions
Section titled “WorkingSetOptions”type
type WorkingSetOptions = { /** * Whether a conflict comes back carrying both values per field: the user's and the one in the database. * * false where nobody is going to be shown a conflict, e.g. a working set a background job submits. * Nothing else about the merge changes -- the conflict is still detected, the write still fails, and * the fields that clashed are still named and marked; what a caller with nobody in front of it declines * is a copy of the other user's values it has no use for. * * The type has to agree, and it is the type that decides: values travel where this is true and the * application declared MergeMetadataProvider#resolveConflicts for the type. Setting it here can only * narrow that, never widen it. Defaults to true, a working set being the thing a form edits through. */ conflictValues?: boolean;};Options a working set is made with. Everything a type decides about merging is type meta data and is declared once, in the application’s MergeMetadataProvider; what is in here is the other half, which is about this caller.
useWorkingSet()
Section titled “useWorkingSet()”function
declare function useWorkingSet(workingSet: WorkingSet, options?: UseWorkingSetOptions): WorkingSetSnapshot;Subscribes the calling component to a working set and returns its current state.
A working set is a store like a query document, so this is the same three lines useInjection() is: the component re-renders whenever anything changes – a field typed into a draft, a row created, a merge coming back with conflicts.
const { dirty, conflicts, merge } = useWorkingSet(ws, {watch: true})Rules of hooks apply. The working set itself is made outside React and lives as long as the editing does. The flag may be turned on and off freely; the watch follows it.
Parameters
workingSet |
the working set to read |
options |
see UseWorkingSetOptions |
Returns a snapshot of it: whether it is dirty, what stood in the way of the last merge, and what moves it on
UseWorkingSetOptions
Section titled “UseWorkingSetOptions”type
type UseWorkingSetOptions = { /** * Whether other people's writes to the rows this working set holds land in it as they happen. * * A field somebody else changed then takes the status it would have taken at save time, so the user * sees it while they are still editing -- the same marking the merge does, arriving early rather than * late. Nothing else changes: the merge is still what detects a conflict and still the authority on * the version, so a working set read without this is a working set that works, and finds out at save * time. Defaults to false, watching costing a subscription and being a choice a form makes. * * A view that sets this must not also call useDocumentWatch() on the document these rows came from. * That is the same news twice, and acting on the document half means update(), which swaps out the row * objects the drafts are standing on. */ watch?: boolean;};How a view reads its working set.
WorkingSetSnapshot
Section titled “WorkingSetSnapshot”type
type WorkingSetSnapshot = { /** * true while the working set holds anything unsaved: a changed field, a new row, a row marked deleted. */ dirty: boolean; /** * One entry per row the last merge could not write. Empty until a merge comes back with conflicts, and * empty again once one lands. */ conflicts: MergeConflict[]; /** * Which values a draft read returns: the user's own edits, what is in the database, or the two folded * together. "merged" until something sets it otherwise. */ view: MergeView; merge: () => Promise<MergeResult>; undo: () => void; clear: () => void; setView: (view: MergeView) => void;};What a view renders: the state of one working set at one point in time, plus what moves it on.
The working set itself is mutated in place – it has to be, or the subscription a view holds would point at a stale object. This is the opposite: a fresh object whenever anything changed and the same object as long as nothing did, which is what makes an edit visible to React.
StoredState
Section titled “StoredState”type
type StoredState = { type: string; id: string; /** * The version the row stands at now, which is what a second attempt is written against. Left out where * the sender does not know it, and null is not that -- an unversioned type has none. */ version?: string | null; /** * true where the row is gone. Nothing to merge into, so no fields come with it. */ deleted?: boolean; /** * The stored values by field name, in the live form of their type. Only the fields that changed: what * is not named here is what the row was read with. */ fields?: Record<string, unknown>;};What somebody else’s write left in the database, as the working set takes it.
An input to the store rather than the shape a merge response happens to have: a field’s state is the base it was registered with, the change the user made, and the value that is stored, and a failed merge is merely today’s only source of the third. A push message saying a row changed is the next one, and nothing below this has to change for it.
useMerge()
Section titled “useMerge()”function
declare function useMerge(draft: object): MergeAccessor;Subscribes the calling component to the merge state of one row and returns the accessor for it.
One hook per entity, not per field. A hook cannot be called from a loop over a field list, so a useMergeField(row, “name”) would only work in a form whose every input is written out by hand – which is the one kind of form that needs the least help. What comes back is an accessor: field(name) is an ordinary function a generic renderer calls as often as it likes, in a loop, in a callback, or in a child component it handed the accessor to.
const bar = ws.edit(row)const merge = useMerge(bar)
for (const name of fieldNames){ const f = merge.field(name) // <input className={ f.className } value={ bar[name] } ... />}Rules of hooks apply.
Parameters
draft |
a draft, i.e. what ws.edit() or ws.create() returned. A row that is not one is a mistake rather than a silent no-op – it is what would otherwise show up as a form that never marks anything |
Returns the merge state of that row
MergeAccessor
Section titled “MergeAccessor”type
type MergeAccessor = { type: string; id: string; /** * true where the last merge found the row gone: somebody else deleted it. There is nothing to merge * into and nothing to choose between, so no field of it carries a conflict. */ gone: boolean; /** true for a row created in the working set and not saved yet */ isNew: boolean; /** true for a row marked for deletion, which the next merge deletes */ deleted: boolean; field(name: string): MergeField; /** fields the user changed, alphabetically */ changedFields(): string[]; /** fields both writes changed and nobody has decided about, alphabetically */ conflictedFields(): string[]; /** fields both writes changed and the user has decided about, alphabetically */ resolvedFields(): string[]; /** fields somebody else changed and the user did not, alphabetically */ remoteChangedFields(): string[]; /** * The accessor for another row of the same working set -- what a form editing a Bar and the Bazes it lists * in one place needs, without a second hook per row. * * @param row row of a registered document, or a draft of one */ of(row: object): MergeAccessor;};The merge state of one row, and the way to a decision about any of its fields.
One of these per entity rather than per field: a hook cannot be called from a loop over a field list, so an accessor is what lets a form render itself from a field list that comes from the schema, a config, or anywhere else.
MergeField
Section titled “MergeField”type
type MergeField = { name: string; status: MergeFieldStatus; /** * The class the status carries, empty for a field nothing happened to. Goes on the input: * `className={ field.className }`. */ className: string; /** * What the current view shows, which is what the draft returns for this field. */ value: unknown; /** * The value the user has, whatever the view is: what they typed, or what the row was read with where * they typed nothing. */ mine: unknown; /** * What is in the database, as far as this working set has been told: the other write's value for a * field it changed, and the value the row was read with otherwise. */ stored: unknown; /** * true while both writes changed this field and nobody has said which of the two wins. */ conflict: boolean; /** * Whether `stored` is the other write's value or merely the value the row was read with. * * false where the field is known to have changed and not known to what -- a push message carries a mask * and no values, and a type that did not opt in to resolution carries none either. A form showing the * two values to choose between asks this first: labeling the read value "saved" names it as something * it is not. */ storedKnown: boolean; /** * What the user chose, or null where they have not been asked or have not answered. */ resolution: Resolution | null; /** * Takes the user's decision about a field both writes changed. * * Only ever a correction: a conflict comes back already standing as `mine`, because the person present * typed that value on purpose and the person who did not is not here to argue. Choosing `stored` leaves * their value in place and does not write ours -- and does not throw it away either, so choosing `mine` * afterwards brings it back. * * @throws if nobody else wrote this field, there being nothing to choose between */ resolve(choice: Resolution): void; /** * Takes a third value, neither the user's nor the stored one, and records that the user decided it. * * Separate from resolve() rather than an overload of it: the choices are strings, and a field whose * value is a string could not tell `resolve("stored")` from a user meaning to store the word. */ resolveWith(value: unknown): void;};The state of one field of one row, as of the moment it was asked for.
A plain object off an ordinary function call, not a hook – so a generic renderer asks for one per field in a loop, in a callback, or in a child component it handed the accessor to.
MergeFieldStatus
Section titled “MergeFieldStatus”type
type MergeFieldStatus = "unchanged" | "changed" | "conflict" | "resolved" | "remoteChanged";What has happened to one field.
unchanged– nobody touched it since the row was readchanged– the user changed it and nobody else didconflict– both changed it, and the user’s value is the one standingresolved– both changed it and the user looked at it and choseremoteChanged– somebody else changed it and the user did not, so their value was taken
fieldStatusText()
Section titled “fieldStatusText()”function
declare function fieldStatusText(status: MergeFieldStatus): string;What a field with the given status is said to be, for a screen reader; empty for an unchanged one. Text that
goes beside the field’s mark, which only the eye sees: visually hidden, in the qlive-visually-hidden class, and
in the input’s aria-describedby or the table cell. Field and DataGrid render it; an application’s own input or
table renders it the same way.
MergeView
Section titled “MergeView”type
type MergeView = "mine" | "stored" | "merged";Which values a read returns: the user’s own edits, what is in the database, or the two folded together.
“merged” is the default and the one a form wants: the user’s edits, with the fields somebody else changed and this user has no opinion about taken silently. The other two are what a “show me what I typed” and a “show me what is saved” toggle switch to, and a form that knows nothing about merging renders all three correctly, because the draft is what decides.
Resolution
Section titled “Resolution”type
type Resolution = "mine" | "stored";Which of two values the user chose for a field both writes changed.
HeldRows
Section titled “HeldRows”interface
interface HeldRowsThe rows of one type a store is holding, and the fields it has of them.
What a subscription to changes of those rows is built from: the ids say which rows are of interest and the fields say which changes to them are, both being what is actually on screen rather than anything the application declared.
HeldRows.type
Section titled “HeldRows.type”type: string;HeldRows.ids
Section titled “HeldRows.ids”ids: Set<string>;ids of the rows held, a row without one being no row anything can be said about
HeldRows.fields
Section titled “HeldRows.fields”fields: Set<string>;the fields the store has of them, which is what the query selected or the form bound