Claude Code Plugins

Community-maintained marketplace

Feedback

airtable-sync-specialist

@antoineschaller/cortex-skills
0
0

Debug and implement Airtable synchronization logic including duplicate prevention, cache management, change detection, and RLS considerations; use when debugging sync failures, stale cache issues, or implementing new Airtable sync features

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name airtable-sync-specialist
description Debug and implement Airtable synchronization logic including duplicate prevention, cache management, change detection, and RLS considerations; use when debugging sync failures, stale cache issues, or implementing new Airtable sync features
version 1.0.0

Airtable Sync Specialist

When to Use This Skill

Use this skill when:

  • Debugging Airtable sync failures or errors
  • Investigating duplicate entity creation
  • Troubleshooting stale cache issues
  • Implementing new Airtable sync features
  • Analyzing change detection logic
  • Working with airtable_entity_mapping table
  • Debugging production map inconsistencies
  • Implementing client bootstrap logic

DO NOT use for:

  • General database migrations (use database-migration-manager)
  • Non-Airtable related sync issues
  • Client-side data fetching

Architecture Overview

Core Components

  1. DuplicatePreventionService (duplicate-prevention.service.ts)

    • Prevents duplicate entities using Airtable UniqueID
    • Validates cached entities still exist
    • Auto-cleanup of stale cache entries
  2. ChangeDetectorService (change-detector.service.ts)

    • Detects changes between new Airtable data and cached data
    • Determines if entity needs updating
  3. AirtableSyncService (airtable-sync.service.ts)

    • Main orchestration service
    • Coordinates duplicate prevention, change detection, entity creation
  4. ClientManager (client-manager.ts)

    • Ensures Fever client exists for Airtable sync
    • Bootstrap agent for client setup
  5. RatingSyncService (rating-sync.service.ts)

    • Syncs ratings from Airtable "Ratings" table
    • Handles rating-specific deduplication

Key Tables

-- Entity mapping cache (deduplication)
CREATE TABLE airtable_entity_mapping (
  airtable_unique_id text PRIMARY KEY,
  entity_type text NOT NULL, -- 'event' | 'venue' | 'production'
  entity_id uuid NOT NULL,
  airtable_data jsonb,
  first_synced_at timestamptz,
  last_synced_at timestamptz
);

-- Fever client (external organization)
CREATE TABLE clients (
  id uuid PRIMARY KEY,
  name text NOT NULL,
  slug text UNIQUE NOT NULL,
  type text -- 'agency' | 'venue' | 'company'
);

-- Productions owned by clients
CREATE TABLE productions (
  id uuid PRIMARY KEY,
  client_id uuid REFERENCES clients(id),
  name text NOT NULL,
  -- ...
);

-- Events inherit client through productions
CREATE TABLE events (
  id uuid PRIMARY KEY,
  production_id uuid REFERENCES productions(id) NOT NULL,
  -- Note: account_id removed in October 2025 migration
  -- Events inherit client relationship through production
);

Critical Fix: Stale Cache Bug (2025-10-20)

The Problem

Symptom: Airtable sync creates duplicate events even though airtable_entity_mapping shows entity already exists.

Root Cause: findExistingEntity() returned cached entity_id without validating that the entity still exists in the target table. When events/productions/venues were deleted, the cache entry remained, causing sync to skip creation and later fail.

Impact: Events failed to sync from Airtable, production map showed "event ID not found" errors.

The Solution

File: apps/web/app/admin/sync/_lib/server/duplicate-prevention.service.ts:19

async findExistingEntity(
  airtableUniqueId: string,
  entityType: 'event' | 'venue' | 'production',
): Promise<string | null> {
  // Step 1: Check cache
  const { data } = await this.client
    .from('airtable_entity_mapping')
    .select('entity_id')
    .eq('airtable_unique_id', airtableUniqueId)
    .eq('entity_type', entityType)
    .single();

  if (!data?.entity_id) return null;

  const cachedId = data.entity_id;

  // Step 2: CRITICAL - Validate entity still exists in target table
  const tableName = entityType === 'production'
    ? 'productions'
    : entityType === 'venue'
      ? 'venues'
      : 'events';

  const { data: entity } = await this.client
    .from(tableName)
    .select('id')
    .eq('id', cachedId)
    .maybeSingle();

  if (!entity) {
    // Stale cache detected - auto-cleanup
    console.warn(`🧹 Stale cache detected for ${entityType} ${cachedId}`);
    await this.deleteEntityMapping(airtableUniqueId);
    return null; // Force entity recreation
  }

  return cachedId; // Valid cached entity
}

Key Points:

  • βœ… Always validate cached entity exists before returning ID
  • βœ… Auto-cleanup stale cache entries
  • βœ… Log warnings for debugging
  • βœ… Return null to force recreation when stale

Common Sync Issues & Debugging

Issue 1: Duplicate Events Created

