@perdieminc/order-pacing
v0.1.0
Published
A library for managing order pacing
Readme
Order Pacing Engine
Installation
npm install @perdieminc/order-pacingUsage
import Redis from 'ioredis';
import { Engine, TimeframeMode, OrderSource } from '@perdieminc/order-pacing';
const redis = new Redis({
host: 'localhost',
port: 6379
});
const engine = new Engine({
redis,
bucket: 'storeId:locationId',
timeframeMode: TimeframeMode.BEFORE_ONLY,
timeZone: 'UTC',
rules: [{
ruleId: 'rule-1',
timeFrameMinutes: 30,
busyTimeMinutes: 15,
categoryIds: [],
weekDays: [],
tags: ['delivery'],
maxOrders: 10,
maxItems: 100,
maxAmountCents: 100000
}]
});
await engine.add({
orderId: '123',
orderTime: new Date(),
totalAmountCents: 5000,
source: OrderSource.PERDIEM,
tags: ['delivery'],
items: [{
itemId: 'item-1',
quantity: 2,
totalAmountCents: 2000,
categoryId: 'cat-1'
}]
});
const busyTimes = await engine.getBusyTimes();
const orders = await engine.getOrders();
const validation = await engine.validateOrderTime(new Date(), ['delivery']);
const stats = await engine.getOrdersStats(
new Date(Date.now() - 24 * 60 * 60 * 1000),
new Date(Date.now() + 24 * 60 * 60 * 1000)
);API
new Engine({ redis, bucket, timeframeMode?, timeZone?, rules?, logger? })
Creates a new Engine instance.
redis: Redis instance from ioredisbucket: Bucket identifier (e.g.,storeId:locationId)timeframeMode: Optional timeframe calculation mode. Options:TimeframeMode.BEFORE_ONLY(default): Look backtimeFrameMinutesfrom order timeTimeframeMode.CENTERED: Half oftimeFrameMinuteson each side of order time (~timeFrameMinutestotal)TimeframeMode.AFTER_ONLY: Look forwardtimeFrameMinutesfrom order timeTimeframeMode.BEFORE_AND_AFTER:timeFrameMinuteson each side of order time (~2xtimeFrameMinutestotal)
timeZone: Optional IANA timezone name, e.g.'America/New_York'(defaults to'UTC'). Used only to evaluate ruleweekDays/startTime/endTimeagainst the order time; all timestamps are stored as UTC epoch seconds.rules: Optional array of rules (defaults to[]). Rules determine when to apply busy time based on order volume in a time window:rules: [{ ruleId: 'rule-1', // Unique rule identifier timeFrameMinutes: 30, // Time window in minutes busyTimeMinutes: 15, // Busy time to apply in minutes (minimum 5) categoryIds: [], // Optional: Filter by category IDs (omitted or empty = all categories) weekDays: [], // Optional: Filter by week days 0-6, 0 = Sunday (omitted or empty = all days) startTime: '09:00', // Optional: Start time for rule (HH:mm or HH:mm:ss, seconds are ignored) endTime: '17:00', // Optional: End time for rule (HH:mm or HH:mm:ss, seconds are ignored) tags: ['delivery'], // Optional: Filter by order tags (omitted or empty = all orders) maxOrders: 10, // Optional: Max orders threshold maxItems: 100, // Optional: Max items threshold maxAmountCents: 100000 // Optional: Max total amount in cents threshold }]At least one threshold (
maxOrders,maxItems, ormaxAmountCents) must be set. When any threshold is reached (>=) within the time window — the order being added counts toward its own window — the busy time is applied. Multiple rules can be set to handle different scenarios; each matching rule creates its own busy time. The constructor throws if any rule is invalid, naming the offending rule.A rule scoped by
tagsonly counts matching orders toward its thresholds, only trips on matching orders, and its busy times only delay matching orders — a['delivery']cap never blocks pickups. An order matches when it carries every rule tag; tag vocabulary is the consumer's to define (e.g.pickup,delivery,catering). All rule filters combine with AND.logger: Optional logger instance (defaults to a noop logger; aconsoleLoggeris also exported)
add(inputOrder)
Adds an order to the engine. If a rule's threshold is reached, a busy time period of exactly busyTimeMinutes is created, ending at the order's scheduled time or busyTimeMinutes from now, whichever is later. Throws a TypeError if orderTime is not a valid Date, orderId is empty, or tags is not an array of non-empty strings.
await engine.add({
orderId: '123',
orderTime: new Date(),
totalAmountCents: 5000, // Amount in cents ($50.00)
source: OrderSource.PERDIEM, // or OrderSource.OTHER
tags: ['pickup'],
items: [{
itemId: 'item-1',
quantity: 2,
totalAmountCents: 2000,
categoryId: 'cat-1'
}]
});Note: Only orders with source: OrderSource.PERDIEM will trigger busy time calculations.
getBusyTimes()
Returns an array of busy time entries:
[
{
busyTimeId: string, // Busy time unique identifier
ruleId: string, // Rule identifier that triggered this busy time
startTime: Date, // Start of busy period
endTime: Date, // End of busy period
orderTimeSeconds: number, // Order time in seconds
currentTimeSeconds: number, // Current time in seconds when busy time was created
busyTimeSeconds: number, // Duration in seconds
scope: string[], // The tripping rule's tags; only matching orders are delayed ([] = all)
busyTimeContext: {
totalAmountCents: number, // Total amount in cents from all orders in the time window
totalItems: number, // Total items from all orders in the time window
totalOrders: number, // Total number of orders in the time window
categoryIds: string[] // All category IDs from all orders in the time window
},
threshold: {
type: 'orders' | 'items' | 'amount', // Type of threshold that was reached
value: number, // Actual value that reached the threshold
limit: number, // Threshold limit that was reached
categoryIds: string[] // The rule's configured categoryIds filter (empty if unfiltered)
}
}
]getOrders()
Returns an array of order entries:
[
{
orderId: '123',
items: [{
itemId: 'item-1',
quantity: 2,
totalAmountCents: 2000,
categoryId: 'cat-1'
}],
totalAmountCents: 5000,
source: OrderSource.PERDIEM, // or OrderSource.OTHER
orderTime: Date,
orderTimeSeconds: number,
currentTimeSeconds: number
}
]validateOrderTime(orderTime, tags?)
Checks if an order placed at the given time would fall within a busy period that applies to its tags (tags defaults to []). Only busy periods whose scope matches are considered — a ['delivery']-scoped busy period never delays a pickup order. Consecutive matching busy periods chain: if pushing the order past one period lands it inside the next, the wait extends until the first free moment. Already-ended busy periods are ignored. Throws a TypeError if orderTime is not a valid Date or tags is not an array of non-empty strings. Returns wait period information:
const validation = await engine.validateOrderTime(new Date(), ['delivery']);
{
waitPeriodSeconds: number, // Seconds to wait until one second past the last delaying busy period (0 if not busy)
ordersInWindow: number // totalOrders recorded by the busy period that delayed the order (0 if not busy)
}getOrdersStats(startTime, endTime)
Retrieves order statistics for a specific time range. Note that orders are only retained for 7 days (see Data retention below), so older ranges return partial or no data. Returns an array of orders sorted by order time:
[
{
orderId: '123',
orderTime: Date,
source: OrderSource.PERDIEM // or OrderSource.OTHER
}
]Example:
const stats = await engine.getOrdersStats(
new Date(Date.now() - 24 * 60 * 60 * 1000),
new Date(Date.now() + 24 * 60 * 60 * 1000)
);Data retention
- Orders are kept for 7 days past their order time; busy times for 1 day past their triggering order's time.
- Pruning happens lazily on engine calls (
add()and the read methods) — there is no TTL on the Redis keys, so buckets that stop receiving traffic keep their last entries until the next call. validateOrderTime()ignores busy periods that have already ended, so lingering expired entries do not affect results.
Redis requirements
ioredis(v5) is a peer dependency; the engine uses an injected client and never opens its own connection.- Each bucket uses two sorted sets:
orders:{bucket}andbusytimes:{bucket}. - Every method, including the
get*reads, may prune expired entries, so the engine needs a writable Redis connection.
