@assembly-js/app-foundation 0.0.13 → 0.0.15

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.
@@ -0,0 +1,71 @@
1
+ import { type IntegrationClient } from './nango.js';
2
+ export interface AirtableFieldSchema {
3
+ id: string;
4
+ name: string;
5
+ type: string;
6
+ options?: Record<string, unknown>;
7
+ }
8
+ export interface AirtableTableSchema {
9
+ id: string;
10
+ name: string;
11
+ primaryFieldId: string;
12
+ fields: AirtableFieldSchema[];
13
+ }
14
+ export interface AirtableRecord {
15
+ id: string;
16
+ createdTime?: string;
17
+ fields: Record<string, unknown>;
18
+ }
19
+ export interface AirtableFormattedField {
20
+ raw: unknown;
21
+ display: string;
22
+ }
23
+ export interface AirtableFormattedRecord {
24
+ id: string;
25
+ createdTime?: string;
26
+ fields: Record<string, AirtableFormattedField>;
27
+ }
28
+ export type AirtableSchemaResult = {
29
+ ok: true;
30
+ schema: AirtableTableSchema;
31
+ } | {
32
+ ok: false;
33
+ error: {
34
+ code: string;
35
+ provider: string;
36
+ };
37
+ };
38
+ export interface FormatAirtableRecordsOpts {
39
+ /** The getIntegration('airtable') client. Required to resolve linked-record labels. */
40
+ client?: IntegrationClient;
41
+ /** Base the records came from. Required to resolve linked-record labels. */
42
+ baseId?: string;
43
+ }
44
+ /**
45
+ * One table's schema from `get-base-schema` (fields with id, name, type,
46
+ * options). Memoized on the client for the rest of this request so a render
47
+ * that formats several pages does not refetch the base. Failures come back as
48
+ * `{ ok: false, error }` — ConnectorError does not survive a Server Action
49
+ * boundary.
50
+ *
51
+ * Server-side only: pass the client from `getIntegration('airtable')`.
52
+ */
53
+ export declare function getAirtableTableSchema(client: IntegrationClient, baseId: string, tableIdOrName: string): Promise<AirtableSchemaResult>;
54
+ /**
55
+ * For each record, return raw + display per field so sorts/edits keep machine
56
+ * values. Formats percent / currency / number / date / dateTime / duration from
57
+ * field options; resolves `multipleRecordLinks` ID arrays to the linked table's
58
+ * primary-field labels (one `list-records` per linked table, chunked
59
+ * `RECORD_ID()` filter, primary field only). Computed fields (formula / rollup /
60
+ * multipleLookupValues) format by their `options.result` inner type, reusing the
61
+ * same typed formatters; arrays (lookups) format each element and join. A cell
62
+ * that comes back as an error-state object (`{state:"error",errorType:...}`,
63
+ * `{error:"#ERROR!"}`) or a numeric special value (`{specialValue:"NaN"|"Infinity"}`)
64
+ * never reaches display as raw JSON — display becomes an em dash, raw is untouched.
65
+ * Unknown types pass through; arrays join with ", ". Linked-table failures degrade
66
+ * to the raw ID and never throw the render.
67
+ *
68
+ * Server-side only when `opts.client` is set (label lookup goes through the
69
+ * broker). Omit client/baseId to format in-memory types only.
70
+ */
71
+ export declare function formatAirtableRecords(records: AirtableRecord[], schema: AirtableTableSchema, opts?: FormatAirtableRecordsOpts): Promise<AirtableFormattedRecord[]>;
@@ -0,0 +1,561 @@
1
+ import { ConnectorError } from './nango.js';
2
+ // Airtable GET list-records puts filterByFormula on the query string. ~40
3
+ // RECORD_ID() clauses stay comfortably under typical URL/formula limits
4
+ // (a page of records can easily collect more linked IDs than that).
5
+ const AIRTABLE_LINKED_ID_CHUNK = 40;
6
+ // Keyed by the per-request client object so a warm serverless isolate cannot
7
+ // serve another invocation's schema. Repeated calls in one render (same client)
8
+ // share the in-flight get-base-schema promise.
9
+ const baseSchemaByClient = new WeakMap();
10
+ /**
11
+ * One table's schema from `get-base-schema` (fields with id, name, type,
12
+ * options). Memoized on the client for the rest of this request so a render
13
+ * that formats several pages does not refetch the base. Failures come back as
14
+ * `{ ok: false, error }` — ConnectorError does not survive a Server Action
15
+ * boundary.
16
+ *
17
+ * Server-side only: pass the client from `getIntegration('airtable')`.
18
+ */
19
+ export async function getAirtableTableSchema(client, baseId, tableIdOrName) {
20
+ const wanted = tableIdOrName.trim();
21
+ if (!baseId.trim() || !wanted) {
22
+ return { ok: false, error: { code: 'invalid_request', provider: 'airtable' } };
23
+ }
24
+ const loaded = await loadBaseSchema(client, baseId);
25
+ if (!loaded.ok)
26
+ return loaded;
27
+ const table = loaded.tables.find((t) => t.id === wanted || t.name === wanted);
28
+ if (!table) {
29
+ return { ok: false, error: { code: 'table_not_found', provider: 'airtable' } };
30
+ }
31
+ return { ok: true, schema: table };
32
+ }
33
+ /**
34
+ * For each record, return raw + display per field so sorts/edits keep machine
35
+ * values. Formats percent / currency / number / date / dateTime / duration from
36
+ * field options; resolves `multipleRecordLinks` ID arrays to the linked table's
37
+ * primary-field labels (one `list-records` per linked table, chunked
38
+ * `RECORD_ID()` filter, primary field only). Computed fields (formula / rollup /
39
+ * multipleLookupValues) format by their `options.result` inner type, reusing the
40
+ * same typed formatters; arrays (lookups) format each element and join. A cell
41
+ * that comes back as an error-state object (`{state:"error",errorType:...}`,
42
+ * `{error:"#ERROR!"}`) or a numeric special value (`{specialValue:"NaN"|"Infinity"}`)
43
+ * never reaches display as raw JSON — display becomes an em dash, raw is untouched.
44
+ * Unknown types pass through; arrays join with ", ". Linked-table failures degrade
45
+ * to the raw ID and never throw the render.
46
+ *
47
+ * Server-side only when `opts.client` is set (label lookup goes through the
48
+ * broker). Omit client/baseId to format in-memory types only.
49
+ */
50
+ export async function formatAirtableRecords(records, schema, opts = {}) {
51
+ const list = Array.isArray(records) ? records : [];
52
+ const fieldsByKey = indexFields(schema);
53
+ let labelsByTable = new Map();
54
+ try {
55
+ labelsByTable = await loadLinkedLabels(list, schema, opts);
56
+ }
57
+ catch {
58
+ // Linked-table I/O is best-effort; typed-column formatting still runs.
59
+ }
60
+ return list.map((record) => {
61
+ const fields = {};
62
+ for (const [key, raw] of Object.entries(record.fields ?? {})) {
63
+ const field = fieldsByKey.get(key);
64
+ fields[key] = {
65
+ raw,
66
+ display: displayForField(raw, field, labelsByTable),
67
+ };
68
+ }
69
+ return {
70
+ id: record.id,
71
+ ...(record.createdTime !== undefined ? { createdTime: record.createdTime } : {}),
72
+ fields,
73
+ };
74
+ });
75
+ }
76
+ function loadBaseSchema(client, baseId) {
77
+ let byBase = baseSchemaByClient.get(client);
78
+ if (!byBase) {
79
+ byBase = new Map();
80
+ baseSchemaByClient.set(client, byBase);
81
+ }
82
+ const cached = byBase.get(baseId);
83
+ if (cached)
84
+ return cached;
85
+ const pending = fetchBaseSchema(client, baseId).then((result) => {
86
+ if (!result.ok)
87
+ byBase.delete(baseId);
88
+ return result;
89
+ }, (err) => {
90
+ byBase.delete(baseId);
91
+ throw err;
92
+ });
93
+ byBase.set(baseId, pending);
94
+ return pending;
95
+ }
96
+ async function fetchBaseSchema(client, baseId) {
97
+ try {
98
+ const raw = await client.action('get-base-schema', { baseId });
99
+ return { ok: true, tables: parseTables(raw?.tables) };
100
+ }
101
+ catch (err) {
102
+ if (err instanceof ConnectorError) {
103
+ return { ok: false, error: { code: err.code, provider: err.provider } };
104
+ }
105
+ throw err;
106
+ }
107
+ }
108
+ function parseTables(raw) {
109
+ if (!Array.isArray(raw))
110
+ return [];
111
+ const tables = [];
112
+ for (const item of raw) {
113
+ const table = asTable(item);
114
+ if (table)
115
+ tables.push(table);
116
+ }
117
+ return tables;
118
+ }
119
+ function asTable(raw) {
120
+ if (!raw || typeof raw !== 'object')
121
+ return null;
122
+ const t = raw;
123
+ if (typeof t.id !== 'string' || typeof t.name !== 'string')
124
+ return null;
125
+ const fields = [];
126
+ if (Array.isArray(t.fields)) {
127
+ for (const item of t.fields) {
128
+ const field = asField(item);
129
+ if (field)
130
+ fields.push(field);
131
+ }
132
+ }
133
+ return {
134
+ id: t.id,
135
+ name: t.name,
136
+ primaryFieldId: typeof t.primaryFieldId === 'string' ? t.primaryFieldId : '',
137
+ fields,
138
+ };
139
+ }
140
+ function asField(raw) {
141
+ if (!raw || typeof raw !== 'object')
142
+ return null;
143
+ const f = raw;
144
+ if (typeof f.id !== 'string' || typeof f.name !== 'string' || typeof f.type !== 'string') {
145
+ return null;
146
+ }
147
+ const options = f.options && typeof f.options === 'object' && !Array.isArray(f.options)
148
+ ? f.options
149
+ : undefined;
150
+ return { id: f.id, name: f.name, type: f.type, ...(options ? { options } : {}) };
151
+ }
152
+ function indexFields(schema) {
153
+ const map = new Map();
154
+ for (const field of schema?.fields ?? []) {
155
+ map.set(field.name, field);
156
+ map.set(field.id, field);
157
+ }
158
+ return map;
159
+ }
160
+ async function loadLinkedLabels(records, schema, opts) {
161
+ const byTable = new Map();
162
+ const client = opts.client;
163
+ const baseId = opts.baseId?.trim();
164
+ if (!client || !baseId)
165
+ return byTable;
166
+ const linkFields = (schema.fields ?? []).filter((field) => field.type === 'multipleRecordLinks' && linkedTableIdOf(field));
167
+ if (linkFields.length === 0)
168
+ return byTable;
169
+ const idsByTable = new Map();
170
+ for (const field of linkFields) {
171
+ const tableId = linkedTableIdOf(field);
172
+ if (!tableId)
173
+ continue;
174
+ let ids = idsByTable.get(tableId);
175
+ if (!ids) {
176
+ ids = new Set();
177
+ idsByTable.set(tableId, ids);
178
+ }
179
+ for (const record of records) {
180
+ for (const id of recordIdsIn(record.fields?.[field.name] ?? record.fields?.[field.id])) {
181
+ ids.add(id);
182
+ }
183
+ }
184
+ }
185
+ await Promise.all([...idsByTable.entries()].map(async ([tableId, ids]) => {
186
+ if (ids.size === 0)
187
+ return;
188
+ const labels = await fetchLinkedLabels(client, baseId, tableId, [...ids]);
189
+ byTable.set(tableId, labels);
190
+ }));
191
+ return byTable;
192
+ }
193
+ async function fetchLinkedLabels(client, baseId, tableId, ids) {
194
+ const labels = new Map();
195
+ let primaryName;
196
+ try {
197
+ const linked = await getAirtableTableSchema(client, baseId, tableId);
198
+ if (linked.ok)
199
+ primaryName = primaryFieldName(linked.schema);
200
+ }
201
+ catch {
202
+ // Without the primary field name a successful list-records would still
203
+ // guess a column — keep raw IDs instead.
204
+ }
205
+ if (!primaryName)
206
+ return labels;
207
+ const safeIds = ids.filter((id) => AIRTABLE_RECORD_ID_RE.test(id));
208
+ for (const chunk of chunkIds(safeIds, AIRTABLE_LINKED_ID_CHUNK)) {
209
+ try {
210
+ let offset;
211
+ do {
212
+ const page = await client.action('list-records', {
213
+ baseId,
214
+ tableIdOrName: tableId,
215
+ filterByFormula: recordIdFormula(chunk),
216
+ fields: [primaryName],
217
+ pageSize: 100,
218
+ ...(offset ? { offset } : {}),
219
+ });
220
+ for (const rec of page.records ?? []) {
221
+ const label = labelFromPrimary(rec.fields?.[primaryName]);
222
+ if (label)
223
+ labels.set(rec.id, label);
224
+ }
225
+ offset = typeof page.offset === 'string' && page.offset ? page.offset : undefined;
226
+ } while (offset);
227
+ }
228
+ catch {
229
+ // Rate-limit / ConnectorError / a deleted-table 404: keep labels from
230
+ // earlier chunks and leave the rest as raw IDs. Never fail the render.
231
+ }
232
+ }
233
+ return labels;
234
+ }
235
+ // Cell-level shapes Airtable's computed fields (formula/lookup/rollup) can
236
+ // return instead of a value: an error state, or a numeric special value.
237
+ // Never let one of these reach display as a raw JSON blob — the guard runs
238
+ // ahead of every field-type branch, including types this file never learns.
239
+ const EM_DASH = '—';
240
+ function isErrorCell(value) {
241
+ if (!value || typeof value !== 'object' || Array.isArray(value))
242
+ return false;
243
+ const obj = value;
244
+ if (obj.state === 'error')
245
+ return true;
246
+ if (typeof obj.errorType === 'string' && obj.errorType)
247
+ return true;
248
+ // Airtable's classic `{error: "#ERROR!"}` cell shape.
249
+ if (typeof obj.error === 'string' && obj.error)
250
+ return true;
251
+ return false;
252
+ }
253
+ function isSpecialValueCell(value) {
254
+ if (!value || typeof value !== 'object' || Array.isArray(value))
255
+ return false;
256
+ const obj = value;
257
+ return obj.specialValue === 'NaN' || obj.specialValue === 'Infinity';
258
+ }
259
+ function isGuardedCell(value) {
260
+ return isErrorCell(value) || isSpecialValueCell(value);
261
+ }
262
+ /** The `options.result` a formula/rollup/lookup field carries: what its computed value looks like. */
263
+ function resultTypeOf(options) {
264
+ const result = options?.result;
265
+ if (!result || typeof result !== 'object' || Array.isArray(result))
266
+ return undefined;
267
+ const r = result;
268
+ if (typeof r.type !== 'string' || !r.type)
269
+ return undefined;
270
+ const nestedOptions = r.options && typeof r.options === 'object' && !Array.isArray(r.options)
271
+ ? r.options
272
+ : undefined;
273
+ return { type: r.type, ...(nestedOptions ? { options: nestedOptions } : {}) };
274
+ }
275
+ /**
276
+ * Formats a formula/rollup/lookup value by its inner result type, reusing the
277
+ * typed formatters below. Arrays (lookups, array-valued rollups) format each
278
+ * element and join for display. Guards error/special-value cells at every
279
+ * level so one bad element never blanks the whole row.
280
+ */
281
+ function formatByResultType(raw, result) {
282
+ if (isGuardedCell(raw))
283
+ return EM_DASH;
284
+ if (Array.isArray(raw)) {
285
+ return raw
286
+ .map((item) => formatByResultType(item, result))
287
+ .filter((s) => s !== '')
288
+ .join(', ');
289
+ }
290
+ if (!result)
291
+ return displayUnknown(raw);
292
+ switch (result.type) {
293
+ case 'percent':
294
+ return formatPercent(raw, optionInt(result.options, 'precision') ?? 0);
295
+ case 'currency':
296
+ return formatCurrency(raw, optionString(result.options, 'symbol') ?? '', optionInt(result.options, 'precision') ?? 2);
297
+ case 'number':
298
+ return formatNumber(raw, optionInt(result.options, 'precision') ?? 0);
299
+ case 'date':
300
+ return formatDateValue(raw, dateFormatName(result.options), undefined);
301
+ case 'dateTime':
302
+ return formatDateValue(raw, dateFormatName(result.options), timeFormatName(result.options));
303
+ case 'duration':
304
+ return formatDuration(raw, optionString(result.options, 'durationFormat') ?? 'h:mm');
305
+ default:
306
+ return displayUnknown(raw);
307
+ }
308
+ }
309
+ function displayForField(raw, field, labelsByTable) {
310
+ // Value-shape guard first — applies regardless of field type or whether
311
+ // the field is even in the schema.
312
+ if (isGuardedCell(raw))
313
+ return EM_DASH;
314
+ if (!field)
315
+ return displayUnknown(raw);
316
+ switch (field.type) {
317
+ case 'percent':
318
+ return formatPercent(raw, optionInt(field.options, 'precision') ?? 0);
319
+ case 'currency':
320
+ return formatCurrency(raw, optionString(field.options, 'symbol') ?? '', optionInt(field.options, 'precision') ?? 2);
321
+ case 'number':
322
+ return formatNumber(raw, optionInt(field.options, 'precision') ?? 0);
323
+ case 'date':
324
+ return formatDateValue(raw, dateFormatName(field.options), undefined);
325
+ case 'dateTime':
326
+ return formatDateValue(raw, dateFormatName(field.options), timeFormatName(field.options));
327
+ case 'duration':
328
+ return formatDuration(raw, optionString(field.options, 'durationFormat') ?? 'h:mm');
329
+ case 'multipleRecordLinks': {
330
+ const tableId = linkedTableIdOf(field);
331
+ const labels = tableId ? labelsByTable.get(tableId) : undefined;
332
+ const ids = recordIdsIn(raw);
333
+ if (ids.length === 0)
334
+ return displayUnknown(raw);
335
+ return ids.map((id) => labels?.get(id) ?? id).join(', ');
336
+ }
337
+ case 'formula':
338
+ case 'rollup':
339
+ case 'multipleLookupValues':
340
+ case 'lookup':
341
+ return formatByResultType(raw, resultTypeOf(field.options));
342
+ default:
343
+ return displayUnknown(raw);
344
+ }
345
+ }
346
+ function formatPercent(raw, precision) {
347
+ const n = asFiniteNumber(raw);
348
+ if (n === null)
349
+ return displayUnknown(raw);
350
+ return `${formatFixed(n * 100, precision)}%`;
351
+ }
352
+ function formatCurrency(raw, symbol, precision) {
353
+ const n = asFiniteNumber(raw);
354
+ if (n === null)
355
+ return displayUnknown(raw);
356
+ const abs = formatFixed(Math.abs(n), precision);
357
+ const sign = n < 0 ? '-' : '';
358
+ return `${sign}${symbol}${abs}`;
359
+ }
360
+ function formatNumber(raw, precision) {
361
+ const n = asFiniteNumber(raw);
362
+ if (n === null)
363
+ return displayUnknown(raw);
364
+ return formatFixed(n, precision);
365
+ }
366
+ function formatDateValue(raw, dateName, timeName) {
367
+ if (typeof raw !== 'string' || !raw.trim())
368
+ return displayUnknown(raw);
369
+ const parts = parseAirtableDate(raw);
370
+ if (!parts)
371
+ return raw;
372
+ const date = formatDateParts(parts, dateName ?? 'local');
373
+ if (!timeName || parts.hours === undefined)
374
+ return date;
375
+ return `${date} ${formatTimeParts(parts, timeName)}`;
376
+ }
377
+ function formatDuration(raw, durationFormat) {
378
+ const n = asFiniteNumber(raw);
379
+ if (n === null)
380
+ return displayUnknown(raw);
381
+ const negative = n < 0;
382
+ const abs = Math.abs(n);
383
+ const hours = Math.floor(abs / 3600);
384
+ const minutes = Math.floor((abs % 3600) / 60);
385
+ const seconds = abs % 60;
386
+ let body;
387
+ switch (durationFormat) {
388
+ case 'h:mm:ss':
389
+ body = `${hours}:${pad2(minutes)}:${pad2(Math.floor(seconds))}`;
390
+ break;
391
+ case 'h:mm:ss.s':
392
+ body = `${hours}:${pad2(minutes)}:${formatFixed(seconds, 1).padStart(4, '0')}`;
393
+ break;
394
+ case 'h:mm:ss.ss':
395
+ body = `${hours}:${pad2(minutes)}:${formatFixed(seconds, 2).padStart(5, '0')}`;
396
+ break;
397
+ case 'h:mm:ss.sss':
398
+ body = `${hours}:${pad2(minutes)}:${formatFixed(seconds, 3).padStart(6, '0')}`;
399
+ break;
400
+ case 'h:mm':
401
+ default:
402
+ body = `${hours}:${pad2(minutes)}`;
403
+ break;
404
+ }
405
+ return negative ? `-${body}` : body;
406
+ }
407
+ function displayUnknown(raw) {
408
+ if (raw == null || raw === '')
409
+ return '';
410
+ if (Array.isArray(raw)) {
411
+ return raw.map(displayUnknown).filter(Boolean).join(', ');
412
+ }
413
+ if (typeof raw === 'string')
414
+ return raw;
415
+ if (typeof raw === 'number' && Number.isFinite(raw))
416
+ return String(raw);
417
+ if (typeof raw === 'boolean')
418
+ return raw ? 'checked' : '';
419
+ if (typeof raw === 'object') {
420
+ const obj = raw;
421
+ if (typeof obj.name === 'string' && obj.name)
422
+ return obj.name;
423
+ if (typeof obj.filename === 'string' && obj.filename)
424
+ return obj.filename;
425
+ if (typeof obj.url === 'string' && obj.url)
426
+ return obj.url;
427
+ }
428
+ return '';
429
+ }
430
+ function recordIdsIn(raw) {
431
+ if (typeof raw === 'string' && raw.startsWith('rec'))
432
+ return [raw];
433
+ if (!Array.isArray(raw))
434
+ return [];
435
+ const ids = [];
436
+ for (const item of raw) {
437
+ if (typeof item === 'string' && item.startsWith('rec'))
438
+ ids.push(item);
439
+ }
440
+ return ids;
441
+ }
442
+ function labelFromPrimary(raw) {
443
+ const text = displayUnknown(raw).trim();
444
+ return text;
445
+ }
446
+ function primaryFieldName(schema) {
447
+ const match = schema.fields.find((f) => f.id === schema.primaryFieldId);
448
+ return match?.name;
449
+ }
450
+ function linkedTableIdOf(field) {
451
+ return optionString(field.options, 'linkedTableId');
452
+ }
453
+ // Airtable record IDs are strictly alphanumeric (rec + 14+ chars). Rather than
454
+ // escape quotes/backslashes for formula interpolation, reject anything else —
455
+ // a non-conforming id keeps its raw value like every other lookup failure.
456
+ const AIRTABLE_RECORD_ID_RE = /^[A-Za-z0-9]+$/;
457
+ function recordIdClause(id) {
458
+ return `RECORD_ID()='${id}'`;
459
+ }
460
+ function recordIdFormula(ids) {
461
+ if (ids.length === 1)
462
+ return recordIdClause(ids[0]);
463
+ return `OR(${ids.map(recordIdClause).join(',')})`;
464
+ }
465
+ function chunkIds(ids, size) {
466
+ const chunks = [];
467
+ for (let i = 0; i < ids.length; i += size) {
468
+ chunks.push(ids.slice(i, i + size));
469
+ }
470
+ return chunks;
471
+ }
472
+ function optionString(options, key) {
473
+ const value = options?.[key];
474
+ return typeof value === 'string' && value ? value : undefined;
475
+ }
476
+ function optionInt(options, key) {
477
+ const value = options?.[key];
478
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
479
+ }
480
+ function nestedFormatName(options, key) {
481
+ const nested = options?.[key];
482
+ if (!nested || typeof nested !== 'object' || Array.isArray(nested))
483
+ return undefined;
484
+ const name = nested.name;
485
+ return typeof name === 'string' && name ? name : undefined;
486
+ }
487
+ function dateFormatName(options) {
488
+ return nestedFormatName(options, 'dateFormat');
489
+ }
490
+ function timeFormatName(options) {
491
+ return nestedFormatName(options, 'timeFormat');
492
+ }
493
+ function asFiniteNumber(raw) {
494
+ return typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
495
+ }
496
+ function formatFixed(n, precision) {
497
+ const digits = Number.isInteger(precision) && precision >= 0 ? precision : 0;
498
+ const factor = 10 ** digits;
499
+ return (Math.round(n * factor) / factor).toFixed(digits);
500
+ }
501
+ const MONTHS = [
502
+ 'January',
503
+ 'February',
504
+ 'March',
505
+ 'April',
506
+ 'May',
507
+ 'June',
508
+ 'July',
509
+ 'August',
510
+ 'September',
511
+ 'October',
512
+ 'November',
513
+ 'December',
514
+ ];
515
+ function parseAirtableDate(value) {
516
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
517
+ if (dateOnly) {
518
+ return {
519
+ year: Number(dateOnly[1]),
520
+ month: Number(dateOnly[2]),
521
+ day: Number(dateOnly[3]),
522
+ };
523
+ }
524
+ const parsed = new Date(value);
525
+ if (Number.isNaN(parsed.getTime()))
526
+ return null;
527
+ return {
528
+ year: parsed.getUTCFullYear(),
529
+ month: parsed.getUTCMonth() + 1,
530
+ day: parsed.getUTCDate(),
531
+ hours: parsed.getUTCHours(),
532
+ minutes: parsed.getUTCMinutes(),
533
+ seconds: parsed.getUTCSeconds(),
534
+ };
535
+ }
536
+ function formatDateParts(parts, name) {
537
+ switch (name) {
538
+ case 'iso':
539
+ return `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)}`;
540
+ case 'european':
541
+ return `${parts.day}/${parts.month}/${parts.year}`;
542
+ case 'friendly':
543
+ return `${MONTHS[parts.month - 1] ?? parts.month} ${parts.day}, ${parts.year}`;
544
+ case 'us':
545
+ case 'local':
546
+ default:
547
+ return `${parts.month}/${parts.day}/${parts.year}`;
548
+ }
549
+ }
550
+ function formatTimeParts(parts, name) {
551
+ const hours = parts.hours ?? 0;
552
+ const minutes = pad2(parts.minutes ?? 0);
553
+ if (name === '24hour')
554
+ return `${pad2(hours)}:${minutes}`;
555
+ const suffix = hours >= 12 ? 'pm' : 'am';
556
+ const hour12 = hours % 12 === 0 ? 12 : hours % 12;
557
+ return `${hour12}:${minutes}${suffix}`;
558
+ }
559
+ function pad2(n) {
560
+ return String(n).padStart(2, '0');
561
+ }
package/dist/nango.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ResourceFieldMap, type ResourcePage } from './resource-resolution.js';
2
- export type Provider = 'slack' | 'gmail' | 'google-calendar' | 'google-drive' | 'google-sheet' | 'hubspot' | 'salesforce' | 'notion' | 'linear' | 'stripe' | 'github';
2
+ export type Provider = 'slack' | 'airtable' | 'gmail' | 'google-calendar' | 'google-drive' | 'google-sheet' | 'hubspot' | 'salesforce' | 'notion' | 'linear' | 'stripe' | 'github';
3
3
  export type ConnectorErrorCode = 'app_key_required' | 'provider_not_declared' | 'connection_required' | 'needs_reauth' | 'needs_reauth_scope' | 'connection_throttled' | 'connector_unavailable' | 'action_failed' | 'invalid_request';