Symptoms:

  • Multiple events with same name/date from Airtable
  • airtable_entity_mapping shows duplicates

Debugging Steps:

-- Check for duplicate mappings
SELECT airtable_unique_id, COUNT(*)
FROM airtable_entity_mapping
WHERE entity_type = 'event'
GROUP BY airtable_unique_id
HAVING COUNT(*) > 1;

-- Find events without cache entries
SELECT e.id, e.name, e.event_date
FROM events e
LEFT JOIN airtable_entity_mapping aem
  ON aem.entity_id = e.id AND aem.entity_type = 'event'
WHERE e.created_at > NOW() - INTERVAL '1 day'
  AND aem.airtable_unique_id IS NULL;

Solution:

  1. Review DuplicatePreventionService.registerEntity()
  2. Ensure Airtable UniqueID is correctly generated
  3. Check for race conditions in parallel sync

Issue 2: Stale Cache Entries

Symptoms:

  • Sync skips creating entities but they don't exist
  • "Event ID not found" errors in production map
  • Cache shows entity_id but table query returns null

Debugging Steps:

# Run diagnostic script
pnpm tsx .claude/skills/airtable-sync-specialist/scripts/diagnose-sync-cache.ts

# Check for stale cache entries manually
pnpm sql:local "
  SELECT aem.*,
         CASE
           WHEN e.id IS NULL THEN 'STALE'
           ELSE 'VALID'
         END as status
  FROM airtable_entity_mapping aem
  LEFT JOIN events e ON e.id = aem.entity_id
  WHERE aem.entity_type = 'event'
    AND e.id IS NULL;
"

Solution:

  1. Upgrade DuplicatePreventionService to validate entities (already implemented)
  2. Run cleanup script to remove stale entries
  3. Re-sync from Airtable

Issue 3: RLS Policy Blocks Sync

Symptoms:

  • Sync fails with "permission denied" or "new row violates row-level security"
  • Works locally but fails in production

Debugging Steps:

# Check RLS policies
pnpm sql:local --inspect events
pnpm sql:local --inspect airtable_entity_mapping

# Test as super admin
pnpm tsx .claude/skills/rls-policy-generator/scripts/diagnose-fever-client-rls.ts

Common RLS Issues:

  • airtable_entity_mapping needs super admin INSERT policy
  • Events need super admin bypass for Airtable sync
  • Client-based ownership not properly configured

Solution:

-- Add super admin bypass to airtable_entity_mapping
CREATE POLICY airtable_mapping_insert ON airtable_entity_mapping
FOR INSERT TO authenticated
WITH CHECK (
  public.is_super_admin() OR
  auth.uid() = created_by -- if you track creator
);

-- Events should allow super admin to create via sync
CREATE POLICY events_insert ON events
FOR INSERT TO authenticated
WITH CHECK (
  public.is_super_admin() OR
  -- other conditions
);

Issue 4: Client Not Found

Symptoms:

  • Sync fails with "client 'fever' not found"
  • Production creation fails

Debugging Steps:

-- Check if Fever client exists
SELECT * FROM clients WHERE slug = 'fever';

-- Check default client config
SELECT * FROM clients WHERE slug = 'default-airtable-client';

Solution:

// Ensure client via ClientManager
import { ensureClient } from '~/app/admin/sync/_lib/server/client-manager';

const client = await ensureClient(supabaseClient);
// Creates Fever client if not exists

File: apps/web/app/admin/sync/_lib/server/client-manager.ts:29

Issue 5: Change Detection Not Working

Symptoms:

  • Sync doesn't update entities when Airtable data changes
  • Entities stuck with old data

Debugging Steps:

// Check change detection logic
import { ChangeDetectorService } from '~/app/admin/sync/_lib/server/change-detector.service';

const detector = new ChangeDetectorService();
const hasChanges = detector.hasChanges(
  newAirtableData,
  cachedAirtableData,
  entityType
);

console.log('Changes detected:', hasChanges);
console.log('New data:', newAirtableData);
console.log('Cached data:', cachedAirtableData);

Solution:

  1. Review which fields are compared
  2. Ensure field names match between Airtable and cache
  3. Check data type transformations

Airtable Sync Workflow

Standard Sync Flow

1. Fetch from Airtable
   ↓
2. Ensure Fever client exists (ClientManager)
   ↓
3. For each Airtable record:
   ↓
   a. Generate UniqueID
   ↓
   b. Check cache (DuplicatePreventionService.findExistingEntity)
      ↓
      EXISTS? β†’ c. Check for changes (ChangeDetectorService)
                ↓
                CHANGES? β†’ d. Update entity
                         β†’ e. Update cache
                NO? β†’ Skip (already synced)
      ↓
      NOT EXISTS? β†’ f. Create entity
                   β†’ g. Register in cache

Production Sync Example

import { AirtableSyncService } from '~/app/admin/sync/_lib/server/airtable-sync.service';
import { ensureClient } from '~/app/admin/sync/_lib/server/client-manager';

