@happyvertical/smrt-inventory

Multi-location stock tracking — SKUs across locations, a materialized level view, an append-only movement ledger, and an ACID stock-mutation service. Strictly industry-neutral.

v0.29.34DomainInventoryMulti-tenantACID ledger

Overview

@happyvertical/smrt-inventory tracks discrete units across locations. It is deliberately industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, or any vertical that counts units. Catalog shapes (Product, Material, ProductVariant, Sku) live in smrt-products; this package adds the stock-motion layer on top.

Three models, one service:

  • InventoryLocation — a physical or virtual stocking site with an open-ended kind.
  • StockLevel — the materialized (skuId, locationId, state) → qty view. Never mutated directly — only through the service.
  • StockMovement — an append-only audit row. Exactly one per mutation; two for a transfer.

Installation

bash
pnpm add @happyvertical/smrt-inventory

Set up SKUs, locations, and a stock service

typescript
import {
  createStockService,
  InventoryLocationCollection,
} from '@happyvertical/smrt-inventory';
// Catalog shapes (Sku, Product, ...) live in smrt-products. For
// server-side scripts, tests, and SSR runtimes that don't run the Vite
// plugin, import from the /collections subpath.
import { SkuCollection } from '@happyvertical/smrt-products/collections';

const db = { type: 'sqlite', url: 'app.db' };
const skus = await SkuCollection.create({ db });
const locations = await InventoryLocationCollection.create({ db });
const stock = await createStockService({ db });

const widget = await skus.create({
  productId: 'prod-1',
  code: 'WIDGET-001',
  attributes: { finish: 'matte' },
});
await widget.save();

const warehouse = await locations.create({
  code: 'WH-EAST',
  name: 'Warehouse East',
  kind: 'warehouse',
});
await warehouse.save();

Move stock through its lifecycle

Every method writes one (or two, for transfers) StockMovement audit rows so the ledger stays in lockstep with the materialized levels. The six methods are the entire mutation surface:

MethodState transitionNotes
receive— → available (+qty)Purchase-order receipts, returns, the "produce" leg of a build.
reserveavailable → allocatedThrows InsufficientStockError if available would go negative.
releaseallocated → availableCancel a reservation; allocated stock returns to the pool.
fulfillallocated → — (-qty)Stock ships / is consumed. Throws if allocated would go negative.
transferavailable@A → available@BTwo legs (transfer_out + transfer_in) in one transaction.
adjustsigned delta on any stateCycle counts / corrections. Rejects a 0 delta; defaults to available, pass state to target another bucket.
typescript
// Inbound receipt — +qty available.
await stock.receive(widget.id, warehouse.id, 100, {
  sourceType: 'PurchaseOrder',
  sourceId: po.id,
});

// Reserve against an order — available -> allocated.
// Throws InsufficientStockError if there isn't enough available.
await stock.reserve(widget.id, warehouse.id, 10, {
  sourceType: 'Contract',
  sourceId: order.id,
});

// Cancel a reservation — allocated -> available.
await stock.release(widget.id, warehouse.id, 4);

// Ship — removes from allocated, leaves the building.
await stock.fulfill(widget.id, warehouse.id, 6, {
  sourceType: 'Fulfillment',
  sourceId: shipment.id,
});

// Cycle count caught five extra units — non-zero signed delta.
await stock.adjust(widget.id, warehouse.id, 5, {
  sourceType: 'CycleCount',
  sourceId: count.id,
});

// Move stock between locations — writes transfer_out + transfer_in legs.
await stock.transfer(widget.id, warehouse.id, store.id, 12, {
  sourceType: 'TransferOrder',
  sourceId: xfer.id,
});

Stock states are available, allocated, wip, qc_hold, and damaged; a single SKU at one location can hold non-zero quantities in several states at once.

Atomicity

Each mutation runs every write inside a single database transaction (db.transaction(...) on @happyvertical/sql ≥ 0.74.0). A partial failure — say the level write succeeds but the movement write throws — rolls the whole mutation back, so the materialized balance and the audit ledger never disagree. When you need atomicity across several stock calls, wrap them in withTransaction:

typescript
// Atomicity across multiple stock calls — e.g. reserve every line of
// an order in one indivisible step. A shortfall on line N rolls back
// lines 1..N-1. Individual methods are already atomic on their own; use
// withTransaction only for cross-call composition.
await stock.withTransaction(async (tx) => {
  for (const line of order.lines) {
    await tx.reserve(line.skuId, line.locationId, line.qty, {
      sourceType: 'Contract',
      sourceId: order.id,
    });
  }
});

