Анатомия SQLite-провайдера: уходим от EF Core — типизированное хранилище для десктопа, мобайла и Blazor WASM

от автора

Серия: redb ecosystem (инженерный разбор после анонса 3.2.1)

О чём это

Когда вышел SQLite-провайдер 3.2.1, анонс был на пару абзацев: «тот же LINQ, одна строка в DI». Эта статья — противоположность анонса. Здесь не «что вышло», а как оно устроено и где у нас потекло. Конкретно: как движок запросов redb переехал в нативное C-расширение там, где у базы нет хранимок; как мы храним DateTimeOffset в базе, у которой нет типа «дата»; и три бага из этого релиза, разобранные с фильтр-JSON, сгенерированным SQL и фиксом.

Это длинно и с кодом. Если хочется коротко — читайте анонс по ссылке выше. Если интересно, что под капотом «одной строки в DI», — устраивайтесь.

Контекст для тех, кто про redb впервые (дальше предполагается, что вы это представляете):

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

Первый — про термин. Да, у redb «гибкая» модель: класс раскладывается по строкам таблицы значений. Нет, это не EAV в том смысле, в каком это слово бросают как ругательство. В базе redb лежит RTTI — настоящая информация о типах: схемы, структуры, типы полей, связи. БД знает, что EmployeeProps.HireDate — это DateTime, что Contacts — массив объектов, а CurrentProject — ссылка на другую схему. Это рантайм-система типов на уровне хранилища, а не «ключ-значение, разбирайся сам». Ниже будет видно, почему без этого ни материализатор, ни компилятор запросов физически не собрались бы — им на каждом шаге нужно знать тип того, что они материализуют или фильтруют.

Второй — про область применения, чтобы не было разочарований. redb — для сложных бизнес-классов: графы объектов, вложенность, ссылки между схемами, деревья, словари, массивы объектов. Складывать в него плоские потоковые данные — поток координат, телеметрию датчиков, метрики тысячами в секунду — технически можно, но это антипаттерн. Под такое есть time-series и колоночные базы; redb платит хранением и типизацией ровно там, где данные богатые и связные, а не там, где одна табличка (timestamp, value) на миллиарды строк. Коротко: redb силён там, где есть настоящая доменная модель.


Часть 0. Развилка, которая определяет всё остальное

У SQLite нет процедурного языка. Ни PL/pgSQL, ни T-SQL — ничего, во что можно положить серверную логику. А «бесплатный» тир redb на Postgres и MSSql устроен именно так: тяжёлая машинерия (компилятор запросов, материализатор, soft-delete, view прав) живёт внутри БД как серверные функции. Это и есть граница Free/Pro: где материализуется JSON и кто генерит SQL.

Из «нет хранимок» следуют ровно два пути, и мы пошли обоими — на два разных тира:

Pro — чистый C#. SQL запроса генерит ProSqlBuilder в коде, props материализуются в коде, ноль вызовов функций БД. Следствие: работает везде, где есть Microsoft.Data.Sqlite — включая Blazor WebAssembly и мобилки (MAUI/iOS/Android), где нативное расширение SQLite загрузить нельзя в принципе.

Free — нативное C-расширение. Тот же подход, что на Postgres/MSSql: движок живёт в базе. На SQLite «в базе» означает загружаемое расширение на C (redb.dll / .so / .dylib) поверх sqlite3ext.h. Работает там, где грузится нативный код: десктоп, сервер, CI — и это же дёрнет не-.NET хост (Python, sqlite3 CLI), если захочет говорить с базой redb напрямую.

Дальше — обе истории по-крупному. Сначала нативка (она интереснее), потом дата (она важнее), потом баги, потом грабли.


Часть 0.5. Что именно воспроизводит провайдер: objects и values

Прежде чем нырять в нативку — что она вообще читает и пишет. Модель redb — это ~13 таблиц (подробный разбор — в статье «13 таблиц»), но для SQLite-истории несущих две, и обе провайдеру пришлось воссоздать колонка-в-колонку.

_objects — «шапка» каждого объекта: идентичность, дерево, владение, даты, ссылка на схему (то есть на тип):

CREATE TABLE _objects(    _id             INTEGER NOT NULL PRIMARY KEY,    _id_parent      INTEGER NULL,            -- дерево (родитель)    _id_scheme      INTEGER NOT NULL,        -- RTTI: какого КЛАССА объект    _id_owner       INTEGER NOT NULL,    _id_who_change  INTEGER NOT NULL,    _date_create    REAL    NOT NULL DEFAULT (julianday('now')),  -- UTC Julian day (REAL)    _date_modify    REAL    NOT NULL DEFAULT (julianday('now')),  -- UTC Julian day (REAL)    _name           TEXT    NULL,    _hash           TEXT    NULL,            -- хэш состава пропсов (для дельты/кэша)    -- слоты для RedbPrimitive<T> (когда Props — это сам примитив, без вложенной структуры)    _value_long     INTEGER NULL,    _value_string   TEXT    NULL,    _value_bool     INTEGER NULL,            -- bool = 0/1    _value_double   REAL    NULL,    _value_numeric  REAL    NULL,            -- NUMERIC(38,18): REAL по умолчанию    ...);

_values — построчное хранилище свойств. Одна строка на (объект, структура, [индекс массива]). Ключевая идея — типизированные колонки-слоты: значение лежит не в одной «универсальной» текстовой колонке, а в колонке своего типа:

CREATE TABLE _values(    _id              INTEGER NOT NULL PRIMARY KEY,    _id_structure    INTEGER NOT NULL,       -- RTTI: КАКОЕ это поле    _id_object       INTEGER NOT NULL,    _String          TEXT    NULL,    _Long            INTEGER NULL,    _Guid            TEXT    NULL,    _Double          REAL    NULL,    _DateTimeOffset  REAL    NULL,           -- DateTime/DateTimeOffset/DateOnly как UTC Julian    _Boolean         INTEGER NULL,           -- 0/1    _ByteArray       BLOB    NULL,    _Numeric         REAL    NULL,    _ListItem        INTEGER NULL,    _Object          INTEGER NULL,           -- ссылка на другой объект    _array_parent_id INTEGER NULL,           -- массивы/словари — реляционно    _array_index     INTEGER NULL);

