Использование с Svelte: stores и liveQuery

Dexie.js предоставляет реактивный слой поверх IndexedDB, но в связке с Svelte особенно ценным становится сочетание liveQuery и Svelte stores, позволяющее синхронизировать состояние базы данных с UI без ручного управления подписками и императивных обновлений.

В основе подхода лежит разделение двух уровней реактивности:

  • реактивность базы данных (Dexie через liveQuery)
  • реактивность UI (Svelte stores)

Dexie.js предоставляет liveQuery, который превращает запрос к IndexedDB в поток значений (Observable), пересчитывающийся при изменениях данных. Svelte предоставляет store-модель, где любое изменение состояния автоматически отражается в UI через подписки.

Ключевая идея интеграции — мост между Observable Dexie и store Svelte.


Dexie.liveQuery как источник реактивного потока

liveQuery оборачивает любой запрос Dexie в реактивный поток:

import { liveQuery } fr om "dexie";

const todos$ = liveQuery(() =>
  db.todos
    .orderBy("createdAt")
    .toArray()
);

Особенности поведения:

  • выполняет запрос при первой подписке
  • пересчитывает результат при изменении таблиц, затронутых запросом
  • кэширует зависимости на уровне транзакций Dexie
  • возвращает Observable (Rx-подобный интерфейс)

Важный момент: liveQuery не знает о Svelte напрямую, он лишь предоставляет поток данных.


Преобразование liveQuery в Svelte store

Svelte store ожидает интерфейс:

  • subscribe(run: (value) => void) => unsubscribe

Поэтому требуется адаптер:

import { readable } fr om "svelte/store";
import { liveQuery } fr om "dexie";

function fromObservable(observable$, initialValue = null) {
  return readable(initialValue, (set) => {
    const subscription = observable$.subscribe({
      next: set,
      error: (err) => console.error(err)
    });

    return () => subscription.unsubscribe();
  });
}

Теперь liveQuery становится store-источником:

const todos$ = fromObservable(
  liveQuery(() => db.todos.toArray()),
  []
);

Базовая архитектура store-слоя

При масштабировании приложения появляется необходимость разделения:

  • domain stores (данные)
  • UI stores (состояние интерфейса)
  • derived stores (вычисляемые значения)

Domain store на Dexie

export const todos = fromObservable(
  liveQuery(() => db.todos.orderBy("createdAt").toArray()),
  []
);

Этот store всегда отражает текущее состояние IndexedDB.


Мутации данных и реактивное обновление

Обновления выполняются через Dexie API, store не мутируется напрямую:

async function addTodo(text) {
  await db.todos.add({
    text,
    done: false,
    createdAt: Date.now()
  });
}

После выполнения:

  • IndexedDB изменяется
  • Dexie фиксирует изменение таблицы
  • liveQuery автоматически пересчитывает результат
  • Svelte store получает новое значение
  • UI обновляется

Никаких ручных set() не требуется.


Интеграция с derived stores

Svelte derived stores позволяют строить вычисления поверх Dexie-данных:

import { derived } from "svelte/store";

export const completedTodos = derived(
  todos,
  ($todos) => $todos.filter(t => t.done)
);

Такая модель сохраняет:

  • простоту декларативного слоя
  • отсутствие дублирования данных
  • предсказуемость вычислений

Фильтрация и параметризованные liveQuery

Частая задача — реактивные запросы с параметрами:

import { writable, derived } from "svelte/store";

export const search = writable("");

export const filteredTodos = derived(
  search,
  ($search, set) => {
    const obs$ = liveQuery(() =>
      db.todos
        .wh ere("text")
        .startsWithIgnoreCase($search)
        .toArray()
    );

    const sub = obs$.subscribe(set);
    return () => sub.unsubscribe();
  }
);

Особенность: каждый новый параметр пересоздаёт подписку на liveQuery.


Оптимизация подписок

При частых изменениях параметров важно избегать:

  • утечек подписок
  • лишних пересозданий потоков

Подходы:

1. Memoization запросов

const queryCache = new Map();

function getTodosByPrefix(prefix) {
  if (!queryCache.has(prefix)) {
    queryCache.set(
      prefix,
      liveQuery(() =>
        db.todos.wh ere("text").startsWith(prefix).toArray()
      )
    );
  }
  return queryCache.get(prefix);
}