// 1. Ensure Fever client exists
const client = await ensureClient(supabaseClient);

// 2. Initialize sync service
const syncService = new AirtableSyncService(
  supabaseClient,
  client.id // Fever client ID
);

// 3. Sync productions from Airtable
const result = await syncService.syncProductions(airtableRecords);

if (result.success) {
  console.log('Synced:', result.created, 'created,', result.updated, 'updated');
} else {
  console.error('Sync failed:', result.error);
}

Diagnostic Scripts

diagnose-sync-cache.ts

Purpose: Detect stale cache entries Usage: pnpm tsx .claude/skills/airtable-sync-specialist/scripts/diagnose-sync-cache.ts What it checks:

  • Cache entries pointing to deleted entities
  • Orphaned cache entries
  • Duplicate mappings

test-airtable-sync-local.ts

Purpose: Test sync logic with local Airtable data Usage: pnpm tsx .claude/skills/airtable-sync-specialist/scripts/test-airtable-sync-local.ts What it does:

  • Tests sync against local database
  • Validates duplicate prevention
  • Checks RLS policies

diagnose-fever-client-rls.ts

Purpose: Debug Fever client RLS access Usage: pnpm tsx .claude/skills/rls-policy-generator/scripts/diagnose-fever-client-rls.ts What it checks:

  • Client exists and accessible
  • Productions linked to client
  • Events linked to productions

Best Practices

Duplicate Prevention

  1. βœ… Always use Airtable UniqueID - Don't rely on event names or dates
  2. βœ… Validate cached entities - Check entity exists before trusting cache
  3. βœ… Auto-cleanup stale entries - Remove cache when entity deleted
  4. βœ… Log all cache operations - Makes debugging easier

RLS Considerations

  1. βœ… Use super admin client for sync - Bypass account-based RLS
  2. βœ… Test RLS policies explicitly - Write tests for sync scenarios
  3. βœ… Document RLS exceptions - Why super admin bypass is needed
  4. βœ… Validate permissions in code - Don't rely solely on RLS

Change Detection

  1. βœ… Compare meaningful fields only - Ignore Airtable metadata changes
  2. βœ… Normalize data before comparison - Handle date formats, timezones
  3. βœ… Log detected changes - Show what triggered update
  4. βœ… Batch updates - Don't update one field at a time

Error Handling

  1. βœ… Log with context - Include Airtable UniqueID, entity type, IDs
  2. βœ… Fail gracefully - Continue sync even if one entity fails
  3. βœ… Track sync results - Return counts of created/updated/failed
  4. βœ… Alert on persistent failures - Integrate with Sentry

Common Code Patterns

Safe Entity Lookup

// βœ… CORRECT - Validates entity exists
const existingId = await duplicatePrevention.findExistingEntity(
  airtableUniqueId,
  'event'
);

if (existingId) {
  // Entity exists and is valid
  const hasChanges = changeDetector.hasChanges(newData, cachedData, 'event');

  if (hasChanges) {
    // Update entity
  }
} else {
  // Create new entity
}

// ❌ WRONG - Trusts cache without validation
const { data } = await supabase
  .from('airtable_entity_mapping')
  .select('entity_id')
  .eq('airtable_unique_id', airtableUniqueId)
  .single();

if (data) {
  // Assumes entity exists - may be stale!
}

Idempotent Sync

// βœ… CORRECT - Can run multiple times safely
async function syncProduction(airtableRecord: AirtableProduction) {
  const uniqueId = generateUniqueId(airtableRecord);

  // Check if exists (validates entity still exists)
  const existingId = await duplicatePrevention.findExistingEntity(
    uniqueId,
    'production'
  );

  if (existingId) {
    // Update if changed
    if (changeDetector.hasChanges(newData, cachedData, 'production')) {
      await updateProduction(existingId, newData);
      await duplicatePrevention.updateCache(uniqueId, newData);
    }
  } else {
    // Create new
    const newId = await createProduction(newData);
    await duplicatePrevention.registerEntity(uniqueId, 'production', newId);
  }
}

Batch Processing with Error Handling

// βœ… CORRECT - Handles failures gracefully
async function syncBatch(records: AirtableRecord[]) {
  const results = {
    created: 0,
    updated: 0,
    failed: 0,
    errors: [] as Array<{ record: string; error: string }>
  };

  for (const record of records) {
    try {
      const result = await syncSingleRecord(record);
      if (result.created) results.created++;
      if (result.updated) results.updated++;
    } catch (error) {
      results.failed++;
      results.errors.push({
        record: record.id,
        error: error.message
      });

      // Log but continue processing
      console.error(`Failed to sync record ${record.id}:`, error);
    }
  }

  return results;
}

Testing Sync Logic

Unit Tests

