Skip to content

Queue Producers

This document describes the producer-side implementation of the automation system - how the main SvelteKit application emits events and schedules time-based jobs for worker consumption.

Architecture Pattern

The automation system uses a database-backed queue pattern:

Key Insight: The producer does NOT send queue messages directly. Instead, it writes records to database tables. Workers poll these tables and send queue messages for execution.

Source Files

FilePurpose
src/lib/automation/events.tsEvent-based automation emission
src/lib/automation/scheduler.tsTime-based job scheduling
src/lib/automation/index.tsModule exports

Event-Based Producers

Core Function: emitAutomationEvent()

The main producer function for event-based automations.

typescript
import { emitAutomationEvent } from '$lib/automation/events';

const result = await emitAutomationEvent(supabase, {
	trigger_type: 'booking.confirmed',
	podcast_id: booking.podcast_id,
	booking_id: booking.id,
	episode_id: booking.episode_id,
	guest_id: booking.guest_id
});

What it does:

  1. Queries automation_rules for enabled rules matching the trigger type
  2. Builds full context via buildFullContext() (fetches podcast, booking, episode, guest data)
  3. Creates automation_executions records with status pending
  4. Returns count of executions created

AutomationEventPayload Interface

typescript
interface AutomationEventPayload {
	trigger_type: AutomationTriggerType;
	podcast_id: string;
	episode_id?: string | null;
	booking_id?: string | null;
	guest_id?: string | null;
	context?: Partial<MagicTagContext>; // Additional context data
}

EmitEventResult Interface

typescript
interface EmitEventResult {
	success: boolean;
	executions_created: number; // How many execution records were created
	rule_ids: string[]; // Which rules matched and were triggered
	error?: string; // Error message if failed
}

Helper Functions

Convenience wrappers for common event types:

typescript
// Booking events
await emitBookingEvent(supabase, 'booking.confirmed', bookingId, podcastId, episodeId, guestId);
await emitBookingEvent(supabase, 'booking.declined', bookingId, podcastId);
await emitBookingEvent(supabase, 'booking.canceled', bookingId, podcastId);
await emitBookingEvent(supabase, 'booking.rescheduled', bookingId, podcastId, episodeId, guestId);

// Episode events
await emitEpisodeEvent(supabase, 'episode.published', episodeId, podcastId);
await emitEpisodeEvent(supabase, 'episode.scheduled', episodeId, podcastId);
await emitEpisodeEvent(supabase, 'episode.draft_created', episodeId, podcastId);

// Guest events
await emitGuestEvent(supabase, 'guest.responded', guestId, episodeId, podcastId);
await emitGuestEvent(supabase, 'guest.reminder_sent', guestId, episodeId, podcastId);

Time-Based Producers

Core Function: scheduleTimeBasedJobs()

Creates scheduled job records for time-based automation rules.

typescript
import { scheduleTimeBasedJobs } from '$lib/automation/scheduler';

const result = await scheduleTimeBasedJobs(supabase, {
	podcast_id: podcastId,
	episode_id: episodeId,
	booking_id: bookingId,
	guest_id: guestId,
	recording_date: new Date(booking.start_time),
	publish_date: episode.scheduled_for ? new Date(episode.scheduled_for) : null,
	booking_date: new Date(),
	timezone: booking.timezone
});

What it does:

  1. Queries automation_rules for enabled time-based rules (trigger types starting with time.)
  2. Calculates scheduled_for timestamp based on reference date and offset
  3. Creates automation_scheduled_jobs records with status pending
  4. Handles idempotency via unique constraint on idempotency_key

Time-Based Trigger Types

Trigger TypeReference FieldExample
time.before_recordingrecording_date24h before start_time
time.after_recordingrecording_date1h after start_time
time.before_publishpublish_date48h before scheduled_for
time.after_publishpublish_date1h after published_at
time.after_bookingbooking_date5min after confirmation

TimeBasedSchedulingContext Interface

typescript
interface TimeBasedSchedulingContext {
	podcast_id: string;
	episode_id?: string | null;
	booking_id?: string | null;
	guest_id?: string | null;
	recording_date?: Date | null; // For recording-related triggers
	publish_date?: Date | null; // For publish-related triggers
	booking_date?: Date | null; // For booking-related triggers
	timezone?: string; // For time calculations
}

ScheduleResult Interface

typescript
interface ScheduleResult {
	success: boolean;
	jobs_created: number; // New scheduled jobs created
	jobs_skipped: number; // Skipped (no reference date, already exists)
	rule_ids: string[]; // Rules that got scheduled
	error?: string;
}

Specialized Schedulers

