Складской учёт на трёх платформах без дублирования логики: расширение Chrome, мобилка и SaaS из одного кода
Я довольно давно вожусь с сервисами вокруг товарных данных: делаю price-matrix.ru (обработка прайсов поставщиков) и 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-компоненты, страницы, хуки, i18napps/ 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.tsexport function createLocalAdapter(db: Db): InventoryAPI { return createInventoryApi(db);}
Удалённый адаптер (SaaS) реализует тот же интерфейс, но каждый вызов уходит на один RPC-эндпойнт. Расписывать сотню методов вручную желания не было, поэтому там Proxy, который превращает обращение api.stock.reserve(...) в HTTP-запрос:
// packages/database/adapters/RemoteSaaSAdapter.tsconst 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.tsexport 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.tsexport 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.tsconst 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-slimWORKDIR /appCOPY package.json package-lock.json ./# ...манифесты воркспейсов отдельным слоем - кэш, пока не менялись package.jsonRUN npm ciCOPY . .RUN npm run build --workspace @inventory/web-saas # собрать фронтENV STATIC_DIR=/app/apps/web-saas/distENV DATA_DIR=/dataENV 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.shif [ "${HTTPS:-false}" = "true" ] && [ "${DOMAIN}" != "localhost" ]; then SITE="${DOMAIN}" # авто-сертификат Let's Encryptelse SITE=":80" # локально - просто HTTPficat > /etc/caddy/Caddyfile <<EOF${SITE} { reverse_proxy saas:4000}EOFexec 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_RETENTIONls -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.
ссылка на оригинал статьи https://habr.com/ru/articles/1077994/