describe('DuplicatePreventionService', () => {
  test('validates cached entity exists', async () => {
    const service = new DuplicatePreventionService(adminClient);

    // Create cache entry for non-existent entity
    await adminClient.from('airtable_entity_mapping').insert({
      airtable_unique_id: 'test-unique-id',
      entity_type: 'event',
      entity_id: 'non-existent-uuid'
    });

    // Should return null and cleanup cache
    const result = await service.findExistingEntity('test-unique-id', 'event');

    expect(result).toBeNull();

    // Verify cache was cleaned up
    const { data } = await adminClient
      .from('airtable_entity_mapping')
      .select()
      .eq('airtable_unique_id', 'test-unique-id')
      .maybeSingle();

    expect(data).toBeNull();
  });
});

Integration Tests

describe('Airtable Sync Integration', () => {
  test('syncs production from Airtable', async () => {
    const syncService = new AirtableSyncService(adminClient, feverClientId);

    const airtableData = {
      id: 'airtable-prod-123',
      fields: {
        Name: 'Swan Lake',
        Type: 'Ballet',
        // ...
      }
    };

    const result = await syncService.syncProductions([airtableData]);

    expect(result.success).toBe(true);
    expect(result.created).toBe(1);

    // Verify production created
    const { data: production } = await adminClient
      .from('productions')
      .select()
      .eq('name', 'Swan Lake')
      .single();

    expect(production).toBeDefined();

    // Verify cache entry
    const { data: mapping } = await adminClient
      .from('airtable_entity_mapping')
      .select()
      .eq('airtable_unique_id', generateUniqueId(airtableData))
      .single();

    expect(mapping.entity_id).toBe(production.id);
  });
});

Airtable Access Configuration (Multi-Table Support)

Environment Variables Setup

The Ballee system supports accessing multiple Airtable tables from the same base using a single API key. All configuration is managed through environment variables.

Base Configuration (Required for All Tables)

# Airtable API Authentication
AIRTABLE_API_KEY=key_xxxxx              # Personal access token from Airtable

# Base ID (workspace + base identifier)
AIRTABLE_BASE_ID=appxxxxx              # Application/Base ID from URL

How to get these:

  1. AIRTABLE_API_KEY:

  2. AIRTABLE_BASE_ID:

    • Open Airtable base in browser
    • URL format: https://airtable.com/appXXXXXX/...
    • Extract the appXXXXXX portion

Table Configuration (Per Table)

Primary Table (Ballee Dates - "Fever" client events):

# Optional: Explicitly configure primary table
AIRTABLE_TABLE_NAME=Ballee Dates       # Table name in Airtable (default)

# Client configuration for Fever sync
AIRTABLE_CLIENT_SLUG=fever              # Client slug in database
AIRTABLE_CLIENT_NAME=Fever              # Display name
AIRTABLE_CLIENT_EMAIL=contact@fever.co  # Contact email
AIRTABLE_CLIENT_TYPE=agency             # Client type (agency, venue, company)
AIRTABLE_CLIENT_CONTACT_NAME=Fever Team # Contact person name

Secondary Tables (Ratings, Reports, etc.):

If you need to access different tables in the same base, modify the sync service to accept table names as parameters:

// Current: Hard-coded to use AIRTABLE_TABLE_NAME or default "Ballee Dates"
const tableName = process.env.AIRTABLE_TABLE_NAME || 'Ballee Dates';
const records = await base(tableName).select().all();

// Future: Make table name configurable per sync operation
async function fetchTableData(tableName: string) {
  const airtable = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY });
  const base = airtable.base(process.env.AIRTABLE_BASE_ID);
  return base(tableName).select().all();
}

Multi-Table Sync Architecture

Current Setup (Single Client - Fever)

Airtable Base (appXXXXXX)
β”‚
β”œβ”€ Table: "Ballee Dates" (Primary)
β”‚  β”œβ”€ Fields: Date, City_linked, Venue, Sub-Programm, Starttime: 1. Show, Starttime: 2. Show, Status
β”‚  β”œβ”€ Client: Fever (AIRTABLE_CLIENT_SLUG=fever)
β”‚  └─ Mapped to: events, productions, venues
β”‚
└─ Table: "Ratings" (Secondary)
   β”œβ”€ Fields: Name, Date, Show Time, Rating, Comment
   β”œβ”€ Client: Fever (same)
   └─ Mapped to: event_ratings

Adding New Tables

