
Офлайн-хранилище со сложным графом и 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 и Customer var draft = await redb.LoadAsync<OrderProps>(id); // весь граф обратно, без Include var 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-провайдера два издания, и для клиента выбора фактически нет.
| ||
|---|---|---|
Реализация | часть логики в нативном расширении 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-android dotnet new maui -n MyApp cd MyApp dotnet add package redb.SQLite.Pro
Если собираете только под Android с Windows, уберите из <TargetFrameworks> строки с ios и maccatalyst — иначе восстановление пакетов потребует workload, которого нет.
Шаг 2. Регистрация в MauiProgram.cs
using redb.Core.Models.Configuration; using redb.Core.Pro.Extensions; // AddRedbPro using redb.SQLite.Pro.Extensions; // UseSqlite public 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 через Props: n.Props.Title. В условиях переменная — это ваши данные, а в проекции доступен объект целиком, включая служебные поля (n.Id, n.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, есть служебные поля самого RedbObject: Id, ParentId, DateCreate, а также «быстрые» слоты — value_string, value_long, value_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(); // все группы пользователя — обратный запрос, тоже без JOIN var groups = await redb.Query<MembershipProps>() .WhereRedb(o => o.ValueLong == userId) .ToListAsync();
Приём не выдуманный для статьи: ровно так устроена система ролей в RedBase Identity — там в комментарии к классу связи прямо записано, что parent_id указывает на роль, value_long зеркалит идентификатор пользователя для обратного запроса, а key даёт уникальный индекс, чтобы назначение роли было идемпотентным без отдельной проверки. Заведите такое соглашение в своих классах с самого начала — потом не придётся переписывать выборки.
Мелочь, о которую спотыкаются: при записи поля называются в нижнем регистре (value_string, parent_id, key), а в WhereRedb читаются в обычном (o.ValueString, o.ParentId, o.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 | |
|---|---|---|---|
Схема под новый тип состояния | сущность + миграция | ничего | ничего |
Сотни классов состояний | сотни таблиц и миграций | одна таблица, но без типов | пометить |
Обновление приложения | миграция на устройстве пользователя | — | нечего мигрировать |
Вложенный граф |
| целиком, одним куском | целиком, одной строкой |
Запрос по вложенным полям | JOIN-ы | нет, только перебор в памяти | LINQ на уровне SQL |
Строгая типизация | есть | теряется | есть |
Прямой SQL, когда он нужен | есть | нет | есть |
Разберём главные строки.
Миграции. На сервере миграция — управляемая процедура: вы её накатываете, смотрите, откатываете. На клиенте она уезжает на чужое устройство и выполняется на данных, которых вы не видели. Чем чаще меняются локальные состояния — а меняются они чаще серверных сущностей, — тем чаще вы играете в эту рулетку. В RedBase этого шага нет вообще: новое свойство появляется само, старые объекты читаются.
Граф. В EF полнота загрузки — ваша ответственность на каждом запросе: не указали Include — получили пустую коллекцию вместо данных, указали слишком много — притащили в память лишнее. На клиенте, где граф нужен целиком почти всегда (пользователь открыл черновик), это ежедневный налог. LoadAsync возвращает объект собранным.
Типизация против JSON. Вариант «ключ → JSON» выигрывает по скорости внедрения ровно один раз — в первый день. Дальше начинается: переименовали поле — тихо потеряли данные; понадобился поиск — переберите всё; понадобилось посмотреть глазами, что там лежит, — удачи. RedBase даёт то же удобство «сохранил объект как есть», но свойства лежат в типизированных колонках и участвуют в запросах.
Прямой SQL. Отдельно, потому что вопрос возникает сразу: а если нужна плоская таблица под тренды или агрегаты? Никто не отнимал — тот же контекст выполняет произвольный SQL, включая ваши собственные таблицы:
@inject IRedbContext Context var 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.

