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');
}