Use Case: Sync from additional Airtable tables for same or different clients:

  1. Same Base, Different Tables:

    // Extend AirtableSyncService to accept table name
    class AirtableSyncService {
      async syncFromTable(
        tableName: string,
        options: SyncOptions
      ): Promise<SyncResult> {
        // Fetch from specified table
        const records = await this.fetchTableData(tableName);
    
        // Parse and sync based on table schema
        return this.processRecords(records, options);
      }
    }
    
  2. Different Bases:

    // Add environment variable for second base
    AIRTABLE_BASE_ID_SECONDARY=appYYYYYY
    
    // Create separate sync instance
    const secondarySync = new AirtableSyncService(
      supabaseClient,
      clientId,
      process.env.AIRTABLE_BASE_ID_SECONDARY  // Different base
    );
    
  3. Different Clients:

    // Each client can have separate Airtable configuration
    AIRTABLE_CLIENT_SLUG=fever              # Client 1
    AIRTABLE_CLIENT_SLUG_2=another-org      # Client 2
    AIRTABLE_BASE_ID_2=appZZZZZZ            # Different base
    

Discovering All Tables in Your Airtable Base

The discover-all-tables.ts script uses the Airtable Metadata API to list all tables in your base, including their fields and types. This is essential for:

  • Identifying all available tables for syncing
  • Finding table IDs and field names
  • Planning multi-table sync strategies
  • Validating your Airtable schema

How to Use Discovery Script

Run the discovery:

# From project root
cd apps/web
npx tsx scripts/discover-all-tables.ts

Output Example:

πŸ” Discovering all tables in Airtable base...

Base ID: appXXXXXXXXXXXXXX

βœ… Found 5 table(s) in the base:

1. Ballee Dates
   ID: tblXXXXXXXXXXXXXX
   Description: Performance schedule for all shows
   Fields: 8
   Field names:
     - Date (date)
     - City_linked (singleCollaborator)
     - Venue (singleLineText)
     - Sub-Programm (singleLineText)
     - Starttime: 1. Show (duration)
     - Starttime: 2. Show (duration)
     - Status (singleSelect)
     - UniqueID (singleLineText)

2. Ratings
   ID: tblYYYYYYYYYYYYYY
   Description: Performance ratings and reviews
   Fields: 5
   Field names:
     - Name (singleLineText)
     - Date (date)
     - Show Time (duration)
     - Rating (number)
     - Comment (multilineText)

3. Cities
   ID: tblZZZZZZZZZZZZZZ
   Description: City reference data
   Fields: 3
   Field names:
     - Name (singleLineText)
     - Code (singleLineText)
     - Country (singleLineText)

4. Venues
   ID: tblVVVVVVVVVVVVVV
   Description: Venue reference data
   Fields: 4
   Field names:
     - Name (singleLineText)
     - City (singleLineText)
     - Address (singleLineText)
     - Capacity (number)

5. Productions
   ID: tblPPPPPPPPPPPPPP
   Description: Production/program reference data
   Fields: 3
   Field names:
     - Name (singleLineText)
     - Type (singleSelect)
     - Description (multilineText)

⭐ Found 1 ratings-related table(s):
   - Ratings (tblYYYYYYYYYYYYYY)

Discovery Output Breakdown

For Each Table, You Get:

  1. Name: Display name in Airtable (e.g., "Ballee Dates")
  2. ID: Table identifier (e.g., tblXXXXXXXXXXXXXX) - Used in API calls
  3. Description: Optional description from Airtable
  4. Field Count: Total number of fields
  5. Field Names & Types: First 10 fields shown with types:
    • singleLineText - Single line text
    • multilineText - Long text
    • date - Date field
    • duration - Time duration
    • number - Numeric field
    • singleSelect - Dropdown with single selection
    • singleCollaborator - Linked person/user
    • And many more...

Using Discovery Results for Syncing

Once you discover tables, you can configure sync for any of them:

// Example: Sync from Venues table instead of Ballee Dates
const venuesTableId = 'tblVVVVVVVVVVVVVV';
const venuesTableName = 'Venues';

const records = await base(venuesTableName).select().all();

// Process venue records
for (const record of records) {
  const venueName = record.fields['Name'];
  const city = record.fields['City'];
  const capacity = record.fields['Capacity'];

  // Sync to Ballee venues table
  await syncVenue({
    name: venueName,
    city,
    capacity
  });
}

Common Table Types You'll Find

Primary Data Tables (usually sync to Ballee):

  • Ballee Dates - Performance schedule (primary - maps to events/productions/venues)
  • Ratings - Reviews and ratings (maps to event_ratings)
  • Invoices - Financial records (maps to invoices)

Reference Tables (lookup/linked data):

  • Cities - Location reference
  • Venues - Venue reference
  • Productions - Program reference
  • Artists - Performer reference
  • Categories - Type classification

Administrative Tables (internal tracking):

  • Sync Logs - Airtable-side sync history
  • Error Log - Failed record tracking
  • Configuration - Airtable settings

Complete Table Reference Map - ACTUAL TABLES (Updated 2025-11-24)

Permissions Status: πŸ”’ READ-ONLY (data.records:read, schema.bases:read)

  • βœ… Can READ records from all tables
  • βœ… Can READ table schema and metadata
  • ❌ Cannot WRITE records (permission denied)
  • ❌ Cannot DELETE records (permission denied)
