@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.
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-endedkind.StockLevel— the materialized(skuId, locationId, state) → qtyview. Never mutated directly — only through the service.StockMovement— an append-only audit row. Exactly one per mutation; two for a transfer.
Installation
pnpm add @happyvertical/smrt-inventorySet up SKUs, locations, and a stock service
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:
| Method | State transition | Notes |
|---|---|---|
receive | — → available (+qty) | Purchase-order receipts, returns, the "produce" leg of a build. |
reserve | available → allocated | Throws InsufficientStockError if available would go negative. |
release | allocated → available | Cancel a reservation; allocated stock returns to the pool. |
fulfill | allocated → — (-qty) | Stock ships / is consumed. Throws if allocated would go negative. |
transfer | available@A → available@B | Two legs (transfer_out + transfer_in) in one transaction. |
adjust | signed delta on any state | Cycle counts / corrections. Rejects a 0 delta; defaults to available, pass state to target another bucket. |
// 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:
// 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
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:
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:created → reserve() and fulfillment:shipped → fulfill(). 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.
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:
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
| Export | Description |
|---|---|
InventoryLocation | Physical or virtual stocking site with open-ended kind. |
StockLevel | Materialized (skuId, locationId, state) → qty row. |
StockMovement | Append-only audit row; one per mutation, two for transfers. |
InventoryLocationCollection | findByCode, findByKind, findByPlace, findActive |
StockLevelCollection | getLevel, findBySku, findByLocation, totalForSku, totalForLocation |
StockMovementCollection | findBySku, findByLocation, findBySource, findByReason |
Service, errors & types
| Export | Description |
|---|---|
StockService | The only sanctioned mutation surface — receive, reserve, release, fulfill, transfer, adjust, plus withTransaction. |
createStockService({ db }) | Convenience factory; shares one DB connection across the internal collections. |
InsufficientStockError | Carries skuId, locationId, state, requested, available. |
installInventoryDispatchHandlers(...) | Opt-in contract:created / fulfillment:shipped wiring. |
StockState | 'available' | 'allocated' | 'wip' | 'qc_hold' | 'damaged' |
StockMovementReason | Canonical reason vocabulary ('receipt', 'reservation', ...) plus free-form strings. |
InventoryLocationKind | Open-ended classifier string (warehouse, factory, retail, ...). |