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

Services — Полный код

Уровень: L3 (deep-dive) | ← Назад к Offline

1. Offline Items Service

Файл: frontend/src/offline/services/offlineItemsService.ts

Бизнес-логика создания, обновления и удаления вещей с поддержкой offline blob storage.

import { db } from '../db/dexie';
import { embeddingRepository } from '../../embeddings/embeddingRepository';
import { itemsRepository } from '../repositories/itemsRepository';
import { getOutfitIdsContainingItem, outfitsRepository } from '../repositories/outfitsRepository';
import { operationQueue } from '../sync/operationQueue';
import { syncEngine } from '../sync/syncEngine';
import type { ItemCreate, ItemUpdate } from '../../types/api';

function uuid(): string {
  return crypto.randomUUID();
}

export const offlineItemsService = {
  /**
   * Создание вещи с опциональным файлом для offline-хранения.
   * 
   * Если файл предоставлен и нет media_id:
   * 1. Blob сохраняется в IndexedDB (таблица blobs)
   * 2. Создаётся операция ITEM_MEDIA_UPLOAD
   * 3. Создаётся операция ITEM_CREATE с зависимостью от upload
   * 
   * Возвращает локальный UUID.
   */
  async create(data: ItemCreate, file?: File | null): Promise<string> {
    const id = uuid();
    const now = new Date().toISOString();
    let uploadOpId: string | undefined;
    let savedMediaLocalId: string | undefined;

    // Если есть файл, но нет media_id — сохранить blob локально
    if (file && !data.primary_media_id) {
      const mediaLocalId = uuid();
      savedMediaLocalId = mediaLocalId;
      const arrayBuffer = await file.arrayBuffer();

      // Создать запись в таблице media
      await db.media.put({
        id: mediaLocalId,
        local_blob_key: mediaLocalId,
        server_media_id: null,
        server_url: null,
        mime_type: file.type || 'image/png',
        status: 'local',
        width: null,
        height: null,
      });

      await db.table('media').update(mediaLocalId, {
        local_blob_key: `blob_${mediaLocalId}`,
      });

      // Сохранить blob как base64 в IndexedDB (нет лимита 5MB)
      const base64 = btoa(
        new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
      );
      const dataUrl = `data:${file.type};base64,${base64}`;
      await db.blobs.put({ id: mediaLocalId, data: dataUrl });

      // Поставить в очередь операцию загрузки
      const uploadOp = await syncEngine.enqueueAndProcess({
        operation_type: 'ITEM_MEDIA_UPLOAD',
        entity_type: 'media',
        entity_id: mediaLocalId,
        payload: { item_local_id: id, mime_type: file.type },
      });
      uploadOpId = uploadOp.id;

      // Убрать media_id из данных создания (будет добавлен после upload)
      data = { ...data, primary_media_id: undefined };
    }

    // Создать локальный черновик в IndexedDB
    await itemsRepository.createLocal({
      id,
      title: data.title ?? null,
      category_id: data.category_id ?? null,
      primary_color_id: data.primary_color_id ?? null,
      style_node_ids: data.style_node_ids ?? [],
      primary_media_id: data.primary_media_id ?? null,
      local_media_ref: savedMediaLocalId ? `blob_${savedMediaLocalId}` : null,
      created_at: now,
      updated_at: now,
    });

    // Поставить в очередь создание на сервере
    // Если был upload — ITEM_CREATE зависит от ITEM_MEDIA_UPLOAD
    await syncEngine.enqueueAndProcess({
      operation_type: 'ITEM_CREATE',
      entity_type: 'item',
      entity_id: id,
      payload: data as unknown as Record<string, unknown>,
      ...(uploadOpId ? { depends_on: uploadOpId } : {}),
    });

    return id;
  },

  /** Обновление вещи — мгновенное локально, в очередь для сервера */
  async update(id: string, data: ItemUpdate): Promise<void> {
    // 1. Обновить локально (UI обновится мгновенно)
    await itemsRepository.markUpdated(id, {
      title: data.title !== undefined ? (data.title ?? null) : undefined,
      category_id: data.category_id !== undefined ? (data.category_id ?? null) : undefined,
      primary_color_id: data.primary_color_id !== undefined ? (data.primary_color_id ?? null) : undefined,
      style_node_ids: data.style_node_ids,
    });

    // 2. Пометить embedding как устаревший
    await embeddingRepository.markStale('item', id);

    // 3. Проверить существующие операции для этой вещи
    const pendingOps = await db.pending_operations
      .where('[entity_type+entity_id]')
      .equals(['item', id])
      .toArray();

    // Coalescence: если уже есть ITEM_UPDATE в очереди — объединить payload
    const existingUpdate = pendingOps.find(
      (op) => op.operation_type === 'ITEM_UPDATE' && (op.status === 'queued' || op.status === 'blocked'),
    );
    if (existingUpdate) {
      const merged = { ...existingUpdate.payload, ...data };
      await db.pending_operations.update(existingUpdate.id, {
        payload: merged,
        updated_at: new Date().toISOString(),
      });
      return;
    }

    // Если есть pending ITEM_CREATE — новый UPDATE зависит от него
    const createOp = pendingOps.find(
      (op) => op.operation_type === 'ITEM_CREATE' && op.status !== 'done' && op.status !== 'cancelled',
    );

    await syncEngine.enqueueAndProcess({
      operation_type: 'ITEM_UPDATE',
      entity_type: 'item',
      entity_id: id,
      payload: data as Record<string, unknown>,
      ...(createOp ? { depends_on: createOp.id } : {}),
    });
  },

  /** Удаление вещи — мгновенное локально, каскадное удаление образов */
  async delete(id: string): Promise<void> {
    // 1. Soft-delete вещи
    await itemsRepository.markDeleted(id);

    // 2. Каскадное удаление образов, содержащих эту вещь
    const outfitIds = await getOutfitIdsContainingItem(id);
    for (const outfitId of outfitIds) {
      await outfitsRepository.markDeleted(outfitId);
      // Отменить pending-операции для каскадно удалённых образов
      const outfitPending = await db.pending_operations
        .where('[entity_type+entity_id]')
        .equals(['outfit', outfitId])
        .toArray();
      for (const op of outfitPending) {
        if (op.status !== 'done' && op.status !== 'cancelled') {
          await operationQueue.cancel(op.id);
        }
      }
    }

    // 3. Зависимость от ITEM_CREATE (если вещь ещё не создана на сервере)
    const pendingOps = await db.pending_operations
      .where('[entity_type+entity_id]')
      .equals(['item', id])
      .toArray();
    const createOp = pendingOps.find(
      (op) => op.operation_type === 'ITEM_CREATE' && op.status !== 'done' && op.status !== 'cancelled',
    );

    await syncEngine.enqueueAndProcess({
      operation_type: 'ITEM_DELETE',
      entity_type: 'item',
      entity_id: id,
      payload: {},
      ...(createOp ? { depends_on: createOp.id } : {}),
    });
  },
};

