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

Repositories — Полный код

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

1. Items Repository

Файл: frontend/src/offline/repositories/itemsRepository.ts

Управление вещами в IndexedDB: CRUD, bulk-операции, дедупликация, reconcile.

import { db } from '../db/dexie';
import type { LocalItem, SyncStatus } from '../sync/syncTypes';
import type { Item, ItemCategory, ItemListParams } from '../../types/api';

/** Получить ID категорий для заданного слота (из localStorage-кеша) */
function getCategoryIdsForSlot(slotId: number): Set<number> | null {
  try {
    const raw = localStorage.getItem('plechiki_ref_categories');
    if (!raw) return null;
    const categories: ItemCategory[] = JSON.parse(raw);
    return new Set(
      categories.filter((c) => c.default_slot_id === slotId).map((c) => c.id),
    );
  } catch {
    return null;
  }
}

/** Конвертация серверной Item → локальной LocalItem */
export function serverItemToLocal(item: Item): LocalItem {
  return {
    id: item.id,
    server_id: item.id,
    local_only: false,
    sync_status: 'synced',
    deleted_locally: false,
    title: item.title ?? null,
    category_id: item.category_id ?? null,
    primary_color_id: item.primary_color_id ?? null,
    style_node_ids: item.style_node_ids ?? [],
    primary_media_id: item.primary_media_id ?? null,
    primary_media_url: item.primary_media_url ?? null,
    local_media_ref: null,
    created_at: item.created_at,
    updated_at: item.updated_at,
    server_updated_at: item.updated_at,
  };
}

/** Конвертация LocalItem → API Item (для передачи в UI) */
export function localItemToApi(local: LocalItem, blobUrl?: string): Item {
  let mediaUrl = local.primary_media_url ?? undefined;
  if (!mediaUrl && blobUrl) {
    mediaUrl = blobUrl;  // Используем blob из IndexedDB
  } else if (!mediaUrl && local.local_media_ref) {
    // Fallback: legacy localStorage blob
    const blob = localStorage.getItem(`plechiki_${local.local_media_ref}`);
    if (blob) mediaUrl = blob;
  }

  return {
    id: local.server_id ?? local.id,
    title: local.title ?? undefined,
    category_id: local.category_id ?? 0,
    primary_color_id: local.primary_color_id ?? undefined,
    primary_media_id: local.primary_media_id ?? undefined,
    primary_media_url: mediaUrl,
    status: local.deleted_locally ? 'deleted' : 'active',
    excluded_from_recommendations: false,
    style_node_ids: local.style_node_ids,
    created_at: local.created_at,
    updated_at: local.updated_at,
  };
}

