airtable-ts 1.2.0 → 1.3.1

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
@@ -27,21 +27,22 @@ const db = new AirtableTs({
27
27
  apiKey: 'pat1234.abcdef',
28
28
  });
29
29
 
30
- export const studentTable: Table<{ id: string, name: string, classes: string[] }> = {
30
+ // Tip: use airtable-ts-codegen to autogenerate these from your Airtable base
31
+ export const studentTable: Table<{ id: string, firstName: string, classes: string[] }> = {
31
32
  name: 'student',
32
33
  baseId: 'app1234',
33
34
  tableId: 'tbl1234',
34
- schema: { name: 'string', classes: 'string[]' },
35
+ schema: { firstName: 'string', classes: 'string[]' },
35
36
  // optional: use mappings with field ids to prevent renamings breaking your app,
36
37
  // or with field names to make handling renamings easy
37
- mappings: { name: 'fld1234', classes: 'Classes student is enrolled in' },
38
+ mappings: { firstName: 'fld1234', classes: 'Classes student is enrolled in' },
38
39
  };
39
40
 
40
- export const classTable: Table<{ id: string, name: string }> = {
41
+ export const classTable: Table<{ id: string, title: string }> = {
41
42
  name: 'class',
42
43
  baseId: 'app1234',
43
44
  tableId: 'tbl4567',
44
- schema: { name: 'string' },
45
+ schema: { title: 'string' },
45
46
  };
46
47
 
47
48
  // Now we can get all the records in a table (a scan)
@@ -49,18 +50,18 @@ const classes = await db.scan(classTable);
49
50
 
50
51
  // Get, update and delete specific records:
51
52
  const student = await db.get(studentTable, 'rec1234');
52
- await db.update(studentTable, { id: 'rec1234', name: 'Adam' });
53
+ await db.update(studentTable, { id: 'rec1234', firstName: 'Adam' });
53
54
  await db.remove(studentTable, 'rec5678');
54
55
 
55
56
  // Or for a more involved example:
56
- async function prefixNameOfFirstClassOfFirstStudent(namePrefix: string) {
57
+ async function prefixTitleOfFirstClassOfFirstStudent(prefix: string) {
57
58
  const students = await db.scan(studentTable);
58
59
  if (!students[0]) throw new Error('There are no students');
59
60
  if (!students[0].classes[0]) throw new Error('First student does not have a class');
60
61
 
61
62
  const currentClass = await db.get(classTable, students[0].classes[0]);
62
- const newName = namePrefix + currentClass.name;
63
- await db.update(classTable, { id: currentClass.id, name: newName });
63
+ const newTitle = prefix + currentClass.title;
64
+ await db.update(classTable, { id: currentClass.id, title: newTitle });
64
65
  }
65
66
 
66
67
  // And should you ever need it, access to the raw Airtable JS SDK
@@ -12,7 +12,7 @@ const typeUtils_1 = require("./mapping/typeUtils");
12
12
  * @param table
13
13
  * @param data
14
14
  */
15
- function assertMatchesSchema(table, data, mode = 'full') {
15
+ function assertMatchesSchema(table, data, mode = 'partial') {
16
16
  if (typeof data !== 'object' || data === null) {
17
17
  throw new Error(`[airtable-ts] Item for ${table.name} is not an object`);
18
18
  }
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare class AirtableTs {
8
8
  constructor(options: AirtableTsOptions);
9
9
  get<T extends Item>(table: Table<T>, id: string): Promise<T>;
10
10
  scan<T extends Item>(table: Table<T>, params?: ScanParams): Promise<T[]>;
11
- insert<T extends Item>(table: Table<T>, data: Omit<T, 'id'>): Promise<T>;
11
+ insert<T extends Item>(table: Table<T>, data: Partial<Omit<T, 'id'>>): Promise<T>;
12
12
  update<T extends Item>(table: Table<T>, data: Partial<T> & {
13
13
  id: string;
14
14
  }): Promise<T>;
package/dist/index.js CHANGED
@@ -46,7 +46,7 @@ class AirtableTs {
46
46
  return (0, recordMapper_1.mapRecordFromAirtable)(table, record);
47
47
  }
48
48
  async update(table, data) {
49
- (0, assertMatchesSchema_1.assertMatchesSchema)(table, { ...data }, 'partial');
49
+ (0, assertMatchesSchema_1.assertMatchesSchema)(table, { ...data });
50
50
  const { id, ...withoutId } = data;
51
51
  const airtableTable = await (0, getAirtableTable_1.getAirtableTable)(this.airtable, table, this.options);
52
52
  const record = await airtableTable.update(data.id, (0, recordMapper_1.mapRecordToAirtable)(table, withoutId, airtableTable));
@@ -1,7 +1,7 @@
1
1
  import { AirtableTypeString, FromAirtableTypeString, FromTsTypeString, TsTypeString } from './typeUtils';
2
2
  type Mapper = {
3
3
  [T in TsTypeString]?: {
4
- [A in AirtableTypeString]?: {
4
+ [A in AirtableTypeString | 'unknown']?: {
5
5
  toAirtable: (value: FromTsTypeString<T>) => FromAirtableTypeString<A>;
6
6
  fromAirtable: (value: FromAirtableTypeString<A> | null | undefined) => FromTsTypeString<T>;
7
7
  };
@@ -1,129 +1,56 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fieldMappers = void 0;
4
- const required = (value) => {
5
- if (value === null || value === undefined) {
6
- throw new Error('[airtable-ts] Missing required value');
7
- }
8
- return value;
9
- };
4
+ const typeUtils_1 = require("./typeUtils");
10
5
  const fallbackMapperPair = (toFallback, fromFallback) => ({
11
6
  toAirtable: (value) => value ?? toFallback,
12
7
  fromAirtable: (value) => value ?? fromFallback,
13
8
  });
14
- const requiredMapperPair = {
15
- toAirtable: (value) => required(value),
16
- fromAirtable: (value) => required(value),
17
- };
18
- exports.fieldMappers = {
19
- string: {
20
- url: fallbackMapperPair('', ''),
21
- email: fallbackMapperPair('', ''),
22
- phoneNumber: fallbackMapperPair('', ''),
23
- singleLineText: fallbackMapperPair('', ''),
24
- multilineText: fallbackMapperPair('', ''),
25
- richText: fallbackMapperPair('', ''),
26
- singleSelect: fallbackMapperPair('', ''),
27
- externalSyncSource: {
28
- toAirtable: () => { throw new Error('[airtable-ts] externalSyncSource type field is readonly'); },
29
- fromAirtable: (value) => value ?? '',
30
- },
31
- multipleSelects: {
32
- toAirtable: (value) => {
33
- return [value];
34
- },
35
- fromAirtable: (value) => {
36
- if (!value) {
37
- throw new Error('[airtable-ts] Failed to coerce multipleSelects type field to a single string, as it was blank');
38
- }
39
- if (value.length !== 1) {
40
- throw new Error(`[airtable-ts] Can't coerce multipleSelects to a single string, as there were ${value?.length} entries`);
41
- }
42
- return value[0];
43
- },
44
- },
45
- multipleRecordLinks: {
46
- toAirtable: (value) => {
47
- return [value];
48
- },
49
- fromAirtable: (value) => {
50
- if (!value) {
51
- throw new Error('[airtable-ts] Failed to coerce multipleRecordLinks type field to a single string, as it was blank');
52
- }
53
- if (value.length !== 1) {
54
- throw new Error(`[airtable-ts] Can't coerce multipleRecordLinks to a single string, as there were ${value?.length} entries`);
55
- }
56
- return value[0];
57
- },
58
- },
59
- date: {
60
- toAirtable: (value) => {
61
- const date = new Date(value);
62
- if (Number.isNaN(date.getTime())) {
63
- throw new Error('[airtable-ts] Invalid date string');
64
- }
65
- return date.toJSON().slice(0, 10);
66
- },
67
- fromAirtable: (value) => {
68
- const date = new Date(value ?? '');
69
- if (Number.isNaN(date.getTime())) {
70
- throw new Error('[airtable-ts] Invalid date string');
71
- }
72
- return date.toJSON();
73
- },
74
- },
75
- dateTime: {
76
- toAirtable: (value) => {
77
- const date = new Date(value);
78
- if (Number.isNaN(date.getTime())) {
79
- throw new Error('[airtable-ts] Invalid dateTime string');
80
- }
81
- return date.toJSON();
82
- },
83
- fromAirtable: (value) => {
84
- const date = new Date(value ?? '');
85
- if (Number.isNaN(date.getTime())) {
86
- throw new Error('[airtable-ts] Invalid dateTime string');
87
- }
88
- return date.toJSON();
89
- },
90
- },
91
- multipleLookupValues: {
92
- toAirtable: () => { throw new Error('[airtable-ts] lookup type field is readonly'); },
93
- fromAirtable: (value) => {
94
- if (!value) {
95
- throw new Error('[airtable-ts] Failed to coerce lookup type field to a single string, as it was blank');
96
- }
97
- if (value.length !== 1) {
98
- throw new Error(`[airtable-ts] Can't coerce lookup to a single string, as there were ${value?.length} entries`);
99
- }
100
- if (typeof value[0] !== 'string') {
101
- throw new Error(`[airtable-ts] Can't coerce singular lookup to a single string, as it was of type ${typeof value[0]}`);
102
- }
103
- return value[0];
104
- },
105
- },
106
- rollup: {
107
- toAirtable: () => { throw new Error('[airtable-ts] rollup type field is readonly'); },
108
- fromAirtable: (value) => {
109
- if (typeof value === 'string')
110
- return value;
111
- if (value === undefined || value === null)
112
- return '';
113
- throw new Error(`[airtable-ts] Can't coerce rollup to a string, as it was of type ${typeof value}`);
114
- },
115
- },
116
- formula: {
117
- toAirtable: () => { throw new Error('[airtable-ts] formula type field is readonly'); },
118
- fromAirtable: (value) => {
119
- if (typeof value === 'string')
120
- return value;
121
- if (value === undefined || value === null)
122
- return '';
123
- throw new Error(`[airtable-ts] Can't coerce formula to a string, as it was of type ${typeof value}`);
124
- },
125
- },
9
+ const dateTimeMapperPair = {
10
+ // Number assumed to be unix time in seconds
11
+ toAirtable: (value) => {
12
+ if (value === null)
13
+ return null;
14
+ const date = new Date(typeof value === 'number' ? value * 1000 : value);
15
+ if (Number.isNaN(date.getTime())) {
16
+ throw new Error('[airtable-ts] Invalid dateTime');
17
+ }
18
+ return date.toJSON();
19
+ },
20
+ fromAirtable: (value) => {
21
+ if (value === null || value === undefined)
22
+ return null;
23
+ const date = new Date(value);
24
+ if (Number.isNaN(date.getTime())) {
25
+ throw new Error('[airtable-ts] Invalid dateTime');
26
+ }
27
+ return date.toJSON();
126
28
  },
29
+ };
30
+ const readonly = (airtableType) => () => { throw new Error(`[airtable-ts] ${airtableType} type field is readonly`); };
31
+ const coerce = (airtableType, tsType) => (value) => {
32
+ const parsedType = (0, typeUtils_1.parseType)(tsType);
33
+ if (!parsedType.array && typeof value === parsedType.single) {
34
+ return value;
35
+ }
36
+ if (parsedType.array && Array.isArray(value) && value.every((v) => typeof v === parsedType.single)) {
37
+ return value;
38
+ }
39
+ if (parsedType.nullable && (value === undefined || value === null || (Array.isArray(value) && value.length === 0))) {
40
+ return null;
41
+ }
42
+ if (parsedType.array && typeof value === parsedType.single) {
43
+ return [value];
44
+ }
45
+ if (!parsedType.array && Array.isArray(value) && value.length === 1 && typeof value[0] === parsedType.single) {
46
+ return value[0];
47
+ }
48
+ if (!parsedType.array && Array.isArray(value) && value.length !== 1) {
49
+ throw new Error(`[airtable-ts] Can't coerce ${airtableType} to a ${tsType}, as there were ${value.length} array entries`);
50
+ }
51
+ throw new Error(`[airtable-ts] Can't coerce ${airtableType} to a ${tsType}, as it was of type ${typeof value}`);
52
+ };
53
+ const stringOrNull = {
127
54
  'string | null': {
128
55
  url: fallbackMapperPair(null, null),
129
56
  email: fallbackMapperPair(null, null),
@@ -132,230 +59,57 @@ exports.fieldMappers = {
132
59
  multilineText: fallbackMapperPair(null, null),
133
60
  richText: fallbackMapperPair(null, null),
134
61
  singleSelect: fallbackMapperPair(null, null),
135
- externalSyncSource: {
136
- toAirtable: () => { throw new Error('[airtable-ts] externalSyncSource type field is readonly'); },
137
- fromAirtable: (value) => value ?? null,
138
- },
139
62
  multipleSelects: {
140
- toAirtable: (value) => {
141
- return value ? [value] : [];
142
- },
143
- fromAirtable: (value) => {
144
- if (!value || value.length === 0) {
145
- return null;
146
- }
147
- if (value.length !== 1) {
148
- throw new Error(`[airtable-ts] Can't coerce multipleSelects to a single string, as there were ${value?.length} entries`);
149
- }
150
- return value[0];
151
- },
63
+ toAirtable: (value) => (value ? [value] : []),
64
+ fromAirtable: coerce('multipleSelects', 'string | null'),
152
65
  },
153
66
  multipleRecordLinks: {
154
- toAirtable: (value) => {
155
- return value ? [value] : [];
156
- },
157
- fromAirtable: (value) => {
158
- if (!value || value.length === 0) {
159
- return null;
160
- }
161
- if (value.length !== 1) {
162
- throw new Error(`[airtable-ts] Can't coerce multipleRecordLinks to a single string, as there were ${value?.length} entries`);
163
- }
164
- return value[0];
165
- },
67
+ toAirtable: (value) => (value ? [value] : []),
68
+ fromAirtable: coerce('multipleRecordLinks', 'string | null'),
166
69
  },
167
70
  date: {
168
- toAirtable: (value) => {
169
- if (value === null)
170
- return null;
171
- const date = new Date(value);
172
- if (Number.isNaN(date.getTime())) {
173
- throw new Error('[airtable-ts] Invalid date');
174
- }
175
- return date.toJSON().slice(0, 10);
176
- },
177
- fromAirtable: (value) => {
178
- if (value === null || value === undefined)
179
- return null;
180
- const date = new Date(value);
181
- if (Number.isNaN(date.getTime())) {
182
- throw new Error('[airtable-ts] Invalid date');
183
- }
184
- return date.toJSON();
185
- },
186
- },
187
- dateTime: {
188
- toAirtable: (value) => {
189
- if (value === null)
190
- return null;
191
- const date = new Date(value);
192
- if (Number.isNaN(date.getTime())) {
193
- throw new Error('[airtable-ts] Invalid dateTime');
194
- }
195
- return date.toJSON();
196
- },
197
- fromAirtable: (value) => {
198
- if (value === null || value === undefined)
199
- return null;
200
- const date = new Date(value);
201
- if (Number.isNaN(date.getTime())) {
202
- throw new Error('[airtable-ts] Invalid dateTime');
203
- }
204
- return date.toJSON();
205
- },
71
+ toAirtable: (value) => dateTimeMapperPair.toAirtable(value)?.slice(0, 10) ?? null,
72
+ fromAirtable: dateTimeMapperPair.fromAirtable,
206
73
  },
74
+ dateTime: dateTimeMapperPair,
75
+ createdTime: dateTimeMapperPair,
76
+ lastModifiedTime: dateTimeMapperPair,
207
77
  multipleLookupValues: {
208
- toAirtable: () => { throw new Error('[airtable-ts] lookup type field is readonly'); },
209
- fromAirtable: (value) => {
210
- if (!value || value.length === 0) {
211
- return null;
212
- }
213
- if (value.length !== 1) {
214
- throw new Error(`[airtable-ts] Can't coerce lookup to a single string, as there were ${value?.length} entries`);
215
- }
216
- if (typeof value[0] !== 'string') {
217
- throw new Error(`[airtable-ts] Can't coerce singular lookup to a single string, as it was of type ${typeof value[0]}`);
218
- }
219
- return value[0];
220
- },
78
+ toAirtable: readonly('multipleLookupValues'),
79
+ fromAirtable: coerce('multipleLookupValues', 'string | null'),
80
+ },
81
+ externalSyncSource: {
82
+ toAirtable: readonly('externalSyncSource'),
83
+ fromAirtable: coerce('externalSyncSource', 'string | null'),
221
84
  },
222
85
  rollup: {
223
- toAirtable: () => { throw new Error('[airtable-ts] rollup type field is readonly'); },
224
- fromAirtable: (value) => {
225
- if (typeof value === 'string')
226
- return value;
227
- if (value === undefined || value === null)
228
- return null;
229
- throw new Error(`[airtable-ts] Can't coerce rollup to a string, as it was of type ${typeof value}`);
230
- },
86
+ toAirtable: readonly('rollup'),
87
+ fromAirtable: coerce('rollup', 'string | null'),
231
88
  },
232
89
  formula: {
233
- toAirtable: () => { throw new Error('[airtable-ts] formula type field is readonly'); },
234
- fromAirtable: (value) => {
235
- if (typeof value === 'string')
236
- return value;
237
- if (value === undefined || value === null)
238
- return null;
239
- throw new Error(`[airtable-ts] Can't coerce formula to a string, as it was of type ${typeof value}`);
240
- },
90
+ toAirtable: readonly('formula'),
91
+ fromAirtable: coerce('formula', 'string | null'),
241
92
  },
242
- },
243
- boolean: {
244
- checkbox: fallbackMapperPair(false, false),
245
- multipleLookupValues: {
246
- toAirtable: () => { throw new Error('[airtable-ts] lookup type field is readonly'); },
247
- fromAirtable: (value) => {
248
- if (!value) {
249
- throw new Error('[airtable-ts] Failed to coerce lookup type field to a single boolean, as it was blank');
250
- }
251
- if (value.length !== 1) {
252
- throw new Error(`[airtable-ts] Can't coerce lookup to a single boolean, as there were ${value?.length} entries`);
253
- }
254
- if (typeof value[0] !== 'boolean') {
255
- throw new Error(`[airtable-ts] Can't coerce singular lookup to a single boolean, as it was of type ${typeof value[0]}`);
256
- }
257
- return value[0];
258
- },
93
+ unknown: {
94
+ toAirtable: (value) => value,
95
+ fromAirtable: coerce('unknown', 'string | null'),
259
96
  },
260
97
  },
98
+ };
99
+ const booleanOrNull = {
261
100
  'boolean | null': {
262
101
  checkbox: fallbackMapperPair(null, null),
263
102
  multipleLookupValues: {
264
- toAirtable: () => { throw new Error('[airtable-ts] lookup type field is readonly'); },
265
- fromAirtable: (value) => {
266
- if (!value || value.length === 0) {
267
- return null;
268
- }
269
- if (value.length !== 1) {
270
- throw new Error(`[airtable-ts] Can't coerce lookup to a single boolean, as there were ${value?.length} entries`);
271
- }
272
- if (typeof value[0] !== 'boolean') {
273
- throw new Error(`[airtable-ts] Can't coerce singular lookup to a single boolean, as it was of type ${typeof value[0]}`);
274
- }
275
- return value[0];
276
- },
103
+ toAirtable: readonly('multipleLookupValues'),
104
+ fromAirtable: coerce('multipleLookupValues', 'boolean | null'),
277
105
  },
278
- },
279
- number: {
280
- number: requiredMapperPair,
281
- rating: requiredMapperPair,
282
- duration: requiredMapperPair,
283
- currency: requiredMapperPair,
284
- percent: requiredMapperPair,
285
- count: {
286
- toAirtable: () => { throw new Error('[airtable-ts] count type field is readonly'); },
287
- fromAirtable: (value) => required(value),
288
- },
289
- autoNumber: {
290
- toAirtable: () => { throw new Error('[airtable-ts] autoNumber type field is readonly'); },
291
- fromAirtable: (value) => required(value),
292
- },
293
- // Number assumed to be unix time in seconds
294
- date: {
295
- toAirtable: (value) => {
296
- const date = new Date(value * 1000);
297
- if (Number.isNaN(date.getTime())) {
298
- throw new Error('[airtable-ts] Invalid date');
299
- }
300
- return date.toJSON().slice(0, 10);
301
- },
302
- fromAirtable: (value) => {
303
- const date = new Date(value ?? '');
304
- if (Number.isNaN(date.getTime())) {
305
- throw new Error('[airtable-ts] Invalid date');
306
- }
307
- return Math.floor(date.getTime() / 1000);
308
- },
309
- },
310
- // Number assumed to be unix time in seconds
311
- dateTime: {
312
- toAirtable: (value) => {
313
- const date = new Date(value * 1000);
314
- if (Number.isNaN(date.getTime())) {
315
- throw new Error('[airtable-ts] Invalid dateTime');
316
- }
317
- return date.toJSON();
318
- },
319
- fromAirtable: (value) => {
320
- const date = new Date(value ?? '');
321
- if (Number.isNaN(date.getTime())) {
322
- throw new Error('[airtable-ts] Invalid dateTime');
323
- }
324
- return Math.floor(date.getTime() / 1000);
325
- },
326
- },
327
- multipleLookupValues: {
328
- toAirtable: () => { throw new Error('[airtable-ts] lookup type field is readonly'); },
329
- fromAirtable: (value) => {
330
- if (!value) {
331
- throw new Error('[airtable-ts] Failed to coerce lookup type field to a single number, as it was blank');
332
- }
333
- if (value.length !== 1) {
334
- throw new Error(`[airtable-ts] Can't coerce lookup to a single number, as there were ${value?.length} entries`);
335
- }
336
- if (typeof value[0] !== 'number') {
337
- throw new Error(`[airtable-ts] Can't coerce singular lookup to a single number, as it was of type ${typeof value[0]}`);
338
- }
339
- return value[0];
340
- },
341
- },
342
- rollup: {
343
- toAirtable: () => { throw new Error('[airtable-ts] rollup type field is readonly'); },
344
- fromAirtable: (value) => {
345
- if (typeof value === 'number')
346
- return value;
347
- throw new Error(`[airtable-ts] Can't coerce rollup to a number, as it was of type ${typeof value}`);
348
- },
349
- },
350
- formula: {
351
- toAirtable: () => { throw new Error('[airtable-ts] formula type field is readonly'); },
352
- fromAirtable: (value) => {
353
- if (typeof value === 'number')
354
- return value;
355
- throw new Error(`[airtable-ts] Can't coerce formula to a number, as it was of type ${typeof value}`);
356
- },
106
+ unknown: {
107
+ toAirtable: (value) => value,
108
+ fromAirtable: coerce('unknown', 'boolean | null'),
357
109
  },
358
110
  },
111
+ };
112
+ const numberOrNull = {
359
113
  'number | null': {
360
114
  number: fallbackMapperPair(null, null),
361
115
  rating: fallbackMapperPair(null, null),
@@ -364,150 +118,147 @@ exports.fieldMappers = {
364
118
  percent: fallbackMapperPair(null, null),
365
119
  count: {
366
120
  fromAirtable: (value) => value ?? null,
367
- toAirtable: () => { throw new Error('[airtable-ts] count type field is readonly'); },
121
+ toAirtable: readonly('count'),
368
122
  },
369
123
  autoNumber: {
370
124
  fromAirtable: (value) => value ?? null,
371
- toAirtable: () => { throw new Error('[airtable-ts] autoNumber field is readonly'); },
125
+ toAirtable: readonly('autoNumber'),
372
126
  },
373
- // Number assumed to be unix time in seconds
374
127
  date: {
375
- toAirtable: (value) => {
376
- if (value === null)
128
+ toAirtable: (value) => dateTimeMapperPair.toAirtable(value)?.slice(0, 10) ?? null,
129
+ fromAirtable: (value) => {
130
+ const nullableValue = dateTimeMapperPair.fromAirtable(value);
131
+ if (nullableValue === null)
377
132
  return null;
378
- const date = new Date(value * 1000);
133
+ const date = new Date(nullableValue);
379
134
  if (Number.isNaN(date.getTime())) {
380
135
  throw new Error('[airtable-ts] Invalid date');
381
136
  }
382
- return date.toJSON().slice(0, 10);
137
+ return Math.floor(date.getTime() / 1000);
383
138
  },
139
+ },
140
+ dateTime: {
141
+ toAirtable: dateTimeMapperPair.toAirtable,
384
142
  fromAirtable: (value) => {
385
- if (value === null || value === undefined)
143
+ const nullableValue = dateTimeMapperPair.fromAirtable(value);
144
+ if (nullableValue === null)
386
145
  return null;
387
- const date = new Date(value);
146
+ const date = new Date(nullableValue);
388
147
  if (Number.isNaN(date.getTime())) {
389
148
  throw new Error('[airtable-ts] Invalid date');
390
149
  }
391
150
  return Math.floor(date.getTime() / 1000);
392
151
  },
393
152
  },
394
- // Number assumed to be unix time in seconds
395
- dateTime: {
396
- toAirtable: (value) => {
397
- if (value === null)
153
+ createdTime: {
154
+ toAirtable: dateTimeMapperPair.toAirtable,
155
+ fromAirtable: (value) => {
156
+ const nullableValue = dateTimeMapperPair.fromAirtable(value);
157
+ if (nullableValue === null)
398
158
  return null;
399
- const date = new Date(value * 1000);
159
+ const date = new Date(nullableValue);
400
160
  if (Number.isNaN(date.getTime())) {
401
- throw new Error('[airtable-ts] Invalid dateTime');
161
+ throw new Error('[airtable-ts] Invalid date');
402
162
  }
403
- return date.toJSON();
163
+ return Math.floor(date.getTime() / 1000);
404
164
  },
165
+ },
166
+ lastModifiedTime: {
167
+ toAirtable: dateTimeMapperPair.toAirtable,
405
168
  fromAirtable: (value) => {
406
- if (value === null || value === undefined)
169
+ const nullableValue = dateTimeMapperPair.fromAirtable(value);
170
+ if (nullableValue === null)
407
171
  return null;
408
- const date = new Date(value);
172
+ const date = new Date(nullableValue);
409
173
  if (Number.isNaN(date.getTime())) {
410
- throw new Error('[airtable-ts] Invalid dateTime');
174
+ throw new Error('[airtable-ts] Invalid date');
411
175
  }
412
176
  return Math.floor(date.getTime() / 1000);
413
177
  },
414
178
  },
415
179
  multipleLookupValues: {
416
- toAirtable: () => { throw new Error('[airtable-ts] lookup type field is readonly'); },
417
- fromAirtable: (value) => {
418
- if (!value || value.length === 0) {
419
- return null;
420
- }
421
- if (value.length !== 1) {
422
- throw new Error(`[airtable-ts] Can't coerce lookup to a single number, as there were ${value?.length} entries`);
423
- }
424
- if (typeof value[0] !== 'number') {
425
- throw new Error(`[airtable-ts] Can't coerce singular lookup to a single number, as it was of type ${typeof value[0]}`);
426
- }
427
- return value[0];
428
- },
180
+ toAirtable: readonly('multipleLookupValues'),
181
+ fromAirtable: coerce('multipleLookupValues', 'number | null'),
429
182
  },
430
183
  rollup: {
431
- toAirtable: () => { throw new Error('[airtable-ts] rollup type field is readonly'); },
432
- fromAirtable: (value) => {
433
- if (typeof value === 'number')
434
- return value;
435
- if (value === null || value === undefined)
436
- return null;
437
- throw new Error(`[airtable-ts] Can't coerce rollup to a number, as it was of type ${typeof value}`);
438
- },
184
+ toAirtable: readonly('rollup'),
185
+ fromAirtable: coerce('rollup', 'number | null'),
439
186
  },
440
187
  formula: {
441
- toAirtable: () => { throw new Error('[airtable-ts] formula type field is readonly'); },
442
- fromAirtable: (value) => {
443
- if (typeof value === 'number')
444
- return value;
445
- if (value === null || value === undefined)
446
- return null;
447
- throw new Error(`[airtable-ts] Can't coerce formula to a number, as it was of type ${typeof value}`);
448
- },
188
+ toAirtable: readonly('formula'),
189
+ fromAirtable: coerce('formula', 'number | null'),
190
+ },
191
+ unknown: {
192
+ toAirtable: (value) => value,
193
+ fromAirtable: coerce('unknown', 'number | null'),
449
194
  },
450
195
  },
451
- 'string[]': {
196
+ };
197
+ const stringArrayOrNull = {
198
+ 'string[] | null': {
452
199
  multipleSelects: fallbackMapperPair([], []),
453
200
  multipleRecordLinks: fallbackMapperPair([], []),
454
201
  multipleLookupValues: {
455
202
  toAirtable: () => { throw new Error('[airtable-ts] lookup type field is readonly'); },
456
- fromAirtable: (value) => {
457
- if (!Array.isArray(value)) {
458
- throw new Error('[airtable-ts] Failed to coerce lookup type field to a string array, as it was not an array');
459
- }
460
- if (value.some((v) => typeof v !== 'string')) {
461
- throw new Error('[airtable-ts] Can\'t coerce lookup to a string array, as it had non string type');
462
- }
463
- return value;
464
- },
203
+ fromAirtable: coerce('multipleLookupValues', 'string[] | null'),
465
204
  },
466
205
  formula: {
467
206
  toAirtable: () => { throw new Error('[airtable-ts] formula type field is readonly'); },
468
- fromAirtable: (value) => {
469
- if (!Array.isArray(value)) {
470
- throw new Error('[airtable-ts] Failed to coerce formula type field to a string array, as it was not an array');
471
- }
472
- if (value.some((v) => typeof v !== 'string')) {
473
- throw new Error('[airtable-ts] Can\'t coerce formula to a string array, as it had non string type');
474
- }
475
- return value;
476
- },
207
+ fromAirtable: coerce('multipleLookupValues', 'string[] | null'),
477
208
  },
478
- },
479
- 'string[] | null': {
480
- multipleSelects: fallbackMapperPair(null, null),
481
- multipleRecordLinks: fallbackMapperPair(null, null),
482
- multipleLookupValues: {
483
- toAirtable: () => { throw new Error('[airtable-ts] lookup type field is readonly'); },
484
- fromAirtable: (value) => {
485
- if (!value && !Array.isArray(value)) {
486
- return null;
487
- }
488
- if (!Array.isArray(value)) {
489
- throw new Error('[airtable-ts] Failed to coerce lookup type field to a string array, as it was not an array');
490
- }
491
- if (value.some((v) => typeof v !== 'string')) {
492
- throw new Error('[airtable-ts] Can\'t coerce lookup to a string array, as it had non string type');
493
- }
494
- return value;
495
- },
496
- },
497
- formula: {
498
- toAirtable: () => { throw new Error('[airtable-ts] formula type field is readonly'); },
499
- fromAirtable: (value) => {
500
- if (!value && !Array.isArray(value)) {
501
- return null;
502
- }
503
- if (!Array.isArray(value)) {
504
- throw new Error('[airtable-ts] Failed to coerce formula type field to a string array, as it was not an array');
505
- }
506
- if (value.some((v) => typeof v !== 'string')) {
507
- throw new Error('[airtable-ts] Can\'t coerce formula to a string array, as it had non string type');
508
- }
509
- return value;
510
- },
209
+ unknown: {
210
+ toAirtable: (value) => value,
211
+ fromAirtable: coerce('unknown', 'string[] | null'),
511
212
  },
512
213
  },
513
214
  };
215
+ exports.fieldMappers = {
216
+ ...stringOrNull,
217
+ string: {
218
+ ...Object.fromEntries(Object.entries(stringOrNull['string | null']).map(([airtableType, nullablePair]) => {
219
+ return [airtableType, {
220
+ toAirtable: nullablePair.toAirtable,
221
+ fromAirtable: (value) => {
222
+ const nullableValue = nullablePair.fromAirtable(value);
223
+ if (nullableValue === null && ['multipleRecordLinks', 'dateTime', 'createdTime', 'lastModifiedTime'].includes(airtableType)) {
224
+ throw new Error(`[airtable-ts] Expected non-null or non-empty value to map to string for field type ${airtableType}`);
225
+ }
226
+ return nullableValue ?? '';
227
+ },
228
+ }];
229
+ })),
230
+ },
231
+ ...booleanOrNull,
232
+ boolean: {
233
+ ...Object.fromEntries(Object.entries(booleanOrNull['boolean | null']).map(([airtableType, nullablePair]) => {
234
+ return [airtableType, {
235
+ toAirtable: nullablePair.toAirtable,
236
+ fromAirtable: (value) => nullablePair.fromAirtable(value) ?? false,
237
+ }];
238
+ })),
239
+ },
240
+ ...numberOrNull,
241
+ number: {
242
+ ...Object.fromEntries(Object.entries(numberOrNull['number | null']).map(([airtableType, nullablePair]) => {
243
+ return [airtableType, {
244
+ toAirtable: nullablePair.toAirtable,
245
+ fromAirtable: (value) => {
246
+ const nullableValue = nullablePair.fromAirtable(value);
247
+ if (nullableValue === null) {
248
+ throw new Error(`[airtable-ts] Expected non-null or non-empty value to map to number for field type ${airtableType}`);
249
+ }
250
+ return nullableValue;
251
+ },
252
+ }];
253
+ })),
254
+ },
255
+ ...stringArrayOrNull,
256
+ 'string[]': {
257
+ ...Object.fromEntries(Object.entries(stringArrayOrNull['string[] | null']).map(([airtableType, nullablePair]) => {
258
+ return [airtableType, {
259
+ toAirtable: nullablePair.toAirtable,
260
+ fromAirtable: (value) => nullablePair.fromAirtable(value) ?? [],
261
+ }];
262
+ })),
263
+ },
264
+ };
@@ -4,6 +4,20 @@ 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 getMapper = (tsType, airtableType) => {
8
+ const tsMapper = fieldMappers_1.fieldMappers[tsType];
9
+ if (!tsMapper) {
10
+ throw new Error(`[airtable-ts] No mappers for ts type ${tsType}`);
11
+ }
12
+ if (tsMapper[airtableType]) {
13
+ return tsMapper[airtableType];
14
+ }
15
+ 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.`);
17
+ return tsMapper.unknown;
18
+ }
19
+ throw new Error(`[airtable-ts] Expected to be able to map to ts type ${tsType}, but got airtable type ${airtableType} which can't.`);
20
+ };
7
21
  /**
8
22
  * This function coerces an Airtable record to a TypeScript object, given an
9
23
  * object type definition. It will do this using the field mappers on each
@@ -28,18 +42,10 @@ const mapRecordTypeAirtableToTs = (tsTypes, record) => {
28
42
  throw new Error(`[airtable-ts] Failed to get Airtable field ${fieldNameOrId}`);
29
43
  }
30
44
  const value = record.fields[fieldDefinition.name];
31
- const tsMapper = fieldMappers_1.fieldMappers[tsType];
32
- if (!tsMapper) {
33
- throw new Error(`[airtable-ts] No mappers for ts type ${tsType}`);
34
- }
35
- const specificMapper = tsMapper[fieldDefinition.type]?.fromAirtable;
36
- if (!specificMapper) {
37
- // eslint-disable-next-line no-underscore-dangle
38
- throw new Error(`[airtable-ts] Expected field ${record._table.name}.${fieldNameOrId} to be able to map to ts type ${tsType}, but got airtable type ${fieldDefinition.type} which can't.`);
39
- }
40
45
  try {
46
+ const { fromAirtable } = getMapper(tsType, fieldDefinition.type);
41
47
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
- item[fieldNameOrId] = specificMapper(value);
48
+ item[fieldNameOrId] = fromAirtable(value);
43
49
  }
44
50
  catch (error) {
45
51
  if (error instanceof Error) {
@@ -88,16 +94,20 @@ const mapRecordTypeTsToAirtable = (tsTypes, tsRecord, airtableTable) => {
88
94
  if (!fieldDefinition) {
89
95
  throw new Error(`[airtable-ts] Failed to get Airtable field ${fieldNameOrId}`);
90
96
  }
91
- const tsMapper = fieldMappers_1.fieldMappers[tsType];
92
- if (!tsMapper) {
93
- throw new Error(`[airtable-ts] No mappers for ts type ${tsType}`);
97
+ try {
98
+ const { toAirtable } = getMapper(tsType, fieldDefinition.type);
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ item[fieldNameOrId] = toAirtable(value);
94
101
  }
95
- const specificMapper = tsMapper[fieldDefinition.type]?.toAirtable;
96
- if (!specificMapper) {
97
- throw new Error(`[airtable-ts] Expected field ${airtableTable.name}.${fieldNameOrId} to be able to map to airtable type \`${fieldDefinition.type}\`, but got ts type \`${tsType}\` which can't.`);
102
+ 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;
98
110
  }
99
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
- item[fieldNameOrId] = specificMapper(value);
101
111
  });
102
112
  return Object.assign(item, { id: tsRecord.id });
103
113
  };
@@ -3,7 +3,13 @@ type NonNullToString<T> = T extends string ? 'string' : T extends number ? 'numb
3
3
  export type ToTsTypeString<T> = null extends T ? `${NonNullToString<T>} | null` : NonNullToString<T>;
4
4
  export type FromTsTypeString<T> = T extends 'string' ? string : T extends 'string | null' ? string | null : T extends 'number' ? number : T extends 'number | null' ? number | null : T extends 'boolean' ? boolean : T extends 'boolean | null' ? boolean | null : T extends 'string[]' ? string[] : T extends 'string[] | null' ? string[] | null : T extends 'number[]' ? number[] : T extends 'number[] | null' ? number[] | null : T extends 'boolean[]' ? boolean[] : T extends 'boolean[] | null' ? boolean[] | null : never;
5
5
  export type AirtableTypeString = 'aiText' | 'autoNumber' | 'barcode' | 'button' | 'checkbox' | 'count' | 'createdBy' | 'createdTime' | 'currency' | 'date' | 'dateTime' | 'duration' | 'email' | 'externalSyncSource' | 'formula' | 'lastModifiedBy' | 'lastModifiedTime' | 'lookup' | 'multipleLookupValues' | 'multilineText' | 'multipleAttachments' | 'multipleCollaborators' | 'multipleRecordLinks' | 'multipleSelects' | 'number' | 'percent' | 'phoneNumber' | 'rating' | 'richText' | 'rollup' | 'singleCollaborator' | 'singleLineText' | 'singleSelect' | 'url';
6
- export type FromAirtableTypeString<T extends AirtableTypeString> = null | (T extends 'url' | 'email' | 'phoneNumber' | 'singleLineText' | 'multilineText' | 'richText' | 'singleSelect' | 'externalSyncSource' | 'date' | 'dateTime' | 'createdTime' | 'lastModifiedTime' ? string : T extends 'multipleRecordLinks' | 'multipleSelects' ? string[] : T extends 'number' | 'rating' | 'duration' | 'currency' | 'percent' | 'count' | 'autoNumber' ? number : T extends 'checkbox' ? boolean : T extends 'lookup' | 'multipleLookupValues' | 'rollup' | 'formula' ? FromAirtableTypeString<any>[] : never);
6
+ export type FromAirtableTypeString<T extends AirtableTypeString | 'unknown'> = null | (T extends 'url' | 'email' | 'phoneNumber' | 'singleLineText' | 'multilineText' | 'richText' | 'singleSelect' | 'externalSyncSource' | 'date' | 'dateTime' | 'createdTime' | 'lastModifiedTime' ? string : T extends 'multipleRecordLinks' | 'multipleSelects' ? string[] : T extends 'number' | 'rating' | 'duration' | 'currency' | 'percent' | 'count' | 'autoNumber' ? number : T extends 'checkbox' ? boolean : T extends 'lookup' | 'multipleLookupValues' | 'rollup' | 'formula' ? FromAirtableTypeString<any>[] : T extends 'unknown' ? unknown : never);
7
+ interface TypeDef {
8
+ single: 'string' | 'number' | 'boolean';
9
+ array: boolean;
10
+ nullable: boolean;
11
+ }
12
+ export declare const parseType: (t: TsTypeString) => TypeDef;
7
13
  /**
8
14
  * Verifies whether the given value is assignable to the given type
9
15
  *
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.airtableFieldNameTsTypes = exports.matchesType = void 0;
3
+ exports.airtableFieldNameTsTypes = exports.matchesType = exports.parseType = void 0;
4
4
  const parseType = (t) => {
5
5
  if (t.endsWith('[] | null')) {
6
6
  return {
@@ -29,6 +29,7 @@ const parseType = (t) => {
29
29
  nullable: false,
30
30
  };
31
31
  };
32
+ exports.parseType = parseType;
32
33
  /**
33
34
  * Verifies whether the given value is assignable to the given type
34
35
  *
@@ -42,7 +43,7 @@ const parseType = (t) => {
42
43
  * @example true
43
44
  */
44
45
  const matchesType = (value, tsType) => {
45
- const expectedType = parseType(tsType);
46
+ const expectedType = (0, exports.parseType)(tsType);
46
47
  if (expectedType.nullable && value === null) {
47
48
  return true;
48
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airtable-ts",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "A type-safe Airtable SDK",
5
5
  "license": "MIT",
6
6
  "author": "Adam Jones (domdomegg)",