Table Name Table ID Purpose Fields Sync Status Permissions
Ballee Dates tblIsDWRcG6w9Dre9 Performance schedule Date, City_linked, Venue, Sub-Programm, Starttime: 1. Show, Starttime: 2. Show, Status, Aurora/Cinderella (+ 8 more) βœ… ACTIVE πŸ”’ READ-ONLY
Dancer_Data tbl8cQHnbsT74Wj0T Artist/dancer information Artist's name, Where based, Status, Start Date, End Date, Email, Phone, Address, ID/Passport, Ballee link (+ 22 more) ⏸️ NOT SYNCED πŸ”’ READ-ONLY
Ratings tblTQDEu8igwQpV1t Performance reviews Name, Date, Show Time, Rating, Comment βœ… ACTIVE πŸ”’ READ-ONLY

Note: Only 3 tables exist in this Airtable base (Fever workspace). The "Cities," "Venues," "Productions," and other example tables mentioned in earlier documentation are NOT present - those were documented as common table types for reference only.

How to Find Tables You Need

Step 1: Run Discovery Script

cd apps/web && npx tsx scripts/discover-all-tables.ts

Step 2: Identify Your Target Table

  • Look for table name and description
  • Note the table ID (starts with tbl)
  • Review the fields available

Step 3: Map Fields to Ballee

// Example: Mapping Invoices table to Ballee invoices
const airtableInvoices = await fetchAirtableTable('Invoices');

const mappedInvoices = airtableInvoices.map(record => ({
  // Airtable field β†’ Ballee field
  externalId: record.fields['Invoice#'],
  date: new Date(record.fields['Date']),
  amount: record.fields['Amount'],
  clientName: record.fields['Client'],
  status: record.fields['Status']
}));

Step 4: Implement Sync

// Generic table sync function
async function syncGenericTable(
  tableName: string,
  mapFunction: (record: any) => object
) {
  const records = await base(tableName).select().all();

  for (const record of records) {
    const mapped = mapFunction(record);
    // Upsert to Ballee database
    await syncToBallee(tableName, mapped);
  }
}

Field Types Reference

When discovering tables, you'll see these Airtable field types. Here's how to handle each:

Field Type Description Example Value Sync Handling
singleLineText Single line text "Swan Lake" Use directly as string
multilineText Long text (paragraph) "The swan lake tells..." Use directly as string
email Email address "dancer@example.com" Validate format, use directly
url URL/link "https://example.com" Validate URL, use directly
number Numeric value 42, 3.14 Parse as number
percent Percentage 75 Store as decimal (0.75)
date Date only "2025-03-15" Convert to ISO 8601
duration Time duration "19:30" Parse as HH:MM or seconds
singleSelect Dropdown (single) "Live" Use as enum/string
multipleSelect Dropdown (multi) ["Option1", "Option2"] Use as array
checkbox Boolean toggle true/false Convert to boolean
singleCollaborator Person/user {name: "John", email: "..."} Extract email or name
multipleCollaborators Multiple people [{...}, {...}] Extract array of people
singleLineText Record link {id: "rec...", name: "..."} Store foreign key ID
multipleRecordLinks Multiple links [{id: "..."}, {...}] Store array of IDs
lookup Formula result (depends on formula) Handle based on result type
formula Calculated field (depends on formula) Handle based on result type
createdTime Auto timestamp "2025-01-15T10:30:00Z" Convert to ISO timestamp
lastModifiedTime Last update time "2025-01-15T10:30:00Z" Convert to ISO timestamp
createdBy Creator info {id: "...", email: "..."} Extract email for audit
lastModifiedBy Last updater {id: "...", email: "..."} Extract email for audit
autoNumber Auto-incrementing 1, 2, 3 Use directly as external ID
barcode Barcode scanner "ABC123456" Store as string
rating Star rating 4 (out of 5) Convert to 1-5 scale
count Count formula 42 Use directly as number
button Interactive button (action) Skip in sync

Example Handling Each Type:

// Type-safe field extraction
interface AirtableRow {
  fields: Record<string, unknown>;
}

function extractAirtableFields(record: AirtableRow) {
  return {
    // Text fields
    name: String(record.fields['Name']),
    description: String(record.fields['Description'] || ''),

    // Numeric fields
    capacity: Number(record.fields['Capacity']),
    rating: Number(record.fields['Rating']),

    // Date fields
    performanceDate: new Date(String(record.fields['Date'])),

    // Time/Duration
    startTime: String(record.fields['Starttime']), // e.g., "19:30"

    // Selections
    status: String(record.fields['Status']), // Single select enum
    tags: Array.isArray(record.fields['Tags'])
      ? record.fields['Tags']
      : [], // Multiple select array

    // Boolean
    isActive: record.fields['Active'] === true,

    // Linked records
    cityId: record.fields['City_linked']?.[0] || null, // First linked record

    // Collaborators
    createdBy: record.fields['Created by']?.email || null,

    // Metadata (auto fields)
    createdAt: new Date(String(record.fields['Created time'])),
    updatedAt: new Date(String(record.fields['Last modified time']))
  };
}

