Офлайн-база (offline library)
Офлайн на сайте работает поверх одного артефакта: dg.db.gz — сжатая база SQLite со всеми текстами на
всех языках и триграммным FTS-индексом. Один и тот же файл обслуживает сайт (как PWA) и приложения для
Android и iOS.
Где что лежит
| Что | Путь | Отдаётся как |
|---|---|---|
| Исходники корпуса: текст на пали и переводы | siteroot/data/suttacentral.net, siteroot/data/dhammagift (симлинки) | — |
| Серверная база (собранная) | dg.db | её использует dg-fastify.js: поиск, ридер, API, MCP |
| Офлайн-библиотека | siteroot/mobile-data/dg.db.gz | /mobile-data/dg.db.gz |
| Её манифест | siteroot/mobile-data/db-manifest.json | /mobile-data/db-manifest.json |
| Офлайн-слой сайта | public/offline/* (app.js, db-worker.js, core-bundle.js, offline-status.js, offline-library-settings.js, platform.js) | /offline/* |
| Кэш оболочки (service worker) | public/service-worker.js | /sw.js |
| Словарь (DPD, ~24 МБ) | public/overrides/js/standalone-dpd/* | /assets/js/standalone-dpd/* |
Сами артефакты в git не кладутся: это сотни мегабайт, и их можно пересобрать. siteroot/mobile-data/ —
настоящий каталог на каждом сервере: у теста и прода он свой.
Что такое библиотека
Это копия серверной dg.db со всеми языками, но без строк переводчиков из
OFFLINE_EXCLUDED_TRANSLATORS (ai, bw): они остаются на сервере, их нет ни в текстах, ни в индексе.
Копия собирается из dg.db заново, с той же схемой, теми же rowid и собственным FTS-индексом, а не
урезается через DELETE + VACUUM. Её build_id — хэш её собственного содержимого.
Таблицы: suttas, texts, html, fts (FTS5, tokenize='trigram remove_diacritics 1', external
content поверх texts), vocab, meta. Около 217 МБ в архиве и 613 МБ на устройстве.
Клиент первым читает db-manifest.json:
{ "schema_version": 1, "build_id": "71381ea7133d4182", "langs": "all", "fts": "trigram",
"source": "dg.db", "built_at": "2026-10-11T22:39:58.752Z", "file_gz": "dg.db.gz",
"bytes_gz": 216794065, "bytes": 613142528, "sha256": "…" }
sha256 — хэш распакованной базы. Клиент не верит одному манифесту: в скачанном файле есть свой
meta.build_id, и файл, не совпадающий со сборкой, под именем которой он сохранён, не открывается.
Как собирается и выкладывается
Прод-сервер базу не собирает: для сборки ему не хватает памяти.
- CI (
.github/workflows/build-db.yml) собираетdg.dbна раннере GitHub и выкладывает скользящий релизdb-latest. В нём серверная база (dg.db.gz, со строками ИИ) и офлайн-библиотека (app-dg.db.gz+app-db-manifest.json), обе сделаныbuild-search-db.js --publish. - Сервер ставит релиз скриптом
scripts/pull-db.sh. С ключом--appон кладёт и офлайн-библиотеку вsiteroot/mobile-data/. Сначала сверяетсяsha256распакованного файла, потом архив встаёт на место, и последним пишется манифест: читатель никогда не увидит манифест файла, которого ещё нет.
На машине, где хватает памяти, те же шаги идут локально:
npm run build-search-db # siteroot/data/* -> dg.db (весь корпус, FTS5 trigram)
npm run publish-offline-db # build-search-db.js --publish: dg.db.gz + db-manifest.json в siteroot/mobile-data/
node build-search-db.js --publish --no-build # выложить уже лежащую dg.db, не собирая заново
--out=<каталог> выкладывает в другой каталог. Новый build_id в манифесте означает для каждого, у
кого библиотека уже скачана, что доступно обновление. Поэтому выкладка — всегда отдельный осознанный
шаг.
Как клиент её использует
distBase = window.DG_DIST_BASE || '/mobile-data'(public/offline/platform.js).- Читается
db-manifest.json. Место (база плюс её архив) проверяется до начала загрузки. Приватное окно (инкогнито) получает прямой отказ: проба хранилища там не проходит, или квота меньше ~300 МБ. Обычному окну, где не хватает места, говорится, сколько нужно и сколько свободно. dg.db.gzскачивается во временный файл в корне OPFS (dg-mobile.<build_id>.gz.partial). Прерванная загрузка продолжается HTTP-запросомRangeс того байта, где остановилась, в том числе при следующем заходе. Отмена удаляет временный файл.- Полностью скачанный архив распаковывается с диска через
DecompressionStreamв пул SQLite (opfs-sahpoolс именемdg-offline) как/dg-mobile.<build_id>.db. Размер распакованного файла должен совпасть сbytes. - Проверка: установку пропускает дешёвая проверка (строки читаются). Полный
PRAGMA quick_checkидёт в фоне и ещё может признать библиотеку негодной. - Воркер (
@sqlite.org/sqlite-wasm) отвечает на/search,/api/text/:id,/api/nav/:id; собственныйfetchсайта подменяется раньше всех остальных скриптов. Он запускает тот жеcore/search-core.js, что и сервер (core-bundle.js). - Обновления: страница сравнивает
build_idоткрытой библиотеки с манифестом и предлагает новую. Новая сборка скачивается рядом со старой, которая отвечает, пока новый файл не проверен; затем старый файл удаляется. - Оболочку (HTML, JS, CSS, wasm,
core-bundle.js) кэширует service worker. Словарь кэширует сама страница сразу после того, как библиотека принята: одного прохода service worker при установке оказалось мало (см. ниже).
Пулом OPFS владеет только одна вкладка (ресурс с одним писателем). Вкладка на переднем плане забирает
его себе, а вкладку без пула может обслуживать владелец через BroadcastChannel.
В приложениях
Приложения скачивают тот же dg.db.gz собственными нативными загрузчиками, поэтому загрузка не
прерывается, когда приложение уходит в фон или блокируется экран:
- Android:
DgDownloadService— foreground-сервис с прогрессом в уведомлении. Потом воркер импортирует файл из хранилища приложения так же, как описано выше. - iOS:
DgDownloadPlugin.swift(фоноваяURLSession). Библиотека хранится обычным файломdg.dbв контейнере App Group и читается нативным SQLite (DgSharedLibrary.swift), потому что расширение «Поделиться» — отдельный процесс — ищет по тому же файлу. Воркер отправляет запросы туда, а не в OPFS.
Проверка
npm run test-offline # сквозная: установка, офлайн-поиск/ридер/оглавление, совпадение с dg.db и dg-fastify
npm run test-offline-parity # ядро воркера против core/search-core.js
npm run test-offline-resume # оборванный ответ -> докачка через Range
npm run test-offline-outage # настоящий отказ сервера: pm2 dg-test остановлен, nginx отвечает 503; поиск, язык, вторая вкладка, оглавление, ридер
test-offline-outage всегда возвращает сервис обратно. Режим «offline» в DevTools только отклоняет
запросы; остановленный сервер ещё и отвечает 5xx — именно этот случай раньше ломал оболочку. Процессы
pm2 называются dg-test и dg-prod.
Подводные камни
- OPFS работает только в защищённом контексте (HTTPS или localhost).
- Браузер может очистить хранилище; страница просит постоянное хранилище, а отсутствие библиотеки — нормальное состояние, о котором сообщает интерфейс («Настройки → Офлайн-библиотека»).
5xxдолжен уходить в кэш, а промах кэша — пробрасываться дальше.respondWith(undefined)в странице — это TypeError («Failed to convert value to 'Response'») и вечный спиннер.- Предзагрузка service worker при установке может молча пропустить файлы (127 из ~145 в одном замере, при том что сервер отдавал их с кодом 200). Всё, что обязано быть офлайн, кэширует и сама страница.
- Жизненный цикл вкладки: страница в back/forward-кэше держит свой воркер и дескрипторы пула. Поэтому
воркер завершается на
pagehide(но никогда во время загрузки), а библиотека открывается заново наfocus/visibilitychange/pageshow.