@happyvertical/smrt-ads

Ad delivery with priority waterfall, weighted A/B testing, and immutable event tracking.

v0.29.34WaterfallA/B TestingIAB FormatsMixed Tenancy

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

bash
npm install @happyvertical/smrt-ads

Quick Start

typescript
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
typescript
// @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.

typescript
// @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)

typescript
// @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.

typescript
// @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.

typescript
// @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

typescript
// 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 variation

Immutable Event Tracking

AdEvent is create-only -- no update or delete in API/MCP. Event types: impression, click, conversion. cli: false due to high volume.

typescript
// 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:

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.

ModelTenancyRationale
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.
AdFormatNOT tenant-scopedIAB standard dimensions are an industry catalog (728ร—90 Leaderboard, 300ร—250 Medium Rectangle, โ€ฆ), not a tenant-specific configuration.
AdDeliveryTierNOT tenant-scopedTier 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/clicks from AdEvent aggregates when exactness matters

DON'Ts

  • Don't modify AdEvent records โ€” immutable by design (no update/delete in API/MCP)
  • Don't set weight to 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

Related Modules