2. Offline Outfits Service

Файл: frontend/src/offline/services/offlineOutfitsService.ts

import { db } from '../db/dexie';
import { outfitsRepository } from '../repositories/outfitsRepository';
import { syncEngine } from '../sync/syncEngine';
import type { OutfitCreate } from '../../types/api';
import type { LocalOutfitItem } from '../sync/syncTypes';

function uuid(): string {
  return crypto.randomUUID();
}

export const offlineOutfitsService = {
  /** Создание образа — транзакционная запись в IndexedDB + очередь */
  async create(data: OutfitCreate): Promise<string> {
    const id = uuid();
    const now = new Date().toISOString();

    // Атомарная запись образа и его вещей
    await db.transaction('rw', [db.outfits, db.outfit_items], async () => {
      await db.outfits.put({
        id,
        server_id: null,
        local_only: true,
        sync_status: 'pending_create',
        deleted_locally: false,
        title: data.title ?? null,
        visibility: 'private',
        origin_type: 'manual',
        is_external: false,
        cover_media_id: null,
        cover_media_url: null,
        item_media_urls: [],
        created_at: now,
        updated_at: now,
        server_updated_at: null,
      });

      if (data.outfit_items) {
        const localItems: LocalOutfitItem[] = data.outfit_items.map((oi, idx) => ({
          id: `${id}_${oi.slot_id}_${oi.layer_index ?? idx}`,
          outfit_id: id,
          item_id: oi.item_id,
          slot_id: oi.slot_id,
          layer_index: oi.layer_index ?? idx,
        }));
        await db.outfit_items.bulkPut(localItems);
      }
    });

    // Поставить в очередь создание на сервере
    await syncEngine.enqueueAndProcess({
      operation_type: 'OUTFIT_CREATE',
      entity_type: 'outfit',
      entity_id: id,
      payload: data as Record<string, unknown>,
    });

    return id;
  },

  /** Обновление образа — мгновенное + очередь */
  async update(id: string, data: { title?: string }): Promise<void> {
    await outfitsRepository.markUpdated(id, {
      title: data.title !== undefined ? (data.title ?? null) : undefined,
    });

    // Зависимость от OUTFIT_CREATE если образ ещё не создан на сервере
    const pendingOps = await db.pending_operations
      .where('[entity_type+entity_id]')
      .equals(['outfit', id])
      .toArray();
    const createOp = pendingOps.find(
      (op) => op.operation_type === 'OUTFIT_CREATE' && op.status !== 'done' && op.status !== 'cancelled',
    );

    await syncEngine.enqueueAndProcess({
      operation_type: 'OUTFIT_UPDATE',
      entity_type: 'outfit',
      entity_id: id,
      payload: data as Record<string, unknown>,
      ...(createOp ? { depends_on: createOp.id } : {}),
    });
  },

  /** Удаление образа */
  async delete(id: string): Promise<void> {
    await outfitsRepository.markDeleted(id);

    await syncEngine.enqueueAndProcess({
      operation_type: 'OUTFIT_DELETE',
      entity_type: 'outfit',
      entity_id: id,
      payload: {},
    });
  },
};

