skills/ballee/airtable-sync-specialist/SKILL.md
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
npx skillsauth add javeedishaq/ai-workflow-orchestrator airtable-sync-specialistInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use this skill when:
airtable_entity_mapping tableDO NOT use for:
DuplicatePreventionService (duplicate-prevention.service.ts)
ChangeDetectorService (change-detector.service.ts)
AirtableSyncService (airtable-sync.service.ts)
ClientManager (client-manager.ts)
RatingSyncService (rating-sync.service.ts)
-- 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
);
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.
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:
Symptoms:
airtable_entity_mapping shows duplicatesDebugging 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:
DuplicatePreventionService.registerEntity()Symptoms:
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:
DuplicatePreventionService to validate entities (already implemented)Symptoms:
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 policySolution:
-- 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
);
Symptoms:
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
Symptoms:
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. 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
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);
}
Purpose: Detect stale cache entries
Usage: pnpm tsx .claude/skills/airtable-sync-specialist/scripts/diagnose-sync-cache.ts
What it checks:
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:
Purpose: Debug Fever client RLS access
Usage: pnpm tsx .claude/skills/rls-policy-generator/scripts/diagnose-fever-client-rls.ts
What it checks:
// ✅ 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!
}
// ✅ 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);
}
}
// ✅ 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;
}
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();
});
});
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);
});
});
The Ballee system supports accessing multiple Airtable tables from the same base using a single API key. All configuration is managed through environment variables.
# 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:
AIRTABLE_API_KEY:
data.records:read scopeAIRTABLE_BASE_ID:
https://airtable.com/appXXXXXX/...appXXXXXX portionPrimary 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
[email protected] # 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();
}
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
Use Case: Sync from additional Airtable tables for same or different clients:
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);
}
}
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
);
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
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:
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)
For Each Table, You Get:
singleLineText - Single line textmultilineText - Long textdate - Date fieldduration - Time durationnumber - Numeric fieldsingleSelect - Dropdown with single selectionsingleCollaborator - Linked person/userOnce 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
});
}
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 referenceVenues - Venue referenceProductions - Program referenceArtists - Performer referenceCategories - Type classificationAdministrative Tables (internal tracking):
Sync Logs - Airtable-side sync historyError Log - Failed record trackingConfiguration - Airtable settingsPermissions Status: 🔒 READ-ONLY (data.records:read, schema.bases:read)
| 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.
Step 1: Run Discovery Script
cd apps/web && npx tsx scripts/discover-all-tables.ts
Step 2: Identify Your Target Table
tbl)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);
}
}
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 | "[email protected]" | 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']))
};
}
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
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
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}`);
});
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: '[email protected]',
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...
}
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);
| 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 |
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?
What This Means for Syncing:
If write access becomes necessary, contact Fever to:
data.records:write scopeAIRTABLE_API_KEY environment variable# 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
[email protected] # 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
apps/web/app/admin/sync/_lib/server/duplicate-prevention.service.tsapps/web/app/admin/sync/_lib/server/change-detector.service.tsapps/web/app/admin/sync/_lib/server/airtable-sync.service.tsapps/web/app/admin/sync/_lib/server/client-manager.tsapps/web/app/admin/sync/_lib/server/airtable-api.service.tsapps/web/app/admin/sync/_lib/server/constants.tsapps/web/app/admin/sync/_lib/server/rating-sync.service.ts.claude/skills/airtable-sync-specialist/scripts/ folderdocs/wip/WIP_airtable_sync_stale_cache_diagnosis_2025_10_20.mddocs/infrastructure/integrations/airtable-sync.mdapps/web/app/admin/sync/_lib/server/dancer-deduplication.service.tsapps/web/app/admin/sync/_lib/server/dancer-data-sync.service.tsapps/web/app/admin/sync/_components/dancer-links-page.tsxapps/web/app/admin/sync/_components/dancer-links-table.tsxapps/web/app/admin/sync/_components/dancer-link-review-dialog.tsxapps/web/app/admin/sync/_lib/server/dancer-links.actions.tsapps/web/app/admin/sync/_lib/hooks/use-dancer-link-mutations.tsdocs/features/airtable-integration/dancer-deduplication-strategy.mddocs/wip/active/WIP_implementing_dancer_deduplication_2025_11_24.mdtools
# Test Patterns Testing patterns for reliable, maintainable, and fast tests. > **Template Usage:** Customize for your test framework (Vitest, Jest, Playwright, etc.) and assertion library. ## Test Structure ```typescript // user.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { userService } from '@/services/user.service'; import { createTestUser, cleanupTestData } from '@/tests/helpers'; describe('UserService', () => { let testUserId: string; befor
tools
# State Management Patterns Client-side state management patterns for modern applications. > **Template Usage:** Customize for your state library (React Query, Zustand, Jotai, Redux, etc.). ## State Categories | Type | Description | Solution | |------|-------------|----------| | **Server State** | Data from API/database | React Query, SWR | | **Client State** | UI state, user preferences | Zustand, Jotai, useState | | **Form State** | Form inputs, validation | React Hook Form, Formik | | **U
development
# Service Patterns Service layer patterns for clean architecture with proper error handling, logging, and type safety. > **Template Usage:** Customize for your ORM (Prisma, Drizzle, TypeORM, etc.) and logging solution. ## Result Type Pattern Never throw exceptions from services. Always return a Result type. ```typescript // lib/result.ts export type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; export function ok<T>(data: T): Result<T, never> { r
testing
# Row-Level Security Patterns Database security patterns for multi-tenant and user-scoped data. > **Template Usage:** Customize for your database (PostgreSQL, Supabase, etc.) and auth system. ## RLS Fundamentals ### Enable RLS on Tables ```sql -- Enable RLS (required before policies take effect) ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ALTER TABLE comments ENABLE ROW LEVEL SECURITY; -- Force RLS for table owners too (recommended) ALTER TABLE