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();
},
};