Перейти к основному содержимому

Офлайн-база (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, и файл, не совпадающий со сборкой, под именем которой он сохранён, не открывается.

Как собирается и выкладывается​

Прод-сервер базу не собирает: для сборки ему не хватает памяти.

  1. 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.
  2. Сервер ставит релиз скриптом 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 в манифесте означает для каждого, у кого библиотека уже скачана, что доступно обновление. Поэтому выкладка — всегда отдельный осознанный шаг.

Как клиент её использует​

  1. distBase = window.DG_DIST_BASE || '/mobile-data' (public/offline/platform.js).
  2. Читается db-manifest.json. Место (база плюс её архив) проверяется до начала загрузки. Приватное окно (инкогнито) получает прямой отказ: проба хранилища там не проходит, или квота меньше ~300 МБ. Обычному окну, где не хватает места, говорится, сколько нужно и сколько свободно.
  3. dg.db.gz скачивается во временный файл в корне OPFS (dg-mobile.<build_id>.gz.partial). Прерванная загрузка продолжается HTTP-запросом Range с того байта, где остановилась, в том числе при следующем заходе. Отмена удаляет временный файл.
  4. Полностью скачанный архив распаковывается с диска через DecompressionStream в пул SQLite (opfs-sahpool с именем dg-offline) как /dg-mobile.<build_id>.db. Размер распакованного файла должен совпасть с bytes.
  5. Проверка: установку пропускает дешёвая проверка (строки читаются). Полный PRAGMA quick_check идёт в фоне и ещё может признать библиотеку негодной.
  6. Воркер (@sqlite.org/sqlite-wasm) отвечает на /search, /api/text/:id, /api/nav/:id; собственный fetch сайта подменяется раньше всех остальных скриптов. Он запускает тот же core/search-core.js, что и сервер (core-bundle.js).
  7. Обновления: страница сравнивает build_id открытой библиотеки с манифестом и предлагает новую. Новая сборка скачивается рядом со старой, которая отвечает, пока новый файл не проверен; затем старый файл удаляется.
  8. Оболочку (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.