export const itemsRepository = {
  /** Все не-удалённые вещи с фильтрацией */
  async getAll(params?: ItemListParams): Promise<LocalItem[]> {
    let results: LocalItem[];

    // Используем индекс category_id если указан фильтр
    if (params?.category_id) {
      results = await db.items.where('category_id').equals(params.category_id).toArray();
    } else {
      results = await db.items.toArray();
    }

    // Исключить soft-deleted
    results = results.filter((i) => !i.deleted_locally);

    // Фильтрация по цвету
    if (params?.color_id) {
      results = results.filter((i) => i.primary_color_id === params.color_id);
    }

    // Фильтрация по слоту (через маппинг категория→слот)
    if (params?.slot_id) {
      const catIds = getCategoryIdsForSlot(params.slot_id);
      if (catIds) {
        results = results.filter((i) => i.category_id !== null && catIds.has(i.category_id));
      }
    }

    // Сортировка: новые первыми
    results.sort((a, b) => {
      const aTs = Date.parse(a.created_at || a.updated_at || '') || 0;
      const bTs = Date.parse(b.created_at || b.updated_at || '') || 0;
      return bTs - aTs;
    });

    return results;
  },

  /** Одна вещь по ID (ищет по id и server_id) */
  async getById(id: string): Promise<LocalItem | undefined> {
    const byId = await db.items.get(id);
    if (byId) return byId;
    return db.items.where('server_id').equals(id).first();
  },

  /** Upsert одной вещи с сервера (не перетирает pending изменения) */
  async upsertFromServer(item: Item): Promise<void> {
    let existing = await db.items.get(item.id);
    if (!existing) {
      existing = await db.items.where('server_id').equals(item.id).first();
    }
    // Не перезаписывать, если есть локальные изменения
    if (existing && existing.sync_status !== 'synced') return;

    if (existing && existing.id !== item.id) {
      // Обновить запись, сохраняя локальный PK
      await db.items.update(existing.id, { ...serverItemToLocal(item), id: existing.id });
    } else {
      await db.items.put(serverItemToLocal(item));
    }
  },

  /** Массовый upsert с сервера — уважает pending изменения */
  async bulkUpsertFromServer(items: Item[]): Promise<void> {
    const serverIds = items.map((i) => i.id);

    // Находим существующие записи по ОБОИМ ключам
    const [byPk, byServerId] = await Promise.all([
      db.items.where('id').anyOf(serverIds).toArray(),
      db.items.where('server_id').anyOf(serverIds).toArray(),
    ]);

    // Строим карту: server_id → локальная запись
    const localByServerId = new Map<string, LocalItem>();
    for (const rec of [...byPk, ...byServerId]) {
      const sid = rec.server_id ?? rec.id;
      if (!localByServerId.has(sid)) localByServerId.set(sid, rec);
    }

    for (const serverItem of items) {
      const local = localByServerId.get(serverItem.id);

      // Пропускаем вещи с pending изменениями
      if (local && local.sync_status !== 'synced') continue;

      if (local && local.id !== serverItem.id) {
        // Offline-created запись с другим PK — обновить на месте
        await db.items.update(local.id, {
          ...serverItemToLocal(serverItem),
          id: local.id,
        });
      } else {
        await db.items.put(serverItemToLocal(serverItem));
      }
    }
  },

  /** Reconcile: удалить то, чего нет на сервере + upsert остальное */
  async reconcileWithServer(serverItems: Item[]): Promise<number> {
    const serverIds = new Set(serverItems.map((i) => i.id));
    const allLocal = await db.items.toArray();

    // Найти synced-записи, которых нет на сервере (удалены на сервере)
    const toDelete = allLocal.filter(
      (local) =>
        local.sync_status === 'synced' &&
        !local.local_only &&
        !serverIds.has(local.server_id ?? local.id),
    );

    if (toDelete.length > 0) {
      const deleteIds = toDelete.map((i) => i.id);
      await db.items.bulkDelete(deleteIds);
      // Удалить осиротевшие эмбеддинги
      for (const id of deleteIds) {
        await db.embeddings.delete(['item', id]);
      }
    }

    // Upsert серверных данных
    await this.bulkUpsertFromServer(serverItems);
    return toDelete.length;
  },

  /** Создать локальный черновик вещи (offline) */
  async createLocal(draft: Omit<LocalItem, 'sync_status' | 'local_only' | 'server_id' | 'server_updated_at' | 'deleted_locally' | 'primary_media_url'>): Promise<LocalItem> {
    const local: LocalItem = {
      ...draft,
      server_id: null,
      local_only: true,
      sync_status: 'pending_create',
      deleted_locally: false,
      primary_media_url: null,
      server_updated_at: null,
    };
    await db.items.put(local);
    return local;
  },

  /** Пометить как обновлённую (pending update) */
  async markUpdated(id: string, changes: Partial<LocalItem>): Promise<void> {
    await db.items.update(id, {
      ...changes,
      sync_status: 'pending_update' as SyncStatus,
      updated_at: new Date().toISOString(),
    });
  },

  /** Пометить как удалённую (soft-delete) */
  async markDeleted(id: string): Promise<void> {
    await db.items.update(id, {
      deleted_locally: true,
      sync_status: 'pending_delete' as SyncStatus,
      updated_at: new Date().toISOString(),
    });
  },

  /** Полное удаление после подтверждения сервером */
  async removeAfterSync(id: string): Promise<void> {
    await db.items.delete(id);
  },

  /** Количество несинхронизированных вещей */
  async getPendingCount(): Promise<number> {
    return db.items
      .where('sync_status')
      .anyOf(['pending_create', 'pending_update', 'pending_delete', 'failed'])
      .count();
  },

  /** Количество активных вещей */
  async count(): Promise<number> {
    return (await db.items.toArray()).filter((i) => !i.deleted_locally).length;
  },

  /** Дедупликация по server_id (one-time cleanup) */
  async deduplicateByServerId(): Promise<number> {
    const all = await db.items.toArray();
    const byServerId = new Map<string, LocalItem[]>();
    for (const item of all) {
      if (!item.server_id) continue;
      const group = byServerId.get(item.server_id) || [];
      group.push(item);
      byServerId.set(item.server_id, group);
    }
    const toDelete: string[] = [];
    for (const [, group] of byServerId) {
      if (group.length <= 1) continue;
      // Оставить запись с local_only=false, удалить дубликаты
      group.sort((a, b) => (a.local_only === b.local_only ? 0 : a.local_only ? 1 : -1));
      for (let i = 1; i < group.length; i++) {
        toDelete.push(group[i].id);
      }
    }
    if (toDelete.length > 0) {
      await db.items.bulkDelete(toDelete);
      console.log(`[Dedup] Removed ${toDelete.length} duplicate items`);
    }
    return toDelete.length;
  },

  /** Очистить всё (logout) */
  async clear(): Promise<void> {
    await db.items.clear();
  },
};

