Назад к блогу

DuckDB-Wasm и OPFS: полноценная база данных прямо в браузере

DuckDB-Wasm и OPFS: полноценная база данных прямо в браузере

DuckDB-Wasm получил поддержку OPFS — приватной файловой системы браузера, благодаря которой база данных наконец-то переживает перезагрузку вкладки и перезапуск браузера без ручной сериализации в Parquet и IndexedDB. Разбираем, как открыть persistent-базу одной строкой кода, какие подводные камни скрываются в версиях npm-пакета и что нужно учесть при настройке воркера и бандла.

Как 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 и обратно.

Похожее