4
4
  export interface ConnectionStatus {
5
5
  provider: string;
package/dist/server.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from './auth.js';
4
4
  export * from './client-access.js';
5
5
  export * from './mint-bearer.js';
6
6
  export * from './nango.js';
7
+ export * from './airtable.js';
7
8
  export * from './app-storage.js';
8
9
  export * from './labels.js';
9
10
  export * from './notifications.js';
package/dist/server.js CHANGED
@@ -8,6 +8,7 @@ export * from './auth.js';
8
8
  export * from './client-access.js';
9
9
  export * from './mint-bearer.js';
10
10
  export * from './nango.js';
11
+ export * from './airtable.js';
11
12
  export * from './app-storage.js';
12
13
  export * from './labels.js';
13
14
  export * from './notifications.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,9 +1,24 @@
1
1
  # Airtable
2
2
 
3
- Pagination keys and the `fields` param disagree across actions — copy the signature below, do not reuse a neighbour's names.
3
+ `list-records` JSON cells are machine values: a linked-record field is an array of `rec…` IDs (not the primary-field label), and a percent is a 0–1 fraction (`0.42`, not `42%`). Currency, date, dateTime, and duration are similarly unformatted. Never render a field value without reading that field's `type` and `options` from `get-base-schema`.
4
4
 
5
+ - **Display (prefer the helper):** `getAirtableTableSchema` + `formatAirtableRecords` from `@assembly-js/app-foundation/server` return raw + display per field so sorts and writes keep machine values. They format percent / currency / number / date / dateTime / duration from field options, and resolve linked IDs to the linked table's primary field. A missing linked record stays the raw ID. Server-side only — pass the `getIntegration` client.
6
+ - **Computed-field errors:** formula/lookup/rollup cells can come back as an error-state object (`{state:"error",errorType:"emptyDependency"}`) or a numeric special value (`{specialValue:"NaN"}`) instead of a real value — never render a cell object raw. The helper guards this (`display` becomes `—`, `raw` stays untouched); code that skips the helper must guard for it itself.
7
+ - **Display without the helper, or a display-only table:** pass `cellFormat: "string"` on `list-records` to get Airtable's formatted strings. Trade-off: values come back as display strings, so you lose machine-readable raw numbers/IDs for sorting and writes — don't use this when you also edit or sort those cells. Otherwise do the schema path yourself: format percent as `(value * 100)` + `%` (precision from field options); for each `multipleRecordLinks` field, `list-records` on `options.linkedTableId` with `filterByFormula` `OR(RECORD_ID()='rec…',…)` (chunk the OR — one page can collect more IDs than a safe formula length), `fields` set to only the linked table's primary field, and substitute labels. Failures keep the raw ID.
5
8
  - **Pagination:** `list-bases` takes `cursor` and returns `nextOffset`. `list-records` takes `offset` and returns `offset`. Passing the wrong key silently drops the next page.
6
9
  - **`fields` is overloaded:** on `list-records` it is an array of field names to return; on `create-record` / `update-record` it is a name-or-id-keyed map of values to write. An array on a write, or a map on a read, is rejected.
7
10
  - Resolve `baseId` with `list-bases` and table/field names with `get-base-schema` before writes. `tableIdOrName` accepts either; schema field ids survive a user renaming the column.
8
11
 
12
+ ```typescript
13
+ import { getIntegration, getAirtableTableSchema, formatAirtableRecords } from '@assembly-js/app-foundation/server';
14
+
15
+ const airtable = await getIntegration('airtable');
16
+ const { records } = await airtable.action('list-records', { baseId, tableIdOrName });
17
+ const table = await getAirtableTableSchema(airtable, baseId, tableIdOrName);
18
+ if (!table.ok) return;
19
+ const rows = await formatAirtableRecords(records, table.schema, { client: airtable, baseId });
20
+ // rows[i].fields.Engagement.raw === 0.42
21
+ // rows[i].fields.Engagement.display === "42%"
22
+ ```
23
+
9
24
  <!-- ASSEMBLY_ACTIONS:airtable -->
@@ -24,4 +24,6 @@ const history = await slack.action('get-conversation-history', {
24
24
 
25
25
  For an operation no action covers, relay through the proxy — e.g. `slack.post({ endpoint: '/chat.update', data: { channel: channelId, ts, text } })`.
26
26
 
27
+ - **DMs are not supported.** The integration does not grant `im:write`, and `conversations.open` is not vendored. A Slack user ID (`U…`) or DM channel ID (`D…`) also fails `slackPostMessage`'s channel-ID check, so it falls through to a channel-name lookup and returns `channel_not_found` instead of opening the DM. If a user asks for DMs, say so up front and offer `createNotification` (in-product + email) or posting to a channel instead.
28
+
27
29
  <!-- ASSEMBLY_ACTIONS:slack -->