# Airtable Client

Create, read, update, and delete records in Airtable bases and tables.

## Methods

| Method                                   | Description                                          |
| ---------------------------------------- | ---------------------------------------------------- |
| `apiRequest(options, schema, metadata?)` | Make any Airtable API request with schema validation |

## Usage

### List Records

```typescript
import { api, z, airtable } from "@superblocksteam/sdk-api";

// Integration ID from the integrations panel
const PROD_AIRTABLE = "a1b2c3d4-5678-90ab-cdef-airtable0001";

const RecordSchema = z.object({
  id: z.string(),
  createdTime: z.string(),
  fields: z.record(z.unknown()),
});

const ListRecordsResponseSchema = z.object({
  records: z.array(RecordSchema),
  offset: z.string().optional(),
});

export default api({
  name: "AirtableExample",
  integrations: {
    airtable: airtable(PROD_AIRTABLE),
  },
  input: z.object({
    baseId: z.string(),
    tableId: z.string(),
  }),
  output: z.object({
    records: z.array(
      z.object({ id: z.string(), fields: z.record(z.unknown()) }),
    ),
  }),
  async run(ctx, { baseId, tableId }) {
    const result = await ctx.integrations.airtable.apiRequest(
      {
        method: "GET",
        path: `/v0/${baseId}/${tableId}`,
        params: {
          maxRecords: 100,
        },
      },
      { response: ListRecordsResponseSchema },
    );

    return {
      records: result.records.map((r) => ({ id: r.id, fields: r.fields })),
    };
  },
});
```

### Create a Record

```typescript
const CreateRecordResponseSchema = z.object({
  id: z.string(),
  createdTime: z.string(),
  fields: z.record(z.unknown()),
});

const result = await ctx.integrations.airtable.apiRequest(
  {
    method: "POST",
    path: `/v0/${baseId}/${tableId}`,
    body: {
      fields: {
        Name: "New Task",
        Status: "To Do",
        Priority: "High",
        "Due Date": "2024-12-31",
        Assignee: ["rec123"], // Linked record IDs
      },
    },
  },
  { response: CreateRecordResponseSchema },
);

console.log(`Created record: ${result.id}`);
```

### Create Multiple Records

```typescript
const CreateMultipleResponseSchema = z.object({
  records: z.array(RecordSchema),
});

const result = await ctx.integrations.airtable.apiRequest(
  {
    method: "POST",
    path: `/v0/${baseId}/${tableId}`,
    body: {
      records: [
        { fields: { Name: "Task 1", Status: "To Do" } },
        { fields: { Name: "Task 2", Status: "To Do" } },
        { fields: { Name: "Task 3", Status: "In Progress" } },
      ],
    },
  },
  { response: CreateMultipleResponseSchema },
);

console.log(`Created ${result.records.length} records`);
```

### Get a Single Record

```typescript
const result = await ctx.integrations.airtable.apiRequest(
  {
    method: "GET",
    path: `/v0/${baseId}/${tableId}/${recordId}`,
  },
  { response: RecordSchema },
);

console.log(`Record: ${result.fields.Name}`);
```

### Update a Record

```typescript
const result = await ctx.integrations.airtable.apiRequest(
  {
    method: "PATCH",
    path: `/v0/${baseId}/${tableId}/${recordId}`,
    body: {
      fields: {
        Status: "Done",
        "Completed Date": new Date().toISOString().split("T")[0],
      },
    },
  },
  { response: RecordSchema },
);
```

### Update Multiple Records

```typescript
const result = await ctx.integrations.airtable.apiRequest(
  {
    method: "PATCH",
    path: `/v0/${baseId}/${tableId}`,
    body: {
      records: [
        { id: "rec1", fields: { Status: "Done" } },
        { id: "rec2", fields: { Status: "Done" } },
      ],
    },
  },
  { response: z.object({ records: z.array(RecordSchema) }) },
);
```

### Delete Records

```typescript
const DeleteResponseSchema = z.object({
  records: z.array(
    z.object({
      id: z.string(),
      deleted: z.boolean(),
    }),
  ),
});

const result = await ctx.integrations.airtable.apiRequest(
  {
    method: "DELETE",
    path: `/v0/${baseId}/${tableId}`,
    params: {
      "records[]": ["rec1", "rec2", "rec3"],
    },
  },
  { response: DeleteResponseSchema },
);
```

### Filter and Sort Records

```typescript
const result = await ctx.integrations.airtable.apiRequest(
  {
    method: "GET",
    path: `/v0/${baseId}/${tableId}`,
    params: {
      filterByFormula: "AND({Status} = 'To Do', {Priority} = 'High')",
      sort: JSON.stringify([
        { field: "Due Date", direction: "asc" },
        { field: "Priority", direction: "desc" },
      ]),
      view: "Grid view", // Use a specific view
      maxRecords: 50,
    },
  },
  { response: ListRecordsResponseSchema },
);
```

### Get Table Schema

