Складской учёт на трёх платформах без дублирования логики: расширение 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.

Мастер импорта: маппинг колонок CSV/XLSX и превью с валидацией строк
Мастер импорта: маппинг колонок CSV/XLSX и превью с валидацией строк

Мастер импорта: авто‑маппинг колонок и превью с валидацией. Логика одна и та же в браузере и на сервере.

Мобилка: где я сильно недооценил трудозатраты

Когда основная кодовая база уже работала на вебе и в расширении, я по наивности рассчитывал, что мобильная версия соберётся почти сама: 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.