Два следствия, на которых держится всё остальное:

  • Типизированные слоты, а не «всё строкой». Boolean — это 0/1DateTimeOffset — REAL Julian, Long — целое. Поэтому сравнения и сортировки в SQL идут по нативному типу колонки (и индексируются), а не через строковый каст. Это и есть «не EAV»: values — типизированное props-хранилище, а idstructure → _structures несёт RTTI о том, какое это поле и какого оно типа. Компилятору запросов на каждом шаге нужно знать тип поля — чтобы выбрать колонку-слот для MAX(...) FILTER в пивоте; без RTTI он бы не знал, из какой колонки доставать LastName.

  • Массивы и словари — реляционно, через arrayparent_id/_array_index, а не JSON-блобом. Поэтому материализатор собирает их GROUP BY-ом по индексу, а компилятор умеет по ним фильтровать (_array_index IS NULL в пивоте как раз отсекает скалярную строку поля от его массивных элементов).

Дальше «движок в базе» = функции, которые читают/пишут ровно эти две таблицы, сверяясь с метаданными из schemes/structures.


Часть 1. Нативное расширение: анатомия

Точка входа

Загружаемое расширение SQLite — это .so/.dll/.dylib с одной экспортируемой функцией-инициализатором. Имя по умолчанию выводится из basename файла: для redb.dll это sqlite3_redb_init. Внутри — регистрация всех наших SQL-функций:

