Safe Entity writes
useEntityMutation() from @moltaro/workspace-ui sends one Entity write on
behalf of the signed-in user and keeps track of what is known about it. The
host owns the parts that are easy to get wrong in page code:
- one
OperationKeyand a frozen copy of the request for each logical operation; - a lock that turns a double click, Enter plus click or two calls into one send;
- the interpretation of the answer through the installation’s installed error catalog;
- the read-only mutation confirmation check after a lost answer;
- an explicit, bounded repeat of exactly the same request;
- optional recovery of the minimal operation handle after a page reload.
It never writes on its own: there is no resend on reload, reconnect, timer or watcher. The person decides to check or to repeat.
Use it from page or component code. It calls the existing Entity endpoints as the current user; server permissions, Business Invariants, validation and the composite transaction rules stay on the server.
Requests
Section titled “Requests”submit(request) takes one typed request. Definition and record IDs are the
stable IDs of the record type and record, not a record type’s name: the
confirmation check selects an operation by them. Read them from the record
type (GET /api/workspace/entity-definitions/{id}) or from a record read.
Operation | Members |
|---|---|
Create | EntityDefinitionId, ExpectedDefinitionRowVersion, Values, optional ResponsibilityCandidates |
Update | EntityDefinitionId, EntityInstanceId, RowVersion, Patch |
Archive, Restore, Delete | EntityDefinitionId, EntityInstanceId, RowVersion, Reason |
Composite | Steps: the composite steps without the OperationKey |
Values, Patch and composite step values are sent as written, in the shapes
of the single-record requests. They must be plain JSON: a value JSON cannot
carry exactly (undefined, a Date, NaN, a class instance) and any
unsupported member are refused with a TypeError before anything is sent.
Markdown draft headers and file uploads are not supported by this helper; use
the single-record flows documented for them. Server limits, such as 16
composite steps or the Reason length, are checked by the server.
The host copies the request before the first send. Editing your form object afterwards does not change what is sent or repeated; a changed request is a new operation with a new key.
state is a read-only reactive snapshot:
| Member | Meaning |
|---|---|
outcome | What is known about the operation: idle, notSent, pending (first answer awaited), rejected (the installed error catalog confirms that the server refused it and nothing was saved), unknown, committed, keyConflict or recoveryLost |
activity | none, preparing, sending or checking |
attempt | The last answer of a send: errors, warnings, HTTP status and the catalog reaction |
check | The last confirmation check: noConfirmedReceipt, failed or cancelled |
commit | How the commit is known: response, check or alreadyCommitted |
canSubmit, canCheck, canRetry, canCancel, canForget | Which action the host accepts now |
retryBlockedBy | Why the same request cannot be repeated: noRequest, catalog, reaction, window, limit, busy |
handle, firstAttemptAt, sends, hasRequest, notice | Operation identity, the time of its first attempt, the number of sends, whether the original request is still in memory and a host notice |
A call that is not allowed now changes nothing and resolves to the current
state. Bind your buttons to the can… members.
The outcome and the last attempt are different things. Once an attempt has
left the outcome unknown, a later refusal of a repeat (for example 401,
403, 404 or a stale version) describes only that repeat: the outcome stays
unknown until a commit is confirmed or you forget the operation.
After a lost answer
Section titled “After a lost answer”A timeout, a lost connection, a 5xx without the response envelope or a
cancelled wait leaves the outcome unknown. So does any error code that the
installed error catalog does not list for the operation, whatever its HTTP
status, and any error while the catalog cannot be read: such an answer does not
show whether the request was saved. Show attempt.errors as the server’s
explanation, not as proof that nothing was saved. A successful answer for
another record or record type is not accepted as yours either. Never send the
same change with a new key at this point. Offer two separate actions:
Check the result. check() makes one read-only GET for the saved handle.
committed with commit.source === 'check' confirms the commit; its result
holds the original IDs and versions, or null when you can no longer read them
(for example after access was revoked or the record was deleted). A null
result is still a confirmed commit. noConfirmedReceipt means only that no
confirmation existed when checked: it does not mean that nothing was saved and
never allows a new write. A failure of the check itself leaves the outcome
unknown.
Repeat the same request. retry() resends the identical body with the
same key. Within the replay window the server answers the stored result
instead of saving twice. The host offers it (canRetry) only when all of these
hold, and checks them again at the moment of the repeat:
- the original request is still in this page’s memory and nothing else is being sent;
- the installed error catalog was read, and every error of the last answer (or
its fallback) has a reaction the host fully understands with
AutomaticRetry=SameRequest;Conditional,Noneand unknown values never offer a repeat; - less than seven days minus one hour have passed since the first attempt, by a known time that was recorded once before the first send;
- fewer than three sends of this operation, including the first.
Create repeats answered with moltaro.instances.replay.resultNotViewable
confirm the commit (commit.source === 'alreadyCommitted') without a result.
moltaro.security.idempotency.keyReuse ends in keyConflict: a different
request already used this key, so neither a repeat nor a key-only check can
show that your request was saved. Review the target records.
cancel() before the request leaves the browser ends in notSent. After it
was sent, cancel() only stops waiting: the outcome becomes unknown.
After a confirmed commit
Section titled “After a confirmed commit”The answer of the write, the confirmation check and the current record are three separate things:
commit.source === 'response'carries the original answer: the record result or receipt, or the composite result, withreplayandwarnings;commit.source === 'check'carries only the receipt times and the original identities ornull;- the current record comes from an ordinary protected read that your page makes afterwards. Its failure is a display problem, not a failed save.
Versions in a commit belong to that operation. Read the record again before the
next edit instead of copying a confirmed RowVersion into a new request.
Recovery after a reload
Section titled “Recovery after a reload”Pass a stable, lowercase kebab-case recoveryKey for one scenario of your
page, such as create-order. Before the first send the host stores only the
operation handle and the time of the first attempt in the current browser tab,
bound to the signed-in user and the installation’s API address. No field
values, Reason, request body, answer, record name or credential is stored. If
it cannot be stored (blocked or full storage, or an unfinished operation with
the same key), nothing is sent and notice says why.
After a reload, the same page with the same key reads that handle and makes at
most one confirmation GET. The original request is gone, so a restored
operation can be confirmed but never repeated. A record older than seven days
or a damaged record ends in recoveryLost; that is not a permission to write
again. The record stays, and every reload reports it again, until you warn the
person and call forget(). A tab keeps at most 16 unfinished operations; the
host never evicts one to make room.
forget() removes the operation and its saved handle. It does not undo
anything and sends nothing. Warn the person before you call it, because the
server may already have saved the request. The old key is never used again.
Without recoveryKey, nothing survives a reload. To check an operation you
saved elsewhere, call attach(handle) with the saved handle and then check().
Recovery is limited to this browser tab: closing the tab, clearing site data or using another browser or device loses it.
Record lifecycle dialog
Section titled “Record lifecycle dialog”MoltaroRecordLifecycleDialog and MoltaroRecordHeader from @moltaro/ui use
the same mechanism for archive, restore and permanent delete. After a lost
answer the dialog keeps the Reason and request frozen and offers Check
result, Retry the same request when allowed, and Forget request with a
warning. A confirmation without the command’s own answer is emitted as
confirmed; see Record components.
Example
Section titled “Example”This page creates one record. The record type ID and version come from page
properties; add orders.* to each locale file.
<script setup lang="ts"> import { WorkspaceUiEntityMutationOperationEnum as Operation, useEntityMutation } from '@moltaro/workspace-ui' import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { VAlert, VBtn, VTextField } from 'vuetify/components'
const props = defineProps<{ definitionId: string, definitionRowVersion: string }>() const { t } = useI18n() const title = ref('') // The host keeps only the handle in this tab; the typed title is never stored. const mutation = useEntityMutation({ recoveryKey: 'create-order' }) const state = computed(() => mutation.state.value) const createdId = computed(() => { const commit = state.value.commit if (commit?.source === 'response' && commit.result.Kind === 'AuthorizedEntityDetail') { return commit.result.Data.EntityInstanceId } return commit?.source === 'check' && commit.result?.Kind === 'Single' ? commit.result.EntityInstanceId : '' })
async function save () { await mutation.submit({ Operation: Operation.Create, EntityDefinitionId: props.definitionId, ExpectedDefinitionRowVersion: props.definitionRowVersion, Values: { Title: title.value }, }) }</script>
<template> <section> <VTextField v-model="title" :disabled="!state.canSubmit" :label="t('orders.title')" @keydown.enter="save" /> <VBtn :disabled="!state.canSubmit" :loading="state.activity === 'sending'" @click="save">{{ t('orders.save') }}</VBtn> <VAlert v-if="state.outcome === 'rejected'" type="error"> {{ state.attempt?.errors[0]?.Message ?? t('orders.rejected') }} </VAlert> <VAlert v-if="state.outcome === 'unknown'" type="warning"> <p>{{ t('orders.unknown') }}</p> <VBtn :disabled="!state.canCheck" @click="mutation.check()">{{ t('orders.check') }}</VBtn> <VBtn v-if="state.canRetry" @click="mutation.retry()">{{ t('orders.retry') }}</VBtn> <p>{{ t('orders.forgetWarning') }}</p> <VBtn :disabled="!state.canForget" @click="mutation.forget()">{{ t('orders.forget') }}</VBtn> </VAlert> <VAlert v-if="state.outcome === 'committed'" type="success">{{ t('orders.saved', { id: createdId }) }}</VAlert> </section></template>Compatibility
Section titled “Compatibility”This export requires a host build that includes it. After installing that
build, run Check → Build → Activate for your project and fully reload the
WebApp. HostContractVersion stays 1; the declaration and host fingerprint
change, so an older artifact is refused as incompatible until it is rebuilt.
Core WebApp pages keep working meanwhile.