typescript
// For booking confirmations
await scheduleTimeBasedJobsForBooking(
	supabase,
	bookingId,
	podcastId,
	episodeId, // May be null
	guestId, // May be null
	recordingDate, // booking.start_time
	bookingDate, // new Date() - when booking was confirmed
	timezone
);

// For episode scheduling/publishing
await scheduleTimeBasedJobsForEpisode(
	supabase,
	episodeId,
	podcastId,
	publishDate, // episode.scheduled_for or published_at
	timezone
);

Cancellation and Rescheduling

typescript
// Cancel pending jobs when booking/episode is cancelled
await cancelPendingTimeBasedJobs(supabase, {
	episode_id: episodeId,
	// OR
	booking_id: bookingId,
	// OR
	podcast_id: podcastId // Cancels ALL for podcast
});

// Reschedule when dates change (cancels + creates new)
await rescheduleTimeBasedJobsForEpisode(
	supabase,
	episodeId,
	podcastId,
	newRecordingDate, // null to skip recording triggers
	newPublishDate, // null to skip publish triggers
	bookingId,
	guestId,
	timezone
);

Context Building

Both producers automatically enrich events with database data before storing:

Data Fetched

SourceFields Used
podcaststitle, slug
bookingsguest_name, guest_email, guest_phone, start_time, timezone, meeting_url
booking_linksname
episodestitle, episode_number, description, status, scheduled_for, published_at
episode_guestsname, email, access_token

Context Snapshot

The enriched context is stored as JSON in:

  • automation_executions.context_data (event-based)
  • automation_scheduled_jobs.context_snapshot (time-based)

This ensures workers have all necessary data without additional DB queries.

Idempotency

Event-Based Idempotency Key

Format: {rule_id}:{trigger_type}:{podcast_id}:{episode_id}:{booking_id}:{guest_id}:{date}

  • Includes date (YYYY-MM-DD) to allow same event daily (for digests)
  • Database UNIQUE constraint prevents duplicates
  • Returns success with 0 executions if duplicate detected

Time-Based Idempotency Key

Format: time_trigger:{rule_id}:{trigger_type}:{entity_id}:{reference_datetime_ISO}

  • Uses full ISO timestamp to support same-day reschedules
  • If user reschedules from 14:00 to 15:45, gets different keys
  • Entity ID is booking_id || episode_id || podcast_id

Real-World Usage

Booking Confirmation Flow

typescript
// src/api/routes/bookings/index.ts (simplified)
async function confirmBooking(bookingId: string) {
	// ... booking logic ...

	// 1. Emit event-based automations (immediate)
	const eventResult = await emitBookingEvent(
		supabase,
		'booking.confirmed',
		bookingId,
		booking.podcast_id,
		booking.episode_id,
		booking.guest_id
	);

	// 2. Schedule time-based automations (delayed)
	const scheduleResult = await scheduleTimeBasedJobsForBooking(
		supabase,
		bookingId,
		booking.podcast_id,
		booking.episode_id,
		booking.guest_id,
		new Date(booking.start_time),
		new Date(),
		booking.timezone
	);

	console.log(
		`Automation: ${eventResult.executions_created} immediate, ${scheduleResult.jobs_created} scheduled`
	);
}

Episode Rescheduling Flow

typescript
// When episode recording or publish date changes
async function updateEpisodeDates(episodeId: string, newDates: Partial<Episode>) {
	// ... update episode ...

	// Reschedule all time-based automations
	const result = await rescheduleTimeBasedJobsForEpisode(
		supabase,
		episodeId,
		episode.podcast_id,
		newDates.recording_scheduled_at ? new Date(newDates.recording_scheduled_at) : null,
		newDates.scheduled_for ? new Date(newDates.scheduled_for) : null,
		episode.booking_id,
		episode.guest_id,
		episode.timezone
	);

	console.log(`Rescheduled: ${result.jobs_cancelled} cancelled, ${result.jobs_created} created`);
}

Database Tables Used

TableWritten ByRead By
automation_rulesUI/APIProducers (for rule matching)
automation_executionsEvent producersExecutor worker
automation_scheduled_jobsTime producersScheduler worker

Design Patterns

PatternImplementation
Database-Backed QueueRecords in DB, workers poll and process
IdempotencyUNIQUE constraint on idempotency_key columns
Context CaptureAll data captured upfront, no worker DB queries for context
Fire-and-ForgetProducers don't wait for execution results
Two-Phase DispatchEvent → immediate; Time → scheduled for later
Admin ClientProducers use service role key to bypass RLS

Error Handling

Producers return structured results instead of throwing:

typescript
const result = await emitAutomationEvent(supabase, payload);

if (!result.success) {
	console.error('Automation failed:', result.error);
	// Non-blocking: don't fail the main operation
}

Internal documentation - Not for public distribution