airtable-ts 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,12 @@ All of these problems are solved with airtable-ts.
15
15
 
16
16
  In development, you'll define the expected types for different fields: enabling you to leverage type hints in your code editor. After deployment, if people make breaking changes to your base schema you'll get clear runtime errors that pinpoint the problem (rather than your app silently failing, or worse: doing something dangerous!) We also fix unintuitive API behavior, like not being able to tell whether a checkbox field has been deleted or the values are just unticked.
17
17
 
18
- ## Usage
18
+ ## Related libraries
19
+
20
+ - [airtable-ts-codegen](https://github.com/domdomegg/airtable-ts-codegen): Autogenerate TypeScript definitions for your Airtable base, with perfect compatibility with airtable-ts
21
+ - [airtable-ts-formula](https://github.com/domdomegg/airtable-ts-formula): Type-safe, securely-escaped and rename-robust formulae for Airtable (e.g. for `filterByFormula`)
22
+
23
+ ## Example
19
24
 
20
25
  Install it with `npm install airtable-ts`. Then, use it like:
21
26
 
@@ -24,6 +29,7 @@ import { AirtableTs, Table } from 'airtable-ts';
24
29
 
25
30
  const db = new AirtableTs({
26
31
  // Create your own at https://airtable.com/create/tokens
32
+ // Recommended scopes: schema.bases:read, data.records:read, data.records:write
27
33
  apiKey: 'pat1234.abcdef',
28
34
  });
29
35
 
@@ -68,6 +74,171 @@ async function prefixTitleOfFirstClassOfFirstStudent(prefix: string) {
68
74
  const rawSdk: Airtable = db.airtable;
69
75
  ```
70
76
 
77
+ ## AirtableTs Class Reference
78
+
79
+ The `AirtableTs` class provides several methods to interact with your Airtable base:
80
+
81
+ ### Constructor
82
+
83
+ ```ts
84
+ new AirtableTs(options: AirtableTsOptions)
85
+ ```
86
+
87
+ Creates a new instance of the AirtableTs client.
88
+
89
+ **Parameters:**
90
+ - `options`: Configuration options
91
+ - `apiKey`: Your Airtable API key (required)
92
+ - Create one at https://airtable.com/create/tokens
93
+ - Recommended scopes: schema.bases:read, data.records:read, data.records:write
94
+ - `baseSchemaCacheDurationMs`: Duration in milliseconds to cache base schema (default: 120,000ms = 2 minutes)
95
+ - Other options from Airtable.js are supported, including: `apiVersion`, `customHeaders`, `endpointUrl`, `noRetryIfRateLimited`, `requestTimeout`
96
+
97
+ **Example:**
98
+ ```ts
99
+ const db = new AirtableTs({
100
+ apiKey: 'pat1234.abcdef',
101
+ baseSchemaCacheDurationMs: 300000, // 5 minutes
102
+ });
103
+ ```
104
+
105
+ ### get
106
+
107
+ ```ts
108
+ async get<T extends Item>(table: Table<T>, id: string): Promise<T>
109
+ ```
110
+
111
+ Retrieves a single record from a table by its ID.
112
+
113
+ **Parameters:**
114
+ - `table`: Table definition object
115
+ - `id`: The ID of the record to retrieve
116
+
117
+ **Example:**
118
+ ```ts
119
+ const student = await db.get(studentTable, 'rec1234');
120
+ console.log(student.firstName); // Access fields with type safety
121
+ ```
122
+
123
+ ### scan
124
+
125
+ ```ts
126
+ async scan<T extends Item>(table: Table<T>, params?: ScanParams): Promise<T[]>
127
+ ```
128
+
129
+ Retrieves all records from a table, with optional filtering parameters.
130
+
131
+ **Parameters:**
132
+ - `table`: Table definition object
133
+ - `params` (optional): Parameters for filtering, sorting, and limiting results
134
+ - `filterByFormula`: An Airtable formula to filter records
135
+ - Tip: use [airtable-ts-formula](https://github.com/domdomegg/airtable-ts-formula) for type-safe, securely-escaped and rename-robust formulae!
136
+ - `sort`: Array of sort objects (e.g., `[{field: 'firstName', direction: 'asc'}]`)
137
+ - `maxRecords`: Maximum number of records to return
138
+ - `view`: Name of a view to use for record selection
139
+ - `timeZone`: Timezone for interpreting date values
140
+ - `userLocale`: Locale for formatting date values
141
+
142
+ **Example:**
143
+ ```ts
144
+ // Get all records
145
+ const allStudents = await db.scan(studentTable);
146
+
147
+ // Get records with filtering and sorting
148
+ const topStudents = await db.scan(studentTable, {
149
+ filterByFormula: '{grade} >= 90',
150
+ sort: [{field: 'grade', direction: 'desc'}],
151
+ maxRecords: 10
152
+ });
153
+ ```
154
+
155
+ ### insert
156
+
157
+ ```ts
158
+ async insert<T extends Item>(table: Table<T>, data: Partial<Omit<T, 'id'>>): Promise<T>
159
+ ```
160
+
161
+ Creates a new record in a table. Returns the new record.
162
+
163
+ **Parameters:**
164
+ - `table`: Table definition object
165
+ - `data`: The data for the new record (without an ID, as Airtable will generate one)
166
+
167
+ **Example:**
168
+ ```ts
169
+ const newStudent = await db.insert(studentTable, {
170
+ firstName: 'Jane',
171
+ classes: ['rec5678', 'rec9012']
172
+ });
173
+ console.log(newStudent.id); // The new record ID generated by Airtable
174
+ ```
175
+
176
+ ### update
177
+
178
+ ```ts
179
+ async update<T extends Item>(table: Table<T>, data: Partial<T> & { id: string }): Promise<T>
180
+ ```
181
+
182
+ Updates an existing record in a table. Returns the updated record.
183
+
184
+ **Parameters:**
185
+ - `table`: Table definition object
186
+ - `data`: The data to update, must include the record ID
187
+
188
+ **Example:**
189
+ ```ts
190
+ const updatedStudent = await db.update(studentTable, {
191
+ id: 'rec1234',
192
+ firstName: 'John',
193
+ // Only include fields you want to update
194
+ });
195
+ ```
196
+
197
+ ### remove
198
+
199
+ ```ts
200
+ async remove<T extends Item>(table: Table<T>, id: string): Promise<{ id: string }>
201
+ ```
202
+
203
+ Deletes a record from a table.
204
+
205
+ **Parameters:**
206
+ - `table`: Table definition object
207
+ - `id`: The ID of the record to delete
208
+
209
+ **Example:**
210
+ ```ts
211
+ await db.remove(studentTable, 'rec1234');
212
+ ```
213
+
214
+ ### table
215
+
216
+ ```ts
217
+ async table<T extends Item>(table: Table<T>): Promise<AirtableTsTable<T>>
218
+ ```
219
+
220
+ Retrieves the AirtableTsTable object for the given table definition. This is the Airtable.js table, enriched with a `fields` key that includes details of the Airtable schema for this table.
221
+
222
+ This is useful for advanced use cases where you need direct access to the Airtable table object.
223
+
224
+ **Parameters:**
225
+ - `table`: Table definition object
226
+
227
+ **Example:**
228
+ ```ts
229
+ const airtableTsTable = await db.table(studentTable);
230
+ // Now you can use the raw Airtable table object with field information
231
+ console.log(airtableTsTable.fields); // Access the table's field definitions
232
+ ```
233
+
234
+ ### airtable
235
+
236
+ The underlying Airtable.js SDK is exposed in the `airtable` property.
237
+
238
+ ```ts
239
+ const rawSdk: Airtable = db.airtable;
240
+ ```
241
+
71
242
  ## Contributing
72
243
 
73
244
  Pull requests are welcomed on GitHub! To get started:
@@ -0,0 +1,18 @@
1
+ import Airtable from 'airtable';
2
+ import { Item, Table } from './mapping/typeUtils';
3
+ import { AirtableTsTable, AirtableTsOptions, ScanParams } from './types';
4
+ export declare class AirtableTs {
5
+ airtable: Airtable;
6
+ private options;
7
+ constructor(options: AirtableTsOptions);
8
+ get<T extends Item>(table: Table<T>, id: string): Promise<T>;
9
+ scan<T extends Item>(table: Table<T>, params?: ScanParams): Promise<T[]>;
10
+ insert<T extends Item>(table: Table<T>, data: Partial<Omit<T, 'id'>>): Promise<T>;
11
+ update<T extends Item>(table: Table<T>, data: Partial<T> & {
12
+ id: string;
13
+ }): Promise<T>;
14
+ remove<T extends Item>(table: Table<T>, id: string): Promise<{
15
+ id: string;
16
+ }>;
17
+ table<T extends Item>(table: Table<T>): Promise<AirtableTsTable<T>>;
18
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.AirtableTs = void 0;
7
+ const airtable_1 = __importDefault(require("airtable"));
8
+ const getAirtableTsTable_1 = require("./getAirtableTsTable");
9
+ const assertMatchesSchema_1 = require("./assertMatchesSchema");
10
+ const recordMapper_1 = require("./mapping/recordMapper");
11
+ const getFields_1 = require("./getFields");
12
+ const wrapToCatchAirtableErrors_1 = require("./wrapToCatchAirtableErrors");
13
+ const AirtableTsError_1 = require("./AirtableTsError");
14
+ class AirtableTs {
15
+ airtable;
16
+ options;
17
+ constructor(options) {
18
+ this.airtable = new airtable_1.default(options);
19
+ this.options = {
20
+ ...airtable_1.default.default_config(),
21
+ ...options,
22
+ baseSchemaCacheDurationMs: options.baseSchemaCacheDurationMs ?? 120000,
23
+ };
24
+ }
25
+ async get(table, id) {
26
+ if (!id) {
27
+ throw new AirtableTsError_1.AirtableTsError({
28
+ message: `The record ID must be supplied when getting a record. This was thrown when trying to get a '${table.name}' (${table.tableId}) record.`,
29
+ type: AirtableTsError_1.ErrorType.INVALID_PARAMETER,
30
+ suggestion: 'Provide a valid record ID when calling the get method.',
31
+ });
32
+ }
33
+ const airtableTsTable = await (0, getAirtableTsTable_1.getAirtableTsTable)(this.airtable, table, this.options);
34
+ const record = await airtableTsTable.find(id);
35
+ if (!record) {
36
+ throw new AirtableTsError_1.AirtableTsError({
37
+ message: `No record with ID '${id}' exists in table '${table.name}'.`,
38
+ type: AirtableTsError_1.ErrorType.RESOURCE_NOT_FOUND,
39
+ suggestion: 'Verify that the record ID is correct and that the record exists in the table.',
40
+ });
41
+ }
42
+ return (0, recordMapper_1.mapRecordFromAirtable)(table, record);
43
+ }
44
+ async scan(table, params) {
45
+ const airtableTsTable = await (0, getAirtableTsTable_1.getAirtableTsTable)(this.airtable, table, this.options);
46
+ const records = await airtableTsTable.select({
47
+ fields: (0, getFields_1.getFields)(table),
48
+ ...params,
49
+ }).all();
50
+ return records.map((record) => (0, recordMapper_1.mapRecordFromAirtable)(table, record));
51
+ }
52
+ async insert(table, data) {
53
+ (0, assertMatchesSchema_1.assertMatchesSchema)(table, { ...data, id: 'placeholder' });
54
+ const airtableTsTable = await (0, getAirtableTsTable_1.getAirtableTsTable)(this.airtable, table, this.options);
55
+ const record = await airtableTsTable.create((0, recordMapper_1.mapRecordToAirtable)(table, data, airtableTsTable));
56
+ return (0, recordMapper_1.mapRecordFromAirtable)(table, record);
57
+ }
58
+ async update(table, data) {
59
+ (0, assertMatchesSchema_1.assertMatchesSchema)(table, { ...data });
60
+ const { id, ...withoutId } = data;
61
+ const airtableTsTable = await (0, getAirtableTsTable_1.getAirtableTsTable)(this.airtable, table, this.options);
62
+ const record = await airtableTsTable.update(data.id, (0, recordMapper_1.mapRecordToAirtable)(table, withoutId, airtableTsTable));
63
+ return (0, recordMapper_1.mapRecordFromAirtable)(table, record);
64
+ }
65
+ async remove(table, id) {
66
+ if (!id) {
67
+ throw new AirtableTsError_1.AirtableTsError({
68
+ message: `The record ID must be supplied when removing a record. This was thrown when trying to get a '${table.name}' (${table.tableId}) record.`,
69
+ type: AirtableTsError_1.ErrorType.INVALID_PARAMETER,
70
+ suggestion: 'Provide a valid record ID when calling the remove method.',
71
+ });
72
+ }
73
+ const airtableTsTable = await (0, getAirtableTsTable_1.getAirtableTsTable)(this.airtable, table, this.options);
74
+ const record = await airtableTsTable.destroy(id);
75
+ return { id: record.id };
76
+ }
77
+ async table(table) {
78
+ return (0, getAirtableTsTable_1.getAirtableTsTable)(this.airtable, table, this.options);
79
+ }
80
+ }
81
+ exports.AirtableTs = AirtableTs;
82
+ // Wrap all methods of AirtableTs with error handling
83
+ // See https://github.com/Airtable/airtable.js/issues/294
84
+ (0, wrapToCatchAirtableErrors_1.wrapToCatchAirtableErrors)(AirtableTs);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Error types for categorizing different kinds of errors
3
+ */
4
+ export declare enum ErrorType {
5
+ SCHEMA_VALIDATION = "SCHEMA_VALIDATION",
6
+ RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND",
7
+ INVALID_PARAMETER = "INVALID_PARAMETER",
8
+ API_ERROR = "API_ERROR"
9
+ }
10
+ /**
11
+ * Base error class for all airtable-ts errors
12
+ */
13
+ export declare class AirtableTsError extends Error {
14
+ /** Error type for categorization */
15
+ type: ErrorType;
16
+ constructor(options: {
17
+ message: string;
18
+ type: ErrorType;
19
+ suggestion?: string;
20
+ });
21
+ }
22
+ export declare const prependError: (error: unknown, prefix: string) => unknown;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.prependError = exports.AirtableTsError = exports.ErrorType = void 0;
4
+ /**
5
+ * Error types for categorizing different kinds of errors
6
+ */
7
+ var ErrorType;
8
+ (function (ErrorType) {
9
+ ErrorType["SCHEMA_VALIDATION"] = "SCHEMA_VALIDATION";
10
+ ErrorType["RESOURCE_NOT_FOUND"] = "RESOURCE_NOT_FOUND";
11
+ ErrorType["INVALID_PARAMETER"] = "INVALID_PARAMETER";
12
+ ErrorType["API_ERROR"] = "API_ERROR";
13
+ })(ErrorType || (exports.ErrorType = ErrorType = {}));
14
+ /**
15
+ * Base error class for all airtable-ts errors
16
+ */
17
+ class AirtableTsError extends Error {
18
+ /** Error type for categorization */
19
+ type;
20
+ constructor(options) {
21
+ const { message, suggestion, type } = options;
22
+ super(suggestion ? `${message} Suggestion: ${suggestion}` : message);
23
+ this.type = type;
24
+ this.name = 'AirtableTsError';
25
+ }
26
+ }
27
+ exports.AirtableTsError = AirtableTsError;
28
+ const prependError = (error, prefix) => {
29
+ if (error instanceof AirtableTsError) {
30
+ // eslint-disable-next-line no-param-reassign
31
+ error.message = `${prefix}: ${error.message}`;
32
+ // eslint-disable-next-line no-param-reassign
33
+ error.stack = `Error: ${prefix}: ${error.stack?.startsWith('Error: ') ? error.stack.slice('Error: '.length) : error.stack}`;
34
+ }
35
+ return error;
36
+ };
37
+ exports.prependError = prependError;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.assertMatchesSchema = void 0;
4
4
  const typeUtils_1 = require("./mapping/typeUtils");
5
+ const AirtableTsError_1 = require("./AirtableTsError");
5
6
  /**
6
7
  * In theory, this should never catch stuff because our type mapping logic should
7
8
  * verify the types are compatible.
@@ -14,7 +15,10 @@ const typeUtils_1 = require("./mapping/typeUtils");
14
15
  */
15
16
  function assertMatchesSchema(table, data, mode = 'partial') {
16
17
  if (typeof data !== 'object' || data === null) {
17
- throw new Error(`[airtable-ts] Item for ${table.name} is not an object`);
18
+ throw new AirtableTsError_1.AirtableTsError({
19
+ message: `Data passed in to airtable-ts should be an object but received ${data === null ? 'null' : typeof data}.`,
20
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
21
+ });
18
22
  }
19
23
  Object.entries(table.schema).forEach(([fieldName, type]) => {
20
24
  const value = data[fieldName];
@@ -22,10 +26,16 @@ function assertMatchesSchema(table, data, mode = 'partial') {
22
26
  if (mode === 'partial') {
23
27
  return;
24
28
  }
25
- throw new Error(`[airtable-ts] Item for ${table.name} table is missing field '${fieldName}' (expected ${type})`);
29
+ throw new AirtableTsError_1.AirtableTsError({
30
+ message: `Data passed in to airtable-ts is missing required field '${fieldName}' in table '${table.name}' (expected type: ${type}).`,
31
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
32
+ });
26
33
  }
27
34
  if (!(0, typeUtils_1.matchesType)(value, type)) {
28
- throw new Error(`[airtable-ts] Item for ${table.name} table has invalid value for field '${fieldName}' (actual type ${typeof value}, but expected ${type})`);
35
+ throw new AirtableTsError_1.AirtableTsError({
36
+ message: `Invalid value passed in to airtable-ts for field '${fieldName}' in table '${table.name}' (received type: ${typeof value}, expected type: ${type}).`,
37
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
38
+ });
29
39
  }
30
40
  });
31
41
  }
@@ -0,0 +1,4 @@
1
+ import Airtable from 'airtable';
2
+ import { Item, Table } from './mapping/typeUtils';
3
+ import { AirtableTsTable, CompleteAirtableTsOptions } from './types';
4
+ export declare const getAirtableTsTable: <T extends Item>(airtable: Airtable, table: Table<T>, options: CompleteAirtableTsOptions) => Promise<AirtableTsTable<T>>;
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.getAirtableTsTable = void 0;
27
+ const axios_1 = __importStar(require("axios"));
28
+ const AirtableTsError_1 = require("./AirtableTsError");
29
+ const getAirtableTsTable = async (airtable, table, options) => {
30
+ const airtableTable = airtable.base(table.baseId).table(table.tableId);
31
+ // We need the schema so we know which type mapper to use.
32
+ // Even if we were inferring a type mapper from the schema, we'd still have to
33
+ // do this as otherwise we can't distinguish a column with all null values from
34
+ // a column that is missing entirely. We'd like to do that as for safety, we'd
35
+ // rather throw an error if the column is missing entirely; this suggests a
36
+ // misconfiguration. But an all-null column is okay. The particular case that
37
+ // this is likely for is checkbox columns.
38
+ const baseSchema = await getAirtableBaseSchema(table.baseId, options);
39
+ const tableDefinition = baseSchema.find((t) => t.id === table.tableId);
40
+ if (!tableDefinition) {
41
+ throw new AirtableTsError_1.AirtableTsError({
42
+ message: `Table '${table.name}' (${table.tableId}) does not exist in base ${table.baseId}.`,
43
+ type: AirtableTsError_1.ErrorType.RESOURCE_NOT_FOUND,
44
+ suggestion: 'Verify that the base ID and table ID are correct.',
45
+ });
46
+ }
47
+ return Object.assign(airtableTable, {
48
+ fields: tableDefinition.fields,
49
+ tsDefinition: table,
50
+ });
51
+ };
52
+ exports.getAirtableTsTable = getAirtableTsTable;
53
+ const baseSchemaCache = new Map();
54
+ /**
55
+ * Get the schemas from the cache or Airtable API for the tables in the given base.
56
+ * @see https://airtable.com/developers/web/api/get-base-schema
57
+ * @param baseId The base id to get the schemas for
58
+ */
59
+ const getAirtableBaseSchema = async (baseId, options) => {
60
+ const fromCache = baseSchemaCache.get(baseId);
61
+ if (fromCache && Date.now() - fromCache.at < options.baseSchemaCacheDurationMs) {
62
+ return fromCache.data;
63
+ }
64
+ if (!options.apiKey) {
65
+ throw new AirtableTsError_1.AirtableTsError({
66
+ message: 'API key is required but was not provided.',
67
+ type: AirtableTsError_1.ErrorType.INVALID_PARAMETER,
68
+ suggestion: 'Provide a valid Airtable API key when initializing AirtableTs.',
69
+ });
70
+ }
71
+ const res = await (0, axios_1.default)({
72
+ baseURL: options.endpointUrl ?? 'https://api.airtable.com',
73
+ url: `/v0/meta/bases/${baseId}/tables`,
74
+ ...(options.requestTimeout ? { timeout: options.requestTimeout } : {}),
75
+ headers: {
76
+ // eslint-disable-next-line no-underscore-dangle
77
+ Authorization: `Bearer ${options.apiKey}`,
78
+ ...options.customHeaders,
79
+ },
80
+ }).catch((err) => {
81
+ const normalizedErrorMessage = err instanceof axios_1.AxiosError
82
+ ? `${err.message}. Status: ${err.status}. Data: ${JSON.stringify(err.response?.data)}`
83
+ : err;
84
+ throw new AirtableTsError_1.AirtableTsError({
85
+ message: `Failed to get base schema: ${normalizedErrorMessage}`,
86
+ type: AirtableTsError_1.ErrorType.API_ERROR,
87
+ suggestion: 'Ensure the API token is correct, and has `schema.bases:read` permission to the target base.',
88
+ });
89
+ });
90
+ const baseSchema = res.data.tables;
91
+ if (baseSchemaCache.size > 100) {
92
+ baseSchemaCache.clear();
93
+ // If you're seeing this warning, then we probably either need to:
94
+ // - Update the maximum limit before clearing the cache, provided we have memory headroom; or
95
+ // - Use a last recently used cache or similar
96
+ console.warn('[airtable-ts] baseSchemaCache cleared to avoid a memory leak: this code is not currently optimized for accessing over 100 different bases from a single instance');
97
+ }
98
+ baseSchemaCache.set(baseId, { at: Date.now(), data: baseSchema });
99
+ return baseSchema;
100
+ };
package/dist/index.d.ts CHANGED
@@ -1,21 +1,5 @@
1
- import Airtable from 'airtable';
2
- import type { QueryParams } from 'airtable/lib/query_params';
3
- import { Item, Table } from './mapping/typeUtils';
4
- import { AirtableTsOptions } from './types';
5
- export declare class AirtableTs {
6
- airtable: Airtable;
7
- private options;
8
- constructor(options: AirtableTsOptions);
9
- get<T extends Item>(table: Table<T>, id: string): Promise<T>;
10
- scan<T extends Item>(table: Table<T>, params?: ScanParams): Promise<T[]>;
11
- insert<T extends Item>(table: Table<T>, data: Partial<Omit<T, 'id'>>): Promise<T>;
12
- update<T extends Item>(table: Table<T>, data: Partial<T> & {
13
- id: string;
14
- }): Promise<T>;
15
- remove<T extends Item>(table: Table<T>, id: string): Promise<{
16
- id: string;
17
- }>;
18
- }
19
- export type ScanParams = Omit<QueryParams<unknown>, 'fields' | 'cellFormat' | 'method' | 'returnFieldsByFieldId' | 'pageSize' | 'offset'>;
20
- export type { AirtableTsOptions } from './types';
1
+ export { AirtableTs } from './AirtableTs';
2
+ export { AirtableTsError } from './AirtableTsError';
3
+ export type { AirtableTsOptions, ScanParams, AirtableTsTable } from './types';
21
4
  export type { Item, Table } from './mapping/typeUtils';
5
+ export type { WrappedAirtableError } from './wrapToCatchAirtableErrors';
package/dist/index.js CHANGED
@@ -1,64 +1,7 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.AirtableTs = void 0;
7
- const airtable_1 = __importDefault(require("airtable"));
8
- const getAirtableTable_1 = require("./getAirtableTable");
9
- const assertMatchesSchema_1 = require("./assertMatchesSchema");
10
- const recordMapper_1 = require("./mapping/recordMapper");
11
- const getFields_1 = require("./getFields");
12
- class AirtableTs {
13
- airtable;
14
- options;
15
- constructor(options) {
16
- this.airtable = new airtable_1.default(options);
17
- this.options = {
18
- ...airtable_1.default.default_config(),
19
- ...options,
20
- baseSchemaCacheDurationMs: options.baseSchemaCacheDurationMs ?? 120000,
21
- };
22
- }
23
- async get(table, id) {
24
- if (!id) {
25
- throw new Error(`[airtable-ts] Tried to get record in ${table.name} with no id`);
26
- }
27
- const airtableTable = await (0, getAirtableTable_1.getAirtableTable)(this.airtable, table, this.options);
28
- const record = await airtableTable.find(id);
29
- if (!record) {
30
- throw new Error(`[airtable-ts] Failed to find record in ${table.name} with key ${id}`);
31
- }
32
- return (0, recordMapper_1.mapRecordFromAirtable)(table, record);
33
- }
34
- async scan(table, params) {
35
- const airtableTable = await (0, getAirtableTable_1.getAirtableTable)(this.airtable, table, this.options);
36
- const records = await airtableTable.select({
37
- fields: (0, getFields_1.getFields)(table),
38
- ...params,
39
- }).all();
40
- return records.map((record) => (0, recordMapper_1.mapRecordFromAirtable)(table, record));
41
- }
42
- async insert(table, data) {
43
- (0, assertMatchesSchema_1.assertMatchesSchema)(table, { ...data, id: 'placeholder' });
44
- const airtableTable = await (0, getAirtableTable_1.getAirtableTable)(this.airtable, table, this.options);
45
- const record = await airtableTable.create((0, recordMapper_1.mapRecordToAirtable)(table, data, airtableTable));
46
- return (0, recordMapper_1.mapRecordFromAirtable)(table, record);
47
- }
48
- async update(table, data) {
49
- (0, assertMatchesSchema_1.assertMatchesSchema)(table, { ...data });
50
- const { id, ...withoutId } = data;
51
- const airtableTable = await (0, getAirtableTable_1.getAirtableTable)(this.airtable, table, this.options);
52
- const record = await airtableTable.update(data.id, (0, recordMapper_1.mapRecordToAirtable)(table, withoutId, airtableTable));
53
- return (0, recordMapper_1.mapRecordFromAirtable)(table, record);
54
- }
55
- async remove(table, id) {
56
- if (!id) {
57
- throw new Error(`[airtable-ts] Tried to remove record in ${table.name} with no id`);
58
- }
59
- const airtableTable = await (0, getAirtableTable_1.getAirtableTable)(this.airtable, table, this.options);
60
- const record = await airtableTable.destroy(id);
61
- return { id: record.id };
62
- }
63
- }
64
- exports.AirtableTs = AirtableTs;
3
+ exports.AirtableTsError = exports.AirtableTs = void 0;
4
+ var AirtableTs_1 = require("./AirtableTs");
5
+ Object.defineProperty(exports, "AirtableTs", { enumerable: true, get: function () { return AirtableTs_1.AirtableTs; } });
6
+ var AirtableTsError_1 = require("./AirtableTsError");
7
+ Object.defineProperty(exports, "AirtableTsError", { enumerable: true, get: function () { return AirtableTsError_1.AirtableTsError; } });
@@ -2,11 +2,17 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fieldMappers = void 0;
4
4
  const typeUtils_1 = require("./typeUtils");
5
+ const AirtableTsError_1 = require("../AirtableTsError");
5
6
  const fallbackMapperPair = (toFallback, fromFallback) => ({
6
7
  toAirtable: (value) => value ?? toFallback,
7
8
  fromAirtable: (value) => value ?? fromFallback,
8
9
  });
9
- const readonly = (airtableType) => () => { throw new Error(`[airtable-ts] ${airtableType} type field is readonly`); };
10
+ const readonly = (airtableType) => () => {
11
+ throw new AirtableTsError_1.AirtableTsError({
12
+ message: `Cannot modify a field of type '${airtableType}' as it is read-only.`,
13
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
14
+ });
15
+ };
10
16
  const coerce = (airtableType, tsType) => (value) => {
11
17
  const parsedType = (0, typeUtils_1.parseType)(tsType);
12
18
  if (!parsedType.array && typeof value === parsedType.single) {
@@ -25,9 +31,17 @@ const coerce = (airtableType, tsType) => (value) => {
25
31
  return value[0];
26
32
  }
27
33
  if (!parsedType.array && Array.isArray(value) && value.length !== 1) {
28
- throw new Error(`[airtable-ts] Can't coerce ${airtableType} to a ${tsType}, as there were ${value.length} array entries`);
34
+ throw new AirtableTsError_1.AirtableTsError({
35
+ message: `Cannot convert array with ${value.length} entries from airtable type '${airtableType} to TypeScript type '${tsType}'.`,
36
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
37
+ suggestion: `Change the type from '${tsType}' to '${tsType}[]' in your table definition.`,
38
+ });
29
39
  }
30
- throw new Error(`[airtable-ts] Can't coerce ${airtableType} to a ${tsType}, as it was of type ${typeof value}`);
40
+ throw new AirtableTsError_1.AirtableTsError({
41
+ message: `Cannot convert value from airtable type '${airtableType}' to '${tsType}', as the Airtable API provided a '${typeof value}'.`,
42
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
43
+ suggestion: 'Update the types in your table definition to compatible types for your Airtable base.',
44
+ });
31
45
  };
32
46
  const dateTimeMapperPair = {
33
47
  // Number assumed to be unix time in seconds
@@ -36,7 +50,10 @@ const dateTimeMapperPair = {
36
50
  return null;
37
51
  const date = new Date(typeof value === 'number' ? value * 1000 : value);
38
52
  if (Number.isNaN(date.getTime())) {
39
- throw new Error('[airtable-ts] Invalid dateTime');
53
+ throw new AirtableTsError_1.AirtableTsError({
54
+ message: 'Invalid date/time value provided.',
55
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
56
+ });
40
57
  }
41
58
  return date.toJSON();
42
59
  },
@@ -45,7 +62,10 @@ const dateTimeMapperPair = {
45
62
  return null;
46
63
  const date = new Date(value);
47
64
  if (Number.isNaN(date.getTime())) {
48
- throw new Error('[airtable-ts] Invalid dateTime');
65
+ throw new AirtableTsError_1.AirtableTsError({
66
+ message: 'Invalid date/time value received from Airtable.',
67
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
68
+ });
49
69
  }
50
70
  return date.toJSON();
51
71
  },
@@ -196,7 +216,10 @@ const numberOrNull = {
196
216
  return null;
197
217
  const date = new Date(nullableValue);
198
218
  if (Number.isNaN(date.getTime())) {
199
- throw new Error('[airtable-ts] Invalid date');
219
+ throw new AirtableTsError_1.AirtableTsError({
220
+ message: 'Invalid date/time value received from Airtable.',
221
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
222
+ });
200
223
  }
201
224
  return Math.floor(date.getTime() / 1000);
202
225
  },
@@ -209,7 +232,10 @@ const numberOrNull = {
209
232
  return null;
210
233
  const date = new Date(nullableValue);
211
234
  if (Number.isNaN(date.getTime())) {
212
- throw new Error('[airtable-ts] Invalid date');
235
+ throw new AirtableTsError_1.AirtableTsError({
236
+ message: 'Invalid date/time value received from Airtable.',
237
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
238
+ });
213
239
  }
214
240
  return Math.floor(date.getTime() / 1000);
215
241
  },
@@ -222,7 +248,10 @@ const numberOrNull = {
222
248
  return null;
223
249
  const date = new Date(nullableValue);
224
250
  if (Number.isNaN(date.getTime())) {
225
- throw new Error('[airtable-ts] Invalid date');
251
+ throw new AirtableTsError_1.AirtableTsError({
252
+ message: 'Invalid date/time value received from Airtable.',
253
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
254
+ });
226
255
  }
227
256
  return Math.floor(date.getTime() / 1000);
228
257
  },
@@ -235,7 +264,10 @@ const numberOrNull = {
235
264
  return null;
236
265
  const date = new Date(nullableValue);
237
266
  if (Number.isNaN(date.getTime())) {
238
- throw new Error('[airtable-ts] Invalid date');
267
+ throw new AirtableTsError_1.AirtableTsError({
268
+ message: 'Invalid date/time value received from Airtable.',
269
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
270
+ });
239
271
  }
240
272
  return Math.floor(date.getTime() / 1000);
241
273
  },
@@ -265,11 +297,21 @@ const stringArrayOrNull = {
265
297
  multipleCollaborators: multipleCollaboratorsMapperPair,
266
298
  multipleAttachments: multipleAttachmentsMapperPair,
267
299
  multipleLookupValues: {
268
- toAirtable: () => { throw new Error('[airtable-ts] lookup type field is readonly'); },
300
+ toAirtable: () => {
301
+ throw new AirtableTsError_1.AirtableTsError({
302
+ message: 'Lookup fields are read-only and cannot be modified.',
303
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
304
+ });
305
+ },
269
306
  fromAirtable: coerce('multipleLookupValues', 'string[] | null'),
270
307
  },
271
308
  formula: {
272
- toAirtable: () => { throw new Error('[airtable-ts] formula type field is readonly'); },
309
+ toAirtable: () => {
310
+ throw new AirtableTsError_1.AirtableTsError({
311
+ message: 'Formula fields are read-only and cannot be modified.',
312
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
313
+ });
314
+ },
273
315
  fromAirtable: coerce('multipleLookupValues', 'string[] | null'),
274
316
  },
275
317
  unknown: {
@@ -287,7 +329,11 @@ exports.fieldMappers = {
287
329
  fromAirtable: (value) => {
288
330
  const nullableValue = nullablePair.fromAirtable(value);
289
331
  if (nullableValue === null && ['multipleRecordLinks', 'dateTime', 'createdTime', 'lastModifiedTime'].includes(airtableType)) {
290
- throw new Error(`[airtable-ts] Expected non-null or non-empty value to map to string for field type ${airtableType}`);
332
+ throw new AirtableTsError_1.AirtableTsError({
333
+ message: `Cannot convert null value to string for field type '${airtableType}'.`,
334
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
335
+ suggestion: 'Provide a non-null value for this field or update your schema to allow null values.',
336
+ });
291
337
  }
292
338
  return nullableValue ?? '';
293
339
  },
@@ -311,7 +357,11 @@ exports.fieldMappers = {
311
357
  fromAirtable: (value) => {
312
358
  const nullableValue = nullablePair.fromAirtable(value);
313
359
  if (nullableValue === null) {
314
- throw new Error(`[airtable-ts] Expected non-null or non-empty value to map to number for field type ${airtableType}`);
360
+ throw new AirtableTsError_1.AirtableTsError({
361
+ message: `Cannot convert null value to number for field type '${airtableType}'.`,
362
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
363
+ suggestion: 'Provide a non-null value for this field or update your schema to allow null values.',
364
+ });
315
365
  }
316
366
  return nullableValue;
317
367
  },
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.mapRecordFieldNamesTsToAirtable = exports.mapRecordFieldNamesAirtableToTs = void 0;
4
+ const AirtableTsError_1 = require("../AirtableTsError");
4
5
  /**
5
6
  * Maps a TS object (matching table.mappings) to another TS object (matching table.schema),
6
7
  * mapping columns based on the table definition.
@@ -86,13 +87,23 @@ const mapRecordFieldNamesTsToAirtable = (table, item) => {
86
87
  return mappingToAirtable.map((airtableFieldName) => [airtableFieldName, null]);
87
88
  }
88
89
  // This should be unreachable because of our types
89
- throw new Error(`[airtable-ts] Expected field ${table.name}.${outputFieldName} to match type \`${tsType}\` but got null. This should never happen in normal operation as it should be caught before this point.`);
90
+ throw new AirtableTsError_1.AirtableTsError({
91
+ message: `Received null for non-nullable field '${outputFieldName}' (${mappingToAirtable}) with type '${tsType}' in table '${table.name}' (${table.tableId}). This should never happen in normal operation as it should be caught before this point.`,
92
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
93
+ });
90
94
  }
91
95
  if (!Array.isArray(value)) {
92
- throw new Error(`[airtable-ts] Got non-array type ${typeof value} for ${table.name}.${outputFieldName}, but expected ${table.schema[outputFieldName]}.`);
96
+ throw new AirtableTsError_1.AirtableTsError({
97
+ message: `Expected an array for field '${outputFieldName}' (${mappingToAirtable}) in table '${table.name}' (${table.tableId}), but received ${typeof value}.`,
98
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
99
+ });
93
100
  }
94
101
  if (value.length !== mappingToAirtable.length) {
95
- throw new Error(`[airtable-ts] Got ${value.length} values for ${table.name}.${outputFieldName}, but ${mappingToAirtable.length} mappings. Expected these to be the same.`);
102
+ throw new AirtableTsError_1.AirtableTsError({
103
+ message: `Array length mismatch for field '${outputFieldName}' (${JSON.stringify(mappingToAirtable)}) in table '${table.name}' (${table.tableId}): received ${value.length} values but had mappings for ${mappingToAirtable.length}.`,
104
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
105
+ suggestion: 'Ensure the array length matches the number of mapped fields in your table definition.',
106
+ });
96
107
  }
97
108
  return mappingToAirtable.map((airtableFieldName, index) => [airtableFieldName, value[index]]);
98
109
  }
@@ -1,17 +1,17 @@
1
1
  import { FieldSet } from 'airtable';
2
- import { AirtableRecord, AirtableTable } from '../types';
2
+ import { AirtableRecord, AirtableTsTable } from '../types';
3
3
  import { FromTsTypeString, Item, Table, TsTypeString } from './typeUtils';
4
4
  export declare const mapRecordFromAirtable: <T extends Item>(table: Table<T>, record: AirtableRecord) => T;
5
- export declare const mapRecordToAirtable: <T extends Item>(table: Table<T>, item: Partial<T>, airtableTable: AirtableTable) => FieldSet;
5
+ export declare const mapRecordToAirtable: <T extends Item>(table: Table<T>, item: Partial<T>, airtableTsTable: AirtableTsTable) => FieldSet;
6
6
  export declare const visibleForTesting: {
7
7
  mapRecordTypeAirtableToTs: <T extends {
8
8
  [fieldNameOrId: string]: TsTypeString;
9
- }>(tsTypes: T, record: AirtableRecord) => { [F in keyof T]: FromTsTypeString<T[F]>; } & {
9
+ }>(table: Table<Item>, tsTypes: T, record: AirtableRecord) => { [F in keyof T]: FromTsTypeString<T[F]>; } & {
10
10
  id: string;
11
11
  };
12
12
  mapRecordTypeTsToAirtable: <T_1 extends {
13
13
  [fieldNameOrId: string]: TsTypeString;
14
14
  }, R extends { [K in keyof T_1]?: FromTsTypeString<T_1[K]>; } & {
15
15
  id?: string;
16
- }>(tsTypes: T_1, tsRecord: R, airtableTable: AirtableTable) => FieldSet;
16
+ }>(table: Table<Item>, tsTypes: T_1, tsRecord: R, airtableTsTable: AirtableTsTable) => FieldSet;
17
17
  };
@@ -4,19 +4,28 @@ exports.visibleForTesting = exports.mapRecordToAirtable = exports.mapRecordFromA
4
4
  const fieldMappers_1 = require("./fieldMappers");
5
5
  const nameMapper_1 = require("./nameMapper");
6
6
  const typeUtils_1 = require("./typeUtils");
7
+ const AirtableTsError_1 = require("../AirtableTsError");
7
8
  const getMapper = (tsType, airtableType) => {
8
9
  const tsMapper = fieldMappers_1.fieldMappers[tsType];
9
10
  if (!tsMapper) {
10
- throw new Error(`[airtable-ts] No mappers for ts type ${tsType}`);
11
+ throw new AirtableTsError_1.AirtableTsError({
12
+ message: `No mapper exists for TypeScript type '${tsType}'.`,
13
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
14
+ suggestion: 'Check that you are using a supported TypeScript type in your schema definition.',
15
+ });
11
16
  }
12
17
  if (tsMapper[airtableType]) {
13
18
  return tsMapper[airtableType];
14
19
  }
15
20
  if (tsMapper.unknown) {
16
- console.warn(`[airtable-ts] Unknown airtable type ${airtableType}. This is not fully supported and exact mapping behaviour may change in a future release.`);
21
+ console.warn(`[airtable-ts] Unknown airtable type ${airtableType} for tsType ${tsType}. This is not fully supported and exact mapping behaviour may change in a future release.`);
17
22
  return tsMapper.unknown;
18
23
  }
19
- throw new Error(`[airtable-ts] Expected to be able to map to ts type ${tsType}, but got airtable type ${airtableType} which can't.`);
24
+ throw new AirtableTsError_1.AirtableTsError({
25
+ message: `Cannot map Airtable type '${airtableType}' to TypeScript type '${tsType}'.`,
26
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
27
+ suggestion: 'Check that your schema definition uses TypeScript types that are compatible with the Airtable field types.',
28
+ });
20
29
  };
21
30
  /**
22
31
  * This function coerces an Airtable record to a TypeScript object, given an
@@ -33,13 +42,17 @@ const getMapper = (tsType, airtableType) => {
33
42
  * @returns An object matching the TypeScript type passed in, based on the Airtable record. Throws if cannot coerce to requested type.
34
43
  * @example { id: 'rec012', a: 'Some text', b: 123, c: false, d: 'rec345' }
35
44
  */
36
- const mapRecordTypeAirtableToTs = (tsTypes, record) => {
45
+ const mapRecordTypeAirtableToTs = (table, tsTypes, record) => {
37
46
  const item = {};
38
47
  Object.entries(tsTypes).forEach(([fieldNameOrId, tsType]) => {
39
48
  // eslint-disable-next-line no-underscore-dangle
40
49
  const fieldDefinition = record._table.fields.find((f) => f.id === fieldNameOrId || f.name === fieldNameOrId);
41
50
  if (!fieldDefinition) {
42
- throw new Error(`[airtable-ts] Failed to get Airtable field ${fieldNameOrId}`);
51
+ // This should not happen normally, as we should only be trying to map fields that are in the table definition
52
+ throw new AirtableTsError_1.AirtableTsError({
53
+ message: `Field '${fieldNameOrId}' does not exist in the table definition. This error should not happen in normal operation.`,
54
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
55
+ });
43
56
  }
44
57
  const value = record.fields[fieldDefinition.name];
45
58
  try {
@@ -48,13 +61,8 @@ const mapRecordTypeAirtableToTs = (tsTypes, record) => {
48
61
  item[fieldNameOrId] = fromAirtable(value);
49
62
  }
50
63
  catch (error) {
51
- if (error instanceof Error) {
52
- // eslint-disable-next-line no-underscore-dangle
53
- error.message = `Failed to map field ${record._table.name}.${fieldNameOrId}: ${error.message}`;
54
- // eslint-disable-next-line no-underscore-dangle
55
- error.stack = `Error: Failed to map field ${record._table.name}.${fieldNameOrId}: ${error.stack?.startsWith('Error: ') ? error.stack.slice('Error: '.length) : error.stack}`;
56
- }
57
- throw error;
64
+ const tsName = table.mappings ? Object.entries(table.mappings).find((e) => e[1] === fieldNameOrId)?.[0] : undefined;
65
+ throw (0, AirtableTsError_1.prependError)(error, `Failed to map field ${tsName ? `${tsName} (${fieldNameOrId})` : fieldNameOrId} from Airtable`);
58
66
  }
59
67
  });
60
68
  return Object.assign(item, { id: record.id });
@@ -71,13 +79,13 @@ const mapRecordTypeAirtableToTs = (tsTypes, record) => {
71
79
  * @param tsRecord TypeScript object to convert.
72
80
  * @example { a: 'Some text', b: 123, c: false, d: 'rec123' }
73
81
  *
74
- * @param airtableTable An Airtable table.
82
+ * @param airtableTsTable An Airtable table.
75
83
  * @example { fields: { a: 'singleLineText', b: 'number', c: 'checkbox', d: 'multipleRecordLinks' }, ... }
76
84
  *
77
85
  * @returns An Airtable FieldSet. Throws if cannot coerce to requested type.
78
86
  * @example { a: 'Some text', b: 123, d: ['rec123'] } // (c is an un-ticked checkbox, d is a multipleRecordLinks)
79
87
  */
80
- const mapRecordTypeTsToAirtable = (tsTypes, tsRecord, airtableTable) => {
88
+ const mapRecordTypeTsToAirtable = (table, tsTypes, tsRecord, airtableTsTable) => {
81
89
  const item = {};
82
90
  Object.entries(tsTypes).forEach(([fieldNameOrId, tsType]) => {
83
91
  const value = tsRecord[fieldNameOrId];
@@ -87,12 +95,21 @@ const mapRecordTypeTsToAirtable = (tsTypes, tsRecord, airtableTable) => {
87
95
  }
88
96
  if (!(0, typeUtils_1.matchesType)(value, tsType)) {
89
97
  // This should be unreachable because of our types
90
- throw new Error(`[airtable-ts] Expected field ${airtableTable.name}.${fieldNameOrId} to match type \`${tsType}\` but got value \`${JSON.stringify(value)}\`. This should never happen in normal operation as it should be caught before this point.`);
98
+ throw new AirtableTsError_1.AirtableTsError({
99
+ message: `Type mismatch for field '${fieldNameOrId}': expected ${tsType} but got a ${typeof value}.`,
100
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
101
+ suggestion: 'Ensure the value matches the expected type in your schema definition.',
102
+ });
91
103
  }
92
104
  // eslint-disable-next-line no-underscore-dangle
93
- const fieldDefinition = airtableTable.fields.find((f) => f.id === fieldNameOrId || f.name === fieldNameOrId);
105
+ const fieldDefinition = airtableTsTable.fields.find((f) => f.id === fieldNameOrId || f.name === fieldNameOrId);
94
106
  if (!fieldDefinition) {
95
- throw new Error(`[airtable-ts] Failed to get Airtable field ${fieldNameOrId}`);
107
+ const tsName = table.mappings ? Object.entries(table.mappings).find((e) => e[1] === fieldNameOrId)?.[0] : undefined;
108
+ throw new AirtableTsError_1.AirtableTsError({
109
+ message: `Field ${tsName ? `${tsName} (${fieldNameOrId})` : fieldNameOrId} does not exist in the Airtable table.`,
110
+ type: AirtableTsError_1.ErrorType.RESOURCE_NOT_FOUND,
111
+ suggestion: 'Verify that the field exists in your Airtable base and that you are using the correct field name or ID.',
112
+ });
96
113
  }
97
114
  try {
98
115
  const { toAirtable } = getMapper(tsType, fieldDefinition.type);
@@ -100,29 +117,34 @@ const mapRecordTypeTsToAirtable = (tsTypes, tsRecord, airtableTable) => {
100
117
  item[fieldNameOrId] = toAirtable(value);
101
118
  }
102
119
  catch (error) {
103
- if (error instanceof Error) {
104
- // eslint-disable-next-line no-underscore-dangle
105
- error.message = `Failed to map field ${airtableTable.name}.${fieldNameOrId}: ${error.message}`;
106
- // eslint-disable-next-line no-underscore-dangle
107
- error.stack = `Error: Failed to map field ${airtableTable.name}.${fieldNameOrId}: ${error.stack?.startsWith('Error: ') ? error.stack.slice('Error: '.length) : error.stack}`;
108
- }
109
- throw error;
120
+ const tsName = table.mappings ? Object.entries(table.mappings).find((e) => e[1] === fieldNameOrId)?.[0] : undefined;
121
+ throw (0, AirtableTsError_1.prependError)(error, `Failed to map field ${tsName ? `${tsName} (${fieldNameOrId})` : fieldNameOrId} to Airtable`);
110
122
  }
111
123
  });
112
124
  return Object.assign(item, { id: tsRecord.id });
113
125
  };
114
126
  const mapRecordFromAirtable = (table, record) => {
115
- const tsTypes = (0, typeUtils_1.airtableFieldNameTsTypes)(table);
116
- const tsRecord = mapRecordTypeAirtableToTs(tsTypes, record);
117
- const mappedRecord = (0, nameMapper_1.mapRecordFieldNamesAirtableToTs)(table, tsRecord);
118
- return mappedRecord;
127
+ try {
128
+ const tsTypes = (0, typeUtils_1.airtableFieldNameTsTypes)(table);
129
+ const tsRecord = mapRecordTypeAirtableToTs(table, tsTypes, record);
130
+ const mappedRecord = (0, nameMapper_1.mapRecordFieldNamesAirtableToTs)(table, tsRecord);
131
+ return mappedRecord;
132
+ }
133
+ catch (error) {
134
+ throw (0, AirtableTsError_1.prependError)(error, `Failed to map record from Airtable format for table '${table.name}' (${table.tableId}) and record ${record.id}`);
135
+ }
119
136
  };
120
137
  exports.mapRecordFromAirtable = mapRecordFromAirtable;
121
- const mapRecordToAirtable = (table, item, airtableTable) => {
122
- const mappedItem = (0, nameMapper_1.mapRecordFieldNamesTsToAirtable)(table, item);
123
- const tsTypes = (0, typeUtils_1.airtableFieldNameTsTypes)(table);
124
- const fieldSet = mapRecordTypeTsToAirtable(tsTypes, mappedItem, airtableTable);
125
- return fieldSet;
138
+ const mapRecordToAirtable = (table, item, airtableTsTable) => {
139
+ try {
140
+ const mappedItem = (0, nameMapper_1.mapRecordFieldNamesTsToAirtable)(table, item);
141
+ const tsTypes = (0, typeUtils_1.airtableFieldNameTsTypes)(table);
142
+ const fieldSet = mapRecordTypeTsToAirtable(table, tsTypes, mappedItem, airtableTsTable);
143
+ return fieldSet;
144
+ }
145
+ catch (error) {
146
+ throw (0, AirtableTsError_1.prependError)(error, `Failed to map record to Airtable format for table '${table.name}' (${table.tableId})`);
147
+ }
126
148
  };
127
149
  exports.mapRecordToAirtable = mapRecordToAirtable;
128
150
  exports.visibleForTesting = {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.airtableFieldNameTsTypes = exports.matchesType = exports.parseType = void 0;
4
+ const AirtableTsError_1 = require("../AirtableTsError");
4
5
  const parseType = (t) => {
5
6
  if (t.endsWith('[] | null')) {
6
7
  return {
@@ -74,7 +75,10 @@ const arrayToSingleType = (tsType) => {
74
75
  if (tsType.endsWith('[]')) {
75
76
  return tsType.slice(0, -'[]'.length);
76
77
  }
77
- throw new Error(`[airtable-ts] Not an array type: ${tsType}`);
78
+ throw new AirtableTsError_1.AirtableTsError({
79
+ message: `The type '${tsType}' is not an array type.`,
80
+ type: AirtableTsError_1.ErrorType.SCHEMA_VALIDATION,
81
+ });
78
82
  };
79
83
  /**
80
84
  * Constructs a TypeScript object type definition given a table definition
@@ -98,13 +102,18 @@ const airtableFieldNameTsTypes = (table) => {
98
102
  const schemaEntries = Object.entries(table.schema);
99
103
  return Object.fromEntries(schemaEntries.map(([outputFieldName, tsType]) => {
100
104
  const mappingToAirtable = table.mappings?.[outputFieldName];
101
- if (!mappingToAirtable) {
102
- return [[outputFieldName, tsType]];
105
+ try {
106
+ if (!mappingToAirtable) {
107
+ return [[outputFieldName, tsType]];
108
+ }
109
+ if (Array.isArray(mappingToAirtable)) {
110
+ return mappingToAirtable.map((airtableFieldName) => [airtableFieldName, arrayToSingleType(tsType)]);
111
+ }
112
+ return [[mappingToAirtable, tsType]];
103
113
  }
104
- if (Array.isArray(mappingToAirtable)) {
105
- return mappingToAirtable.map((airtableFieldName) => [airtableFieldName, arrayToSingleType(tsType)]);
114
+ catch (error) {
115
+ throw (0, AirtableTsError_1.prependError)(error, `Error with field ${JSON.stringify(outputFieldName)} (${mappingToAirtable})`);
106
116
  }
107
- return [[mappingToAirtable, tsType]];
108
117
  }).flat(1));
109
118
  };
110
119
  exports.airtableFieldNameTsTypes = airtableFieldNameTsTypes;
package/dist/types.d.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import type { FieldSet, Table as AirtableSdkTable, Record as AirtableSdkRecord, AirtableOptions } from 'airtable';
2
+ import { QueryParams } from 'airtable/lib/query_params';
3
+ import { Item, Table } from './mapping/typeUtils';
2
4
  export type AirtableRecord = Omit<AirtableSdkRecord<FieldSet>, '_table'> & {
3
- _table: AirtableTable;
5
+ _table: AirtableTsTable;
4
6
  };
5
- export type AirtableTable = AirtableSdkTable<FieldSet> & {
7
+ export type AirtableTsTable<T extends Item = Item> = AirtableSdkTable<FieldSet> & {
6
8
  fields: {
7
9
  id: string;
8
10
  name: string;
9
11
  type: string;
10
12
  }[];
13
+ tsDefinition: Table<T>;
14
+ __brand?: T;
11
15
  };
12
16
  interface AirtableTsSpecificOptions {
13
17
  /** The Airtable base schema is used to determine the appropriate type mapper for the field type (for example converting a number to a string representing a date is different to converting a number to a singleLineText). For performance reasons, airtable-ts caches base schemas so we don't refetch it for every request. Note that we always still do validation against the expected type at runtime so the library is always type-safe. @default 120_000 */
@@ -15,4 +19,5 @@ interface AirtableTsSpecificOptions {
15
19
  }
16
20
  export type AirtableTsOptions = AirtableOptions & AirtableTsSpecificOptions;
17
21
  export type CompleteAirtableTsOptions = AirtableTsOptions & Required<AirtableTsSpecificOptions>;
22
+ export type ScanParams = Omit<QueryParams<unknown>, 'fields' | 'cellFormat' | 'method' | 'returnFieldsByFieldId' | 'pageSize' | 'offset'>;
18
23
  export {};
@@ -0,0 +1,13 @@
1
+ import AirtableError from 'airtable/lib/airtable_error';
2
+ export declare class WrappedAirtableError extends Error {
3
+ /** The original error thrown by Airtable.js */
4
+ originalError: AirtableError;
5
+ /** The error type from Airtable.js */
6
+ error?: string;
7
+ /** The HTTP status code if applicable */
8
+ statusCode?: number;
9
+ constructor(originalError: AirtableError);
10
+ }
11
+ export declare const wrapToCatchAirtableErrors: <T extends {
12
+ prototype: object;
13
+ }>(c: T) => void;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.wrapToCatchAirtableErrors = exports.WrappedAirtableError = void 0;
7
+ const airtable_error_1 = __importDefault(require("airtable/lib/airtable_error"));
8
+ class WrappedAirtableError extends Error {
9
+ /** The original error thrown by Airtable.js */
10
+ originalError;
11
+ /** The error type from Airtable.js */
12
+ error;
13
+ /** The HTTP status code if applicable */
14
+ statusCode;
15
+ constructor(originalError) {
16
+ super(originalError.message);
17
+ this.name = 'WrappedAirtableError';
18
+ this.originalError = originalError;
19
+ this.error = originalError.error;
20
+ this.statusCode = originalError.statusCode;
21
+ }
22
+ }
23
+ exports.WrappedAirtableError = WrappedAirtableError;
24
+ /**
25
+ * Wraps any error thrown that isn't a proper Error object to ensure it has a stack trace for debugging.
26
+ * @see https://github.com/Airtable/airtable.js/issues/294
27
+ */
28
+ function wrapAirtableError(error) {
29
+ if (error instanceof Error) {
30
+ return error;
31
+ }
32
+ if (error instanceof airtable_error_1.default) {
33
+ return new WrappedAirtableError(error);
34
+ }
35
+ return new Error(String(error));
36
+ }
37
+ const wrapToCatchAirtableErrors = (c) => {
38
+ // Cast to any to bypass TypeScript's type checking, as unfortunately this is too funky for TypeScript
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ const prototype = c.prototype;
41
+ const methods = Object.getOwnPropertyNames(prototype).filter((prop) => {
42
+ return prop !== 'constructor' && typeof prototype[prop] === 'function';
43
+ });
44
+ methods.forEach((method) => {
45
+ const original = prototype[method];
46
+ if (typeof original === 'function') {
47
+ // eslint-disable-next-line func-names
48
+ prototype[method] = function (...args) {
49
+ try {
50
+ const result = original.apply(this, args);
51
+ if (result instanceof Promise) {
52
+ return result.catch((error) => {
53
+ throw wrapAirtableError(error);
54
+ });
55
+ }
56
+ return result;
57
+ }
58
+ catch (error) {
59
+ throw wrapAirtableError(error);
60
+ }
61
+ };
62
+ }
63
+ });
64
+ };
65
+ exports.wrapToCatchAirtableErrors = wrapToCatchAirtableErrors;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airtable-ts",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "A type-safe Airtable SDK",
5
5
  "license": "MIT",
6
6
  "author": "Adam Jones (domdomegg)",
@@ -1,4 +0,0 @@
1
- import Airtable from 'airtable';
2
- import { Item, Table } from './mapping/typeUtils';
3
- import { AirtableTable, CompleteAirtableTsOptions } from './types';
4
- export declare const getAirtableTable: <T extends Item>(airtable: Airtable, table: Table<T>, options: CompleteAirtableTsOptions) => Promise<AirtableTable>;
@@ -1,60 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getAirtableTable = void 0;
7
- const axios_1 = __importDefault(require("axios"));
8
- const getAirtableTable = async (airtable, table, options) => {
9
- const airtableTable = airtable.base(table.baseId).table(table.tableId);
10
- // We need the schema so we know which type mapper to use.
11
- // Even if we were inferring a type mapper from the schema, we'd still have to
12
- // do this as otherwise we can't distinguish a column with all null values from
13
- // a column that is missing entirely. We'd like to do that as for safety, we'd
14
- // rather throw an error if the column is missing entirely; this suggests a
15
- // misconfiguration. But an all-null column is okay. The particular case that
16
- // this is likely for is checkbox columns.
17
- const baseSchema = await getAirtableBaseSchema(table.baseId, options);
18
- const tableDefinition = baseSchema.find((t) => t.id === table.tableId);
19
- if (!tableDefinition) {
20
- throw new Error(`[airtable-ts] Failed to find table ${table.tableId} in base ${table.baseId}`);
21
- }
22
- return Object.assign(airtableTable, { fields: tableDefinition.fields });
23
- };
24
- exports.getAirtableTable = getAirtableTable;
25
- const baseSchemaCache = new Map();
26
- /**
27
- * Get the schemas from the cache or Airtable API for the tables in the given base.
28
- * @see https://airtable.com/developers/web/api/get-base-schema
29
- * @param baseId The base id to get the schemas for
30
- */
31
- const getAirtableBaseSchema = async (baseId, options) => {
32
- const fromCache = baseSchemaCache.get(baseId);
33
- if (fromCache && Date.now() - fromCache.at < options.baseSchemaCacheDurationMs) {
34
- return fromCache.data;
35
- }
36
- // eslint-disable-next-line no-underscore-dangle
37
- if (!options.apiKey) {
38
- throw new Error('[airtable-ts] Missing API key');
39
- }
40
- const res = await (0, axios_1.default)({
41
- baseURL: options.endpointUrl ?? 'https://api.airtable.com',
42
- url: `/v0/meta/bases/${baseId}/tables`,
43
- ...(options.requestTimeout ? { timeout: options.requestTimeout } : {}),
44
- headers: {
45
- // eslint-disable-next-line no-underscore-dangle
46
- Authorization: `Bearer ${options.apiKey}`,
47
- ...options.customHeaders,
48
- },
49
- });
50
- const baseSchema = res.data.tables;
51
- if (baseSchemaCache.size > 100) {
52
- baseSchemaCache.clear();
53
- // If you're seeing this warning, then we probably either need to:
54
- // - Update the maximum limit before clearing the cache, provided we have memory headroom; or
55
- // - Use a last recently used cache or similar
56
- console.warn('[airtable-ts] baseSchemaCache cleared to avoid a memory leak: this code is not currently optimized for accessing over 100 different bases from a single instance');
57
- }
58
- baseSchemaCache.set(baseId, { at: Date.now(), data: baseSchema });
59
- return baseSchema;
60
- };