Складской учёт на трёх платформах без дублирования логики: расширение Chrome, мобилка и SaaS из одного кода
Я довольно давно вожусь с сервисами вокруг товарных данных: делаю price‑matrix (обработка прайсов поставщиков) и CatalogLoader (парсеры сайтов). InventoryMod вырос из той же темы, но с другого конца — не «собрать и разобрать данные», а «вести по ним складской учёт».
InventoryMod — учётная система для склада: товары, остатки, движения, заказы, закупки, инвентаризация, отчёты. Начал где‑то в середине апреля 2026-го с расширения для Chrome с локальной базой (первый коммит в git датирован 19 мая — до него первая версия довольно долго собиралась «в столе»), потом добавились мобильное приложение и полноценный SaaS с сервером.
Главная проблема, с которой я жил всё это время, формулируется просто: как не писать три раза одну и ту же бизнес‑логику, когда база данных в каждой среде своя. Где‑то SQLite прямо в браузере, где‑то удалённый сервер по HTTP.
Дальше разберу на реальном коде из репозитория, как из query‑модулей выводится API‑контракт, почему я в итоге слез с IndexedDB на SQLite‑WASM и что творилось с транзакциями в резервировании остатков. Про сам продукт скажу ровно столько, сколько нужно для контекста.
Контекст: что вообще считает складская система
«Сколько чего и где лежит» — это только видимая часть. Под ней лежит история движений, а остаток из неё вычисляется. Приход, отгрузка, перемещение, списание, возврат, резерв под заказ, снятие резерва, приёмка закупки. Каждое событие пишется в stock_movements с полями «было/стало», и вместе с ним атомарно пересчитывается баланс. Стоит начать просто перезаписывать поле «остаток», как учёт однажды разойдётся с реальностью, и дальше пользователь перестаёт ему верить.
Отсюда растёт основная сложность: почти каждая операция состоит из нескольких шагов — проверить доступный остаток, обновить баланс, записать движение, иногда ещё тронуть резерв или заказ, — и выполнять её «наполовину» нельзя.