2. Outfits Repository

Файл: frontend/src/offline/repositories/outfitsRepository.ts

import { db } from '../db/dexie';
import type { LocalOutfit, LocalOutfitItem, SyncStatus } from '../sync/syncTypes';
import type { Outfit, OutfitListParams } from '../../types/api';

/** Конвертация серверного Outfit → LocalOutfit */
export function serverOutfitToLocal(outfit: Outfit): LocalOutfit {
  return {
    id: outfit.id,
    server_id: outfit.id,
    local_only: false,
    sync_status: 'synced',
    deleted_locally: false,
    title: outfit.title ?? null,
    visibility: outfit.visibility,
    origin_type: outfit.origin_type,
    is_external: outfit.is_external,
    cover_media_id: outfit.cover_media_id ?? null,
    cover_media_url: outfit.cover_media_url ?? null,
    item_media_urls: outfit.item_media_urls ?? [],
    created_at: outfit.created_at,
    updated_at: outfit.updated_at,
    server_updated_at: outfit.updated_at,
  };
}

/** Конвертация серверных outfit_items → локальные */
function serverOutfitItemsToLocal(outfitId: string, outfit: Outfit): LocalOutfitItem[] {
  return (outfit.outfit_items ?? []).map((oi) => ({
    id: `${outfitId}_${oi.slot_id}_${oi.layer_index}`,
    outfit_id: outfitId,
    item_id: oi.item_id,
    slot_id: oi.slot_id,
    layer_index: oi.layer_index,
  }));
}

/** Fingerprint набора вещей образа (для поиска дубликатов) */
export function outfitFingerprint(items: Array<{ slot_id: number; item_id: string }>): string {
  return items
    .map((i) => `${i.slot_id}:${i.item_id}`)
    .sort()
    .join('|');
}

/** Поиск дубликата образа с таким же набором вещей */
export async function findDuplicateOutfit(items: Array<{ slot_id: number; item_id: string }>): Promise<string | null> {
  const fp = outfitFingerprint(items);
  if (!fp) return null;

  const allOutfits = await db.outfits.toArray();
  for (const outfit of allOutfits) {
    if (outfit.deleted_locally) continue;
    const outfitItems = await db.outfit_items.where('outfit_id').equals(outfit.id).toArray();
    const ofp = outfitFingerprint(outfitItems.map((oi) => ({ slot_id: oi.slot_id, item_id: oi.item_id })));
    if (ofp === fp) return outfit.title ?? 'Без названия';
  }
  return null;
}

/** Все ID образов, содержащих указанную вещь */
export async function getOutfitIdsContainingItem(itemLocalId: string): Promise<string[]> {
  const item = await db.items.get(itemLocalId);
  const itemIds = new Set<string>([itemLocalId]);
  if (item?.server_id) itemIds.add(item.server_id);
  const outfitIds = new Set<string>();
  for (const iid of itemIds) {
    const rows = await db.outfit_items.where('item_id').equals(iid).toArray();
    for (const r of rows) outfitIds.add(r.outfit_id);
  }
  return [...outfitIds];
}