3. Initial Sync Service

Файл: frontend/src/offline/services/initialSync.ts

Первичная загрузка данных после логина и периодическое обновление.

import { itemsApi } from '../../api/items';
import { outfitsApi } from '../../api/outfits';
import { referenceApi } from '../../api/reference';
import { embeddingRepository } from '../../embeddings/embeddingRepository';
import { outfitEmbeddingService } from '../../embeddings/outfitEmbeddingService';
import { similarityService } from '../../embeddings/similarityService';
import { queryClient } from '../../queryClient';
import { itemsRepository } from '../repositories/itemsRepository';
import { outfitsRepository } from '../repositories/outfitsRepository';
import { syncRepository } from '../repositories/syncRepository';
import { networkStatus } from '../network/networkStatus';
import { clearLocalData } from './localData';

const REF_STORAGE_PREFIX = 'plechiki_ref_';

function saveRefToStorage(key: string, data: unknown) {
  try {
    localStorage.setItem(REF_STORAGE_PREFIX + key, JSON.stringify(data));
  } catch { /* quota exceeded */ }
}

const SYNC_PAGE_SIZE = 500;

/** Пагинированная загрузка всех вещей */
async function fetchAllItems(params?: { include_embeddings?: boolean }) {
  const all: import('../../types/api').Item[] = [];
  let offset = 0;
  let hasMore = true;
  while (hasMore) {
    const { items } = await itemsApi.list({ ...params, limit: SYNC_PAGE_SIZE, offset });
    all.push(...items);
    hasMore = items.length === SYNC_PAGE_SIZE;
    offset += SYNC_PAGE_SIZE;
  }
  return all;
}

/** Пагинированная загрузка всех образов */
async function fetchAllOutfits() {
  const all: import('../../types/api').Outfit[] = [];
  let offset = 0;
  let hasMore = true;
  while (hasMore) {
    const { outfits } = await outfitsApi.list({ limit: SYNC_PAGE_SIZE, offset });
    all.push(...outfits);
    hasMore = outfits.length === SYNC_PAGE_SIZE;
    offset += SYNC_PAGE_SIZE;
  }
  return all;
}

/** Защита от параллельных sync */
let _syncRunning = false;

/**
 * Первичная синхронизация после логина.
 * Загружает все данные с сервера → сохраняет в IndexedDB.
 * Пропускает если уже выполнялась (проверяет sync_metadata).
 */