История движений. Каждое событие пишет «было/стало», а остаток из неё вычисляется.
Стек и структура
npm workspaces монорепозиторий, ~129 TS‑файлов. React 19, Vite 6, Tailwind v4, Drizzle ORM поверх SQLite, TypeScript 5.8, Express на сервере.
packages/ shared/ - доменные модели + чистая логика импорта (без БД и DOM) database/ - Drizzle-схема, queries/, api.ts, адаптеры Local/Remote ui/ - React-компоненты, страницы, хуки, i18n apps/ chrome-extension/ - расширение Chrome, автономно, SQLite-WASM mobile-capacitor/ - мобильное приложение, тот же build в Capacitor WebView web-saas/ - тот же UI, но данные через сеть backend-node/ - Express: JWT, RPC-эндпойнт, API-ключи
Правило простое: всё, что в packages/, ничего не знает про платформу, а приложения в apps/ — тонкие обёртки, которые подставляют реализацию хранилища. Как именно подставляют — ниже.
Один API‑контракт, выведенный из реализации
Держать интерфейс InventoryAPI отдельно от реализации я пробовал недолго. Они разъезжаются примерно на второй неделе: где‑то поменял сигнатуру, интерфейс забыл. Поэтому query‑функции пишутся в форме (db, ...args), а контракт для UI выводится из них типами — у каждой функции просто снимается первый параметр db. UI получает сигнатуры без Drizzle и не может случайно дёрнуть базу напрямую.
// packages/database/api.ts // Снять первый параметр (db) у функции. type OmitDb<F> = F extends (db: any, ...rest: infer R) => infer Ret ? (...rest: R) => Ret : F; type BoundModule<M> = { [K in keyof M]: M[K] extends (...a: any) => any ? OmitDb<M[K]> : M[K] }; export interface InventoryAPI { products: BoundModule<typeof productQueries>; stock: BoundModule<typeof stockQueries>; orders: BoundModule<typeof orderQueries>; import: BoundModule<typeof importQueries>; // ...ещё десяток модулей } // Рантайм-привязка: каждой функции пробрасываем конкретный db. function bindModule<M extends Record<string, any>>(mod: M, db: Db): BoundModule<M> { const out: any = {}; for (const key of Object.keys(mod)) { const val = mod[key]; out[key] = typeof val === 'function' ? (...args: any[]) => val(db, ...args) : val; } return out; }
Синхронизировать контракт с реализацией руками не приходится, потому что контракт — это и есть реализация с отрезанным db. Добавил функцию в stockQueries — она сама появилась в InventoryAPI, и TypeScript начинает требовать её в обеих средах.
Два адаптера: локальный SQLite и удалённый по HTTP
Локальный адаптер (расширение, мобилка) собирает API прямо поверх Drizzle‑инстанса, тут и смотреть не на что:
// packages/database/adapters/LocalSQLiteAdapter.ts export function createLocalAdapter(db: Db): InventoryAPI { return createInventoryApi(db); }
Удалённый адаптер (SaaS) реализует тот же интерфейс, но каждый вызов уходит на один RPC‑эндпойнт. Расписывать сотню методов вручную желания не было, поэтому там Proxy, который превращает обращение api.stock.reserve(...) в HTTP‑запрос:
// packages/database/adapters/RemoteSaaSAdapter.ts const moduleProxy = (module: string) => new Proxy({}, { get: (_t, method: string) => (...args: unknown[]) => call(module, method, args) }); return new Proxy({}, { get: (_t, module: string) => moduleProxy(module) }) as unknown as InventoryAPI;
Раскладку аргументов по query‑string и телу запроса берёт общий реестр маршрутов, тот же, что генерит серверные роуты и документацию, поэтому клиент и сервер не разъезжаются. UI вызывает api.orders.create(...) одинаково и вообще не знает, локальная под ним SQLite или сеть.
Минус у Proxy тоже есть, и он вылезает не сразу: по нему невозможно кликнуть «go to definition», а стек в консоли обрывается на анонимной функции внутри прокси. Пока модулей было пять, это не мешало; сейчас я иногда жалею, что не сделал кодогенерацию клиента из того же реестра маршрутов.
Почему я ушёл с IndexedDB на SQLite‑WASM
Первая версия была расширением для Chrome и жила на IndexedDB через Dexie. На старте выбор казался очевидным: расширению не нужен сервер, IndexedDB есть в любом браузере, Dexie удобный. Для сценария «положить‑достать» так и есть, всё отлично.
Добили меня отчёты. Стоимость запасов, ожидаемая маржа, прогресс закупок — под капотом это join’ы и агрегаты по движениям, а на IndexedDB каждый такой отчёт превращается в ручную склейку массивов в JS. Ещё на мобилке через Capacitor вылезали странные баги, вроде алиаса колонки, который в одной сборке возвращал -1 вместо значения.
Переезд оказался дорогим, и это была главная расплата за раннее решение. Код на IndexedDB к тому моменту уже работал, а у пользователей уже лежали данные, так что задач было две, и обе неприятные. Первая — переписать всю работу с данными под другую модель и SQL. Вторая, которая хуже, — перевезти существующих пользователей со старой IndexedDB‑базы в новую SQLite, ничего не потеряв. Заложи я SQLite сразу (в браузере это реально через WASM), не потратил бы неделю с лишним на переписывание и на код миграции.
Взамен получил нормализованную схему (3NF, честные внешние ключи) и настоящий SQL для отчётов, при этом всё по‑прежнему крутится в браузере пользователя, без сервера. Учёт работает офлайн, данные никуда не уезжают, пока сам не включишь бэкап в Google Drive, а чтобы начать, не надо поднимать никакую инфраструктуру.

Отчёты считаются SQL‑запросами по движениям. Ради этого и был переезд с IndexedDB на SQLite.
Мультитенантность я заложил в модель сразу, ещё в локальной версии: у всех сущностей есть campaignId, локально просто зафиксированный в 1. Из‑за этого переход от одного пользователя локально к команде на сервере не потребовал переписывать доменный слой, а на SaaS каждый тенант стал отдельным SQLite‑файлом.
Транзакции: место, где нельзя ошибиться
Возьмём резервирование товара под заказ. Тут одновременно меняются три вещи: запись резерва, баланс склада и история движений. Упади что‑нибудь между ними — и получишь «зарезервировано 5, а в остатке этого не видно», после чего доверие к системе кончается быстро. Поэтому вся операция целиком завёрнута в транзакцию (withTx), а первым делом проверяется доступный остаток:
// packages/database/queries/stock/reservationQueries.ts export async function reserve(db: Db, input: ReserveInput, campaignId = DEFAULT_CAMPAIGN): Promise<number> { if (input.items.some(i => i.qty <= 0)) throw new Error('Quantities must be positive for reserve'); return await withTx(db, async (tx) => { const operationId = await addOperation(tx, campaignId, { operationType: 'Reserve', ...input }); for (const item of input.items) { const balance = await getBalance(tx, input.warehouseId, item.productId, campaignId); if (balance.availableQty < item.qty) throw new Error(`Insufficient available stock. Requested: ${item.qty}, Available: ${balance.availableQty}`); // 1) запись/наращивание резерва // 2) upsert баланса: reservedQty += qty, availableQty = qty - reserved // 3) движение типа 'Reserve' с reservedDelta и полями before/after await tx.insert(stockMovements).values({ campaignId, warehouseId: input.warehouseId, productId: item.productId, movementType: 'Reserve', qtyDelta: 0, reservedDelta: item.qty, qtyBefore, qtyAfter: qtyBefore, reservedBefore, reservedAfter, operationId, /* ... */ }); } return operationId; }); }