Query balances and the audit log

typescript
import {
  StockLevelCollection,
  StockMovementCollection,
} from '@happyvertical/smrt-inventory';

const levels = await StockLevelCollection.create({ db });
const movements = await StockMovementCollection.create({ db });

// What's on hand at this location across every state?
const here = await levels.findByLocation(warehouse.id);

// Available total for a SKU across all locations?
const availableTotal = await levels.totalForSku(widget.id, 'available');

// What movements were caused by a particular contract?
const audit = await movements.findBySource('Contract', order.id);

Catch the insufficient-stock error

InsufficientStockError is thrown by reserve, fulfill, transfer, and a negative adjust when stock would go negative. It carries skuId, locationId, state, requested, and available so a UI can decide whether to retry, backorder, or cancel:

typescript
import { InsufficientStockError } from '@happyvertical/smrt-inventory';

try {
  await stock.reserve(widget.id, warehouse.id, 9999);
} catch (err) {
  if (err instanceof InsufficientStockError) {
    // err carries skuId, locationId, state, requested, available
    console.log(
      `Only ${err.available} available for ${err.skuId}, requested ${err.requested}`,
    );
  } else {
    throw err;
  }
}

Opt-in DispatchBus wiring

The package ships handlers that bridge contract:createdreserve() and fulfillment:shippedfulfill(). They are off by default — install them explicitly once you've decided you want automatic stock motion. Each event is processed atomically across its lines, so a shortfall on one line rolls back the rest; malformed payloads are logged with the specific reason rather than silently dropped.

typescript
import { createDispatchBus } from '@happyvertical/smrt-core';
import { installInventoryDispatchHandlers } from '@happyvertical/smrt-inventory';

const bus = await createDispatchBus({ db });

// Off by default — opt in explicitly in your smrt.ts.
const handlers = await installInventoryDispatchHandlers({
  dispatchBus: bus,
  db,
  // Per-handler toggles (both default true):
  installContractReserved: true,
  installFulfillmentShipped: true,
});

// In smrt-commerce (or your own code):
await bus.emit('contract:created', {
  contractId: order.id,
  lines: [{ skuId, locationId, qty }],
});

await bus.emit('fulfillment:shipped', {
  fulfillmentId: shipment.id,
  lines: [{ skuId, locationId, qty }],
});

// handlers.dispose() detaches the subscribers (tests / shutdown).

Multi-tenancy

The three inventory models use @TenantScoped({ mode: 'optional' }) with a nullable tenantId. Wrap mutations in withTenant() from smrt-tenancy to scope reads and writes automatically:

typescript
import { withTenant } from '@happyvertical/smrt-tenancy';

await withTenant({ tenantId: 'tenant-a' }, async () => {
  // Every read/write through locations, levels, movements, and the
  // StockService is filtered by tenant_id = 'tenant-a'.
  await stock.receive(widget.id, warehouse.id, 100);
});

API surface

Models & collections

ExportDescription
InventoryLocationPhysical or virtual stocking site with open-ended kind.
StockLevelMaterialized (skuId, locationId, state) → qty row.
StockMovementAppend-only audit row; one per mutation, two for transfers.
InventoryLocationCollectionfindByCode, findByKind, findByPlace, findActive
StockLevelCollectiongetLevel, findBySku, findByLocation, totalForSku, totalForLocation
StockMovementCollectionfindBySku, findByLocation, findBySource, findByReason

Service, errors & types

ExportDescription
StockServiceThe only sanctioned mutation surface — receive, reserve, release, fulfill, transfer, adjust, plus withTransaction.
createStockService({ db })Convenience factory; shares one DB connection across the internal collections.
InsufficientStockErrorCarries skuId, locationId, state, requested, available.
installInventoryDispatchHandlers(...)Opt-in contract:created / fulfillment:shipped wiring.
StockState'available' | 'allocated' | 'wip' | 'qc_hold' | 'damaged'
StockMovementReasonCanonical reason vocabulary ('receipt', 'reservation', ...) plus free-form strings.
InventoryLocationKindOpen-ended classifier string (warehouse, factory, retail, ...).

Related Modules