Нативный IndexedDB устроен на событиях: open возвращает объект запроса, вы вешаете onsuccess и onerror, вложенные операции превращаются в лестницу колбэков. Код рабочий, но шумный, и легко ошибиться. Отсюда соблазн взять обёртку - и важно понять, чего она не отменяет: абстракция убирает ceremony, обрядовость событийного API, но не знание транзакций, ключей и миграций. Библиотека делает те же вызовы платформы, только удобнее; версии, upgradeneeded и правила жизни транзакции остаются вашими.
Выбор обычно между тремя вариантами. Нативный API - ноль зависимостей и полный контроль ценой многословного событийного кода. Библиотека idb - тонкая Promise-обёртка вокруг тех же примитивов: open превращается в openDB с колбэком upgrade, запросы становятся await-абельными; схему и реактивный слой вы строите сами. Dexie идёт дальше: декларативная схема, язык запросов, транзакции и liveQuery для реактивных подписок - за это платите объёмом абстракции и веса в бандле.
| Выбор | Сильная сторона | Цена |
|---|---|---|
| Нативный IndexedDB | Ноль зависимостей, полный контроль | Событийный API, много рутины |
| idb 8.0.3 | Тонкая Promise-обёртка, близко к platform API | Схему и реактивный слой строите сами |
| Dexie 4.4.4 | Удобная схема, запросы, транзакции, liveQuery | Больше абстракции и веса в бандле |
Какую бы обёртку вы ни взяли, отделите её от остального кода. Заведите repository-слой, чтобы UI не знал о конкретной библиотеке: notes.list(), notes.save(), outbox.pending(). Это не украшательство - это то, что делает базу тестируемой и заменяемой. Компоненты вызывают методы репозитория, а внутри может быть idb, Dexie или мок в тестах. Когда вы решите сменить storage, менять придётся один модуль, а не сотню мест.
Вторая половина картины - место на диске, и здесь наивная модель ломается: браузерное хранилище не бесконечно и по умолчанию может быть очищено под давлением диска. Это eviction. У origin есть quota - лимит, который назначает браузер, глядя на свободное место и поведение пользователя, - и когда система решает освободить диск, данные best-effort origin выселяются целиком. Оценку даёт navigator.storage.estimate(): он возвращает usage и quota в байтах.
Защита от выселения - persistent storage. Вызов navigator.storage.persist() просит перевести origin в durable-режим, где данные не удаляют автоматически под давлением диска; navigator.storage.persisted() сообщает состояние. Но и размер quota, и решение выдать persistence принадлежат браузеру, а не вам. Отсюда правило: не просите persistence на первом экране - в вакууме запрос отклонят. Привяжите его к ценности: пользователь скачал offline-пакет заметок - вот теперь уместно просить не удалять его.
Даже полученный persistent storage - не серверный backup. Пользователь может очистить данные сайта руками, сменить устройство, переустановить браузер. Durable значит лишь, что система не выселит данные сама под нехватку места - это гарантия против фонового eviction, а не против потери. Поэтому outbox с неотправленными правками - транзитное состояние, а не архив: как только сервер подтвердил операцию, источник истины снова на нём. Persistent убирает один класс отказов, но не отменяет синхронизацию.
Практика сводится к нескольким привычкам. Показывайте размер offline-пакета до скачивания, чтобы пользователь понимал цену. Разрешайте удалять загруженное отдельно от черновиков и outbox: тяжёлое кэшируемое медиа удалять можно, несохранённый интент нельзя. К такому медиа применяйте LRU или TTL. Всегда оставляйте запас и ловите QuotaExceededError на записи. И главный антипаттерн: никогда не чистите outbox фоновой оптимизацией места - так вы молча теряете правки, которые пользователь считал сохранёнными.
import { openDB } from 'idb';
export const db = openDB('offline-notes', 3, {
upgrade(db, oldVersion, newVersion, tx) {
if (oldVersion < 1) db.createObjectStore('notes', { keyPath: 'id' });
if (oldVersion < 2) db.createObjectStore('outbox', { keyPath: 'operationId' });
if (oldVersion < 3) tx.objectStore('notes')
.createIndex('syncState', 'syncState');
},
blocked() { showCloseOtherTabsMessage(); },
blocking() { location.reload(); }
});const { usage = 0, quota = 0 } = await navigator.storage.estimate();
const ratio = quota ? usage / quota : 0;
let persistent = await navigator.storage.persisted();
if (!persistent && shouldAskAfterMeaningfulUse()) {
persistent = await navigator.storage.persist();
}