Резервы отделяют «есть на складе» от «доступно к продаже». На этом же строится работа под заказ.
Обратите внимание на движение: qtyDelta: 0, но reservedDelta: item.qty — физически товар со склада не ушёл, просто перестал быть доступным. На этом разделении «есть на складе» и «доступно к продаже» держится сценарий работы под заказ с виртуальным складом поставщика: заводим склад поставщика, резервируем на нём под заказ клиента, формируем закупку, приёмка создаёт обычный приход на реальный склад, отгрузка закрывает резерв. Отдельной «дропшип‑подсистемы» тут нет, работают те же примитивы движений, и отчёты по марже поэтому остаются корректными сами собой.
Импорт из парсинга магазинов и одна коварная мелочь с числами
Многие приходят с готовыми выгрузками — каталоги и цены, собранные парсингом магазинов. Логика импорта лежит в packages/shared, намеренно без зависимостей от БД и DOM, чтобы одним и тем же кодом рисовать превью в браузере и писать данные на сервере.
Отдельная головная боль — парсинг чисел. В файлах у людей встречается всё подряд: 1 234,56, 1,234.56, 1234,56, неразрывные пробелы из Excel. Наивный replace(',', '.') ломается на первом же числе с разделителем тысяч. Пришлось честно вычислять, какой разделитель десятичный, — правый:
// packages/shared/logic/import.ts export function parseNumber(v: unknown): number | null { if (typeof v === 'number') return Number.isFinite(v) ? v : null; let s = String(v ?? '').trim(); if (!s) return null; s = s.replace(/[\s ]/g, ''); // убрать пробелы, включая неразрывные const lastComma = s.lastIndexOf(','), lastDot = s.lastIndexOf('.'); if (lastComma > -1 && lastDot > -1) { // оба разделителя: правый - десятичный, левый - разряды s = lastComma > lastDot ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, ''); } else if (lastComma > -1) { s = s.replace(',', '.'); } const n = Number(s); return Number.isFinite(n) ? n : null; }
Ещё один урок из того же слоя. Сначала тексты ошибок валидации были зашиты по‑русски прямо в shared. Когда дошло до английской и испанской локали, выяснилось очевидное: shared‑пакет обязан быть локаленезависимым. Ошибки переехали на коды (FIELD_REQUIRED, INVALID_NUMBER), а переводы к ним — в UI.