2. Разделение stable и reactive уровней

const baseTodos$ = liveQuery(() => db.todos.toArray());

export const todos = fromObservable(baseTodos$, []);

Фильтрация переносится в Svelte слой.


Обработка ошибок в liveQuery + store

Ошибки IndexedDB или Dexie транзакций должны корректно попадать в UI:

function fromObservable(observable$, initialValue = null) {
  return readable(initialValue, (set) => {
    const sub = observable$.subscribe({
      next: set,
      error: (error) => {
        console.error("Dexie error:", error);
        set(initialValue);
      }
    });

    return () => sub.unsubscribe();
  });
}

Расширенный вариант:

  • отдельный error store
  • логирование
  • fallback state

Комбинирование нескольких liveQuery потоков

Сложные экраны требуют агрегации нескольких источников:

const todos$ = liveQuery(() => db.todos.toArray());
const stats$ = liveQuery(async () => ({
  total: await db.todos.count(),
  done: await db.todos.wh ere("done").equals(1).count()
}));

Svelte stores:

export const todos = fromObservable(todos$, []);
export const stats = fromObservable(stats$, { total: 0, done: 0 });

Синхронизация транзакций и UI состояния

Dexie транзакции обеспечивают атомарность, но UI может требовать промежуточных состояний:

async function toggleTodo(id) {
  await db.transaction("rw", db.todos, async () => {
    const todo = await db.todos.get(id);
    await db.todos.update(id, { done: !todo.done });
  });
}

После завершения транзакции:

  • liveQuery фиксирует изменения
  • store обновляется автоматически
  • UI остаётся консистентным без ручных промежуточных state-обновлений

SSR и гидратация в Svelte

При использовании Svelte в SSR-сценариях возникает проблема:

  • IndexedDB отсутствует на сервере
  • liveQuery не может выполняться

Решение:

import { browser } fr om "$app/environment";

const todos$ = browser
  ? liveQuery(() => db.todos.toArray())
  : {
      subscribe: (run) => {
        run([]);
        return () => {};
      }
    };

Это позволяет:

  • избежать ошибок SSR
  • сохранить одинаковый store-интерфейс

Архитектурный паттерн: Dexie Store Layer

Слой данных обычно структурируется так:

  • db.ts — схема IndexedDB
  • queries.ts — liveQuery функции
  • stores.ts — Svelte stores
  • actions.ts — мутации

Пример разделения:

// queries.ts
export const allTodos$ = () => liveQuery(() => db.todos.toArray());

// stores.ts
export const todos = fromObservable(allTodos$(), []);

Поведение при конкурентных изменениях

Dexie.js гарантирует:

  • последовательность транзакций
  • отсутствие частичных обновлений

liveQuery получает уже консистентное состояние, поэтому Svelte store всегда отражает финальную версию данных без промежуточных артефактов.


Гранулярность реактивности

Важно понимать уровень обновлений:

  • изменение одной записи → пересчёт полного запроса
  • Svelte store получает новый массив
  • UI diffing происходит на уровне Svelte runtime

Оптимизация:

  • уменьшение размера результата запросов
  • использование индексов Dexie
  • разбиение таблиц по сущностям

Поток данных в интеграции

Типичный pipeline:

IndexedDB → Dexie mutation → liveQuery invalidation → Observable emission → Svelte store update → UI rerender

Ключевая особенность — отсутствие ручных bridge-слоёв после инициализации.


Поведение при масштабировании приложения

При росте количества stores возникают проблемы:

  • дублирование liveQuery
  • избыточные подписки
  • конкурирующие фильтры

Решение — централизованный query layer:

class TodoRepository {
  all() {
    return liveQuery(() => db.todos.toArray());
  }

  byStatus(done) {
    return liveQuery(() =>
      db.todos.wh ere("done").equals(done ? 1 : 0).toArray()
    );
  }
}

export const repo = new TodoRepository();

Stores строятся поверх репозитория, сохраняя единый источник истины.


Интеграция событий UI и Dexie реактивности

UI события не влияют на store напрямую:

  • store отражает базу данных
  • UI вызывает actions
  • actions изменяют Dexie
  • Dexie триггерит liveQuery

Такой цикл исключает:

  • ручные синхронизации
  • двойное состояние
  • конфликтующие источники данных