Офлайн-хранилище со сложным графом и outbox: быстрый старт в MAUI и в браузере, базовые операции и почему в этой роли EF Core только мешает. Курьер спустился в подвал склада — связи нет. Кладовщик третий час принимает поставку в зоне, где вайфай добивает через раз. Пользователь заполнил форму на четыре экрана и нажал «Сохранить» в момент, когда сервер уехал на деплой.Приложение должно продолжать работать. Значит ему нужна своя база на устройстве: не кеш ответов, а полноценное локальное хранилище, где живут незавершённые документы, локальные состояния и очередь изменений на отправку — тот самый outbox, который дошлёт всё, когда связь вернётся.И вот тут начинается знакомое. Читать далее

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 и 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.

redb.SQLite
Начнём с мобильного — там всё проще, потому что база это обычный файл, который сам переживает перезапуски.
Шаг 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, которого нет.
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 означает «писать только изменившиеся свойства» вместо полной перезаписи объекта. На мобильном устройстве это заметно экономит и время, и износ флеш-памяти.
Вот первое место, где легко ошибиться. В серверном приложении инициализация происходит сама, на старте хоста. 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 фоновые сервисы не запускает.
Файловая система браузера в .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 — это не только 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.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Конвейеры на Channels: как не получить тихую утечку памяти и вечно висящий воркер | 0 | 8.4 | 27-07-2026 |
| 2 | Как пишут базы данных на C#: RavenDb | 0 | 7.6 | 06-08-2026 |
| 3 | Архитектура TMS на .NET: кластер, потоки координат и объектное хранилище | 0 | 8.14 | 30-07-2026 |
| 4 | Кэш результатов запросов в Postgres Pro: как ускорить часто выполняющиеся запросы и разгрузить базу | 0 | 7.15 | 15-05-2026 |
| 5 | Background jobs в.NET: retry есть, а exactly‑once никто не завозил | 0 | 7.37 | 29-07-2026 |
| 6 | Бенчмаркая LINQ: подстава с OrderBy — одно условие и полная сортировка | 0 | 8.68 | 22-08-2026 |
| 7 | Я написал мессенджер в одиночку. Поможете устроить ему краш‑тест? | 0 | 11.26 | 04-08-2026 |
| 8 | Домашняя кластер-лаба с капелькой колхоза | 0 | 11.5 | 14-04-2026 |
| 9 | AS2 в .NET без отдельного Java-гейтвея: EDI-обмен с партнёрами прямо в маршруте | 0 | 16.04 | 12-08-2026 |
| 10 | PostgreSQL для бэкендера: 10 фич, которыми мало пользуются, а зря | 5 | 7 | 30-06-2026 |