Мастер импорта: авто‑маппинг колонок и превью с валидацией. Логика одна и та же в браузере и на сервере.
Мобилка: где я сильно недооценил трудозатраты
Когда основная кодовая база уже работала на вебе и в расширении, я по наивности рассчитывал, что мобильная версия соберётся почти сама: Capacitor берёт тот же build, оборачивает в WebView, готово. На практике уперся в две вещи, и каждая съела заметно больше времени, чем я закладывал.
Первая — нативный SQLite. В браузере у меня SQLite‑WASM, а на устройстве нативный SQLite через @capacitor-community/sqlite, обёрнутый в drizzle sqlite-proxy. Я самонадеянно считал, что это «тот же SQLite», а на уровне драйвера нашлись два неочевидных отличия.
Первое касается BLOB‑параметров. В BLOB у меня лежат картинки товаров и штрихкоды, и плагин отказывался их биндить: «голый» Uint8Array он не понимает и падает с No value for type. Выяснилось, что значение он ждёт строго в формате Node Buffer, { type: 'Buffer', data: [...] }. Ушло на это часа три с учётом тестирования, потому что сообщение об ошибке никак не намекает, чего именно плагину не хватает. Конвертирую параметры прямо на границе драйвера:
// apps/mobile-capacitor/src/sqlite.ts const encodeParams = (params: unknown[] | undefined): unknown[] => (params ?? []).map((p) => p instanceof Uint8Array ? { type: 'Buffer', data: Array.from(p) } : p);
Второе отличие — транзакции. Drizzle через sqlite-proxy шлёт ручные BEGIN/COMMIT обычным run(), и это конфликтует с собственным управлением транзакциями внутри плагина. Bulk‑операции зависали или срывались на середине — ровно там, где всё обязано быть атомарным. Пришлось переопределить db.transaction на нативные begin/commit/rollback плагина:
(db as any).transaction = async (fn: (tx: Db) => Promise<any>) => { let active = false; try { active = (await conn.isTransactionActive())?.result === true; } catch {} if (active) return await fn(db); // уже внутри - просто выполняем await conn.beginTransaction(); try { const r = await fn(db); await conn.commitTransaction(); return r; } catch (e) { try { await conn.rollbackTransaction(); } catch {} throw e; } };
В обоих случаях правился только тонкий слой на границе с драйвером, бизнес‑логику остатков и заказов трогать не пришлось.
Вторая статья расходов — вёрстка под маленькие экраны, и её я недооценил сильнее всего. То, что на десктопе смотрелось нормально, на узком экране телефона ломалось: таблицы движений, длинные формы товара, модалки. Довести это до приемлемого вида, а потом ещё оттестировать на разных размерах, заняло непропорционально много времени — куда больше, чем сама «мобильная сборка».
Бэкапы: что и где
Раз данные по умолчанию лежат у пользователя локально, бэкап становится обязательной частью, а не приятной опцией: удалил расширение, переставил телефон — и без копии всё потеряно. Поэтому бэкап есть на каждой платформе, но в трёх разных формах.
Первая — файловый бэкап, сырой .sqlite. Самый честный вариант: выгрузить всю базу одним файлом и при желании открыть её чем угодно. Экспорт идёт через VACUUM INTO, поэтому копия консистентна, даже если в этот момент кто‑то пишет. В браузере это обычное скачивание файла, на мобилке — нативное сохранение или share через @capacitor/filesystem. Восстановление на мобилке устроено аккуратно: файл кладётся во временный, дальше ATTACH DATABASE, проверка, что это вообще наша база (есть таблица products), и только потом копирование.
Вторая — облачный бэкап в Google Drive. Тут есть деталь для тех, кто делал OAuth на нескольких платформах: способ авторизации везде свой, а работа с Drive общая. Расширение получает токен через chrome.identity, веб — через Google Identity Services, мобилка — через нативный Google Sign‑In (@capgo/capacitor-social-login). Дальше все трое идут в один makeDriveCloudBackup поверх Drive REST:
// packages/ui/src/util/driveCloudBackup.ts // Платформа поставляет только АВТОРИЗАЦИЮ (getToken/connect/...), // а upload/list/ротация - общие. export function makeDriveCloudBackup(auth: DriveAuth, opts: DriveCloudOpts = {}): CloudBackupIO { const client = createDriveClient({ getToken: auth.getToken, /* ... */ }); // upload с ротацией: хранить только последние keepN копий в своей папке }
Scope принципиально узкий, drive.file: приложение видит только свою папку бэкапов, а не весь диск пользователя. Ротация (keepN) подчищает старые копии автоматически.
Третья форма есть только на SaaS — серверный автобэкап по cron, про него ниже.
Способ бэкапа | Расширение Chrome | Веб (SaaS) | Мобильное приложение |
|---|---|---|---|
Файл.sqlite (экспорт/импорт) | да, скачивание | да, скачивание | да, save/share нативно |
Google Drive (авто, ротация) | да (chrome.identity) | да (GIS) | да (нативный Sign‑In) |
Серверный автобэкап по cron | - | да | - |
Как деплоится веб‑версия
SaaS я сознательно держу простым в эксплуатации: один Docker‑образ вместо зоопарка сервисов, раскатка сводится к docker compose up с двумя контейнерами.
Образ один, и он делает всё. На этапе сборки в Dockerfile собирается статика фронтенда (npm run build --workspace @inventory/web-saas), а в рантайме тот же Node‑бэкенд и отдаёт эту статику, и обслуживает /api. Отдельный веб‑сервер под фронт я не ставил:
# docker/saas.dockerfile (сокращённо) FROM node:20-bookworm-slim WORKDIR /app COPY package.json package-lock.json ./ # ...манифесты воркспейсов отдельным слоем - кэш, пока не менялись package.json RUN npm ci COPY . . RUN npm run build --workspace @inventory/web-saas # собрать фронт ENV STATIC_DIR=/app/apps/web-saas/dist ENV DATA_DIR=/data ENV PORT=4000
В compose два контейнера. Первый, saas, — Node на:4000, наружу не торчит, слушает только 127.0.0.1. Второй, caddy, — reverse‑proxy на 80/443.
Про Caddy отдельно, потому что выбор неочевидный. Первый вариант был nginx + certbot, как у всех, и он даже работал. Но при каждой пересборке я заново вспоминал, где лежат сертификаты, не забыл ли смонтировать /etc/letsencrypt и почему certbot renew в кроне контейнера не отработал. У Caddy это одна строчка конфига: если задан реальный домен и HTTPS=true, он сам получает и продлевает сертификат Let’s Encrypt. Caddyfile генерится на лету в entrypoint:
# docker/caddy-entrypoint.sh if [ "${HTTPS:-false}" = "true" ] && [ "${DOMAIN}" != "localhost" ]; then SITE="${DOMAIN}" # авто-сертификат Let's Encrypt else SITE=":80" # локально - просто HTTP fi cat > /etc/caddy/Caddyfile <<EOF ${SITE} { reverse_proxy saas:4000 } EOF exec caddy run --config /etc/caddy/Caddyfile
Всё состояние (control‑база, tenant‑файлы tenants/*.sqlite, логи, бэкапы) лежит в томе /data, смонтированном наружу, так что сам контейнер остаётся stateless и пересобрать его не жалко. Внутри работает supervisord и держит два процесса: бэкенд и cron для ночных бэкапов.
Серверный бэкап устроен так же, как в клиенте: консистентная копия каждого SQLite через VACUUM INTO плюс простой ретеншн по числу папок.
# docker/backup-internal.sh (по cron внутри контейнера) for f in "$DATA_DIR/control.db" "$DATA_DIR"/tenants/*.sqlite; do [ -f "$f" ] || continue sqlite3 "$f" "VACUUM INTO '$DEST/$(basename "$f")'" done # удалить папки бэкапов сверх BACKUP_RETENTION ls -1dt "$DATA_DIR"/backups/*/ | tail -n +"$((RET + 1))" | xargs -r rm -rf
Обновление сводится к одному скрипту rebuild.sh: git pull, затем docker compose build, затем up -d. Node пересобирается со свежей статикой, том /data переживает пересборку, Caddy остаётся с уже выписанными сертификатами. Отдельный CI/CD‑конвейер под маленький сервис я заводить не стал.
Почему так примитивно, а не Kubernetes с managed‑Postgres? Потому что каждый тенант — это отдельный SQLite‑файл, а не строки в общей базе. Резервная копия тенанта делается через cp одного файла, «переезд» клиента — тоже. Красивых цифр под нагрузкой я пока не покажу: сервис только появился, тенантов мало, базы крошечные, всё отвечает практически мгновенно — это не price‑matrix, где у клиента внутри одной базы миллионы товаров и гигабайты данных. Так что честнее сказать так: до потолка одной машины я ещё даже не приблизился, и решение выбрано не по замерам, а по стоимости эксплуатации. Горизонтальное масштабирование отдельного тенанта я при этом теряю — когда упрусь, придётся переезжать, и я это понимаю.
Что бы я сделал иначе
Пара честных недоработок, до которых руки ещё не дошли:
Повсеместный
id?: number. После чтения из БД id всегда есть, но тип этого не знает, и код усыпан проверками на null. Правильнее параPersisted<T>/New<T>.Часть статусных полей всё ещё
string, хотя должны быть union‑литералами.OrderStatusуже сделан как'new' | 'reserved' | 'shipped' | ..., а вотmovementTypeпока голая строка — и это периодически аукается опечатками.
Итог
В следующем проекте с первого дня сделаю три вещи. Возьму SQL‑базу сразу, даже если она нужна «только для положить‑достать»: отчёты приходят позже, а миграция уже с живыми пользователями стоит дороже, чем весь выигрыш на старте. Буду писать доменные функции в форме (db, ...args) и выводить публичный контракт из них типами, а не держать интерфейс руками. И заложу tenantId в схему сразу, даже в однопользовательской версии — эта строчка стоила мне ничего, а сэкономила переписывание доменного слоя.
Если хотите поспорить про Proxy вместо кодогенерации для RPC, про SQLite‑WASM в проде или про транзакции в резервах — я в комментариях. Сам продукт, если интересно посмотреть глазами пользователя: InventoryMod.

