Локальная база на клиенте: Blazor WebAssembly и MAUI на SQLite — без EF DbContext, Include и миграций

от автора

redb.SQLite

redb.SQLite

Офлайн-хранилище со сложным графом и outbox: быстрый старт в MAUI и в браузере, базовые операции и почему в этой роли EF Core только мешает.

Курьер спустился в подвал склада — связи нет. Кладовщик третий час принимает поставку в зоне, где вайфай добивает через раз. Пользователь заполнил форму на четыре экрана и нажал «Сохранить» в момент, когда сервер уехал на деплой.

Приложение должно продолжать работать. Значит ему нужна своя база на устройстве: не кеш ответов, а полноценное локальное хранилище, где живут незавершённые документы, локальные состояния и очередь изменений на отправку — тот самый outbox, который дошлёт всё, когда связь вернётся.

И вот тут начинается знакомое.

Знакомо?

«Сохраним в key-value, это же просто». localStorage, Preferences, файл с JSON. Работает ровно до первого вопроса «покажи незавершённые заявки за последнюю неделю, отсортированные по приоритету». Ответ на него — загрузить всё в память и перебрать руками. На двух сотнях записей незаметно, на десяти тысячах телефон начинает греться.

«Возьмём EF Core, он знакомый». И приносим на клиент миграции. Приложение обновилось — миграция должна отработать на устройстве пользователя, на его данных, без вашего наблюдения. Упала — вы об этом узнаете из отзыва в маркете. Причём боль эта не разовая: локальные состояния меняются гораздо чаще серверных, потому что это черновики, шаги мастера, статусы синхронизации.

«Граф всё равно сложный». Заявка со списком позиций, у позиции — вложения и история статусов, у заявки — клиент с адресом. На сервере вы это разложили по таблицам и написали Include / ThenInclude. На клиенте вам нужен тот же граф целиком: пользователь открыл черновик — покажи всё. Забыли Include — получили null там, где ожидали данные, или N+1 на ровном месте.

«Ладно, сериализуем граф в JSON-колонку». Классический обходной путь: сложное — в текст, простое — в колонки. Граф сохранился, но запросы по нему кончились: искать «где статус = черновик и сумма > 10 000» теперь можно только перебором. А типизация превратилась в надежду, что при следующем чтении JsonSerializer не встретит поле, которого он не знает.

А теперь умножьте на масштаб приложения. В нём не одна сущность, а сотни классов, и у каждого — свои локальные состояния: черновик формы, шаг мастера, выбранные фильтры списка, снимок для отката, кеш ответа под конкретный ключ. Причём состояния сами по себе сложные — вложенные, со своими коллекциями и статусами.

Делать под каждое таблицу — это сотни таблиц и сотни миграций, которые поедут на устройства пользователей. Свалить всё в одну табличку «ключ → JSON» — быстро и знакомо, вот только:

  • типизации больше нет: поле переименовали в классе, старый JSON молча прочитался с null, баг выстрелит через неделю у пользователя;

  • искать невозможно: «покажи все незавершённые черновики, где сумма больше лимита» — это выгрузить всю кучу и разобрать её в памяти;

  • разбирать эту кучу глазами тоже удовольствие ниже среднего.

Итог знакомый: половина кода локального хранилища — это не бизнес-логика, а обслуживание способа хранения.

Как это выглядит иначе

Схема — обычный C# класс. Никакого DbContext, никаких файлов миграций, никаких Include.