export async function performInitialSync(): Promise<void> {
  if (!networkStatus.online) {
    console.log('[Sync] Offline, skipping initial sync');
    return;
  }
  if (_syncRunning) {
    console.log('[Sync] Another sync is running, skipping');
    return;
  }

  _syncRunning = true;
  try {
    // Одноразовая очистка дубликатов
    await itemsRepository.deduplicateByServerId();

    // Синхронизация справочников (всегда)
    await syncReferenceData();

    // ─── Вещи ────────────────────────────────────────────────
    let syncedItems: Array<{ primary_media_url?: string }> = [];
    const itemsMeta = await syncRepository.getMeta('item');
    if (!itemsMeta?.last_full_sync_at) {
      console.log('[Sync] Initial items sync...');
      const items = await fetchAllItems();
      await itemsRepository.bulkUpsertFromServer(items);
      await syncRepository.markFullSync('item');
      syncedItems = items;
      console.log(`[Sync] Synced ${items.length} items`);

      // Загрузка эмбеддингов отдельным запросом
      const itemsWithEmb = await fetchAllItems({ include_embeddings: true });
      const embItems = itemsWithEmb.filter((i) => i.embedding);
      if (embItems.length > 0) {
        await embeddingRepository.bulkUpsertFromServer(
          'item',
          embItems.map((i) => ({ entityId: i.id, embedding: i.embedding! }))
        );
        similarityService.invalidateCache('item');
        console.log(`[Sync] Synced ${embItems.length} item embeddings`);
      }
    }

    // ─── Образы ──────────────────────────────────────────────
    let syncedOutfits: Array<{ cover_media_url?: string; item_media_urls?: string[] }> = [];
    const outfitsMeta = await syncRepository.getMeta('outfit');
    if (!outfitsMeta?.last_full_sync_at) {
      console.log('[Sync] Initial outfits sync...');
      const outfits = await fetchAllOutfits();
      await outfitsRepository.bulkUpsertFromServer(outfits);
      await syncRepository.markFullSync('outfit');
      syncedOutfits = outfits;
      console.log(`[Sync] Synced ${outfits.length} outfits`);

      // Вычислить производные эмбеддинги образов
      await outfitEmbeddingService.recomputeAll();
      similarityService.invalidateCache('outfit');
      console.log('[Sync] Computed outfit embeddings');
    }

    // ─── Предзагрузка изображений ───────────────────────────
    if (syncedItems.length > 0 || syncedOutfits.length > 0) {
      precacheImages(syncedItems, syncedOutfits);
    }

    // Инвалидация React Query
    if (syncedItems.length > 0) queryClient.invalidateQueries({ queryKey: ['items'] });
    if (syncedOutfits.length > 0) queryClient.invalidateQueries({ queryKey: ['outfits'] });
  } catch (err) {
    console.error('[Sync] Initial sync failed:', err);
  } finally {
    _syncRunning = false;
  }
}

/** Фоновая предзагрузка изображений (SW закеширует через CacheFirst) */
function precacheImages(
  items: Array<{ primary_media_url?: string }>,
  outfits: Array<{ cover_media_url?: string; item_media_urls?: string[] }>,
) {
  const urls = new Set<string>();
  for (const item of items) {
    if (item.primary_media_url) urls.add(item.primary_media_url);
  }
  for (const outfit of outfits) {
    if (outfit.cover_media_url) urls.add(outfit.cover_media_url);
    outfit.item_media_urls?.forEach((url) => urls.add(url));
  }
  if (urls.size === 0) return;

  console.log(`[Sync] Precaching ${urls.size} images...`);
  const urlArray = [...urls];
  let i = 0;
  // Batch-загрузка по 10 для щадящего использования сети
  const next = () => {
    const batch = urlArray.slice(i, i + 10);
    if (batch.length === 0) {
      console.log('[Sync] Image precache complete');
      return;
    }
    i += 10;
    Promise.allSettled(batch.map((url) => fetch(url, { mode: 'no-cors' }))).then(next);
  };
  // Использовать requestIdleCallback если доступен
  if ('requestIdleCallback' in window) {
    (window as any).requestIdleCallback(next);
  } else {
    setTimeout(next, 100);
  }
}

/** Загрузка справочников в localStorage */
async function syncReferenceData(): Promise<void> {
  console.log('[Sync] Syncing reference data...');
  const [categories, colors, styles, slots] = await Promise.all([
    referenceApi.categories(),
    referenceApi.colors(),
    referenceApi.styles(),
    referenceApi.slots(),
  ]);
  saveRefToStorage('categories', categories);
  saveRefToStorage('colors', colors);
  saveRefToStorage('styles', styles);
  saveRefToStorage('slots', slots);
  console.log('[Sync] Reference data synced');
}