```typescript
const TableSchemaResponseSchema = z.object({
  tables: z.array(
    z.object({
      id: z.string(),
      name: z.string(),
      primaryFieldId: z.string(),
      fields: z.array(
        z.object({
          id: z.string(),
          name: z.string(),
          type: z.string(),
          options: z.unknown().optional(),
        }),
      ),
    }),
  ),
});

const result = await ctx.integrations.airtable.apiRequest(
  {
    method: "GET",
    path: `/v0/meta/bases/${baseId}/tables`,
  },
  { response: TableSchemaResponseSchema },
);

result.tables.forEach((table) => {
  console.log(`Table: ${table.name}`);
  table.fields.forEach((field) => {
    console.log(`  - ${field.name} (${field.type})`);
  });
});
```

## Trace Metadata

All methods accept an optional `metadata` parameter as the last argument for diagnostics labeling. See the [root SDK README](../../../README.md#trace-metadata) for details.

## Common Pitfalls

### No Specialized Methods

```typescript
// WRONG - These methods do not exist
await ctx.integrations.airtable.listRecords({ ... });
await ctx.integrations.airtable.createRecord({ ... });

// CORRECT - Use apiRequest
await ctx.integrations.airtable.apiRequest(
  { method: "GET", path: `/v0/${baseId}/${tableId}` },
  { response: ListRecordsResponseSchema }
);
```

### URL Encoding Table Names

Table names with spaces need URL encoding:

```typescript
// If table name is "My Tasks"
const tableName = encodeURIComponent("My Tasks");
const path = `/v0/${baseId}/${tableName}`;

// Or use table ID instead (preferred)
const path = `/v0/${baseId}/tbl123abc`;
```

### Field Names are Case-Sensitive

```typescript
// WRONG - Wrong case
const fields = {
  name: "Task", // Should be "Name"
  STATUS: "To Do", // Should be "Status"
};

// CORRECT - Match exact field names from Airtable
const fields = {
  Name: "Task",
  Status: "To Do",
};
```

### Linked Records Use Record IDs

```typescript
// For linked record fields, use an array of record IDs
const fields = {
  Name: "New Task",
  Project: ["recABC123"], // Single link
  Assignees: ["recDEF456", "recGHI789"], // Multiple links
};
```

### Formula Syntax

Airtable formulas have specific syntax:

```typescript
// String comparison (use single quotes)
const filter1 = "{Status} = 'Done'";

// Number comparison
const filter2 = "{Count} > 10";

// Date comparison
const filter3 = "IS_AFTER({Due Date}, TODAY())";

// AND/OR
const filter4 = "AND({Status} = 'To Do', {Priority} = 'High')";
const filter5 = "OR({Status} = 'Done', {Status} = 'Archived')";

// SEARCH for partial match
const filter6 = "SEARCH('project', LOWER({Name}))";

// NULL check
const filter7 = "{Assignee} = BLANK()";
const filter8 = "NOT({Assignee} = BLANK())";
```

### Pagination

Airtable returns max 100 records per request:

```typescript
async function getAllRecords(
  airtable: AirtableClient,
  baseId: string,
  tableId: string,
) {
  const allRecords: Record[] = [];
  let offset: string | undefined;

  do {
    const result = await airtable.apiRequest(
      {
        method: "GET",
        path: `/v0/${baseId}/${tableId}`,
        params: {
          pageSize: 100,
          ...(offset && { offset }),
        },
      },
      { response: ListRecordsResponseSchema },
    );

    allRecords.push(...result.records);
    offset = result.offset;
  } while (offset);

  return allRecords;
}
```

### Rate Limits

Airtable has a rate limit of 5 requests per second per base:

```typescript
// Add delays for bulk operations
for (const batch of batches) {
  await ctx.integrations.airtable.apiRequest(...);
  await new Promise((r) => setTimeout(r, 200)); // 5 req/sec = 200ms delay
}
```

### Batch Operations Limited to 10 Records

```typescript
// WRONG - More than 10 records
const body = {
  records: Array(15).fill({ fields: { Name: "Task" } }), // Will fail
};

// CORRECT - Batch in groups of 10
const records = Array(15).fill({ fields: { Name: "Task" } });
const batches = [];
for (let i = 0; i < records.length; i += 10) {
  batches.push(records.slice(i, i + 10));
}
```

## Error Handling

```typescript
import { RestApiValidationError } from "@superblocksteam/sdk-api";

try {
  const result = await ctx.integrations.airtable.apiRequest(
    { method: "GET", path: `/v0/${baseId}/${tableId}` },
    { response: ListRecordsResponseSchema },
  );
} catch (error) {
  if (error instanceof RestApiValidationError) {
    console.error("Validation failed:", error.details.zodError);
  }
}
```

## API Reference

- [Airtable API Documentation](https://airtable.com/developers/web/api/introduction)
- [List Records](https://airtable.com/developers/web/api/list-records)
- [Create Records](https://airtable.com/developers/web/api/create-records)
- [Formula Reference](https://support.airtable.com/docs/formula-field-reference)