export const outfitsRepository = {
  /** Все не-удалённые образы */
  async getAll(params?: OutfitListParams): Promise<LocalOutfit[]> {
    let results = await db.outfits.toArray();
    results = results.filter((o) => !o.deleted_locally);

    if (params?.origin_type === 'own') {
      results = results.filter((o) => !o.is_external);
    } else if (params?.origin_type === 'external') {
      results = results.filter((o) => o.is_external);
    }

    return results;
  },

  /** Один образ по ID */
  async getById(id: string): Promise<LocalOutfit | undefined> {
    const byId = await db.outfits.get(id);
    if (byId) return byId;
    return db.outfits.where('server_id').equals(id).first();
  },

  /** Вещи в образе */
  async getOutfitItems(outfitId: string): Promise<LocalOutfitItem[]> {
    return db.outfit_items.where('outfit_id').equals(outfitId).toArray();
  },

  /** Upsert образа + его вещей из серверных данных (в транзакции) */
  async upsertFromServer(outfit: Outfit): Promise<void> {
    let existing = await db.outfits.get(outfit.id);
    if (!existing) {
      existing = await db.outfits.where('server_id').equals(outfit.id).first();
    }
    if (existing && existing.sync_status !== 'synced') return;

    const localPk = existing?.id ?? outfit.id;
    await db.transaction('rw', [db.outfits, db.outfit_items], async () => {
      await db.outfits.put({ ...serverOutfitToLocal(outfit), id: localPk });
      // Пересоздать связи вещей
      await db.outfit_items.where('outfit_id').equals(localPk).delete();
      const localItems = serverOutfitItemsToLocal(localPk, outfit);
      if (localItems.length > 0) {
        await db.outfit_items.bulkPut(localItems);
      }
    });
  },

  /** Массовый upsert образов */
  async bulkUpsertFromServer(outfits: Outfit[]): Promise<void> {
    const sids = outfits.map((o) => o.id);
    const [byPk, byServerId] = await Promise.all([
      db.outfits.where('id').anyOf(sids).toArray(),
      db.outfits.where('server_id').anyOf(sids).toArray(),
    ]);
    const localByServerId = new Map<string, LocalOutfit>();
    for (const rec of [...byPk, ...byServerId]) {
      const sid = rec.server_id ?? rec.id;
      if (!localByServerId.has(sid)) localByServerId.set(sid, rec);
    }

    const toProcess = outfits.filter((o) => {
      const local = localByServerId.get(o.id);
      return !local || local.sync_status === 'synced';
    });
    if (toProcess.length === 0) return;

    await db.transaction('rw', [db.outfits, db.outfit_items], async () => {
      for (const outfit of toProcess) {
        const local = localByServerId.get(outfit.id);
        const localPk = local?.id ?? outfit.id;
        await db.outfits.put({ ...serverOutfitToLocal(outfit), id: localPk });
        await db.outfit_items.where('outfit_id').equals(localPk).delete();
        const localItems = serverOutfitItemsToLocal(localPk, outfit);
        if (localItems.length > 0) {
          await db.outfit_items.bulkPut(localItems);
        }
      }
    });
  },

  /** Reconcile: удалить отсутствующие на сервере + upsert */
  async reconcileWithServer(serverOutfits: Outfit[]): Promise<number> {
    const serverIds = new Set(serverOutfits.map((o) => o.id));
    const allLocal = await db.outfits.toArray();

    const toDelete = allLocal.filter(
      (local) =>
        local.sync_status === 'synced' &&
        !local.local_only &&
        !serverIds.has(local.server_id ?? local.id),
    );

    if (toDelete.length > 0) {
      await db.transaction('rw', [db.outfits, db.outfit_items, db.embeddings], async () => {
        for (const outfit of toDelete) {
          await db.outfit_items.where('outfit_id').equals(outfit.id).delete();
          await db.embeddings.delete(['outfit', outfit.id]);
        }
        await db.outfits.bulkDelete(toDelete.map((o) => o.id));
      });
    }

    await this.bulkUpsertFromServer(serverOutfits);
    return toDelete.length;
  },

  async markUpdated(id: string, changes: Partial<LocalOutfit>): Promise<void> {
    await db.outfits.update(id, {
      ...changes,
      sync_status: 'pending_update' as SyncStatus,
      updated_at: new Date().toISOString(),
    });
  },

  async markDeleted(id: string): Promise<void> {
    await db.outfits.update(id, {
      deleted_locally: true,
      sync_status: 'pending_delete' as SyncStatus,
      updated_at: new Date().toISOString(),
    });
  },

  async getPendingCount(): Promise<number> {
    return db.outfits
      .where('sync_status')
      .anyOf(['pending_create', 'pending_update', 'pending_delete', 'failed'])
      .count();
  },

  async count(): Promise<number> {
    return (await db.outfits.toArray()).filter((o) => !o.deleted_locally).length;
  },

  async clear(): Promise<void> {
    await db.transaction('rw', [db.outfits, db.outfit_items], async () => {
      await db.outfits.clear();
      await db.outfit_items.clear();
    });
  },
};

3. Sync Repository

Файл: frontend/src/offline/repositories/syncRepository.ts