[RedbScheme("Order")]public class OrderProps{    public string Number { get; set; } = "";    public OrderStatus Status { get; set; }    public decimal Total { get; set; }    public DateTime CreatedAt { get; set; }    public Customer? Customer { get; set; }        // вложенный объект    public List<OrderItem> Items { get; set; } = new();   // вложенная коллекция    public string[]? Tags { get; set; }}

Сохранение графа целиком — одна строка. Загрузка графа целиком — одна строка. Запрос по вложенным полям — LINQ, который выполняется в базе, а не в памяти:

await redb.SaveAsync(order);                       // весь граф, включая Items и Customervar draft = await redb.LoadAsync<OrderProps>(id);  // весь граф обратно, без Includevar pending = await redb.Query<OrderProps>()    .Where(o => o.Status == OrderStatus.Draft && o.Total > 10000m)    .OrderByDescending(o => o.CreatedAt)    .ToListAsync();

Добавили в класс новое свойство — оно просто появляется. Мигрировать нечего: файлов миграций нет, ALTER TABLE писать не нужно, ранее сохранённые объекты продолжают читаться.

При этом это не JSON-блоб: каждое свойство лежит в типизированной колонке и индексируется, поэтому условие выше — настоящий SQL-фильтр, а не перебор. Строгая типизация сохраняется целиком, включая вложенные объекты, коллекции и словари.

А про сотни классов — их не нужно нигде перечислять. Пометили классы атрибутом [RedbScheme], и инициализация сама находит их в сборке и заводит схемы:

// одна строка на всё приложение: и на первый класс, и на трёхсотыйawait redb.InitializeAsync(ensureCreated: true, typeof(OrderProps).Assembly);

Новый вид состояния — это новый класс в коде и ничего больше. Ни таблицы, ни миграции, ни строчки в реестре.

Под капотом — обычный SQLite, тот же файл, который вы и так носите в приложении. И тот же код работает на сервере поверх PostgreSQL или SQL Server: модель у клиента и у бэкенда получается одна.

Дальше — быстрый старт для мобильного приложения и для браузера, базовые операции и сравнение с привычными вариантами. Про устройство самого провайдера не будет ни слова: это статья о том, как пользоваться.

Модель для примеров

Чтобы дальше было о чём говорить, возьмём что-нибудь простое — заметку. Всё показанное работает и на графе из первого примера, просто короче читается:

[RedbScheme("Note")]public class NoteProps{    public string Title { get; set; } = "";    public string Body { get; set; } = "";    public int Priority { get; set; }    public DateTime CreatedAt { get; set; }    public string[]? Tags { get; set; }}

Провайдеров у RedBase три: PostgreSQL, SQL Server и SQLite. Клиент — это SQLite.

Какой пакет ставить

У SQLite-провайдера два издания, и для клиента выбора фактически нет.

redb.SQLite

redb.SQLite.Pro

Реализация

часть логики в нативном расширении SQLite

чистый C#

Сервер, десктоп

да

да

Blazor WebAssembly

нет

да

Android, iOS

нет

да

Free-издание держит часть логики в нативном расширении SQLite, а браузер такие расширения загружать не умеет; под мобильные платформы это расширение не собирается. Pro написан на C# целиком, поэтому работает везде.

Pro бесплатен и не требует лицензионного ключа — вся линия 3.x, включая коммерческую эксплуатацию. Пакет закрытый, но платить и что-то активировать не нужно: поставили и работаете.

dotnet add package redb.SQLite.Pro

Больше ничего добавлять не надо — redb.Core, сам SQLite и остальное приезжают транзитивно. Нужен .NET 8, 9 или 10. Всё, что ниже, проверялось на 3.5.0.

Быстрый старт: мобильное приложение (MAUI)

Начнём с мобильного — там всё проще, потому что база это обычный файл, который сам переживает перезапуски.

Шаг 1. Проект и пакет

dotnet workload install maui-androiddotnet new maui -n MyAppcd MyAppdotnet add package redb.SQLite.Pro

Если собираете только под Android с Windows, уберите из <TargetFrameworks> строки с ios и maccatalyst — иначе восстановление пакетов потребует workload, которого нет.

Шаг 2. Регистрация в MauiProgram.cs

using redb.Core.Models.Configuration;using redb.Core.Pro.Extensions;      // AddRedbProusing redb.SQLite.Pro.Extensions;    // UseSqlitepublic static MauiApp CreateMauiApp(){    var builder = MauiApp.CreateBuilder();    builder.UseMauiApp<App>();    // AppDataDirectory — приватный каталог приложения. Файл переживает перезапуск    // и обновление, удаляется вместе с приложением, в бэкапы не утекает.    var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db");    builder.Services.AddRedbPro(options => options        .UseSqlite($"Data Source={dbPath}")        .Configure(c => c.PropsSaveStrategy = PropsSaveStrategy.ChangeTracking));    builder.Services.AddSingleton<RedbBootstrap>();    builder.Services.AddSingleton<MainPage>();    return builder.Build();}

PropsSaveStrategy.ChangeTracking означает «писать только изменившиеся свойства» вместо полной перезаписи объекта. На мобильном устройстве это заметно экономит и время, и износ флеш-памяти.

Шаг 3. Инициализация — ровно один раз

Вот первое место, где легко ошибиться. В серверном приложении инициализация происходит сама, на старте хоста. MAUI фоновые сервисы не запускает, поэтому её нужно вызвать руками:

var redb = services.GetRequiredService<IRedbService>();// Создаст структуру базы, если её нет, и заведёт схемы для всех классов// с [RedbScheme] из указанной сборки. Перечислять классы не нужно.await redb.InitializeAsync(ensureCreated: true, typeof(NoteProps).Assembly);

Сборку можно и не указывать — тогда просканируются все загруженные. На клиенте лучше указать явно: быстрее и предсказуемее.

Второе место: Android пересоздаёт Activity при повороте экрана и при возврате из фона. Если привязать инициализацию к событию страницы, она отработает несколько раз. Привязывайте к процессу — Lazy<Task> делает это в одну строку и корректно ведёт себя при параллельных вызовах:

public sealed class RedbBootstrap{    private readonly Lazy<Task> _init;    public RedbBootstrap(IServiceProvider services)    {        _init = new Lazy<Task>(async () =>        {            var redb = services.GetRequiredService<IRedbService>();            await redb.InitializeAsync(ensureCreated: true, typeof(NoteProps).Assembly);        });    }    /// Вызывать перед любой работой с БД. Реально отработает один раз за процесс.    public Task EnsureInitializedAsync() => _init.Value;}

Шаг 4. Страница

public partial class MainPage : ContentPage{    private readonly IRedbService _redb;    private readonly RedbBootstrap _bootstrap;    public MainPage(IRedbService redb, RedbBootstrap bootstrap)    {        InitializeComponent();        _redb = redb;        _bootstrap = bootstrap;    }    protected override async void OnAppearing()    {        base.OnAppearing();        await _bootstrap.EnsureInitializedAsync();        CountLabel.Text = $"Заметок: {await _redb.Query<NoteProps>().CountAsync()}";    }}

Всё. Запускаете dotnet build -f net10.0-android -t:Run, приложение открывается, база создаётся при первом запуске и лежит на устройстве до удаления приложения.

Release-сборка использует тримминг и AOT — провайдер это переживает, ничего дополнительно настраивать не нужно. Если включите тримминг агрессивнее стандартного, оставьте в корнях линкера сборку, где лежат ваши классы схем: они читаются рефлексией, и линкер о них не знает.

Быстрый старт: Blazor WebAssembly

В браузере тот же код работает, но есть три особенности. Каждая из них ведёт себя одинаково неприятно: проект собирается без ошибок, а ломается уже в браузере. Поэтому разберём все три.

Особенность 1. Сборка требует дополнительного инструмента

dotnet workload install wasm-tools
<PropertyGroup>  <WasmBuildNative>true</WasmBuildNative></PropertyGroup>

Причина: в браузере нет системного загрузчика библиотек, поэтому SQLite должен быть вкомпилирован в рантайм при сборке, а не подгружен рядом. В Release это включается само, а для dotnet run и Debug нужен флаг выше. Без него приложение соберётся со стандартным рантаймом, где SQLite нет, и упадёт при первом обращении к базе.

Первая сборка после этого станет заметно дольше обычной — идёт нативная линковка. Это разово, инкрементальные сборки быстрые.

Особенность 2. Инициализация — тоже вручную

Ровно как в MAUI и ровно по той же причине: WebAssemblyHost фоновые сервисы не запускает.

Особенность 3. Персистентность — на вашей стороне

Файловая система браузера в .NET — это память. Пока вкладка открыта, база работает как обычно; после перезагрузки страницы её нет. Механизма сохранения RedBase не предоставляет — и правильно делает, потому что выбор зависит от приложения: IndexedDB, Cache API или OPFS.

Разберём рабочий вариант на IndexedDB. Он не требует специальных сборочных флагов и обходится обычным File API.

Один нюанс, из-за которого наивная реализация выглядит работающей и теряет данные. SQLite в браузере пишет в режиме WAL: свежие изменения попадают в файл-спутник app.db-wal, а основной файл остаётся почти пустым. Сохраните только app.db — получите базу, которая «восстановилась» и оказалась пустой. Переносить надо оба файла.

wwwroot/js/dbPersistence.js:

const DB_NAME = "myapp-db";const STORE = "files";function openIdb() {    return new Promise((resolve, reject) => {        const req = indexedDB.open(DB_NAME, 1);        req.onupgradeneeded = () => req.result.createObjectStore(STORE);        req.onsuccess = () => resolve(req.result);        req.onerror = () => reject(req.error);    });}export async function load(key) {    const db = await openIdb();    try {        return await new Promise((resolve, reject) => {            const tx = db.transaction(STORE, "readonly");            const req = tx.objectStore(STORE).get(key);            req.onsuccess = () => resolve(req.result ? new Uint8Array(req.result) : null);            req.onerror = () => reject(req.error);        });    } finally { db.close(); }}export async function save(key, bytes) {    const db = await openIdb();    try {        await new Promise((resolve, reject) => {            const tx = db.transaction(STORE, "readwrite");            tx.objectStore(STORE).put(new Uint8Array(bytes), key);            tx.oncomplete = () => resolve();            tx.onerror = () => reject(tx.error);            tx.onabort = () => reject(tx.error);        });    } finally { db.close(); }}

Services/SqliteFilePersistence.cs:

using Microsoft.JSInterop;using redb.Core.Data;public sealed class SqliteFilePersistence{    private readonly IJSRuntime _js;    private readonly string _dbPath;    private IJSObjectReference? _module;    public SqliteFilePersistence(IJSRuntime js, string dbPath)    {        _js = js;        _dbPath = dbPath;    }    private async Task<IJSObjectReference> ModuleAsync()        => _module ??= await _js.InvokeAsync<IJSObjectReference>("import", "./js/dbPersistence.js");    /// Поднять базу из IndexedDB. Строго до первого обращения к базе,    /// иначе SQLite создаст пустой файл и восстанавливать будет нечего.    public async Task RestoreAsync()    {        var module = await ModuleAsync();        foreach (var path in new[] { _dbPath, _dbPath + "-wal" })        {            var bytes = await module.InvokeAsync<byte[]?>("load", path);            if (bytes is { Length: > 0 })                await File.WriteAllBytesAsync(path, bytes);        }    }    /// Сохранить текущее состояние базы.    public async Task PersistAsync(IRedbContext context)    {        // PASSIVE — важно. Вариант TRUNCATE требует эксклюзивной блокировки, а в        // однопоточном браузере снять её некому: вызов просто зависнет.        try { await context.ExecuteAsync("PRAGMA wal_checkpoint(PASSIVE);"); } catch { }        var module = await ModuleAsync();        foreach (var path in new[] { _dbPath, _dbPath + "-wal" })        {            if (File.Exists(path))                await module.InvokeVoidAsync("save", path, await File.ReadAllBytesAsync(path));        }    }}

Program.cs — здесь важен порядок:

const string DbPath = "/app.db";var builder = WebAssemblyHostBuilder.CreateDefault(args);builder.RootComponents.Add<App>("#app");builder.RootComponents.Add<HeadOutlet>("head::after");builder.Services.AddSingleton(sp =>    new SqliteFilePersistence(sp.GetRequiredService<IJSRuntime>(), DbPath));builder.Services.AddRedbPro(options => options.UseSqlite($"Data Source={DbPath}"));var host = builder.Build();// 1. Сначала восстановить файлы...await host.Services.GetRequiredService<SqliteFilePersistence>().RestoreAsync();// 2. ...и только потом обращаться к базе.var redb = host.Services.GetRequiredService<IRedbService>();await redb.InitializeAsync(ensureCreated: true, typeof(NoteProps).Assembly);await host.RunAsync();

Сохранение выгружает файл целиком, поэтому вызывать его на каждую запись не стоит. Разумные точки: после значимого действия пользователя, по таймеру, на beforeunload.

await Redb.SaveAsync(note);await Persistence.PersistAsync(Context);   // здесь в реальном приложении — дебаунс

Базовые операции

Дальше всё одинаково для мобильного приложения и браузера. Сервис получаете через DI:

@inject IRedbService Redb

Создать

Объект состоит из «оболочки» RedbObject<T> и ваших данных в Props. У оболочки есть служебные поля, из которых на старте пригодится только name — человекочитаемое имя объекта.

var note = new RedbObject<NoteProps>{    name = "Купить молоко",    Props = new NoteProps    {        Title = "Купить молоко",        Body = "И хлеб",        Priority = 2,        CreatedAt = DateTime.UtcNow,        Tags = ["дом", "покупки"]    }};long id = await Redb.SaveAsync(note);

SaveAsync возвращает идентификатор. Он же проставляется в сам объект, так что note.Id после вызова тоже заполнен.

Если объектов несколько — не сохраняйте их в цикле. Тот же метод принимает коллекцию и пишет её пакетно, возвращая список идентификаторов:

var notes = new List<RedbObject<NoteProps>> { note1, note2, note3 };List<long> ids = await Redb.SaveAsync(notes);

Прочитать по идентификатору

var loaded = await Redb.LoadAsync<NoteProps>(id);if (loaded is not null){    Console.WriteLine(loaded.Props.Title);      // "Купить молоко"    Console.WriteLine(loaded.Props.Tags![0]);   // "дом"}

Загружается объект целиком, включая массивы и вложенные объекты. Забыть «подгрузить связанное» здесь нельзя: свойства всегда на месте.

Если объекта с таким идентификатором нет, по умолчанию вернётся null — отсюда проверка выше.

Изменить

Отдельного Update нет — меняете загруженный объект и сохраняете снова:

var note = await Redb.LoadAsync<NoteProps>(id);note.Props.Priority = 5;note.Props.Body = "И хлеб, и кефир";await Redb.SaveAsync(note);

С PropsSaveStrategy.ChangeTracking в базу уйдут только два изменённых свойства, а не весь объект.

Удалить

await Redb.DeleteAsync(note);

Запросы

Обычный LINQ. Условия выполняются на стороне базы, а не в памяти:

// все важные заметки, свежие сверхуvar important = await Redb.Query<NoteProps>()    .Where(n => n.Priority >= 3)    .OrderByDescending(n => n.CreatedAt)    .ToListAsync();// поиск по подстрокеvar found = await Redb.Query<NoteProps>()    .Where(n => n.Title.Contains("молоко"))    .ToListAsync();// диапазон дат и составное условиеvar lastWeek = DateTime.UtcNow.AddDays(-7);var recent = await Redb.Query<NoteProps>()    .Where(n => n.CreatedAt >= lastWeek && n.Priority > 1)    .ToListAsync();

Пагинация, счётчики и проверки существования:

var page = await Redb.Query<NoteProps>()    .OrderByDescending(n => n.CreatedAt)    .Skip(20).Take(20)    .ToListAsync();int total = await Redb.Query<NoteProps>().CountAsync();bool any = await Redb.Query<NoteProps>().AnyAsync(n => n.Priority == 5);

Если весь объект не нужен, берите только нужные поля — меньше данных поднимется с диска:

var titles = await Redb.Query<NoteProps>()    .Where(n => n.Priority >= 3)    .Select(n => new { n.Props.Title, n.Props.CreatedAt })    .ToListAsync();

Обратите внимание на разницу, о которую спотыкаются в первый раз: в Where и OrderBy вы пишете свойства напрямую — n.Priority, — а в Select через Propsn.Props.Title. В условиях переменная — это ваши данные, а в проекции доступен объект целиком, включая служебные поля (n.Idn.name), поэтому и нужен явный Props.

Массивы

Массив в свойстве — не строка с разделителями, по нему можно искать:

var home = await Redb.Query<NoteProps>()    .Where(n => n.Tags!.Contains("дом"))    .ToListAsync();

Добавить поле в схему

Самая частая операция при развитии приложения. Дописываете свойство в класс:

public class NoteProps{    // ...то, что было    public bool IsDone { get; set; }   // новое}

И всё. Тот же InitializeAsync на старте подхватит изменение сам. Файлов миграций нет, писать ALTER TABLE не нужно, ранее сохранённые объекты продолжают читаться — у них новое свойство просто примет значение по умолчанию.

Сравните с тем, как это выглядит в мире миграций: создать миграцию, проверить сгенерированный SQL, подумать про откат, выкатить на устройства и надеяться, что на чужих данных всё пройдёт гладко. Здесь этого шага просто нет.

Outbox: очередь на отправку

Ради этого сценария локальная база чаще всего и заводится, поэтому покажем целиком. Задача: пока связи нет, изменения копятся на устройстве; когда появилась — уходят на сервер по порядку и с понятным статусом.

Очередь — обычный класс. Заметьте, что в нём лежит не строка с JSON, а типизированная полезная нагрузка со своей вложенной структурой:

[RedbScheme("OutboxEntry")]public class OutboxEntryProps{    public string Operation { get; set; } = "";      // "order.create", "order.update"    public OutboxState State { get; set; }           // Pending, Sending, Failed, Sent    public int Attempts { get; set; }    public DateTime CreatedAt { get; set; }    public DateTime? LastTriedAt { get; set; }    public string? LastError { get; set; }    public OrderProps? Payload { get; set; }         // тот самый граф целиком}

Запись в очередь — обычное сохранение:

await redb.SaveAsync(new RedbObject<OutboxEntryProps>{    name = $"outbox {order.Number}",    Props = new OutboxEntryProps    {        Operation = "order.create",        State = OutboxState.Pending,        CreatedAt = DateTime.UtcNow,        Payload = order          // вложенный граф сохраняется вместе с записью    }});

Отправка, когда связь вернулась. Здесь и видно, зачем нужны запросы, а не перебор: выбрать нужное из очереди — обычный LINQ, а не «загрузить всё и профильтровать в памяти».

var batch = await redb.Query<OutboxEntryProps>()    .Where(e => e.State == OutboxState.Pending && e.Attempts < 5)    .OrderBy(e => e.CreatedAt)          // строго в порядке появления    .Take(20)                           // порциями, чтобы не залипнуть    .ToListAsync();foreach (var entry in batch){    try    {        await api.SendAsync(entry.Props.Operation, entry.Props.Payload!);        entry.Props.State = OutboxState.Sent;    }    catch (Exception ex)    {        entry.Props.Attempts++;        entry.Props.State = OutboxState.Failed;        entry.Props.LastError = ex.Message;    }    entry.Props.LastTriedAt = DateTime.UtcNow;}// Вся пачка — одним вызовом, а не по записи в цикле.await redb.SaveAsync(batch);

Обратите внимание на последнюю строку: SaveAsync принимает коллекцию и сохраняет её пакетно. В цикле остаётся только то, что действительно поштучное, — обращение к сети; результаты уезжают в базу одним заходом. На двадцати записях разница незаметна, на двух тысячах — уже нет.

Показать пользователю, что происходит, — тоже запрос, а не подсчёт в цикле:

int waiting = await redb.Query<OutboxEntryProps>()    .Where(e => e.State == OutboxState.Pending)    .CountAsync();var problems = await redb.Query<OutboxEntryProps>()    .Where(e => e.Attempts >= 5)    .ToListAsync();

Сравните с тем же на «ключ → JSON»: чтобы найти зависшие записи, пришлось бы вычитать всю очередь, десериализовать каждую и перебрать. А чтобы переименовать поле — молиться, что старые записи прочитаются.

Ускоряем выборки: отсечение по базовым полям

Приём, который стоит завести сразу, а не когда список начнёт тормозить.

У каждого объекта, помимо ваших Props, есть служебные поля самого RedbObjectIdParentIdDateCreate, а также «быстрые» слоты — value_stringvalue_longvalue_datetime и другие. Они лежат прямо в записи объекта, поэтому фильтр по ним — самое дешёвое, что может быть: он отсекает выборку до того, как дело дойдёт до свойств.

Фильтруются они методом WhereRedb, который спокойно комбинируется с обычным Where.

Идея простая: то, по чему вы ищете чаще всего — ключ ситуации, внешний идентификатор, дату — кладите не только в Props, но и в быстрый слот. Тогда поиск состояния под конкретный ключ становится попаданием по индексированному полю.

Записываем — заполняем слот вместе с данными:

var key = $"{stateType}:{documentId}";      // "order-draft:12345"await redb.SaveAsync(new RedbObject<DraftStateProps>{    name = $"Черновик {key}",    value_string = key,                     // ключ ситуации    value_long = documentId,                // внешний id    value_datetime = DateTimeOffset.UtcNow,  // отметка времени    Props = new DraftStateProps { /* ... */ }});

Читаем — сначала отсекаем по слоту, потом уточняем по свойствам:

// состояние под конкретный ключ — попадание, а не сканированиеvar draft = await redb.Query<DraftStateProps>()    .WhereRedb(o => o.ValueString == key)    .FirstOrDefaultAsync();// всё, что относится к документуvar byDocument = await redb.Query<DraftStateProps>()    .WhereRedb(o => o.ValueLong == documentId)    .ToListAsync();// отсечение по дате: чистим протухшееvar threshold = DateTimeOffset.UtcNow.AddDays(-30);var stale = await redb.Query<DraftStateProps>()    .WhereRedb(o => o.ValueDatetime <= threshold)    .ToListAsync();// сначала дешёвый отсев по слоту, потом условие по свойствамvar actual = await redb.Query<OutboxEntryProps>()    .WhereRedb(o => o.ValueDatetime > threshold)    .Where(e => e.State == OutboxState.Pending)    .ToListAsync();

Группа — это ParentId. Важно понимать, что это не просто число, а настоящий внешний ключ на другой объект в той же базе — на его Id. Положить туда идентификатор из чужой системы нельзя, для этого есть value_long. Зато взамен вы получаете целостность на уровне базы и каскад: удалили родителя — вложенные объекты удалились вместе с ним, вручную подчищать не нужно.

То есть родитель должен существовать: сначала сохраняете объект-сессию (или документ, или маршрут) и берёте его Id, затем указываете этот Id в parent_id у дочерних. После этого выборка всей группы — одно условие:

var groupItems = await redb.Query<DraftStateProps>()    .WhereRedb(o => o.ParentId == sessionId)    .ToListAsync();// или сразу по набору группvar manyGroups = await redb.Query<DraftStateProps>()    .WhereRedb(o => o.ParentId != null && sessionIds.Contains(o.ParentId.Value))    .ToListAsync();

Связь «многие ко многим» без таблицы связей

Тот же приём решает задачу, ради которой обычно заводят промежуточную таблицу. Допустим, нужно хранить принадлежность: пользователь состоит в группах, документ отнесён к нескольким категориям.

Кладём связь объектом и заполняем сразу два слота: ParentId — одна сторона, value_long — другая. Тогда оба направления выборки становятся одним индексированным запросом без JOIN:

await redb.SaveAsync(new RedbObject<MembershipProps>{    name = $"member {userId}",    // одна сторона связи — родитель    parent_id = groupId,    // вторая сторона — в быстром слоте    value_long = userId,    // и в key: по нему заводится уникальный индекс, который сам    // не даст записать одну и ту же связь дважды    key = userId,    Props = new MembershipProps { AssignedAt = DateTimeOffset.UtcNow }});// все участники группыvar members = await redb.Query<MembershipProps>()    .WhereRedb(o => o.ParentId == groupId)    .ToListAsync();// все группы пользователя — обратный запрос, тоже без JOINvar groups = await redb.Query<MembershipProps>()    .WhereRedb(o => o.ValueLong == userId)    .ToListAsync();

Приём не выдуманный для статьи: ровно так устроена система ролей в RedBase Identity — там в комментарии к классу связи прямо записано, что parent_id указывает на роль, value_long зеркалит идентификатор пользователя для обратного запроса, а key даёт уникальный индекс, чтобы назначение роли было идемпотентным без отдельной проверки. Заведите такое соглашение в своих классах с самого начала — потом не придётся переписывать выборки.

Мелочь, о которую спотыкаются: при записи поля называются в нижнем регистре (value_stringparent_idkey), а в WhereRedb читаются в обычном (o.ValueStringo.ParentIdo.Key). Это одни и те же поля.

Кстати, о деревьях

Раз ParentId — это ссылка на другой объект, то из объектов естественно складывается иерархия. И она не остаётся вашей заботой: для неё есть готовый API — загрузить поддерево целиком, взять только прямых потомков, построить путь до корня для хлебных крошек, перенести узел вместе со всем содержимым, спросить «является ли A потомком B», обойти в глубину или в ширину, выбрать только корни или только листья, отфильтровать по уровню вложенности.

// вся ветка одним запросомvar subtree = await redb.TreeQuery<CategoryProps>(rootId).ToListAsync();// перенос узла — дети переезжают самиawait redb.MoveObjectAsync(node, newParent);

Для клиентского приложения это чаще всего каталог, дерево папок, структура подразделений или вложенные комментарии — всё то, что иначе пришлось бы собирать рекурсивными запросами вручную.

И это далеко не всё

Чтобы не превращать статью в справочник: кроме показанного здесь, в RedBase есть агрегации и GroupBy, оконные функции, справочники-списки, полиморфные выборки по иерархии классов, мягкое удаление с фоновой очисткой, встроенные поля аудита (кто и когда менял), владелец объекта и права, экспорт-импорт базы. Всё это работает одинаково на всех трёх провайдерах — то есть и на клиентском SQLite тоже.

В репозитории лежит проект redb.Examples — там 148 запускаемых примеров, разложенных по темам: запросы, аналитика, деревья, списки, CRUD. Это самый быстрый способ посмотреть, как делается конкретная вещь, не читая документацию целиком.

Синхронизация с сервером, если там тоже RedBase

Отдельный приятный эффект: RedBase — это не только SQLite. Те же классы работают на сервере поверх PostgreSQL или SQL Server. Если вынести схемы в общий проект, который ссылают и клиент, и бэкенд, модель данных становится буквально одной на всю систему.

Что это меняет на практике: между клиентом и сервером не нужен слой преобразования. Ни DTO, ни маппера, ни отдельного «контракта синхронизации», который приходится править с двух сторон при каждом изменении поля. Объект, вынутый из локальной базы, — это тот же тип, который сервер кладёт в свою:

// на клиенте: достали из очередиvar entry = ...;// на сервере: приняли тот же тип и сохранилиawait redb.SaveAsync(order);

Добавили поле в общий класс — оно появилось и в локальной базе, и в серверной, и в том, что летит по сети. Согласовывать три места и следить, чтобы миграция на сервере совпала с версией клиента, здесь не нужно.

Чем это отличается от привычных вариантов

Сравниваем в конкретной роли: приватное локальное хранилище клиентского приложения. Не серверная база, не аналитическое хранилище — именно то, что лежит на устройстве пользователя.

EF Core + SQLite

Ключ → JSON

RedBase

Схема под новый тип состояния

сущность + миграция

ничего

ничего

Сотни классов состояний

сотни таблиц и миграций

одна таблица, но без типов

пометить [RedbScheme]

Обновление приложения

миграция на устройстве пользователя

нечего мигрировать

Вложенный граф

Include / ThenInclude на каждый уровень

целиком, одним куском

целиком, одной строкой

Запрос по вложенным полям

JOIN-ы

нет, только перебор в памяти

LINQ на уровне SQL

Строгая типизация

есть

теряется

есть

Прямой SQL, когда он нужен

есть

нет

есть

Разберём главные строки.

Миграции. На сервере миграция — управляемая процедура: вы её накатываете, смотрите, откатываете. На клиенте она уезжает на чужое устройство и выполняется на данных, которых вы не видели. Чем чаще меняются локальные состояния — а меняются они чаще серверных сущностей, — тем чаще вы играете в эту рулетку. В RedBase этого шага нет вообще: новое свойство появляется само, старые объекты читаются.

Граф. В EF полнота загрузки — ваша ответственность на каждом запросе: не указали Include — получили пустую коллекцию вместо данных, указали слишком много — притащили в память лишнее. На клиенте, где граф нужен целиком почти всегда (пользователь открыл черновик), это ежедневный налог. LoadAsync возвращает объект собранным.

Типизация против JSON. Вариант «ключ → JSON» выигрывает по скорости внедрения ровно один раз — в первый день. Дальше начинается: переименовали поле — тихо потеряли данные; понадобился поиск — переберите всё; понадобилось посмотреть глазами, что там лежит, — удачи. RedBase даёт то же удобство «сохранил объект как есть», но свойства лежат в типизированных колонках и участвуют в запросах.

Прямой SQL. Отдельно, потому что вопрос возникает сразу: а если нужна плоская таблица под тренды или агрегаты? Никто не отнимал — тот же контекст выполняет произвольный SQL, включая ваши собственные таблицы:

@inject IRedbContext Contextvar total = await Context.ExecuteScalarAsync<long>(    "SELECT COUNT(*) FROM my_metrics WHERE bucket = '2026-08'");await Context.ExecuteAsync(    "CREATE TABLE IF NOT EXISTS my_metrics (bucket TEXT, value REAL)");

То есть выбор не «объекты или SQL», а «объекты по умолчанию, SQL там, где он уместнее».

Когда логичнее остаться на EF Core. Если у приложения уже есть EF-модель, общая с сервером, и переписывать её незачем. Или если локальная база должна иметь конкретную физическую схему, потому что её читает что-то ещё, кроме вашего приложения. В остальных клиентских случаях вы платите миграциями и Include за схему, которую всё равно никто снаружи не увидит.

А MongoDB? На клиенте её не бывает — это сервер. Если вы думаете о документной модели («сохранил объект целиком»), то RedBase даёт ровно это ощущение, но поверх обычного SQLite: с транзакциями, строгой типизацией и LINQ вместо собственного языка запросов. Локальные документные варианты вроде LiteDB ближе по духу, но там вы снова оказываетесь между «храню документ» и «умею искать».

Что учесть заранее

Несколько вещей, которые лучше знать до того, как они удивят.

SQLite — один писатель. Это свойство самого SQLite, не обёртки. Для клиентского приложения обычно неважно, но если планируете писать из нескольких потоков, держите транзакции короткими.

В браузере одна вкладка на базу. Две вкладки — это два независимых экземпляра приложения, каждый со своей копией файла в памяти. Кто сохранил последним, тот и переписал. Нужна многовкладочность — согласовывайте через BroadcastChannel или блокируйте вторую вкладку.

Браузер однопоточный. Тяжёлая выборка подвесит интерфейс, поэтому не тащите на страницу всё подряд: Take и пагинация здесь не про красоту, а про отзывчивость.

Размер загрузки. Управляемые сборки — порядка двух мегабайт плюс сам SQLite внутри рантайма. Для внутренних инструментов и офлайн-приложений это нормально, для лендинга — нет. Brotli-сжатие обязательно.

Первый запуск в браузере занимает секунду-две, пока создаётся структура базы. Покажите индикатор.

Точная денежная арифметика. decimal в SQLite хранится приближённо. Для финансовых расчётов с требованием точности до копейки это ограничение SQLite, а не обёртки, — учитывайте при выборе хранилища.

Итого

Для мобильного приложения всё сводится к трём действиям: поставить пакет, указать путь к файлу в каталоге приложения и один раз вызвать инициализацию. Дальше — обычный C# с LINQ.

Для браузера добавляются три вещи: wasm-tools с флагом сборки, та же ручная инициализация и собственный слой сохранения в IndexedDB, где главное — переносить оба файла базы и не трогать TRUNCATE-контрольную точку.

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

Документация и примеры: redb.ru. Исходники, шаблоны и трекер — github.com/redbase-app/redb.

Другие мои статьи — habr.com/ru/users/grelikt/articles.

ссылка на оригинал статьи https://habr.com/ru/articles/1068388/