/**
 * Refresh sync — перезагрузка и reconcile.
 * Удаляет удалённые на сервере, обновляет изменённые.
 * Вызывается при visibilitychange (фокус вкладки) и reconnect.
 */
export async function performRefreshSync(): Promise<void> {
  if (!networkStatus.online) return;
  if (_syncRunning) return;

  _syncRunning = true;
  try {
    console.log('[Sync] Refresh sync...');
    await syncReferenceData();

    const [allItems, allOutfits] = await Promise.all([
      fetchAllItems(),
      fetchAllOutfits(),
    ]);

    const deletedItems = await itemsRepository.reconcileWithServer(allItems);
    const deletedOutfits = await outfitsRepository.reconcileWithServer(allOutfits);

    if (deletedItems > 0 || deletedOutfits > 0) {
      console.log(`[Sync] Reconciled: removed ${deletedItems} items, ${deletedOutfits} outfits`);
    }

    // Обновить эмбеддинги
    try {
      const itemsWithEmb = await fetchAllItems({ include_embeddings: true });
      const embItems = itemsWithEmb.filter((i) => i.embedding);
      if (embItems.length > 0) {
        await embeddingRepository.bulkUpsertFromServer(
          'item',
          embItems.map((i) => ({ entityId: i.id, embedding: i.embedding! }))
        );
        await outfitEmbeddingService.recomputeAll();
        similarityService.invalidateCache('item');
        similarityService.invalidateCache('outfit');
      }
    } catch (err) {
      console.warn('[Sync] Embedding refresh failed:', err);
    }

    queryClient.invalidateQueries({ queryKey: ['items'] });
    queryClient.invalidateQueries({ queryKey: ['outfits'] });
    console.log('[Sync] Refresh sync complete');
  } catch (err) {
    console.error('[Sync] Refresh sync failed:', err);
  } finally {
    _syncRunning = false;
  }
}

/** Регистрация слушателя видимости для refresh sync */
let _visibilityListenerRegistered = false;
export function registerRefreshSyncListener(): void {
  if (_visibilityListenerRegistered) return;
  _visibilityListenerRegistered = true;

  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      import('../../stores/auth').then(({ useAuthStore }) => {
        if (useAuthStore.getState().isAuthenticated) {
          performRefreshSync();
        }
      });
    }
  });
}

export { clearLocalData };

4. Local Data Service

Файл: frontend/src/offline/services/localData.ts

Полная очистка локальных данных при logout.

import { db } from '../db/dexie';
import { similarityService } from '../../embeddings/similarityService';
import { itemsRepository } from '../repositories/itemsRepository';
import { mediaRepository } from '../repositories/mediaRepository';
import { outfitsRepository } from '../repositories/outfitsRepository';
import { syncRepository } from '../repositories/syncRepository';

const REF_STORAGE_PREFIX = 'plechiki_ref_';
const BLOB_STORAGE_PREFIX = 'plechiki_blob_';

/** Очистить ВСЕ локальные данные (при logout) */
export async function clearLocalData(): Promise<void> {
  // 1. Очистить все таблицы IndexedDB
  await Promise.all([
    itemsRepository.clear(),
    outfitsRepository.clear(),
    mediaRepository.clear(),
    db.embeddings.clear(),
    syncRepository.clearAll(),
    syncRepository.clearMeta(),
  ]);

  // 2. Очистить справочники из localStorage
  ['categories', 'colors', 'styles', 'slots', 'collections'].forEach((key) => {
    localStorage.removeItem(REF_STORAGE_PREFIX + key);
  });

  // 3. Очистить blobs из IndexedDB
  await db.blobs.clear();

  // 4. Очистить legacy-blobs из localStorage
  const blobKeys: string[] = [];
  for (let i = 0; i < localStorage.length; i += 1) {
    const key = localStorage.key(i);
    if (key?.startsWith(BLOB_STORAGE_PREFIX)) {
      blobKeys.push(key);
    }
  }
  blobKeys.forEach((key) => localStorage.removeItem(key));

  // 5. Остановить Web Worker similarity search
  similarityService.terminate();

  console.log('[Sync] Local data cleared');
}