SQLITE_EXTENSION_INIT1int sqlite3_redb_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi){  SQLITE_EXTENSION_INIT2(pApi);  int rc;  rc = sqlite3_create_function(db, "get_object_json", 1, SQLITE_UTF8, 0,                               getObjectJsonFunc, 0, 0);  if(rc != SQLITE_OK) return rc;  rc = sqlite3_create_function(db, "get_object_json", 2, SQLITE_UTF8, 0,                               getObjectJsonFunc, 0, 0);   // overload: (_id, max_depth)  if(rc != SQLITE_OK) return rc;  rc = sqlite3_create_function(db, "save_object_json", 1, SQLITE_UTF8, 0,                               saveObjectJsonFunc, 0, 0);  if(rc != SQLITE_OK) return rc;  rc = redbRegisterPvt(db);   // весь pvt_*-компилятор  return rc;}

SQLITE_EXTENSION_INIT1 / INIT2 — это макросы из sqlite3ext.h, которые подменяют прямые вызовы sqlite3_* на вызовы через таблицу указателей pApi. Нюанс, который легко проглядеть: после INIT2 всё API SQLite внутри расширения идёт через эту таблицу. Забыли INIT2 — расширение собирается, но падает на первом же вызове sqlite3_* с мусорным указателем.

Загрузка — на каждый коннект

Главная особенность загружаемых расширений: они не персистентны. SQLite забывает зарегистрированные функции при новом соединении. А Microsoft.Data.Sqlite пулит соединения. Значит грузить расширение надо на каждый коннект, после PRAGMA. У нас это делает обёртка над SqliteConnection: открыли → выставили foreign_keys=ON и busy_timeout → загрузили расширение → отдали в пул.

Путь к бинарнику ищется так:

  1. Явный SqliteDataSource.NativeExtensionPath (если выставлен в коде).

  2. Переменная окружения REDB_SQLITE_EXTENSION.

  3. Иначе — redb.{dll,so,dylib} из runtimes/<rid>/native/ NuGet-пакета; в деве — проходом вверх по дереву каталогов до redb.SQLite/native/build.

Pro путь не ставит вообще: ему нативка не нужна, и в WASM/мобилке её и не было бы.

Доставка бинарника: почему он кросс-компилируется даром

Расширение — загружаемый модуль, и у этого есть неожиданный упаковочный бонус: оно не линкуется ни с чемsqlite3ext.h отдаёт API таблицей указателей, которая резолвится у хоста в момент загрузки (это и делает SQLITE_EXTENSION_INIT2) — значит, нет ни libsqlite3, который надо искать, ни import-библиотеки, ни target-sysroot. Кросс-компиляция схлопывается до «укажи CMake кросс-компилятор»:

# linux-arm64 с x64-машины, в одноразовом контейнере — sysroot не нуженdocker run --rm -v "$PWD:/work" debian:bookworm bash -c '  apt-get update && apt-get install -y cmake make gcc-aarch64-linux-gnu  cd /work/redb.SQLite/native  cmake -S . -B build-linux-arm64 -DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc  cmake --build build-linux-arm64'# → build-linux-arm64/redb.so: ELF 64-bit LSB shared object, ARM aarch64

Нативной библиотеке, которая линкуется с libsqlite3, понадобился бы arm64-билд этой библиотеки для линковки; этой — нет, потому что она дёргает SQLite только через таблицу указателей хоста. Один кросс-gcc — один валидный arm64-бинарь.

У доставки свой поворот. SQLite грузит расширение по явному пути (conn.LoadExtension(...)), а не как P/Invoke-нативку, которую хост резолвит из NuGet-кеша — поэтому файл должен физически лежать в output приложения. RID-таргет dotnet publish -r <rid> разворачивает runtimes/<rid>/native/ за вас; framework-dependent сборка (без RID) — нет, поэтому пакет везёт buildTransitive .targets, который копирует подходящий под ОС бинарь в output потребителя, с гейтом по Exists на каждый RID. Поэтому «добавить ещё платформу» = «собрать ещё один redb.{so,dylib} и положить в native/build-<rid>/» — csproj и .targets уже перечисляют все пять RID.

War story #5: тот самый .targets, что сломал всех потребителей (3.2.0 → 3.2.1)

И именно этот доставочный .targets в этом релизе пустил кровь — а раз у нас честный разбор, вот он. Мы выпустили 3.2.1 с баннер-комментарием в redb.SQLite.targets, где внутри <!-- … --> стояла строка из дефисов. XML-комментарий не может содержать --, поэтому MSBuild отказывался даже загрузить файл: любой потребитель, подтянувший пакет, получал

error MSB4024: An XML comment cannot contain '--', and '-' cannot be the last character.

и не собирался вообще. И это ехало транзитивно — redb.SQLite.Pro зависит от redb.SQLite, а buildTransitive-ассеты пробрасываются зависимым, так что Pro-пакет был сломан ровно так же.

Почему 200/200 тестов не поймали? Потому что buildTransitive импортируется только когда пакет потребляют как NuGet-пакет. Наше собственное решение ссылается на проекты через ProjectReference, а он импорт build/ пропускает целиком — поэтому битый файл лежал в каждой зелёной сборке невидимым, пока не случился первый настоящий dotnet add package redb.SQLite. Мы нашли это ровно в момент, когда собрали пакетного потребителя (сэмпл в публичном репо), ни секундой раньше.

Фикс — удаление одной строки (и да — я воспроизвёл ровно тот же баг в комментарии csproj, пока писал фикс-- — упрямая маленькая мина). Перезаписать опубликованную версию нельзя, поэтому исправленные пакеты ушли как redb.SQLite / redb.SQLite.Pro 3.2.1, а битая пара 3.2.1 — unlisted. Так что точности ради: движок, работа с датами, баги выше — всё 3.2.1; два SQLite-NuGet-пакета — 3.2.1. Неприглядный урок: build/ и buildTransitive пакета — это тоже отгружаемый код, валидируйте его из пакетного потребителя в CI, потому что ProjectReference с радостью вам соврёт.

Что внутри: материализатор и компилятор

Внутри расширения две большие подсистемы.

get_object_json(_id [, max_depth]) — рекурсивный материализатор. Берёт id, читает values, собирает JSON-объект, который дальше десериализует System.Text.Json в типизированный RedbObject<TProps>. Собирает всё: base-поля из objects, скаляры, массивы и словари (реляционно, через array_index / arrayparent_id), вложенные Class-поля, ссылки на объекты, ListItem (включая ListItem, который сам несёт Object). Именно тут нужна RTTI: чтобы собрать вложенный объект, материализатору надо знать его схему, типы его полей и то, что вот это поле — массив, а вон то — ссылка. Без метаданных типов это была бы просто куча строк в _values.

pvt_*-компилятор — порт ~9 тысяч строк PL/pgSQL-логики в C как генератор SQL-строк: pvt_build_query_sqlpvt_build_aggregate_sqlpvt_build_groupby_sqlpvt_build_window_sqlpvt_build_projection_sqlpvt_build_array_groupby_sql. Конвейер такой:

LINQ-выражение   → (C#) фасет-фильтр в JSON + список полей   → (нативка) pvt_build_query_sql(scheme, filterJson, ...)   → готовая строка SQL-SELECT   → (C#) выполнили, материализовали через get_object_json

То есть нативка — это транслятор фильтр-JSON в SQL. C# не строит SQL сам (это сделал бы Pro), он строит фильтр-JSON и просит базу собрать SQL. Звучит наизнанку, но именно это даёт паритет: один и тот же фильтр-JSON и на Postgres, и на SQLite Free превращается в SQL одним и тем же движком — просто на разных диалектах.

Чуть глубже про get_object_json: рекурсия, массивы, ссылки

«Собирает JSON» звучит просто, пока не вспомнишь, что собирать надо граф. Материализатор обходит структуру схемы и для каждого поля решает, что это, по RTTI из _structures:

  • скаляр — берёт значение из соответствующей колонки-слота _values (для даты — оборачивает в strftime, чтобы наружу ушла ISO-строка);

  • массив/словарь — собирает все строки с тем же arrayparent_id, упорядочивая по arrayindex, в JSON-массив/объект (вот зачем в пивоте arrayindex IS NULL — отделить «скалярную» строку поля от его элементов);

  • вложенный Class — рекурсивно зовёт сборку поддерева;

  • ссылка (_Object) — по max_depth либо разворачивает целевой объект (рекурсия с уменьшением глубины), либо оставляет id-ссылку;

  • ListItem — пункт справочника; причём ListItem сам может нести Object, так что это ещё один уровень разворота.

Параметр max_depth (вторая перегрузка функции) — предохранитель от бесконечной рекурсии на циклических ссылках и одновременно бюджет на то, как глубоко тащить связанные объекты в один JSON. Без RTTI ни один из этих переходов невозможен: материализатор обязан знать, что вот это поле — массив, а вот это — ссылка, иначе на руках просто строки _values без смысла.

Обратная сторона: save_object_json — путь записи

У чтения есть симметричный близнец — save_object_json(json). Принимает JSON объекта, по схеме раскладывает его обратно в objects (base) и values (props). Две тонкости, специфичные для SQLite:

  • Даты на запись. В приходящем JSON даты — ISO-строки (так их сериализует System.Text.Json из DateTimeOffset). Нативная запись оборачивает их в julianday('<iso>'), чтобы в DateTimeOffset лёг REAL Julian. Это зеркало read-side strftime — и ровно то, что чинилось в этом релизе под «saveobject_json тоже правь»: до фикса дата на запись уходила строкой и не сходилась с REAL-колонкой.

  • Стратегия сохранения пропсов. На Free действует PropsSaveStrategy.DeleteInsert: сохранение = удалить существующие строки values объекта и вставить новый набор (ChangeTracking — дельта по hash — это Pro-фича). Просто, предсказуемо, без следящего слоя — за счёт лишних перезаписей; для embedded-нагрузки приемлемо.

Загрузить правильный тип: CLR-реестр и полиморфизм

get_object_json отдаёт JSON — но во что его десериализовать? Для LoadAsync<EmployeeProps>(id) ответ известен из дженерика. А для полиморфной загрузки (GetChildren дерева, где дети — объекты разных схем; LoadDynamicObject) тип на этапе компиляции неизвестен: нужно по idscheme объекта понять, в какой *Props-класс материализовать.

Это разворачивалось в этом же цикле в двухслойный CLR-реестр (чинили полиморфную LoadAsync, которая на ровном месте отдавала не тот тип):

  • глобальный индекс имя схемы ↔ Type — самовосстанавливающийся: подписан на AppDomain.AssemblyLoad, так что подгрузка сборки с новыми *Props поднимает «поколение» и индекс перестраивается лениво (никакого «зарегистрируйте все типы на старте»);

  • пер-доменный кэш scheme_id → Type — где «домен» = SHA256(sanitized connection string) или явный CacheDomain. Партиционирование по домену нужно, потому что один процесс может держать несколько баз (в т.ч. несколько SQLite-файлов), и scheme_id=1000010 в одной — это не тот же класс, что в другой.

К SQLite это привязано напрямую: материализатор отдаёт JSON + idscheme, а реестр превращает idscheme в Type для десериализации полиморфного потомка. Ошибись здесь — и дерево разнотипных детей материализуется в один (неверный) тип.

Словарь Postgres → SQLite

Порт — это не «перепечатать на C», это перевод диалекта. Самые частые замены (всё — реальные строки из SqliteDialect/нативки):

Postgres

SQLite

где

array_agg(x) FILTER (WHERE …)

json_group_array(x) FILTER (WHERE …)

агрегация массивов в пивоте

x = ANY($1)

x IN (SELECT value FROM json_each($1))

IN-списки (SQLite не умеет массивы-параметры)

EXTRACT(year FROM x)

CAST(strftime('%Y', x) AS INTEGER)

компоненты даты

col ILIKE $1

col LIKE $1 ESCAPE '\'

регистронезависимый LIKE

DELETE … WHERE _id = ANY($1)

DELETE … WHERE id IN (SELECT value FROM jsoneach($1))

удаление по списку id

DISTINCT ON (col)

ROW_NUMBER() OVER (PARTITION BY col) + WHERE rn=1

DistinctBy (об этом ниже отдельно)

Про ESCAPE '\' стоит сказать пару слов, потому что это классическая мина. PG по умолчанию использует \ как escape-символ в LIKE. SQLite (и MSSQL) — нет. А UserProviderBase экранирует пользовательский ввод обратным слешем (50% → 50\%), рассчитывая на PG-семантику. Если на SQLite не дописать ESCAPE '\', экранированный \% начинает матчить буквальный бэкслеш + что угодно — тихая порча поиска. Поэтому в SQLite-диалекте LIKE всегда идёт с явным ESCAPE '\'. Мелочь, на которой легко потерять полдня.

Минимальная версия и почему

SQLite 3.44.0+ (ноябрь 2023). Мы намеренно опираемся на FILTER (WHERE …), современные оконные функции, RETURNING, JSON1 и рекурсивные CTE. Цель — чтобы SQLite-SQL оставался структурно близок к Postgres-SQL, а не превращался в переписывание с нуля. Чем ближе диалекты, тем меньше мест, где они разъезжаются в поведении (а не в синтаксисе) — а именно поведенческие расхождения ловятся в проде, а не в компиляторе.

Идентификаторы: AUTOINCREMENT вместо sequences

В SQLite нет sequences. А redb нужны глобально-уникальные id, которые умеет раздавать и .NET-генератор, и нативка (для не-.NET хостов). Решение: нативная AUTOINCREMENT-таблица и sqlite_sequence как общий high-water mark. И C-расширение, и C#-генератор ключей двигают один и тот же счётчик и резервируют блоки id из него. В результате id уникальны независимо от того, кто их раздал — .NET или Python, пишущие в один файл.

Боевая байка №1: %% в sqlite3_mprintf

Чтобы «порт PL/pgSQL на C» не звучал стерильно — вот тип бага, который этот порт дарит бесплатно.

В материализаторе есть макрос со списком колонок VCOLS, который подставляется в формат-строку sqlite3_mprintf:

char *sql = sqlite3_mprintf("SELECT" VCOLS "FROM _values WHERE _id_structure=?1 "                            "AND _id_object=?2 AND %s LIMIT 1", cond);

Когда я переводил datetime-колонки на вывод через strftime('%Y-%m-%dT%H:%M:%fZ', _DateTimeOffset), я честно положил этот strftime прямо в VCOLS. И всё развалилось молча: объекты начали грузиться с пустыми properties, а base-даты — нормально.

Полчаса я смотрел не туда. Разгадка: VCOLS уходит в формат-строку sqlite3_mprintf, а %Y %m %d %H %M %f для mprintf — это спецификаторы формата. Он начал «съедать» аргумент cond на первом же %Y, дальше форматирование поехало, SQL получился битый, sqlite3_prepare_v2 молча вернул ошибку, функция пропсов вернула пусто. Base-даты при этом уцелели, потому что их SELECT идёт через prepare_v2 напрямую, без mprintf.

Лечится экранированием — %% (mprintf свернёт %%% до того, как строку увидит SQLite):

// было: ... strftime('%Y-%m-%dT%H:%M:%fZ',_DateTimeOffset) ...   // ломает mprintf// стало:#define VCOLS " _id,_String,_Long,_Guid,_Double, " \  "strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ',_DateTimeOffset), " \  "_Boolean,_ByteArray,_Numeric,_ListItem,_Object,_array_parent_id,_array_index "

Мораль на будущее: когда генерируешь SQL через printf-подобный форматтер, любая % в данных — мина. Особенно коварно, что падение тихое: prepare не кидает, он возвращает код ошибки, который легко проглядеть, и наружу это выходит как «почему-то пустые пропсы».


Часть 2. У SQLite нет типа «дата». Как мы с этим прожили релиз

Это центральная инженерная история выпуска, и на момент анонса её ещё не было.

Три класса хранения, и ни одного типа

В SQLite дата-время — это соглашение поверх трёх классов хранения, а не тип:

  • TEXT — ISO-8601 строкой ('2024-06-15 13:45:30').

  • REAL — Julian day, число с плавающей точкой (астрономический счёт дней).

  • INTEGER — Unix-эпоха в секундах.

DateTimeOffset из .NET сюда не ложится сам собой. Нужно выбрать представление и держаться его везде: на записи (биндер параметров), на чтении (материализатор + конвертеры скаляров), в сравнениях фильтра, в агрегатах. Один промах в любой из этих точек — и даты «как бы работают», но врут на границах.

Почему не TEXT (хотя так и начиналось)

Первая версия хранила даты строкой ISO. И сломалась ровно так, как ломается строковое сравнение дат.

datetime('now') в SQLite возвращает строку с пробелом между датой и временем: 2024-06-15 13:45:30. А литерал, который C#-слой подставляет в сравнение, приходит с T2024-06-15T13:45:30. Сравнение TEXT в SQLite — лексикографическое, побайтовое. И вот что происходит на позиции 10:

'2024-06-15 13:45:30'   байт[10] = 0x20 (пробел)'2024-06-15T13:45:30'   байт[10] = 0x54 ('T')0x20 < 0x54  →  строка с пробелом ВСЕГДА «меньше» строки с 'T'

То есть любое сравнение «хранимое (с пробелом) ⟷ литерал (с T)» уходит в одну сторону всегда, безотносительно реального времени. Диапазонные фильтры начали тихо врать. В кластере это однажды пометило живую ноду мёртвой: heartbeat-сравнение last_seen < cutoff было «всегда истинно», потому что хранимое last_seen (с пробелом) лексикографически меньше cutoff-литерала (с T). Нода жива, а мониторинг считает её трупом.

Можно было бы нормализовать сепаратор. Но это лечение симптома: TEXT-сравнение остаётся лексикографическим, и любой другой формат-дрейф (миллисекунды, таймзонный суффикс, ведущие нули) снова вскроет ту же дыру. Нужно было уйти от строк.

Решение: REAL Julian day, всё в UTC

Перешли на REAL Julian day, всё в UTC — ровно как Postgres держит timestamptz в UTC. Три причины, почему это не «ещё одно соглашение», а правильный выбор:

1. Родные функции SQLite едят Julian-число напрямую. julianday()strftime()datetime()date() принимают REAL Julian как есть, без обёрток:

sqlite> SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 2460477.0732638887);2024-06-15T13:45:30.000Zsqlite> SELECT datetime(2460477.0732638887);2024-06-15 13:45:30sqlite> SELECT julianday('2024-06-15T13:45:30Z');2460477.0732638887

То есть для вывода даты в JSON материализатор просто оборачивает колонку в strftime — и получает ISO, который System.Text.Json парсит штатно.

2. Сравнение становится численным. col < X по double — корректно и однозначно. Никакого лексикографического сюрприза, потому что сравниваются числа, а не байты.

3. И, что важно для прода, — sargable (дружит с индексом). Вот тонкость, ради которой всё и затевалось. Если обернуть колонку под функцию — julianday(col) < X — индекс по col умирает: оптимизатор не может использовать индекс по выражению от колонки. А вот сравнение сырой REAL-колонки с константой — col < julianday('2024-06-15') — индексируемо: слева голая колонка, справа константа. Поэтому в генерации SQL мы вешаем julianday(...) на сторону литерала, никогда на колонку:

-- НЕ так (убивает индекс по _date_create):WHERE julianday(o._date_create) >= julianday('2023-01-01')-- а так (sargable):WHERE o._date_create >= julianday('2023-01-01')

Колонка хранит Julian-число → её и сравниваем с Julian-числом, посчитанным из литерала на стороне константы.

Конвертация: без магии, на встроенном .NET

Перевод DateTime/DateTimeOffset ⟷ Julian — это арифметика на встроенных ToOADate/FromOADate. OLE Automation date (эпоха 1899-12-30) отличается от Julian day ровно на константу 2415018.5:

internal static class SqliteJulian{    // Julian = OADate + 2415018.5. ToOADate/FromOADate встроены и lossless    // в пределах точности double — той же, в которой живёт julianday() SQLite.    private const double OADateToJulianOffset = 2415018.5;    // DateTimeOffset → UTC Julian. .UtcDateTime ПРИМЕНЯЕТ смещение → истинный момент в UTC.    public static double ToJulian(DateTimeOffset dto) => dto.UtcDateTime.ToOADate() + OADateToJulianOffset;    // DateTime → UTC Julian. Clock-значение трактуется как UTC по контракту redb    // (NormalizeForStorage ставит Kind=Utc без конвертации). ToOADate игнорит Kind — совпадает.    public static double ToJulian(DateTime dt) => dt.ToOADate() + OADateToJulianOffset;    // REAL Julian → DateTimeOffset(+00:00)    public static DateTimeOffset FromJulian(double julian)    {        var utc = DateTime.SpecifyKind(DateTime.FromOADate(julian - OADateToJulianOffset), DateTimeKind.Utc);        return new DateTimeOffset(utc, TimeSpan.Zero);    }}

Где это втыкается: четыре точки

Чтобы даты не врали, REAL-Julian-представление надо соблюсти во всех точках, где значение пересекает границу C# ⟷ SQLite:

Запись — центральный биндер параметров. Все записи (и base-даты, и props) идут через одну точку — CreateCommand в SqliteRedbConnection. Там DateTimeOffset/DateTime превращаются в double:

switch (param){    case DateTimeOffset dto:        // REAL Julian day (UTC) — родной формат SQLite-дат.        sqliteParam.Value = SqliteJulian.ToJulian(dto);        break;    case DateTime dt2:        sqliteParam.Value = SqliteJulian.ToJulian(dt2);        break;    // ...}

Чтение скаляров — ConvertScalar. Значение из SQLite приходит как double; для темпоральной цели конвертим обратно:

if (targetType == typeof(DateTimeOffset))    return (T)(object)(value is double jdo ? SqliteJulian.FromJulian(jdo)        : value is DateTimeOffset d ? d        : value is DateTime dt ? new DateTimeOffset(dt, TimeSpan.Zero)        : DateTimeOffset.Parse(value.ToString()!, ...));

Чтение строк — MapRow. Тот же double → DateTimeOffset/DateTime/DateOnly при маппинге колонок в свойства.

Нативка. get_object_json выводит дату через strftime(ISO, col) (см. байку про %%), а pvt-сравнения оборачивают литерал в julianday('<iso>').

Три CLR-типа на одной колонке

Колонка values.DateTimeOffset (REAL) обслуживает три CLR-типа:

  • DateTimeOffset — напрямую.

  • DateTime — clock-значение как UTC (контракт redb).

  • DateOnly — полночь UTC.

(TimeOnly/TimeSpan едут в _String.) Различает их RTTI в схеме: db-тип поля говорит материализатору, во что разворачивать double.

Таймзоны: почему +4 резолвится правильно

Частый вопрос из комментов: «я напишу в LINQ DateTimeOffset с зоной +04:00 — оно сравнится правильно с тем, что в базе?» Да, и вот по двум причинам:

  • C#-сторона: ToJulian(DateTimeOffset) берёт dto.UtcDateTime — а .UtcDateTime применяет смещение и даёт истинный UTC-момент. То есть +04:00 сворачивается в UTC ещё до Julian.

  • Нативная сторона: даже если в литерал просочился offset, сравнение оборачивается в julianday('<iso-с-offset>'), а julianday() сам парсит смещение:

sqlite> SELECT julianday('2026-06-25T20:00:00+04:00') = julianday('2026-06-25T16:00:00Z');1

Хранили UTC → сравниваем по UTC-моменту → всё сходится с любым входным смещением.

Боевая байка №2: аналитика и FormatException из ниоткуда

Самая каверзная часть datetime-истории.

Обычная загрузка объекта идёт через get_object_json — он отдаёт дату строкой ISO (через strftime), и C# её парсит штатно. Но аналитика — MinRedbAsync/MaxRedbAsyncAggregateRedbAsync, оконные, группировки — get_object_json минует. Она тащит дату-колонку прямо в SELECT и отдаёт сырое Julian-число в общий конвертер ядра. А конвертер ждал строку или DateTime. Результат — FormatException на ровном месте (а ещё веселее: elem.GetInt64() на дробном 2460477.07 — потому что код предполагал целочисленный Unix-timestamp).

Развилка фикса была идеологическая. Можно залатать SQLite-костылём прямо в ядре — но у redb.Core нет (и не должно быть) знаний про Julian: это деталь хранилища SQLite, а ядро обслуживает три диалекта. PG/MSSql отдают дату нормально, и тащить в общий конвертер слово «Julian» — это протечь деталью одного провайдера во все.

Сделали через нейтральную точку расширения. В ядре — опциональный хук «число → темпоральный тип», по умолчанию пустой:

// redb.Core: ядро НЕ знает слова "Julian". Только: "если ЧИСЛО метит в дату// и зарегистрирован декодер — спроси его".public static class TemporalDecoder{    public static Func<double, Type, object?>? NumericDecoder;    public static bool IsTemporal(Type t) =>        t == typeof(DateTime) || t == typeof(DateTimeOffset) || t == typeof(DateOnly);    // Convert.ChangeType, который сначала даёт числу-в-дату пройти через декодер.    public static object ChangeType(object value, Type targetType) =>        TryDecode(value, targetType, out var d) && d != null ? d        : System.Convert.ChangeType(value, targetType);}

А регистрирует декодер сам SQLite-провайдер — в статическом конструкторе SqliteDataSource, который дёргается и для Free, и для Pro:

TemporalDecoder.NumericDecoder = static (julian, targetType) =>{    var dto = SqliteJulian.FromJulian(julian);    if (targetType == typeof(DateTimeOffset)) return dto;    if (targetType == typeof(DateOnly))       return DateOnly.FromDateTime(dto.UtcDateTime);    return dto.UtcDateTime; // DateTime};

Дальше — два места в ядре, через которые сходится вся материализация аналитики (и для Free, и для Pro, потому что у Pro нет своего материализатора — он переиспользует конвертеры ядра):

  1. JsonValueConverter — ветка Number → темпоральный тип (group-by, window, проекции).

  2. Обёртка TemporalDecoder.ChangeType в скалярных точках (MinRedbAsync/MaxRedbAsyncAggregateResult.Get<T>).

PG/MSSql число для даты не отдают — у них NumericDecoder так и остаётся null, поведение не меняется ни на байт. Константа 2415018.5 и слово «Julian» остаются внутри redb.SQLite, а ядро — storage-agnostic.

Это, на мой вкус, и есть форма правильного фикса: проблема локальная (SQLite хранит дату числом), а лечится не размазыванием SQLite-специфики по ядру, а одной обобщённой точкой расширения, которую дёргает только тот, кому надо.


Часть 3. pvt-компилятор: как фильтр становится SQL

Раз уж движок — транслятор фильтр-JSON в SQL, разберём кусок этого транслятора. Это самая «база данных в базе данных» часть.

Фильтр-JSON

LINQ-Where C# сворачивает в фасет-фильтр — JSON, который понимает нативка. Скажем, Where(e => e.LastName == "NullableTest") для prop-поля даёт:

{ "LastName": { "$eq": "NullableTest" } }

А WhereRedb(o => o.ParentId == null) для base-поля (маркер 0$: — «это base, не prop»):

{ "0$:ParentId": null }

Комбинация двух условий — неявный $and по ключам объекта:

{ "0$:ParentId": null, "LastName": { "$eq": "NullableTest" } }

Расщепление: push vs residual

Ключевая функция — pvtSplitFilter. Она делит фильтр на две части:

  • push — условия по base-полям и пропсам, которые можно затолкать внутрь CTE (в подзапрос по objects/values).

  • residual — то, что применяется снаружи, поверх собранного пивота.

Это разделение и определяет, какой из «форм» запроса соберётся. Их три:

  • Shape A — pure-base flat: фильтр только по base-полям, пропсов нет. Тогда CTE не нужен вообще: SELECT id FROM objects o WHERE o._id_scheme=? AND <push>.

  • narrow — есть пропсы, фильтр сводится к пивоту: строим CTE pvtcte (пивотим нужные структуры через MAX(...) FILTER (WHERE idstructure=? AND arrayindex IS NULL)), джойним с _objects.

  • non-narrow — есть нерасщепимые проверки (например, по присутствию), нужен внешний WHERE.

Реальный собранный SQL для Where(LastName) + WhereRedb(ParentId IS NULL) (narrow-форма) выглядит так:

WITH _pvt_cte AS (    SELECT v._id_object,           MAX(v._String) FILTER (WHERE v._id_structure = 1000012 AND v._array_index IS NULL) AS "LastName"    FROM _values v    WHERE v._id_structure IN (1000012)      AND v._id_object IN (            SELECT o._id FROM _objects o            WHERE o._id_scheme = 1000010 AND o._id_parent IS NULL   -- ← push base-условия          )    GROUP BY v._id_object)SELECT o._id FROM _pvt_cteJOIN _objects o ON o._id = _pvt_cte._id_objectWHERE "LastName" = 'NullableTest'                                    -- ← residual prop-условие

Обратите внимание: base-условие o._id_parent IS NULL уехало внутрь подзапроса по _objects (push), а prop-условие по LastName осталось снаружи (residual). Это не случайность — это ровно то, что делает pvtSplitFilter, и ровно то место, где у нас был баг (см. ниже).

Боевая байка №3: мульти-ключевой фильтр терял null

WhereRedb(o => o.ParentId == null).Where(e => e.LastName == "X") на Free возвращал строки, у которых родитель есть. То есть условие IS NULL молча терялось — но только в комбинации с prop-фильтром. Base-only WhereRedb(o => o.ParentId == null) работал.

Диагностика. Прямой вызов нативки на трёх фильтрах:

-- 1) только base IS NULL — РАБОТАЕТ:SELECT pvt_build_query_sql(1000010, '{"0$:ParentId":null}', ...);→ ... WHERE o._id_scheme = 1000010 AND o._id_parent IS NULL-- 2) base equality — РАБОТАЕТ:SELECT pvt_build_query_sql(1000010, '{"0$:ParentId":5}', ...);→ ... WHERE o._id_scheme = 1000010 AND o._id_parent = 5-- 3) base IS NULL + prop — base-условие ИСЧЕЗЛО:SELECT pvt_build_query_sql(1000010, '{"0$:ParentId":null,"LastName":{"$eq":"X"}}', ...);→ ... (только LastName в CTE, никакого o._id_parent IS NULL)

То есть сам по себе {"0$:ParentId":null} нативка обрабатывает, а в составе нескольких ключей — теряет. Корень — в мульти-ключевой ветке pvtSplitFilter. Чтобы расщепить каждый ключ по отдельности, она пересобирает из ключа одиночный фильтр-объект через pvtSingleton:

static char *pvtSingleton(sqlite3 *db, const char *k, const char *v_json){  sqlite3_stmt *st = 0; char *r = 0;  sqlite3_prepare_v2(db, "SELECT json_object(?1, json(?2))", -1, &st, 0);  // ...}

А значение каждого ключа цикл брал из json_each:

// БЫЛО:"SELECT key, value FROM json_each(?1)"

И вот ловушка: для JSON-null колонка value в json_each — это SQL NULL. То есть v_json приходил пустой строкой, json("") — ошибка парсинга, json_object(...) возвращал NULL, синглтон получался NULL → pvtSplitFilter на NULL-фильтре отдавал «ничего» → условие тихо исчезало. (То же самое случилось бы с голым текстовым значением: json_each.value отдаёт текст без кавычек, json("NullableTest") — снова ошибка.) В base-only случае работал другой путь — там тип значения берётся из отдельной колонки type, и null детектится корректно.

Фикс — пересобирать значение в валидный JSON-атом по типу прямо в SQL, до pvtSingleton:

// СТАЛО:"SELECT key, CASE type ""  WHEN 'text'  THEN json_quote(value) "   // "X" с кавычками"  WHEN 'null'  THEN 'null' "              // валидный JSON null"  WHEN 'true'  THEN 'true' ""  WHEN 'false' THEN 'false' ""  ELSE value END "                        // integer/real/object/array — уже валидный JSON"FROM json_each(?1)"

После пересборки {"0$:ParentId":null} остаётся валидным JSON, синглтон собирается, условие доезжает до push и приклеивается к подзапросу по objects. Урок: jsoneach.value — лоссовый источник, он теряет тип (null → SQL NULL, text → без кавычек); если из него реконструируешь JSON, делай это по колонке type.

DistinctBy: эмуляция DISTINCT ON через ROW_NUMBER()

У Postgres есть DISTINCT ON (col) — «по одной строке на каждое значение col». У SQLite такого нет. На Pro это уже было решено в ProSqlBuilder через ROW_NUMBER(); на Free нативка distinct_on игнорировала (был явный TODO), и DistinctBy(e => e.Department) возвращал дубликаты.

Доводили до паритета. Сама pvt_build_query_sql принимает 12-й аргумент distinct_on — но функция-обёртка читала только до 11-го, так что параметр C# слал, а нативка выбрасывала. Починка тройная:

  1. Прочитать 12-й аргумент и пробросить внутрь.

  2. Затащить distinct-поле в пивот. Если поле не упомянуто в фильтре/сортировке, его нет в собранных полях → нет в CTE → не по чему партиционировать. Поэтому distinct-поле подмешивается в сбор полей (pvtCollectFields) тем же механизмом, что и ORDER BY.

  3. Завернуть результат в ranked-CTE с ROWNUMBER() и оставить rn=1:

WITH _pvt_cte AS ( ... пивот Department ... ),_ranked AS (  SELECT o._id AS _id,         ROW_NUMBER() OVER (PARTITION BY _pvt_cte."Department" ORDER BY o._id) AS _rn  FROM _pvt_cte  JOIN _objects o ON o._id = _pvt_cte._id_object)SELECT _id FROM _ranked WHERE _rn = 1

Выражение партиции резолвится из метаданных поля: base → o.<column>, prop → pvtcte."<FieldName>" (пивотная колонка). Представитель группы — строка с минимальным o._id (как и у Pro). Всё это включается только при наличии distinct_on; обычные запросы идут прежним путём — нулевой риск регресса для 99% запросов.


Часть 4. Free vs Pro: где Pro случайно лез во Free

Архитектурно Free и Pro делят базовые провайдеры (redb.Core), но расходятся в материализации:

  • Free зовёт get_object_json (нативка) для сборки объектов.

  • Pro материализует в C# (ProLazyPropsLoaderProSqlBuilder) и ни разу не должен дёргать нативные функции.

И вот тут вылез принципиальный баг. DeleteSubtreeAsync (удаление поддерева) собирает id потомков через базовый TreeProviderBase.CollectDescendantIds. Pro переопределяет загрузочные tree-методы (GetChildrenGetPolymorphicChildrenLoadDynamicObject) на C#-материализацию — а вот CollectDescendantIds не переопределял, и он использовал рецепт с get_object_json:

-- Tree_SelectPolymorphicChildren — рецепт, который дёргал нативку:SELECT o._id as ObjectId, o._id_scheme as SchemeId, get_object_json(o._id, 1) as JsonDataFROM _objects o WHERE o._id_parent = $1

На PG/MSSql это проходит молча: get_object_json там — серверная функция, есть в любом тире. На SQLite Pro функции нет (нативку Pro не грузит) → жёсткий креш no such function: get_object_json. А на PG/MSSql Pro было тихое расточительство: материализовали полный JSON каждого узла поддерева — ради того, чтобы выкинуть JSON и взять _id.

Фикс не в том, чтобы добавить Pro-override (это оставило бы базу дёргать get_object_json ради метода, которому JSON не нужен), а в том, чтобы убрать JSON из самого базового метода: ему нужен только список id. Добавили id-only рецепт во все три диалекта:

// ISqlDialect + PostgreSqlDialect / MsSqlDialect / SqliteDialect:public string Tree_SelectChildrenIds() =>    "SELECT o._id FROM _objects o WHERE o._id_parent = $1 ORDER BY o._name, o._id";
// CollectDescendantIds — было QueryAsync<ChildObjectInfo>(Tree_SelectPolymorphicChildren), стало:var childIds = await Context.QueryScalarListAsync<long>(Sql.Tree_SelectChildrenIds(), parentId);foreach (var childId in childIds) { ids.Add(childId); await CollectDescendantIds(childId, ids, ...); }

Теперь течь закрыта в источнике для всех тиров: Free не считает лишний JSON ради списка id, Pro не лезет в нативку, PG/MSSql Pro перестают зря материализовать. В исходниках Pro get_object_json теперь ровно ноль вызовов. И, кстати, SQLite Pro оказался идеальным детектором таких протечек: он крешится на любом нативном вызове из Pro — то, что на PG/MSSql молчит.

Заодно: DeleteSubtree и каскад

Тот же DeleteSubtree возвращал неверное число удалённых. На SQLite в схеме есть FK idparent ... ON DELETE CASCADE — удаляешь родителя, дети уходят каскадом. А changes() (rows-affected) каскадно-удалённые строки не считает. Поэтому DELETE WHERE id IN (родитель, дети) мог вернуть 1 (только родитель удалён напрямую, дети — каскадом). Починили семантикой: метод возвращает размер собранного поддерева (objectIds.Count), а не cascade-зависимый rows-affected. На PG/MSSql (где каскада на id_parent нет) это то же число — никакого расхождения.

Bool — это INTEGER

Ещё одна мелочь, всплывшая в группировках. SQLite не имеет булева типа — хранит 0/1 как INTEGER. В пивоте/проекции значение bool прилетает в общий конвертер как JSON-число, а ветка bool в JsonValueConverter ловила только true/строку → число 1 давало false. Группировка по bool-ключу схлопывалась (все «false»). Фикс — принять Number в bool-ветке:

Type t when t == typeof(bool) => elem.ValueKind == JsonValueKind.True    || (elem.ValueKind == JsonValueKind.Number && elem.TryGetDouble(out var bn) && bn != 0)    || (elem.ValueKind == JsonValueKind.String && bool.TryParse(elem.GetString(), out var bl) && bl),

PG/MSSql шлют true/false — их не задевает.


Часть 5. Как пощупать самому

Это не псевдокод из статьи. В репозитории лежат два инструмента, которыми всё проверяется руками.

redb.Examples — ~150 запускаемых примеров, которые гоняются на любом провайдере, включая SQLite:

dotnet run --project redb.Examples -- E021 E146 E148   # фильтр по дате, агрегаты, оконные

Переключаете AddRedb/AddRedbPro + UseSqlite — и тот же набор бежит вживую на SQLite Free или Pro. Тот же код пойдёт на Postgres/MSSql без единого изменения.

redb.CLI — глобальный .NET-тул для управления схемой и данными, поддерживает sqlite во всех командах:

redb schema -p sqlite -o redb_sqlite.sql            # выгрузить весь SQL-скрипт схемы (для ревью/CI)redb init   -p sqlite -c "Data Source=app.db"       # создать таблицы в пустой базеredb export -p sqlite -c "Data Source=app.db" -o data.redb --compressredb import -p sqlite -c "Data Source=app.db" -i data.redb --clean

И главное — доверие проверяется тестами. SQLite Free и Pro проходят интеграционный набор по 200/200 — тот же, что гейтит Postgres и MSSql. Для нового провайдера это весомее любых слов: тот же набор, та же планка.

Боевая байка №4: CLI, который «поддерживал» sqlite, но молча — нет

Готовя эту статью, я хотел показать redb schema -p sqlite — и наткнулся на собственную мину в тулинге. Код redb.CLI sqlite поддерживал: был полноценный SqliteProvider, фабрика ProviderFactory.Create("sqlite"), ресурс схемы redbSqlite.sql. А csproj — нет:

<!-- redb.CLI.csproj — тянул движок из NuGet старой версии: --><PackageReference Include="redb.SQLite" Version="1.2.*" />

1.2.* — это версия, в которой SQLite-провайдера не существовало вообще (он новый, с 3.2.1). То есть typeof(redb.SQLite.RedbService).Assembly и встроенный ресурс redbSqlite.sql резолвились в сборку, где их нет, и любая -p sqlite-команда тихо разъезжалась с реальным кодом. Фикс — перевести ссылки на локальные проекты (как уже было сделано в redb.Examples):

<ProjectReference Include="..\redb.SQLite\redb.SQLite.csproj" />

Мораль из той же серии, что и %%-байка: «в коде поддержка есть» ≠ «сборка её видит». Версионный пин — это тоже часть контракта, и устаревший пин ломает фичу так же глухо, как опечатка в SQL. Проверяется тривиально — прогоном самой команды: redb schema -p sqlite теперь выгружает настоящую схему (REAL Julian, _DateTimeOffset REAL), а не падает на пустом ресурсе.


Часть 6. Грабли, на которые наступите вы

Серия честная про «что не готово и обо что споткнётесь», так что без приукрашивания:

  • Путь к .db зависит от рабочей директории. Относительная строка (Data Source=app.db) создаёт файл относительно cwd процесса, а не папки проекта. Я сам на этом потерял пару часов: dotnet run из разных директорий писал в разные файлы, и тесты «проходили/падали» по разной БД. Берите абсолютный путь или фиксируйте cwd.

  • :memory: — per-connection. Чтобы пул соединений видел одну in-memory базу, нужен Mode=Memory;Cache=Shared плюс один удерживаемый коннект. Это жизненный цикл SQLite, не redb: закрыли последний коннект — база испарилась.

  • NUMERIC → REAL по умолчанию. Быстро, но лоссово за пределами double. Точный вариант через TEXT — запланированная настройка. Известное слабое место SQLite.

  • SQLite — single-writer. Один писатель на файл; на конкурентную запись redb ставит busy_timeout, но Postgres-уровня параллелизма ждать не надо. Для embedded/локальных сценариев — норма.

  • Нативные бинарники Free идут под Windows x64, Linux x64 и Linux arm64. Все три лежат в runtimes/<rid>/native/ и доставляются во framework-dependent сборки через buildTransitive .targets — расширение грузится по явному пути, поэтому файл должен физически попасть в ваш output, а для сборки без RID NuGet сам runtimes/ не разворачивает. macOS (osx-x64/osx-arm64 .dylib) собирается из того же CMake-проекта, но требует macOS-раннера — единственный оставшийся пробел, его закрывает CI-матрица. У Pro нативной зависимости нет — он уже сегодня везде, и это ровно то, что нужно WASM/мобилкам.

  • bool в сыром виде — 0/1. Помните при дебаге Boolean/value_booltrue лежит как 1.

Ни один из этих пунктов не торчит в обычном коде — но в дебаге каждый экономит вечер.


Часть 7. Pro на мобилке и в браузере — и да, бесплатно

Возвращаюсь к тому, ради чего весь SQLite и затевался.

Пишете Blazor WebAssembly, MAUI или standalone-клиент — вам нужен SQLite Pro: чистый C#, нативку не грузит, работает в браузерной песочнице и на телефоне. Типизированное LINQ-хранилище в одном файле внутри приложения.

И ключевое, что вызывает недоверие, поэтому прямо: Pro для этого — бесплатный.

  • Идёте на redbase.app (он же redb.ru), регистрируетесь и отправляете запрос ключа на почту — в ответ присылают бесплатный лицензионный ключ.

  • Никаких банковских/платёжных реквизитов. Карту не спрашивают. Регистрация — механизм выдачи ключа, а не воронка продаж.

  • Ключ — в .WithLicense(...), инструкция по подключению там же, сразу после регистрации.

Барьер на вход в клиентский сценарий — нулевой.


Итог

SQLite-провайдер заставил нас сделать две неочевидные вещи и поймать три бага, которые в проде стоили бы дороже любого ревью.

Неочевидные вещи: перенести весь движок запросов в C-расширение там, где у базы нет хранимок (и где % в данных ломает mprintf), и заново ответить на вопрос «как хранить дату» для базы, у которой типа «дата» нет — REAL Julian в UTC, sargable-сравнения, julianday() на стороне литерала, и нейтральный хук в ядре вместо протечки SQLite-специфики.

Баги: тихо терявшийся IS NULL в мульти-ключевом фильтре (json_each.value лоссов по типу), DISTINCT ON через ROW_NUMBER() вместо игнора, и Pro, случайно лезущий в Free-функцию get_object_json на пути удаления поддерева.

Всё это — ровно те места, где абстракция «один LINQ для всех баз» либо держит удар, либо течёт. У нас держит: Free и Pro зелёные по 200/200 на том же наборе, что и остальные диалекты, а пощупать можно redb.Examples и redb.CLI из репозитория.

Репозиторий, доки, пакеты — redbase.app. Версия стека — 3.2.1; два SQLite-NuGet-пакета (redb.SQLite / redb.SQLite.Pro) — 3.2.1 (хотфикс buildTransitive .targets, см. War story #5). Вопросы, «а делает ли оно X на SQLite», баг-репорты — несите; провайдер свежий, обратная связь открыта.

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