@sequent-org/moodboard 1.4.66 → 1.4.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.66",
3
+ "version": "1.4.68",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -26,6 +26,10 @@ export class SaveManager {
26
26
  // Если изменение пришло, пока предыдущий запрос ещё в полёте, помечаем
27
27
  // необходимость дозаписи: иначе последняя правка перед выходом теряется.
28
28
  this._pendingResave = false;
29
+ // Счётчик последовательных rebase после 409: ограничивает цикл
30
+ // «reload → reapply → resave → снова 409», если сервер уходит вперёд
31
+ // в каждом раунде. Сбрасывается при успешном сохранении.
32
+ this._staleRebaseCount = 0;
29
33
  this._listenersAttached = false;
30
34
  this._handlers = {};
31
35
 
@@ -134,9 +138,13 @@ export class SaveManager {
134
138
  }
135
139
  this.isRequestInProgress = true;
136
140
 
141
+ // Доступно и в catch: на 409 нужно переприменить именно эти локальные данные.
142
+ let pendingSaveData = null;
143
+
137
144
  try {
138
145
  // Получаем данные для сохранения
139
146
  const saveData = data || await this.getBoardData();
147
+ pendingSaveData = saveData;
140
148
 
141
149
  // Защита: если данные не получены (например, при уничтожении/отписке обработчиков) — выходим без ошибки
142
150
  if (!saveData || typeof saveData !== 'object') {
@@ -194,6 +202,7 @@ export class SaveManager {
194
202
  this.lastSavedData = JSON.parse(JSON.stringify(saveData));
195
203
  this.hasUnsavedChanges = false;
196
204
  this.retryCount = 0;
205
+ this._staleRebaseCount = 0;
197
206
  this.updateSaveStatus('saved');
198
207
 
199
208
  // Эмитируем событие успешного сохранения
@@ -214,26 +223,54 @@ export class SaveManager {
214
223
  return;
215
224
  }
216
225
 
217
- // 409 stale_base_version: экземпляр устарел нельзя перезаписывать сервер.
218
- // Перечитываем latest. Retry исключён это не transient-ошибка.
226
+ // 409 stale_base_version: локальный baseVersion отстал от сервера
227
+ // (типично гонка «финальное сохранение при закрытии ещё не доехало,
228
+ // а переоткрытие уже загрузило предыдущую версию»). Перезаписывать
229
+ // сервер целиком нельзя — затрём чужие правки. Поэтому:
230
+ // 1) перечитываем latest (reloadHandler выставляет актуальный baseVersion),
231
+ // 2) переприменяем поверх локальные НОВЫЕ объекты (например, только что
232
+ // нарисованный штрих) — reloadHandler возвращает их число,
233
+ // 3) пересохраняем с актуальной версией.
234
+ // Так рисунок не теряется, а аддитивный merge не удаляет серверные данные.
219
235
  if (error?.code === 'stale_base_version') {
220
236
  console.warn(
221
237
  'SaveManager: 409 stale_base_version — версия доски устарела. Перечитываю latest с сервера...',
222
238
  error.message
223
239
  );
224
240
  if (typeof this._reloadHandler === 'function') {
241
+ this._staleRebaseCount++;
225
242
  try {
226
- await this._reloadHandler();
227
- // После перезагрузки сбрасываем кеш: следующее изменение
228
- // сохранится с актуальной версией без ложного дедупа.
243
+ const result = (this._staleRebaseCount <= this.options.maxRetries)
244
+ ? await this._reloadHandler({
245
+ pendingData: pendingSaveData,
246
+ currentVersion: error.currentVersion ?? null,
247
+ })
248
+ : await this._reloadHandler();
249
+ // После перезагрузки сбрасываем кеш дедупа: пересохранение
250
+ // должно уйти на сервер с актуальной версией.
229
251
  this.lastSavedData = null;
230
- this.hasUnsavedChanges = false;
231
- this.updateSaveStatus('saved');
232
- console.warn(
233
- 'SaveManager: состояние доски перечитано с сервера. ' +
234
- 'Локальное pending-изменение отменено во избежание затирания чужих данных. ' +
235
- 'TODO: переприменить pending-изменение поверх свежего состояния.'
236
- );
252
+
253
+ const reapplied = Number(result?.reapplied) || 0;
254
+ // resave=true приходит, когда перечитать сервер не удалось,
255
+ // но версия принята из ответа 409 — локальное состояние нужно
256
+ // пересохранить с актуальным baseVersion (иначе рисунок не уйдёт).
257
+ const forceResave = result?.resave === true;
258
+ if ((reapplied > 0 || forceResave) && this._staleRebaseCount <= this.options.maxRetries) {
259
+ // Локальные новые объекты переприменены поверх свежего
260
+ // состояния (или версия принята напрямую) — пересохраняем
261
+ // их с актуальным baseVersion.
262
+ this.hasUnsavedChanges = true;
263
+ this._pendingResave = true; // finally запустит saveImmediately()
264
+ this.updateSaveStatus('saving');
265
+ } else {
266
+ // Нечего переприменять (или превышен лимит rebase) —
267
+ // оставляем перечитанное серверное состояние как есть.
268
+ this.hasUnsavedChanges = false;
269
+ this.updateSaveStatus('saved');
270
+ if (this._staleRebaseCount > this.options.maxRetries) {
271
+ console.warn('SaveManager: превышен лимит rebase после 409, оставляю серверное состояние.');
272
+ }
273
+ }
237
274
  } catch (reloadErr) {
238
275
  console.error('SaveManager: ошибка перечитывания после 409:', reloadErr);
239
276
  this.updateSaveStatus('error', reloadErr.message);
@@ -111,6 +111,62 @@ export function bindTopbarEvents(board) {
111
111
  });
112
112
  }
113
113
 
114
+ /**
115
+ * Аддитивно переприменяет локальные объекты, которых нет в текущем (только что
116
+ * перечитанном с сервера) состоянии доски. Вызывается после 409
117
+ * stale_base_version, чтобы не потерять только что созданные локально объекты
118
+ * (например, нарисованный штрих). Серверные объекты не трогаем — это безопасный
119
+ * union по id, а не перезапись чужих данных.
120
+ * @returns {number} сколько объектов переприменено
121
+ */
122
+ function reapplyLocalOnlyObjects(board, pendingData) {
123
+ const core = board?.coreMoodboard;
124
+ if (!core || typeof core.createObjectFromData !== 'function') {
125
+ return 0;
126
+ }
127
+
128
+ const pendingObjects = Array.isArray(pendingData?.boardData?.objects)
129
+ ? pendingData.boardData.objects
130
+ : Array.isArray(pendingData?.objects)
131
+ ? pendingData.objects
132
+ : [];
133
+ if (pendingObjects.length === 0) {
134
+ return 0;
135
+ }
136
+
137
+ const currentObjects = Array.isArray(core.state?.state?.objects)
138
+ ? core.state.state.objects
139
+ : [];
140
+ const currentIds = new Set(currentObjects.map((obj) => obj && obj.id).filter(Boolean));
141
+
142
+ let reapplied = 0;
143
+ for (const obj of pendingObjects) {
144
+ if (!obj || !obj.id || currentIds.has(obj.id)) {
145
+ continue;
146
+ }
147
+ try {
148
+ // Клонируем: createObjectFromData мутирует objectData (transform/properties).
149
+ const created = core.createObjectFromData(JSON.parse(JSON.stringify(obj)));
150
+ if (created) {
151
+ reapplied++;
152
+ }
153
+ } catch (error) {
154
+ console.warn('⚠️ rebase после 409: не удалось переприменить объект', obj.id, error);
155
+ }
156
+ }
157
+
158
+ if (reapplied > 0) {
159
+ // PIXI-объекты (рисунок) перерисовываются сразу; для текстовых/заметок
160
+ // синхронизируем HTML-оверлеи, как это делает DataManager.loadData.
161
+ setTimeout(() => {
162
+ try { window.moodboardHtmlTextLayer?.rebuildFromState?.(); } catch (_) {}
163
+ try { window.moodboardHtmlTextLayer?.updateAll?.(); } catch (_) {}
164
+ }, 0);
165
+ }
166
+
167
+ return reapplied;
168
+ }
169
+
114
170
  export function bindSaveCallbacks(board) {
115
171
  if (!board.coreMoodboard || !board.coreMoodboard.eventBus) {
116
172
  return;
@@ -125,9 +181,36 @@ export function bindSaveCallbacks(board) {
125
181
  return (v !== null && Number.isFinite(Number(v)) && Number(v) > 0) ? Number(v) : null;
126
182
  });
127
183
 
128
- // На 409 stale_base_version — перечитываем latest с сервера, не затираем чужие данные.
129
- saveManager.setReloadHandler(async () => {
130
- await loadExistingBoard(board, null, { fallbackToSeedOnError: false });
184
+ // На 409 stale_base_version — перечитываем latest с сервера (не затираем
185
+ // чужие данные) и аддитивно переприменяем локальные НОВЫЕ объекты, которых
186
+ // ещё нет в свежем состоянии (например, только что нарисованный штрих).
187
+ // Возвращаем число переприменённых объектов, чтобы SaveManager понял,
188
+ // нужно ли пересохранять с актуальной версией.
189
+ saveManager.setReloadHandler(async (context = {}) => {
190
+ const serverVersion = Number(context?.currentVersion);
191
+ const hasServerVersion = Number.isFinite(serverVersion) && serverVersion > 0;
192
+ try {
193
+ await loadExistingBoard(board, null, { fallbackToSeedOnError: false });
194
+ const reapplied = reapplyLocalOnlyObjects(board, context?.pendingData);
195
+ return { reapplied };
196
+ } catch (error) {
197
+ // Перечитать latest не удалось (типичный случай — GET вернул 404,
198
+ // потому что мета-запись доски ещё не создана, а история уже есть).
199
+ // Но сервер сообщил авторитетную версию прямо в ответе 409
200
+ // (currentVersion). Берём её напрямую, чтобы пересохранить локальное
201
+ // состояние с корректным baseVersion и не зациклиться на 409.
202
+ if (hasServerVersion) {
203
+ board.historyHeadVersion = serverVersion;
204
+ board.currentLoadedVersion = serverVersion;
205
+ board.historyCursorVersion = serverVersion;
206
+ console.warn(
207
+ `⚠️ rebase после 409: перечитывание не удалось (${error?.message || error}); `
208
+ + `приняли версию ${serverVersion} из ответа сервера и пересохраняем локальное состояние.`
209
+ );
210
+ return { reapplied: 0, resave: true };
211
+ }
212
+ throw error;
213
+ }
131
214
  });
132
215
  }
133
216