Перейти к содержанию

Версия записи и конфликт редактирования

Оглавление серии «Vue 3: кабинет сотрудников «Команда»»

У двух руководителей открыта карточка Анны версии 1. Первый изменяет доступность, и сервер возвращает версию 2. Второй отправляет собственный черновик с версией 1. Если сервер безусловно применит запрос, он может затереть более новое решение. Результат урока — явный конфликт 409 и сохранение намерения пользователя до его осознанного выбора обновления.

Наш договор требует атомарного сравнения version на сервере. Клиентская проверка «версия ещё такая же» недостаточна: между чтением и записью другой запрос может изменить запись. Здесь не показывается серверная реализация и не заявляется фактическая конкурентная проверка. Мы объясняем необходимое условие, форму ответа и поведение Vue при таком ответе.

Снимок, на котором основан черновик

Проблема редактора прошлого шага тоньше обычного 409. Если общий список обновился, props.member мог уже содержать новую version, хотя черновик был начат по старой. При отправке нельзя незаметно приписать старому намерению новую основу. Поэтому вводим base — отдельный снимок, который изменяется только после собственного удачного сохранения или явного согласия получить актуальную карточку.

Ниже полностью заменяется src/components/MemberEditor.vue. transport продолжает возвращать ApiError с status и data, а общий save всё так же меняет коллекцию только после 200.

<script setup>
import { ref } from 'vue'
import { parseMember } from '../api/team.js'
import { useSharedTeam } from '../state/team.js'
const props = defineProps({ member: { type: Object, required: true } })
const { save, load, members, error: loadError } = useSharedTeam()
const base = ref({ ...props.member })
const draftAvailable = ref(base.value.available)
const saving = ref(false)
const conflict = ref(null)
const error = ref('')
const status = ref('')
async function submit() {
  if (saving.value || conflict.value) return
  saving.value = true; error.value = ''; status.value = ''
  try {
    const result = await save({ ...base.value }, draftAvailable.value)
    base.value = { ...result }
    status.value = 'Доступность подтверждена сервером.'
  } catch (failure) {
    if (failure.status === 409) {
      try {
        const current = parseMember(failure.data?.current)
        if (current.id !== base.value.id) throw new Error('wrong id')
        conflict.value = current
        error.value = 'Карточка изменилась. Черновик сохранён; выберите актуальную основу.'
      } catch { error.value = 'Конфликт с неверным ответом. Обновите команду.' }
    } else error.value = 'Изменение не подтверждено. Обновите основу перед повтором.'
  } finally { saving.value = false }
}
async function acceptCurrent() {
  saving.value = true; error.value = ''; status.value = ''
  try {
    const refreshed = await load()
    if (!refreshed || loadError.value) { error.value = 'Не удалось получить новую основу.'; return }
    const current = members.value.find(item => item.id === base.value.id)
    if (!current) { error.value = 'Участник отсутствует.'; return }
    base.value = { ...current }; draftAvailable.value = current.available; conflict.value = null
    status.value = 'Загружена текущая основа; при необходимости измените поле заново.'
  } finally { saving.value = false }
}
</script>
<template>
  <form @submit.prevent="submit">
    <p>Основа черновика: версия {{ base.version }}.</p>
    <label><input v-model="draftAvailable" type="checkbox" :disabled="saving"> Доступен для задачи</label>
    <button type="submit" :disabled="saving || !!conflict">Сохранить доступность</button>
    <button type="button" :disabled="saving" @click="acceptCurrent">Заменить черновик актуальной карточкой</button>
    <p v-if="conflict">В конфликте сервер сообщил версию {{ conflict.version }} и состояние
      {{ conflict.available ? 'Доступен' : 'Занят' }}.</p>
    <p v-if="error" role="alert">{{ error }}</p>
    <p v-if="status" role="status">{{ status }}</p>
  </form>
</template>

На 409 прежнее draftAvailable не очищается и base.version остаётся старой. Кнопка сохранения блокируется до разрешения конфликта. Отдельная команда честно называется заменой черновика: она получает команду заново и переносит актуальное значение в форму. Пользователь видит, что прежнее намерение перестанет быть текущим вводом. Автоматическое незаметное переписывание нарушило бы смысл сохранения черновика.

Почему конфликт не исправляется повтором