Troubleshooting Discovery

Error: "403 Forbidden"

The API key lacks Metadata API access. Solution:

# Create new Personal Access Token with correct scopes:
1. Go to https://airtable.com/account/tokens
2. Create new token
3. Add scope: "schema.bases:read"
4. Also add: "data.records:read"
5. Copy and use new token

Error: "401 Unauthorized"

API key is invalid or expired. Solution:

# Check your token
echo $AIRTABLE_API_KEY  # Should start with "pat_" or "key_"

# If empty, set it
export AIRTABLE_API_KEY="your_token_here"

No tables returned

Base ID might be wrong. Solution:

# Verify base ID from Airtable URL
# https://airtable.com/appXXXXXXXXXXXXXX/...
# Extract: appXXXXXXXXXXXXXX

echo $AIRTABLE_BASE_ID  # Should match the app... from URL

Accessing Fever Table

Field Mapping

The "Ballee Dates" table (Fever client) has the following field structure:

Field Name Type Usage Example
Date Date Event date 2025-03-15
City_linked Linked Records City identifier Links to cities table
Venue Text Venue name Theatre Royal
Sub-Programm Text Production name Swan Lake
Starttime: 1. Show Time First show time 19:30
Starttime: 2. Show Time (optional) Second show time 20:00
Status Single Select Event status Live, Canceled

UniqueID Generation (from constants.ts:86-92):

// Unique ID created from composite data
const uniqueId = `${date}_${venue}_${city}_${program}_${startTime1}`;

// Used for deduplication in airtable_entity_mapping

Reading Fever Table Data

import { fetchAirtableData } from '~/app/admin/sync/_lib/server/airtable-api.service';

// Fetch all Fever dates from Ballee Dates table
const { shows, venues, productions } = await fetchAirtableData();

// Shows format: ParsedShow[]
shows.forEach(show => {
  console.log(`${show.date} @ ${show.venue} (${show.city})`);
  console.log(`  Program: ${show.program}`);
  console.log(`  Show 1: ${show.startTime1}`);
  if (show.startTime2) console.log(`  Show 2: ${show.startTime2}`);
  if (show.canceled) console.log(`  Status: CANCELED`);
});

// Venues format: Map<string, VenueWithCity>
// Key: "venueName_city"
venues.forEach((venue, key) => {
  console.log(`${venue.name} (${venue.city})`);
});

// Productions format: Set<string>
productions.forEach(prod => {
  console.log(`Production: ${prod}`);
});

Syncing Fever Table to Ballee

Full Sync Workflow:

import { AirtableSyncService } from '~/app/admin/sync/_lib/server/airtable-sync.service';
import { ensureClient } from '~/app/admin/sync/_lib/server/client-manager';

// 1. Ensure Fever client exists in database
const feverClient = await ensureClient(supabaseClient, {
  slug: 'fever',
  name: 'Fever',
  email: 'contact@fever.co',
  type: 'agency',
  contactName: 'Fever Team'
});

// 2. Initialize sync service for Fever
const syncService = new AirtableSyncService(
  supabaseClient,
  feverClient.id
);

// 3. Execute full sync with change tracking
const result = await syncService.syncWithChangeTracking({
  clientId: feverClient.id,
  triggeredBy: currentUserId,        // Admin user ID
  triggerType: 'manual',             // 'manual' | 'cron' | 'webhook'
  dryRun: false,                     // Preview without applying
  notifyOnChanges: true              // Send Slack notifications
});

// 4. Check results
console.log(`
  βœ… Venues synced: ${result.venues.created} created, ${result.venues.updated} updated
  βœ… Productions synced: ${result.productions.created} created, ${result.productions.updated} updated
  βœ… Events synced: ${result.events.created} created, ${result.events.updated} updated
  βœ… Ratings synced: ${result.ratings.created} created, ${result.ratings.updated} updated
`);

if (result.errors.length > 0) {
  console.error('⚠️  Errors during sync:', result.errors);
  // Handle errors...
}

Testing Fever Table Access

Verify Token Works (before production sync):

# List all tables in base to verify access
pnpm tsx .claude/skills/production-database-query/scripts/discover-all-tables.ts

# Output:
# Tables in base appXXXXXX:
# - Ballee Dates (primary sync table)
# - Ratings (secondary table)
# - Cities (reference data)

Check Field Names (ensure schema matches):

# Inspect Ballee Dates table structure
pnpm tsx .claude/skills/airtable-sync-specialist/scripts/test-ratings-table-id.ts

# Validates:
# - Table exists and is accessible
# - All required fields present
# - Field names match constants.ts definitions

Test Sync Without Applying Changes:

