Как DuckDB-Wasm научился переживать перезагрузку вкладки
В 2021 году, когда вышел DuckDB-Wasm, о постоянном хранении речи не шло: база целиком жила в куче Wasm и исчезала вместе с закрытием вкладки. Чтобы сохранить таблицы между сессиями, приходилось сериализовать их в Parquet, складывать байты в IndexedDB и регистрировать заново при следующей загрузке страницы. Схема рабочая, но вся логика ложилась на приложение, и сам DuckDB-Wasm её не предлагал.
Точкой перелома стал OPFS — Origin Private File System. С марта 2023 года его поддерживают современные браузеры: это песочница в рамках origin с произвольным доступом на чтение и запись. DuckDB-Wasm (проверено на 1.32.0 и 1.33.1-dev64.0) умеет использовать OPFS как хранилище. База, открытая по пути opfs://, переживает и перезагрузку страницы, и перезапуск браузера.
Открывается она одним вызовом:
await db.open({
path: 'opfs://analytics.duckdb',
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
});На выходе — обычный файл .duckdb с журналом упреждающей записи (WAL) и чекпойнтами. Префикс opfs:// говорит файловому слою DuckDB-Wasm резолвить путь не во временной файловой системе Emscripten, а в приватной файловой системе origin. Никаких шагов синхронизации, экспорта или ключа в localStorage для этого не требуется: перезагрузите страницу и запустите тот же код — CREATE TABLE IF NOT EXISTS увидит существующую таблицу и ничего не сделает, вставка добавит вторую строку, а счётчик выведет 2.
Здесь есть практическая оговорка, которую стоит держать в голове при выборе версии. Сборка, которую npm отдаёт по тегу latest (1.33.1-dev57.0), создаёт файлы в OPFS, но никогда в них не пишет — данные не сохраняются. Причина в том, что она канонизирует путь к виду opfs:/analytics.duckdb с одним слэшем, и он перестаёт совпадать с дескриптором OPFS. Поэтому закрепите 1.32.0 или возьмите тег next (1.33.1-dev64.0 и новее) — эта версия уже исправлена.
Настройка экземпляра: бандлы, воркер и открытие базы
Старт приложения с постоянной базой отличается от обычного запуска DuckDB-Wasm ровно одним вызовом — db.open. Всё остальное знакомо: выбираете бандл, поднимаете воркер, создаёте AsyncDuckDB. Дальше разберём шаги по порядку и покажем, где именно вклинивается OPFS.
Шаг 1. Выбор бандла. getJsDelivrBundles() возвращает список доступных сборок, а selectBundle() оставляет одну — с подходящим воркером и .wasm. Импорт резолвится в ту версию, что стоит у вас в package.json, поэтому вопрос версии решается на этом шаге, а не позже.
Шаг 2. Воркер через Blob. Скрипты воркеров обязаны быть same-origin, а CDN — нет. Обходится это обёрткой: содержимое воркера подставляется в Blob, из Blob создаётся URL, из URL — сам Worker. После запуска объектный URL освобождается через revokeObjectURL.
Шаг 3. Инстанцирование. new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker) — и await db.instantiate(bundle.mainModule, bundle.pthreadWorker). Логгер здесь не декорация: он пишет каждый HTTP-запрос на чтение, и по этим строкам удобно проверять, что данные действительно берутся из OPFS, а не тянутся из сети.
Шаг 4. Открытие базы. Вместо :memory: по умолчанию указываете путь с префиксом opfs:// и режим READ_WRITE:
await db.open({
path: 'opfs://analytics.duckdb',
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
});
const conn = await db.connect();На этом месте создаётся обычный файл .duckdb со своим WAL и чекпоинтами. Никакой сериализации в Parquet, никакого localStorage с ключом «не забыть экспортировать», никакого синхронизирующего шага при следующем запуске.
Одна оговорка про версию. Сборка, которую npm отдаёт по тегу latest (1.33.1-dev57.0), файлы в OPFS создаёт, но не пишет в них: путь канонизируется к виду opfs:/analytics.duckdb с одним слэшем и перестаёт совпадать с дескриптором OPFS, поэтому данные не сохраняются. Закрепите рабочую версию — npm install @duckdb/duckdb-wasm@1.32.0 — или возьмите тег next (1.33.1-dev64.0 и новее). Проверять это стоит до того, как напишете первую строку прикладного кода: иначе потеря данных обнаружится только после перезагрузки страницы.
Подводный камень: почему latest-сборка молча теряет данные
Прежде чем открывать постоянную базу, проверьте, какую сборку ставит npm. Тег latest сейчас отдаёт 1.33.1-dev57.0, и это проблемная версия: файлы в OPFS она создаёт, но записать в них ничего не может. Данные просто не сохраняются между перезагрузками.
Причина конкретна и легко воспроизводима. Путь opfs://analytics.duckdb эта сборка канонизирует в opfs:/analytics.duckdb — с одним слэшем после двоеточия. Такой путь уже не совпадает с дескриптором OPFS, поэтому все операции записи уходят в никуда, хотя сам файл на диске появляется.
Лечится это на уровне выбора версии, ещё до написания кода:
npm install @duckdb/duckdb-wasm@1.32.0Либо перейдите на тег next — сборку 1.33.1-dev64.0 или более позднюю:
npm install @duckdb/duckdb-wasm@nextРаботающая версия проверяется так: после CREATE TABLE, INSERT и CHECKPOINT перезагрузите страницу и посмотрите на результат SELECT count(*). Если число растёт после каждой вставки — запись идёт в OPFS. Если всегда возвращается начальное значение — вы попали на дефектную сборку, и её нужно заменить. Оба варианта — 1.32.0 и 1.33.1-dev64.0 — работоспособны, так что выбирайте по остальным возможностям, которые вам нужны.
WAL, контрольные точки и почему нельзя закрывать вкладку спокойно
Запись в OPFS идёт не напрямую в основной файл, а через журнал упреждающей записи. Когда вы делаете INSERT, зафиксированные транзакции сначала дописываются в analytics.duckdb.wal. Сам файл базы обновляется только в момент checkpoint. Это привычная схема из настольного DuckDB, и работает она так же.
Checkpoint запускается в трёх случаях: WAL перерос порог checkpoint_threshold (по умолчанию 16 МБ), база закрыта корректно, либо вы сами выполнили CHECKPOINT. Первые два варианта в браузере ненадёжны. Пользователь закрывает вкладку, телефон выгружает фоновую страницу из памяти, ноутбук уходит в сон — ни одно из этих событий не гарантирует, что ваш код корректного завершения вообще выполнится.
Отсюда два практических правила.
Ставьте CHECKPOINT после записей, которые нельзя потерять. Документация DuckDB прямо говорит: именно CHECKPOINT сбрасывает данные в OPFS. Транзакции лежат в WAL, и при следующем открытии WAL будет воспроизведён, но вкладку могут убить в любой момент. Checkpoint — единственный способ гарантировать, что данные оказались в основном файле.
Делайте checkpoint на пакет, а не на каждый оператор. Большой WAL замедляет следующее открытие: журнал проигрывается до первого запроса. Для интерактивного приложения разумно фиксировать состояние после серии правок пользователя:
await conn.query('INSERT INTO transactions VALUES (...)');
await conn.query('CHECKPOINT');Если не хочется вручную отслеживать пакеты, установите порог в ноль сразу после подключения:
await conn.query(`SET checkpoint_threshold = '0KB'`);Тогда DuckDB будет чекпоинтить после каждого оператора. Это стоит части пропускной способности на запись, зато снимает вопрос о потерях вовсе. Корректное завершение выглядит так:
await conn.query('CHECKPOINT');
await conn.close();
await db.terminate();Отдельно стоит помнить про долговечность уровнем ниже. OPFS — это хранилище браузера, а не жёсткая гарантия. Браузер вправе вытеснить данные при нехватке места или после долгого отсутствия визитов, а пользователь может очистить их из настроек сайта. Относитесь к OPFS как к быстрому локальному кэшу для ускорения старта и сохранения рабочего состояния, а не как к единственной копии важных данных. Источник истины держите на стабильной стороне и синхронизируйте обратно.
И наконец, версия. Именно на этом слое проявляется проблема сборки, которую npm отдаёт по тегу latest: она создаёт файлы в OPFS, но записать в них ничего не может, и данные не переживают перезагрузку. Закрепляйте рабочую версию явно или переходите на тег next — детали выбора версии и способ проверки записи разобраны выше.
Чтение и запись файлов OPFS из SQL
По умолчанию вы получаете доступ только к самой базе. Любой другой путь — Parquet-файл, CSV, промежуточный результат агрегации — придётся регистрировать вручную. Это и есть развилка между двумя режимами.
Включите opfs: { fileHandling: 'auto' } при открытии — и DuckDB-Wasm начнёт сам сканировать каждый запрос на предмет строковых литералов вида 'opfs://...'. Найденные файлы регистрируются до выполнения, недостающие директории создаются, а после выполнения дескрипторы освобождаются. Опция работает только тогда, когда сама база открыта по opfs://-пути.
// Вариант 1: авторегистрация путей из SQL
await db.open({
path: 'opfs://analytics.duckdb',
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
opfs: { fileHandling: 'auto' },
});
// Вариант 2: ручная регистрация (режим по умолчанию)
await db.open({
path: 'opfs://analytics.duckdb',
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
});
await db.registerOPFSFileName('opfs://cache/monthly_totals.parquet');
// ... запросы к файлу ...
await db.dropFile('opfs://cache/monthly_totals.parquet');Автоматический режим удобен для разовых чтений. Ручной требует больше кода, но избавляет от повторного захвата access handle на каждом стейтменте — а это заметно, когда приложение шлёт сотни мелких запросов. Учтите ограничение: файл держит только один handle за раз, поэтому перед открытием его другой сессией или инстансом базы вызывайте db.dropFile().
Директории создаются по требованию, так что cache/ или export/ в пути появятся сами. Вложенные каталоги поддерживаются, а opfs://-пути — это обычные файловые пути DuckDB: glob-паттерны и read_csv работают с ними без изменений.
Есть и обратная сторона медали: fileHandling: 'auto' лишь частично решает проблему версий. Сборка, которую npm отдаёт по тегу latest (1.33.1-dev57.0), файлы в OPFS создаёт, но не пишет в них — данные не сохраняются. Причина в том, что она канонизирует путь к виду opfs:/analytics.duckdb с одним слэшем, и он перестаёт совпадать с дескриптором OPFS. Закрепите рабочую версию: npm install @duckdb/[email protected] или используйте тег next (1.33.1-dev64.0 и новее). Без этого никакой режим регистрации не спасёт — писать будет попросту некуда.
Когда данные лежат локально, агрегат можно выгрузить в Parquet и читать его при следующем запуске:
COPY (
SELECT o_orderpriority AS priority,
date_trunc('month', o_orderdate) AS month,
sum(o_totalprice) AS total
FROM orders
GROUP BY ALL
) TO 'opfs://cache/monthly_totals.parquet';
SELECT * FROM 'opfs://cache/monthly_totals.parquet';Экспорт из SQL тоже умеет Parquet со сжатием:
COPY transactions
TO 'opfs://export/transactions.parquet'
(FORMAT parquet, COMPRESSION zstd);Это позволяет готовить и чистить данные прямо в браузере перед отправкой на сервер. Формат стандартный, поэтому работает и обратный сценарий: положите готовый .duckdb-файл в комплект приложения, при первом запуске скопируйте его в OPFS и откройте — пользователь получит локальный датасет без шага импорта. Основные ограничения перечислены в документации DuckDB: один handle на файл, а переименования из SQL возможны только между двумя уже зарегистрированными OPFS-файлами.
Кэш, а не хранилище: границы надёжности OPFS
OPFS — это хранилище браузера, а не жёсткая гарантия. Браузер вправе вытеснить данные, когда на диске заканчивается место или когда источник давно не открывали; пользователь может очистить хранилище из настроек сайта. Считайте OPFS быстрым локальным кешем: он ускоряет запуск и сохраняет рабочее состояние, но не заменяет единственную копию данных, которые нельзя потерять. Источник истины держите вне браузера — каталог DuckLake или обычные файлы в объектном хранилище по путям s3://, — и синхронизируйте с ним изменения.
Есть и более приземлённое ограничение: файл в OPFS может быть открыт только одним дескриптором за раз. Поэтому перед тем как другое соединение или экземпляр базы откроет файл, зарегистрированный вручную, освободите его через db.dropFile() — так советует документация DuckDB. Автоматический режим fileHandling: 'auto' снимает этот вопрос для одиночных чтений: он освобождает дескрипторы сразу после выполнения запроса.
Отдельно про версии: сборка, которую npm отдаёт по тегу latest (1.33.1-dev57.0), создаёт файлы в OPFS, но никогда в них не пишет — данные не сохраняются. Причина в том, что она канонизирует путь к виду opfs:/analytics.duckdb с одним слэшем, и он перестаёт совпадать с дескриптором OPFS. Закрепите рабочую версию: ставьте npm install @duckdb/duckdb-wasm@1.32.0 либо используйте тег next (1.33.1-dev64.0 или новее).
Итог: когда OPFS-база в браузере оправдана
Persistent-режим DuckDB-Wasm через OPFS закрывает давнюю проблему: база переживает перезагрузку страницы и перезапуск браузера, а .duckdb-файл остаётся обычным файлом DuckDB — его можно выгрузить и открыть в CLI или Python-клиенте. Три вещи определяют, будет ли это работать предсказуемо. Первое: версия сборки. Установите @duckdb/duckdb-wasm@1.32.0 или берите тег next (1.33.1-dev64.0 и новее), потому что сборка под тегом latest (1.33.1-dev57.0) создаёт файлы в OPFS, но не пишет в них: она канонизирует путь до opfs:/analytics.duckdb с одним слэшем, и он перестаёт совпадать с дескриптором OPFS, так что данные не сохраняются. Закрепляйте рабочую версию в зависимостях, а не полагайтесь на latest. Второе: CHECKPOINT после каждой пачки записей, а не после отдельного оператора — вкладку могут закрыть в любой момент, и только чекпойнт переносит данные из WAL в основной файл. Третье: OPFS — быстрый кеш, а не единственная копия; источник истины держите вне браузера.
Практический совет: дайте пользователю кнопку скачивания базы. После CHECKPOINT заберите файл через OPFS API (navigator.storage.getDirectory() → getFileHandle('analytics.duckdb') → getFile()), оберните в URL.createObjectURL(file) и отдайте как загрузку — это заодно закрывает резервное копирование и перенос данных на другое устройство, пока DuckDB-Wasm сам не умеет двигать файлы в OPFS и обратно.