@happyvertical/smrt-ads
Ad delivery with priority waterfall, weighted A/B testing, and immutable event tracking.
Overview
smrt-ads manages ad campaigns with a priority waterfall, zone-based
targeting, weighted creative variations for A/B testing, and immutable
impression/click/conversion events. Cross-package references all use plain string IDs (no @foreignKey()) to avoid circular dependencies.
Installation
npm install @happyvertical/smrt-adsQuick Start
import {
AdDeliveryTier, AdDeliveryTierCollection,
AdGroup, AdGroupCollection,
AdVariation, AdVariationCollection,
AdEvent, AdEventCollection,
AdEventType, PricingModel, AdGroupStatus
} from '@happyvertical/smrt-ads';
// Define delivery tiers (lower priority number = served first)
const tiers = new AdDeliveryTierCollection(db);
const sponsorship = await tiers.create({
name: 'Sponsorship',
priority: 1,
pricingModel: PricingModel.FIXED,
});
const standard = await tiers.create({
name: 'Standard',
priority: 2,
pricingModel: PricingModel.CPM,
});
// Create an ad group with targeting and budget
const groups = new AdGroupCollection(db);
const group = await groups.create({
name: 'Holiday Campaign',
tierId: sponsorship.id,
contractId: 'contract-uuid', // plain string FK to smrt-commerce
status: AdGroupStatus.ACTIVE,
dailyBudget: 100.00,
totalBudget: 3000.00,
startDate: new Date('2025-06-01'),
endDate: new Date('2025-08-31'),
});
group.setTargeting({ device: 'desktop', geo: 'US' });
group.setZoneIds(['zone-1', 'zone-2']); // FK to smrt-properties zones
await group.save();
// Add variations with relative weights for A/B testing
// weight=2 is selected 2x more often than weight=1
const variations = new AdVariationCollection(db);
const varA = await variations.create({
groupId: group.id,
name: 'Version A - Blue CTA',
weight: 2,
status: 'active',
});
// Track an immutable event (create-only, no update/delete)
const events = new AdEventCollection(db);
await events.create({
variationId: varA.id,
zoneId: 'zone-uuid',
siteId: 'site-uuid',
eventType: AdEventType.IMPRESSION,
});Core Models
AdDeliveryTier (Priority Waterfall)
Lower priority number = higher priority in selection. Typical tiers:
- Sponsorship (priority 1): guaranteed premium placements, FIXED pricing
- Standard (priority 2): regular programmatic ads, CPM pricing
- House (priority 3): self-promotional fallback ads
// @smrt(...) โ NOT @TenantScoped (shared catalog; see Tenancy section)
class AdDeliveryTier extends SmrtObject {
name: string
priority: number // 1=highest, 2, 3...
pricingModel: 'fixed' | 'cpm' | 'cpc' | 'cpa' // PricingModel enum
description?: string
}AdFormat (IAB Standard Dimensions)
AdFormat rows describe industry-standard IAB dimensions: 728ร90 Leaderboard,
300ร250 Medium Rectangle, etc. Like AdDeliveryTier, it is a shared catalog table and is deliberately not tenant-scoped.
// @smrt(...) โ NOT @TenantScoped (IAB industry standard; see Tenancy section)
class AdFormat extends SmrtObject {
name: string // e.g. 'Leaderboard', 'Medium Rectangle'
width: number
height: number
formatType: 'banner' | 'native' | 'video' // AdFormatType enum
description?: string
}AdGroup (Campaign)
// @smrt({ tableStrategy: 'sti', ... }) + @TenantScoped({ mode: 'optional' })
class AdGroup extends SmrtObject {
name: string
tierId: string // @foreignKey('AdDeliveryTier')
contractId: string // @crossPackageRef to smrt-commerce Contract
verticalSlug: string // tag slug from smrt-tags
status: 'draft' | 'active' | 'paused' | 'completed' // AdGroupStatus
dailyBudget: number = 0.0 // DECIMAL
totalBudget: number = 0.0 // DECIMAL
startDate: Date | null
endDate: Date | null
// JSON fields with getter/setter helpers (no schema validation on targeting rules)
setTargeting(rules: Record<string, any>): void
getTargeting(): Record<string, any>
setZoneIds(ids: string[]): void // plain string IDs โ smrt-properties zones
getZoneIds(): string[]
}AdVariation (Creative, STI)
Weight is a relative integer, not a percentage. A variation with weight: 2 is
twice as likely to be chosen as one with weight: 1. Denormalized counters (impressions, clicks) are eventually consistent โ refresh from AdEvent aggregates
if you need exact totals.
// @smrt({ tableStrategy: 'sti', ... }) + @TenantScoped({ mode: 'optional' })
class AdVariation extends SmrtObject {
groupId: string // @foreignKey('AdGroup')
formatId: string // @foreignKey('AdFormat')
name: string
clickUrl: string // Click destination URL
altText: string // Accessibility alt text
weight: number = 1 // Relative weight for A/B (2 = 2x more likely than 1)
impressions: number = 0 // Denormalized count (eventually consistent)
clicks: number = 0 // Denormalized count (eventually consistent)
status: 'draft' | 'active' | 'paused' // AdVariationStatus
assetId: string // @crossPackageRef โ smrt-assets Asset
}AdEvent (Immutable, STI)
Create-only: the generated REST API and MCP server omit update and delete operations, and cli: false suppresses CLI generation because event volume is high. Track impressions, clicks, and conversions
here.
// @smrt({ tableStrategy: 'sti', api: { include: ['create', 'list'] }, mcp: { include: ['create'] }, cli: false })
// + @TenantScoped({ mode: 'optional' })
class AdEvent extends SmrtObject {
variationId: string // @foreignKey('AdVariation')
zoneId: string // @crossPackageRef โ smrt-properties Zone
siteId: string // denormalized from Zone for query efficiency
eventType: 'impression' | 'click' | 'conversion' // AdEventType
timestamp: Date
metadata: string // JSON (IP, user agent, referrer, etc.)
// immutable: no update or delete in API/MCP
}Waterfall Priority & Selection
// Ad selection algorithm
async function selectAd(zoneId: string) {
// 1. Find eligible groups for zone
const eligibleGroups = await groups.findEligibleForZone(zoneId);
// Filters: ACTIVE, in date range, has zoneId
// 2. Sort by tier priority (1 first)
eligibleGroups.sort((a, b) =>
tierMap[a.tierName].priority - tierMap[b.tierName].priority
);
// 3. Select first group with available variations
for (const group of eligibleGroups) {
const variation = await variations.selectByWeight(group.id);
if (variation) return variation;
}
return null; // No ads available
}
// Weighted selection (A/B testing)
const selected = await variations.selectByWeight(groupId);
// Weight 70 vs 30 = 70% chance of first variationImmutable Event Tracking
AdEvent is create-only -- no update or delete in API/MCP. Event types: impression, click, conversion. cli: false due to high volume.
// Track events (immutable -- create only)
await events.create({
eventType: AdEventType.IMPRESSION,
variationId: variation.id,
zoneId: zoneId,
siteId: propertyId,
});
await events.create({
eventType: AdEventType.CLICK,
variationId: variation.id,
zoneId: zoneId,
siteId: propertyId,
});Cross-Package References
All cross-package links use plain string IDs (no @foreignKey()) to avoid circular
dependencies:
contractIdโ smrt-commercezoneId,siteIdโ smrt-propertiesassetIdโ smrt-assetsverticalSlugโ smrt-tags
Tenancy
This package uses a mixed tenancy policy: the three transactional models that
participate in per-tenant ad serving and event tracking apply @TenantScoped({ mode: 'optional' }), while two catalog/lookup tables are
deliberately global.
| Model | Tenancy | Rationale |
|---|---|---|
AdGroup | @TenantScoped({ mode: 'optional' }) | Per-tenant campaign with budgets and targeting. |
AdVariation | @TenantScoped({ mode: 'optional' }) | Per-tenant creative variant with weighted selection. |
AdEvent | @TenantScoped({ mode: 'optional' }) | Per-tenant immutable impression/click/conversion log. |
AdFormat | NOT tenant-scoped | IAB standard dimensions are an industry catalog (728ร90 Leaderboard, 300ร250 Medium Rectangle, โฆ), not a tenant-specific configuration. |
AdDeliveryTier | NOT tenant-scoped | Tier ordering is part of the package's ad-serving contract (Sponsorship โ Standard โ House). Per-tenant tier definitions would fragment the selection algorithm without a clear use case. |
Each @smrt(...) block on AdFormat and AdDeliveryTier carries an inline comment pointing back to this rationale. This mirrors the documented exception
pattern used in smrt-secrets for TenantKey. Either model can still be filtered by tenant manually if a deployment
needs custom overrides, but the default is global. Cross-link: the canonical rule is in docs/content/standards.md ยง7.
Best Practices
DOs
- Use lower priority numbers for premium tiers (1 = highest)
- Set reasonable weights for A/B tests (sum doesn't need to be 100)
- Track impressions immutably via
AdEventโ never mutate them - Use targeting JSON for flexible audience rules
- Link to smrt-commerce contracts for billing
- Refresh denormalized
impressions/clicksfromAdEventaggregates when exactness matters
DON'Ts
- Don't modify
AdEventrecords โ immutable by design (no update/delete in API/MCP) - Don't set
weightto 0 โ that makes the variation unselectable - Don't expect frequency capping or budget enforcement โ external responsibility
- Don't trust targeting JSON shape โ there is no schema validation
- Don't forget to filter eligibility by date range and status
- Don't tenant-scope
AdFormat/AdDeliveryTierโ they are shared catalogs