Skip to content

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 OperationKey and 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.

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.

OperationMembers
CreateEntityDefinitionId, ExpectedDefinitionRowVersion, Values, optional ResponsibilityCandidates
UpdateEntityDefinitionId, EntityInstanceId, RowVersion, Patch
Archive, Restore, DeleteEntityDefinitionId, EntityInstanceId, RowVersion, Reason
CompositeSteps: 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:

MemberMeaning
outcomeWhat 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
activitynone, preparing, sending or checking
attemptThe last answer of a send: errors, warnings, HTTP status and the catalog reaction
checkThe last confirmation check: noConfirmedReceipt, failed or cancelled
commitHow the commit is known: response, check or alreadyCommitted
canSubmit, canCheck, canRetry, canCancel, canForgetWhich action the host accepts now
retryBlockedByWhy the same request cannot be repeated: noRequest, catalog, reaction, window, limit, busy
handle, firstAttemptAt, sends, hasRequest, noticeOperation 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.

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, None and 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.

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, with replay and warnings;
  • commit.source === 'check' carries only the receipt times and the original identities or null;
  • 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.

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.

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.

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>

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.