Повтор того же запроса с той же version может снова получить 409. Подстановка новой version без пересмотра значения превращает защиту от потери изменения в формальность. Для нашего одного булева поля решение простое: загрузить актуальное состояние, выбрать желаемую доступность ещё раз и сохранить уже по новой основе. Для многострочной формы можно предложить сопоставление полей, но это потребует отдельного правила слияния, которого данный редактор не реализует.

Ответ current проходит parseMember. Неверный формат или другой id не должны стать новой основой Анны. Полезный неправильный случай — считать, что любой ответ с кодом 409 содержит пригодную карточку. Код статуса описывает класс отказа, но не гарантирует соответствие внешнего JSON нашему конкретному контракту. Поэтому обработка конфликта сохраняет ту же дисциплину границы, что и первоначальное чтение.

Снимок base отделён от props. Новый общий ответ может обновить подтверждённую подпись над формой, но не меняет основания уже начатого черновика автоматически. Пользователь может увидеть разницу версий и выбрать замену. Односторонний поток props Vue помогает понять, почему отдельное состояние редактора следует рассматривать как локальную модель, а не как псевдоним свойства.

Обновление и отказ чтения

load прошлого шага обрабатывает отказ внутри state и не выбрасывает его наружу. Чтобы команда замены основы не использовала старую карточку после неудачного либо вытесненного чтения, acceptCurrent проверяет булевый результат refreshed и error общего состояния, импортированный как loadError. Завершение Promise само по себе ещё не является признаком удачи: важно читать объявленный результат операции.

Это ещё одна причина иметь ясный контракт. Можно было бы изменить load так, чтобы он возвращал true или false, и разбирать его значение. В нашем снимке сохраняем общий error для отображения обоими экранами. Перед переносом решения в рабочий проект выберите один последовательный способ: статус, типизированный результат или исключение. Случайное смешение способов создаёт ошибки, которые трудно заметить при одних успешных ответах.

Отмена запроса не отменяет уже совершённую серверную запись. Более того, клиент не должен трактовать исчезновение формы при переходе как откат PATCH. Сохранение может завершиться и обновить общий state, даже если пользователь уже смотрит другой экран. Для отменяемых предметных операций серверу потребовался бы отдельный договор отмены. Мы отменяем чтение, но не заявляем обратимость удалённого изменения по закрытию компонента.

Два ожидаемых сценария

В первом сценарии пользователь сохраняет по version 1 без чужих изменений и получает 200 с version 2. base обновляется, подтверждённая подпись меняется, следующий ввод опирается на version 2. Во втором сервер уже имеет version 2, поэтому запрос version 1 получает 409. Общая команда не изменяется от отказа, черновик остаётся, и пользователь видит выбор актуальной основы. Оба сценария являются ожидаемыми, поскольку сервер и конкурентные запросы здесь не запускались.

Разберите последовательность, где обновление списка происходит между началом ввода и нажатием сохранения. Без отдельного base клиент мог бы скрыть потерю основания. С base запрос честно сообщает версию, на которой пользователь сформировал намерение. Следующий урок передаст кабинет как законченный учебный исходник: вместе с версиями, условиями сервера, границами безопасности и перечнем поведения, которое ещё предстоит проверить в собственной среде.

Серверное условие и пользовательское решение

Атомарная запись может быть выражена условием обновления по id и version, либо блокировкой строки внутри транзакции. Это выбор серверной реализации, который клиент не наблюдает непосредственно. В обоих случаях нужно исключить промежуток между успешным сравнением и безусловной записью. Если сервер сначала прочитал версию, отпустил транзакцию, а потом обновил запись, два конкурирующих запроса всё ещё способны пройти.

Даже корректный конфликт не говорит, какое решение руководителя лучше. Сервер сообщает, что основание устарело, а интерфейс помогает получить новое. Автоматическое слияние двух противоположных булевых значений не имеет нейтрального результата: одно намерение неизбежно победит. Поэтому явное повторное рассмотрение проще и честнее скрытого правила «последний клик выигрывает». Если продукт потребует историю решений, её нужно хранить отдельными событиями, не только текущим available.

Возвращаемое load значение также защищает замену основы от вытесненного запроса. Если пользователь нажал общее обновление во время получения новой основы, прежнее чтение может завершиться без актуального результата и без обычной ошибки. refreshed будет false, поэтому редактор не примет прежнюю карточку молча. Успешное завершение Promise и получение актуальных данных остаются разными обстоятельствами.

Для самостоятельного воспроизведения результата «Объяснить 409 и сохранить черновик при конфликте» выберите lesson-15 в архиве исходников. Статус выполнения этого состояния: not_run.