Управление очередью операций и метаданными синхронизации.

import { db } from '../db/dexie';
import type { PendingOperation, SyncMetadata, SyncEntityType, OperationStatus } from '../sync/syncTypes';

export const syncRepository = {
  // ── Pending Operations ──

  /** Добавить операцию в очередь */
  async enqueue(op: PendingOperation): Promise<void> {
    await db.pending_operations.put(op);
  },

  /** Получить готовые к выполнению операции (без неразрешённых зависимостей) */
  async getReady(): Promise<PendingOperation[]> {
    const queued = await db.pending_operations.where('status').equals('queued').toArray();
    const ready: PendingOperation[] = [];
    for (const op of queued) {
      if (!op.depends_on) {
        ready.push(op);
        continue;
      }
      // Проверить, завершена ли зависимость
      const dep = await db.pending_operations.get(op.depends_on);
      if (dep && dep.status === 'done') {
        ready.push(op);
      }
    }
    // Сортировка по времени создания (FIFO)
    return ready.sort((a, b) => a.created_at.localeCompare(b.created_at));
  },

  /** Обновить статус операции */
  async updateStatus(id: string, status: OperationStatus, error?: string): Promise<void> {
    const updates: Partial<PendingOperation> = {
      status,
      updated_at: new Date().toISOString(),
    };
    if (error !== undefined) updates.last_error = error;
    if (status === 'failed') {
      const op = await db.pending_operations.get(id);
      if (op) updates.retry_count = op.retry_count + 1;
    }
    await db.pending_operations.update(id, updates);
  },

  /** Количество активных операций (для badge) */
  async getPendingCount(): Promise<number> {
    return db.pending_operations
      .where('status')
      .anyOf(['queued', 'running', 'failed', 'blocked'])
      .count();
  },

  /** Получить упавшие операции */
  async getFailed(): Promise<PendingOperation[]> {
    return db.pending_operations.where('status').equals('failed').toArray();
  },

  /** Очистить завершённые (сборка мусора) */
  async pruneCompleted(): Promise<void> {
    await db.pending_operations.where('status').equals('done').delete();
  },

  /** Очистить всё (logout) */
  async clearAll(): Promise<void> {
    await db.pending_operations.clear();
  },

  // ── Sync Metadata ──

  async getMeta(entityType: SyncEntityType): Promise<SyncMetadata | undefined> {
    return db.sync_metadata.get(entityType);
  },

  async setMeta(meta: SyncMetadata): Promise<void> {
    await db.sync_metadata.put(meta);
  },

  /** Отметить успешную полную синхронизацию */
  async markFullSync(entityType: SyncEntityType): Promise<void> {
    const now = new Date().toISOString();
    const existing = await db.sync_metadata.get(entityType);
    await db.sync_metadata.put({
      entity_type: entityType,
      last_full_sync_at: now,
      last_delta_sync_at: now,
      etag: existing?.etag ?? null,
      version: (existing?.version ?? 0) + 1,
    });
  },

  async clearMeta(): Promise<void> {
    await db.sync_metadata.clear();
  },
};

4. Media Repository

Файл: frontend/src/offline/repositories/mediaRepository.ts

Управление метаданными медиа и резолв URL.

import { db } from '../db/dexie';
import type { LocalMedia } from '../sync/syncTypes';

export const mediaRepository = {
  async getById(id: string): Promise<LocalMedia | undefined> {
    return db.media.get(id);
  },

  async getByServerId(serverId: string): Promise<LocalMedia | undefined> {
    return db.media.where('server_media_id').equals(serverId).first();
  },

  /** Резолв URL: предпочитает server URL, fallback на локальный blob */
  async resolveUrl(mediaId: string | null | undefined): Promise<string | null> {
    if (!mediaId) return null;
    const record = await db.media.get(mediaId);
    if (!record) return null;
    if (record.server_url) return record.server_url;
    if (record.local_blob_key) {
      return null; // В будущем: вернуть blob URL
    }
    return null;
  },

  /** Upsert медиа с сервера */
  async upsertFromServer(id: string, serverUrl: string, mimeType: string): Promise<void> {
    await db.media.put({
      id,
      local_blob_key: null,
      server_media_id: id,
      server_url: serverUrl,
      mime_type: mimeType,
      status: 'uploaded',
      width: null,
      height: null,
    });
  },

  /** Очистить (logout) */
  async clear(): Promise<void> {
    await db.media.clear();
  },
};