// Use dryRun mode to preview changes
const result = await syncService.syncWithChangeTracking({
  clientId: feverClient.id,
  dryRun: true,  // Preview only, no database changes
  notifyOnChanges: false
});

// Review what would be synced
console.log('Would create events:', result.events.created);
console.log('Would update events:', result.events.updated);
console.log('Changes:', result.changeLog);

Troubleshooting Fever Table Access

Issue Cause Solution
"Missing Airtable configuration" AIRTABLE_API_KEY or AIRTABLE_BASE_ID not set Set environment variables
"Table not found" AIRTABLE_TABLE_NAME wrong or doesn't exist Verify exact table name in Airtable
"Field not found" Field names don't match Airtable schema Check AIRTABLE_FIELDS in constants.ts
"Permission denied" API token lacks data.records:read Create new token with correct scopes
"Invalid credentials" Wrong API key for base Verify key matches base ID
"Rate limited" Too many requests to Airtable Check AIRTABLE_CONFIG.RATE_LIMIT_PER_SECOND

API Token Permissions & Scopes

Current Token Status: πŸ”’ READ-ONLY (Limited Scope)

Actual Permissions (Tested):

βœ… data.records:read     - Can read records from all tables
βœ… schema.bases:read     - Can read table schema and metadata
❌ data.records:write    - Cannot create/update records
❌ data.records:delete   - Cannot delete records

Why READ-ONLY?

  • Token scopes intentionally limited to data.records:read + schema.bases:read
  • Prevents accidental data modifications in Fever Airtable base
  • Maintains data integrity (one-way sync FROM Airtable TO Ballee)
  • Safer for production environments

What This Means for Syncing:

  • βœ… Can sync data FROM Airtable to Ballee database
  • βœ… Can validate data against Airtable schema
  • ❌ Cannot push changes back to Airtable
  • ❌ Cannot delete Airtable records

If write access becomes necessary, contact Fever to:

  1. Create new Personal Access Token with data.records:write scope
  2. Update AIRTABLE_API_KEY environment variable
  3. Test permissions with provided verification script

Environment Variable Reference

# REQUIRED - API Authentication
AIRTABLE_API_KEY=patXXXXXXXXXXXXXX              # Personal access token

# REQUIRED - Base identification
AIRTABLE_BASE_ID=appwh9Xy2DZzbjg4J             # Fever base ID

# OPTIONAL - Table name (default: "Ballee Dates")
AIRTABLE_TABLE_NAME=Ballee Dates                # Primary sync table

# OPTIONAL - Fever client configuration
AIRTABLE_CLIENT_SLUG=fever                      # DB client slug
AIRTABLE_CLIENT_NAME=Fever                      # Display name
AIRTABLE_CLIENT_EMAIL=contact@fever.co          # Contact email
AIRTABLE_CLIENT_TYPE=agency                     # Client type
AIRTABLE_CLIENT_CONTACT_NAME=Fever Team         # Contact person

# OPTIONAL - Ratings table
RATING_TABLE_NAME=Ratings                       # Ratings sync table

Reference Files

Airtable Entity Sync

  • Duplicate prevention: apps/web/app/admin/sync/_lib/server/duplicate-prevention.service.ts
  • Change detection: apps/web/app/admin/sync/_lib/server/change-detector.service.ts
  • Main sync: apps/web/app/admin/sync/_lib/server/airtable-sync.service.ts
  • Client management: apps/web/app/admin/sync/_lib/server/client-manager.ts
  • API service: apps/web/app/admin/sync/_lib/server/airtable-api.service.ts
  • Constants & field mapping: apps/web/app/admin/sync/_lib/server/constants.ts
  • Ratings sync: apps/web/app/admin/sync/_lib/server/rating-sync.service.ts
  • Diagnostic scripts: .claude/skills/airtable-sync-specialist/scripts/ folder
  • WIP doc: docs/wip/WIP_airtable_sync_stale_cache_diagnosis_2025_10_20.md
  • Infrastructure doc: docs/infrastructure/integrations/airtable-sync.md

Dancer Deduplication System

  • Service: apps/web/app/admin/sync/_lib/server/dancer-deduplication.service.ts
  • Integration: apps/web/app/admin/sync/_lib/server/dancer-data-sync.service.ts
  • Admin page: apps/web/app/admin/sync/_components/dancer-links-page.tsx
  • Data table: apps/web/app/admin/sync/_components/dancer-links-table.tsx
  • Review dialog: apps/web/app/admin/sync/_components/dancer-link-review-dialog.tsx
  • Server actions: apps/web/app/admin/sync/_lib/server/dancer-links.actions.ts
  • Query mutations: apps/web/app/admin/sync/_lib/hooks/use-dancer-link-mutations.ts
  • Strategy doc: docs/features/airtable-integration/dancer-deduplication-strategy.md
  • WIP doc: docs/wip/active/WIP_implementing_dancer_deduplication_2025_11_24.md