Удаление — это запись: надгробие
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Журнал «Подхода» умеет рассказывать про изменения: у каждой правки есть имя, поля и отметка времени, сервер выдаёт её другим устройствам с курсора, а память вердиктов бережёт от повторов. Теперь человек делает самое обычное дело — удаляет подход, записанный по ошибке. И выясняется, что это единственное действие, которому в протоколе нет места: отсутствие строки — не изменение. У «ничего» нет ни имени, ни полей, ни номера, а значит, с курсора его не отдать. Статья о том, чем это кончается на трёх устройствах, и о поле deleted, которое возвращает удаление в обычный ряд операций.
У хранилища «Подхода» метод remove уже есть: он появился в статье про цикл синхронизации, когда после отказа сервера строку понадобилось убрать с экрана. Соблазн применить его и здесь огромный — человек нажал «удалить», строка исчезла, лента перерисовалась, всё честно. Ровно до того места, где у дневника вторая копия.
Проверим на трёх устройствах. Подход записан в зале с телефона и уехал на сервер, веб-версия дома его забрала — и там его удаляют, просто убрав строку. Дальше синхронизируются оба, потом телефон, ещё не зная об удалении, правит в этом подходе вес. В конце появляется новое устройство с нулевым курсором. Прогноз: сколько строк окажется у телефона и у веб-версии, что веб-версия увидит после синхронизации и что достанется новому устройству?
Три разных беды из одного удаления. Первая: устройства разошлись молча — на телефоне подход есть, на вебе его нет, и ни одна сторона об этом не узнает. Вторая: правка веса, отправленная телефоном, воскресила подход на вебе, причём огрызком — {"id":"A-1","weightKg":82.5}. Операция несёт только изменённое поле, а накладывать его оказалось не на что: точно такой же обрубок собирал наивный сервер в статье «Сервер — партнёр, а не хозяин». Третья: новое устройство получило подход целиком и считает его живым.
Причина у всех трёх одна, и она в природе журнала. Журнал — список того, что произошло, и каждая запись имеет имя, поля и номер. «Ничего» имени не имеет. Сервер отдаёт с курсора операции, а операции об удалении никто не создавал: store.remove тронул только память веб-версии. Для остальных устройств ничего не случилось — и это не ошибка синхронизации, а буквальная правда.
Физическое удаление работает ровно в одном случае: когда копия данных одна. Как только копий две, «строки нет» перестаёт быть фактом и становится вопросом: её удалили или я про неё ещё не знаю? Различить эти два состояния по отсутствию строки нельзя никак.
Можно: завести sync.delete(rowId), и сервер честно выбросит строку. Но тогда у sync.pull появляется второй поток данных — список удалённых имён, — и его тоже надо привязывать к курсору, иначе отставшее устройство его не получит. А чтобы такой список собрать, серверу всё равно придётся где-то держать отметку «эта строка была удалена тогда-то».
Именно так устроен WatermelonDB: в его changes у каждой таблицы три ключа — created, updated и deleted: RecordId[] (sync/index.d.ts, строка 14). Отдельный список работает, но отметку на сервере не отменяет — он её лишь пересказывает другими словами. Сравнение двух моделей целиком — в статье «Готовые движки: WatermelonDB и PowerSync» в конце главы.
Раз протокол умеет только операции, удаление надо сделать операцией. Строка никуда не девается — у неё меняется одно поле: { deleted: true }. Такую строку называют надгробием (tombstone): записи как бы нет, но отметка о том, что она была и её убрали, остаётся — и уезжает с курсора наравне со всем остальным.
Серверу при этом менять нечего, и это стоит проверить отдельно. Он проверяет строку после применения операции — правило из статьи про сервера-партнёра. После надгробия в строке остаются и упражнение, и вес, и повторы, поэтому проверка проходит, операция получает номер и едет дальше. Правка несуществующей строки по-прежнему отвергается, а удаление существующей — обычная принятая операция.
Тот же сценарий, что и в прошлом разделе, только удаление теперь пишет операцию, а экран показывает не все строки, а живые. Прогноз: что окажется на всех трёх устройствах, сколько строк будет у сервера и как будет выглядеть сама строка?
На всех трёх устройствах пусто — включая новое, которое пришло с нулевого курсора и получило обе операции: и вставку, и надгробие. У сервера строка одна, видимых нет, а сама строка сохранила все поля и получила deleted: true. Теперь «удалено» и «не знаю про такую строку» различимы: в первом случае строка есть и помечена, во втором её нет вовсе.
Цена у надгробия двойная. Первая — место: строк становится ровно столько, сколько их когда-либо создавали, и с этим разбираться в последнем разделе. Вторая — фильтр: каждое место, где раньше был просто список, теперь обязано спрашивать про deleted. Класть этот фильтр надо в хранилище и снимком по версии — фильтр внутри getSnapshot отдаёт новый массив на каждый вызов, и экран падает, как в статье «Главная копия — в телефоне».
Дальше эти двое лежат в подложке и работают до конца части: store.visible() и journal.remove(). В общем контракте меняется тоже немного — у значения поля появляется третий тип, и отдельной процедуры на удаление не заводится:
Второй распространённый вид надгробия — не флаг, а отметка времени: deletedAt вместо deleted. Она даёт даром то, что понадобится в конце статьи, — возраст надгробия, по которому решают, когда его можно убрать. В «Подходе» хватает флага: у самой операции отметка времени уже есть, и сервер знает, когда она к нему приехала.
На экране удаление выглядит просто: из data у FlatList пропала строка. Ключи здесь важнее, чем кажется, и берутся они не из пропса key, а из keyExtractor — это отличие React Native из третьей главы. Ключ по id строки переживает удаление любой соседки; ключ по индексу после удаления сдвинет всё, и почему это плохо, часть IV уже объясняла.
«Вернуть» — не корзина, а такая же операция: { deleted: false }. Прогноз: что напечатает лента на трёх шагах — при первой отрисовке, после удаления и после возврата, — и сколько операций окажется в журнале телефона?
Три отрисовки, и в хранилище всё это время две строки: лента берёт живые, а надгробие остаётся лежать. В журнале четыре операции — две записи, удаление и возврат, — и все четыре уедут обычной пачкой. Вот в чём разница между операцией и корзиной: корзина — это состояние, которое надо где-то держать и синхронизировать отдельно, а здесь и удаление, и возврат стоят по одной записи и едут тем же путём, что правка веса.
Лента подписана на хранилище через useSyncExternalStore — «Свой хук: логика без разметки» части IV, заново это не объясняется. А вот отличие React Native стоит назвать: на телефоне «удалить» почти никогда не кнопка в строке. Это свайп по строке — система ответчика из главы 8, «Жест как разговор», — и подтверждение своим диалогом на Modal из главы 4, потому что штатный Alert в вебе пустой метод.
Практика, которая дешевле подтверждения: удалять сразу и показывать снизу полоску «удалено · вернуть». В local-first это почти бесплатно — возврат такая же операция, как удаление, и никакой «отложенной корзины» для него держать не нужно.
Дальше начинается то, ради чего вся глава. Дома подход удалили, а в зале — в тот же вечер, без сети, ничего не зная об этом — поправили в нём повторы. Обе операции уедут на сервер и лягут в один журнал. Слияние по полям про надгробие ничего не знает: deleted для него такое же поле, как reps.
Сравним два правила на одном и том же журнале. Первое — то, что получается само собой, без единой дополнительной строки кода. Второе — «правка воскрешает»: любое поле, кроме самого надгробия, снимает отметку. Прогноз: что окажется в журнале сервера и что получится у каждого правила?
При первом правиле правка не потерялась — она прилипла к надгробию: в строке лежат и deleted: true, и reps: 12, а видно ноль. Человек, поправивший повторы в зале, никогда не узнает, что его правка доехала: экран покажет, что подхода нет. При втором правиле подход вернулся вместе с правкой, и теперь его не видит только тот, кто удалял.
Самое важное в этом прогоне — что первое правило уже действует, хотя его никто не выбирал. apply накладывает поля операции на строку и не трогает те, которых в операции нет, поэтому deleted переживает любую правку. Молчаливого ответа не бывает: код отвечает на вопрос «кто победит» всегда, просто иногда автор об этом не знает. Значит, правило надо выбрать и записать — хотя бы затем, чтобы на отзыв «я удалил, а оно вернулось» было что показать.
| правило | что происходит с правкой | когда его выбирают |
|---|---|---|
| «удаление побеждает» (по умолчанию) | ложится в строку, но остаётся невидимой | удаление намеренное и редкое: свой дневник, свои заметки |
| «правка воскрешает» | снимает отметку, запись возвращается вместе с правкой | общий список, где удаление одним не должно стирать работу другого |
| спросить человека | обе версии показываются, решение откладывается | дорогие данные и осознанный выбор; для одного пользователя с двумя устройствами это перебор |
У «правки воскрешает» есть цена, о которой стоит знать заранее. Сейчас порядок в журнале — это порядок прибытия на сервер, а не порядок событий, и телефон, пролежавший неделю в кармане без сети, воскресит подход правкой, сделанной до удаления. Чтобы правило смотрело на время события, а не на время доставки, у каждого поля должна появиться своя отметка — и тут выяснится, что часы устройства врут. Об этом следующая статья, «Кто прав, если оба правы: последняя запись, слияние по полям и часы».
Правильного ответа здесь нет — это решение про продукт, а не про код. Но проверить своё решение можно дёшево: удалить запись на одном устройстве, поправить её на втором и посмотреть, что покажет третье. Если никто в команде не может предсказать результат заранее, правило не выбрано.
Надгробия не исчезают. Строк в базе становится ровно столько, сколько их когда-либо создавали, и у дневника, который ведут годами, заметная часть — записи, которых человек давно не видит. Отсюда естественная мысль: чистить по сроку. Полгода прошло — надгробие никому не нужно, выбросим.
Проверим эту мысль на самом безобидном сценарии: телефон записал два подхода, один удалил, всё уехало. Потом сервер прибрался, выбросив надгробие из журнала. Прогноз: что увидит новое устройство, пришедшее до чистки, и что — пришедшее после?
Устройство, успевшее прийти до чистки, увидело дневник правильно. Устройство, пришедшее после, воскресило удалённый подход: вставка в журнале осталась, а единственное свидетельство удаления пропало. Получилось ровно то же, с чего начиналась статья, только на этот раз данные испортил не забывчивый клиент, а аккуратный сервер.
Отсюда правило: надгробие можно убирать не раньше, чем его увидели все устройства — то есть за горизонтом самого отставшего курсора. Считать этот горизонт, хранить сроки и отдавать снимок дневника тому, кто отстал безнадёжно, — работа сервера, и она в части VI. Клиент сам надгробия не чистит: он не знает ни про другие устройства, ни про их курсоры.
На настоящей базе телефона надгробие — обычная колонка. Схема меняется расширяющей миграцией (базу в local-first нельзя «снести и загрузить заново» — правило из статьи «Диск под памятью»), а фильтр переезжает с экрана в запрос:
И последнее следствие, которое всплывёт в главе про доводку проекта. Кнопка «удалить дневник везде» в настройках — это надгробия на все строки, а не drop table. Таблицу можно снести только на этом телефоне; на втором устройстве дневник останется целым и вернётся первой же синхронизацией — той самой, которая честно заберёт с курсора всё, про что ей не сказали «удалено».
Надгробие живёт дольше, чем кажется нужным, ровно по той же причине, по которой курсор только растёт: отставшее устройство имеет право прийти через полгода. Всё, что удалено «навсегда», обязано оставаться удалённым и для того, кто об этом ещё не слышал.
- Отсутствие строки нельзя отдать с курсора: журнал — это список того, что произошло, а «ничего» в него не записать. Физическое удаление на одном устройстве остальные не видят вовсе.
- Три следствия из одного прогона: устройства расходятся молча (на телефоне 1, на вебе 0), чужая правка воскрешает удалённое обрубком
{"id":"A-1","weightKg":82.5}, а новое устройство с нулевого курсора получает подход целиком. - Надгробие — обычная операция
{ deleted: true }: строка остаётся, меняется одно поле. Серверу менять нечего, потому что он проверяет строку после применения, а в ней все поля на месте. Экран показывает живые строки снимком по версии, а не фильтром внутриgetSnapshot. - «Вернуть» — тоже операция,
{ deleted: false }, а не корзина: удаление и возврат стоят по одной записи в журнале и уезжают обычной пачкой. - Правило «удаление против правки» в этой модели уже действует по умолчанию: правка прилипает к надгробию и остаётся невидимой (
deleted true, reps 12, видно 0). Выбор между «удаление побеждает» и «правка воскрешает» надо сделать явно и записать кодом. - Надгробия копятся, и чистить их по сроку нельзя: в прогоне устройство, пришедшее после чистки, воскресило удалённый подход. Убирать надгробие можно не раньше, чем его увидели все устройства, — это горизонт курсора и работа сервера части VI.
1/4Подход удалили на веб-версии обычным удалением строки, а потом к серверу пришло новое устройство с нулевым курсором. Что оно увидит?
2/4Почему надгробию не понадобилась отдельная процедура на сервере?
3/4Дома подход удалили, а телефон без сети поправил в нём повторы. Обе операции доехали. Что получится при слиянии по полям, если правило не выбрано явно?
4/4Сервер выбросил из журнала надгробие старше срока хранения. Кому и чем это грозит?
Удаление, которое не уехало
Здесь «удалить» сделано самым коротким способом: строка убирается из хранилища. Подход записан на телефоне, уехал на сервер и забран веб-версией, а дома его удаляют. Посмотрите, что видит каждое устройство после синхронизации, и почините deleteSet так, чтобы удаление доехало до телефона.
Удаление должно стать операцией — тогда оно попадёт в журнал и уедет обычной пачкой:
Вывод становится видно на вебе: 0 | видно на телефоне: 0 и в очереди веба: 0 | записей в журнале сервера: 2: вторая запись в журнале — это и есть надгробие.
Сломанный вариант коварен тем, что на том устройстве, где нажали «удалить», всё выглядит правильно: строка исчезла мгновенно, очередь пуста, ошибок нет. Расхождение видно только со второго устройства — а до него обычно доходят не сразу.
Лента, которая рисует надгробия
Удаление здесь уже операция, и в журнале всё правильно. А вот лента берёт снимок хранилища целиком. Посмотрите, что она печатает, и почините подписку так, чтобы удалённый подход пропал с экрана, оставшись в хранилище.
Экрану нужен снимок живых строк, а не всех:
Вывод становится в ленте: A-1, A-3 | из них надгробий: 0.
Заманчиво починить это фильтром прямо в компоненте — rows.filter((row) => !row.deleted) в теле или в самом getSnapshot. В теле компонента это работает, но фильтр придётся повторять в каждом экране и в каждом счётчике; внутри getSnapshot — не работает вовсе: новый массив на каждый вызов роняет экран, и как именно, показано в статье «Главная копия — в телефоне». Поэтому фильтр живёт в хранилище, снимком по версии.
Воскрешение, отменившее удаление
В этом приложении выбрали правило «правка воскрешает»: правка снимает отметку удаления. Написано оно, однако, так, что удаление перестало работать вовсе. Посмотрите на вывод и почините reviving.
Снимать отметку имеет право любая операция, кроме самого надгробия:
Вывод становится после слияния: deleted true | видно 0.
Без проверки правило применялось и к операции удаления: она ставила deleted: true, а следующая же строка кода снимала отметку обратно. Удаление при этом честно уезжало на сервер, занимало номер и приезжало на другие устройства — и там тоже ничего не удаляло. Ошибка, которую невозможно найти по журналу: в нём всё правильно, а на экране запись жива на всех устройствах сразу.