zitejs 0.10.0-start.7 → 0.10.0-start.8

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.
Files changed (32) hide show
  1. package/dist/cjs/sync/lib.js +4 -7
  2. package/dist/cjs/vite/index.js +8 -3
  3. package/dist/cjs/vite/index.test.js +10 -2
  4. package/dist/esm/start/airtable/arrayFields.d.ts +10 -0
  5. package/dist/esm/start/airtable/arrayFields.js +30 -0
  6. package/dist/esm/start/airtable/dispatch.d.ts +54 -0
  7. package/dist/esm/start/airtable/dispatch.js +465 -0
  8. package/dist/esm/start/airtable/formulas.d.ts +69 -0
  9. package/dist/esm/start/airtable/formulas.js +235 -0
  10. package/dist/esm/start/airtable/index.d.ts +11 -0
  11. package/dist/esm/start/airtable/index.js +70 -0
  12. package/dist/esm/start/data/baseRunner.d.ts +92 -0
  13. package/dist/esm/start/data/baseRunner.js +83 -0
  14. package/dist/esm/start/data/dispatch.d.ts +16 -0
  15. package/dist/esm/start/data/dispatch.js +198 -0
  16. package/dist/esm/start/data/index.d.ts +18 -0
  17. package/dist/esm/start/data/index.js +76 -0
  18. package/dist/esm/start/data/sqlNameMappings.d.ts +58 -0
  19. package/dist/esm/start/data/sqlNameMappings.js +92 -0
  20. package/dist/esm/start/data/transformations.d.ts +128 -0
  21. package/dist/esm/start/data/transformations.js +318 -0
  22. package/dist/esm/start/data/types.d.ts +91 -0
  23. package/dist/esm/start/data/types.js +14 -0
  24. package/dist/esm/start/server.js +54 -5
  25. package/dist/esm/start/types.d.ts +40 -0
  26. package/dist/esm/start/version.d.ts +1 -1
  27. package/dist/esm/start/version.js +1 -1
  28. package/dist/esm/start/vite.js +2 -1
  29. package/dist/esm/sync/lib.js +4 -7
  30. package/dist/esm/vite/index.js +8 -3
  31. package/dist/esm/vite/index.test.js +10 -2
  32. package/package.json +1 -1
@@ -83,6 +83,7 @@ const FIELD_TYPE_MAP = {
83
83
  formula: "any",
84
84
  created_at: "string",
85
85
  updated_at: "string",
86
+ created_by: "string",
86
87
  updated_by: "string",
87
88
  };
88
89
  /**
@@ -108,7 +109,7 @@ const FIELD_INPUT_TYPE_MAP = {
108
109
  * rather than typed and then refused at runtime.
109
110
  *
110
111
  * Mirrors `COMPUTED_FIELD_TYPES` in the product's own `shared/bases/fields`.
111
- * The first six are rejected by `createRecordDataSchema` — each has a
112
+ * The first seven are rejected by `createRecordDataSchema` — each has a
112
113
  * `refine(val => val == null)`, so a non-null write errors (null is a no-op
113
114
  * clear). `created_at` / `updated_at` are refused differently: they have no
114
115
  * column at all, so a write is silently dropped, which is the worse outcome to
@@ -120,6 +121,7 @@ const READ_ONLY_FIELD_TYPES = new Set([
120
121
  "source",
121
122
  "formula",
122
123
  "autonumber",
124
+ "created_by",
123
125
  "updated_by",
124
126
  "created_at",
125
127
  "updated_at",
@@ -260,13 +262,8 @@ function fieldJsdoc(schemaField, table, schema) {
260
262
  const parts = [];
261
263
  if (table.primaryFieldId === schemaField.id)
262
264
  parts.push("Primary field");
263
- if (def.type === "created_at" ||
264
- def.type === "updated_at" ||
265
- def.type === "lookup" ||
266
- def.type === "formula" ||
267
- def.type === "autonumber") {
265
+ if (READ_ONLY_FIELD_TYPES.has(def.type))
268
266
  parts.push("Read-only");
269
- }
270
267
  if (def.type === "linked_record") {
271
268
  const tpl = def.template;
272
269
  if (tpl.tableId) {
@@ -75,11 +75,12 @@ function ziteId() {
75
75
  name: "vite-plugin-zite-id",
76
76
  enforce: "pre",
77
77
  async transform(code, id) {
78
- if (!validExtensions.has(path_1.default.extname(id)) ||
79
- id.includes("node_modules")) {
78
+ // TanStack's route code splitting moves a route's component into its own module, `route.tsx?tsr-split=…`.
79
+ const file = id.split("?")[0];
80
+ if (!validExtensions.has(path_1.default.extname(file)) || file.includes("node_modules")) {
80
81
  return null;
81
82
  }
82
- const relativePath = path_1.default.relative(cwd, id);
83
+ const relativePath = path_1.default.relative(cwd, file);
83
84
  try {
84
85
  const ast = (0, parser_1.parse)(code, {
85
86
  sourceType: "module",
@@ -134,6 +135,10 @@ function ziteId() {
134
135
  if (!isTagged) {
135
136
  return;
136
137
  }
138
+ // A module split from one already tagged keeps its tags.
139
+ if (openingElement.attributes?.some((attr) => attr.type === "JSXAttribute" && attr.name?.name === "data-zite-id")) {
140
+ return;
141
+ }
137
142
  const line = openingElement.loc?.start?.line ?? 0;
138
143
  const col = openingElement.loc?.start?.column ?? 0;
139
144
  const dataComponentId = `${relativePath}|${line}|${col}`;
@@ -6,16 +6,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const path_1 = __importDefault(require("path"));
7
7
  const vitest_1 = require("vitest");
8
8
  const index_js_1 = require("./index.js");
9
- async function transform(code) {
9
+ async function transform(code, file = 'src/App.tsx') {
10
10
  const plugin = (0, index_js_1.ziteId)();
11
11
  const handler = plugin.transform;
12
- const result = await handler(code, path_1.default.join(process.cwd(), 'src/App.tsx'));
12
+ const result = await handler(code, path_1.default.join(process.cwd(), file));
13
13
  if (!result) {
14
14
  throw new Error('transform returned null');
15
15
  }
16
16
  return result.code;
17
17
  }
18
18
  (0, vitest_1.describe)('ziteId', () => {
19
+ (0, vitest_1.it)("tags a route's component that TanStack split into its own module, by the source file", async () => {
20
+ const code = await transform('const a = <p>Hi</p>;', 'src/routes/index.tsx?tsr-split=component');
21
+ (0, vitest_1.expect)(code).toContain('<p data-zite-id="src/routes/index.tsx|1|10" data-zite-editable="true">');
22
+ });
23
+ (0, vitest_1.it)('leaves an element that already has its tag', async () => {
24
+ const code = await transform('const a = <p data-zite-id="src/routes/index.tsx|1|10">Hi</p>;');
25
+ (0, vitest_1.expect)(code.match(/data-zite-id/g)).toHaveLength(1);
26
+ });
19
27
  (0, vitest_1.it)('tags html and svg elements without a component name', async () => {
20
28
  const code = await transform('const a = <div><svg><path d="M0" /></svg><p>Hello</p></div>;');
21
29
  (0, vitest_1.expect)(code).toContain('<div data-zite-id="src/App.tsx|1|10">');
@@ -0,0 +1,10 @@
1
+ export type AirtableFieldShape = {
2
+ type: string;
3
+ options?: unknown;
4
+ };
5
+ export declare const isArrayValuedAirtableField: (field: AirtableFieldShape) => boolean;
6
+ export declare const airtableEqualsFormula: ({ field, fieldName, value, }: {
7
+ field: AirtableFieldShape;
8
+ fieldName: string;
9
+ value: string;
10
+ }) => string;
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ const ARRAY_VALUED_AIRTABLE_FIELD_TYPES = new Set([
3
+ 'multipleLookupValues',
4
+ 'multipleSelects',
5
+ 'multipleRecordLinks',
6
+ 'multipleCollaborators',
7
+ 'multipleAttachments',
8
+ ]);
9
+ const resultTypeOptionsSchema = z.object({
10
+ result: z.object({ type: z.string().optional() }).optional(),
11
+ });
12
+ export const isArrayValuedAirtableField = (field) => {
13
+ if (field.type === 'formula' || field.type === 'rollup') {
14
+ const resultType = resultTypeOptionsSchema.safeParse(field.options).data
15
+ ?.result?.type;
16
+ return resultType
17
+ ? ARRAY_VALUED_AIRTABLE_FIELD_TYPES.has(resultType)
18
+ : false;
19
+ }
20
+ return ARRAY_VALUED_AIRTABLE_FIELD_TYPES.has(field.type);
21
+ };
22
+ const ELEMENT_SEPARATOR = ', ';
23
+ export const airtableEqualsFormula = ({ field, fieldName, value, }) => {
24
+ if (!isArrayValuedAirtableField(field)) {
25
+ return `${fieldName}=${value}`;
26
+ }
27
+ const needle = `"${ELEMENT_SEPARATOR}" & ${value} & "${ELEMENT_SEPARATOR}"`;
28
+ const haystack = `"${ELEMENT_SEPARATOR}" & ARRAYJOIN(${fieldName}) & "${ELEMENT_SEPARATOR}"`;
29
+ return `SEARCH(${needle}, ${haystack}) != ""`;
30
+ };
@@ -0,0 +1,54 @@
1
+ /** As `@fillout/zite`'s `IntegrationFieldMappingType`, from zite.lock. */
2
+ export type AirtableFieldMapping = {
3
+ mapping: {
4
+ sdkName: string;
5
+ };
6
+ fieldType?: string;
7
+ fieldName?: string;
8
+ fieldOptions?: Record<string, unknown>;
9
+ };
10
+ export type AirtableFieldMappings = Record<string, AirtableFieldMapping>;
11
+ export type AirtableNameMappings = {
12
+ bases: Record<string, {
13
+ mapping: {
14
+ sdkName: string;
15
+ };
16
+ tables: Record<string, {
17
+ mapping: {
18
+ sdkName: string;
19
+ };
20
+ primaryFieldId?: string;
21
+ fields: AirtableFieldMappings;
22
+ }>;
23
+ }>;
24
+ };
25
+ /** An app's Airtable integration as its build declares it. */
26
+ export type AirtableSettings = {
27
+ baseId: string;
28
+ nameMappings: AirtableNameMappings | null;
29
+ };
30
+ /** Answers one Airtable request; the platform puts the connection's token on it. */
31
+ export type AirtableFetch = (path: string, init: {
32
+ method: string;
33
+ body?: unknown;
34
+ }) => Promise<Response>;
35
+ export type AirtableContext = {
36
+ settings: AirtableSettings;
37
+ fetch: AirtableFetch;
38
+ /** One request's reads, so the same read twice in a request goes to Airtable once. */
39
+ reads?: Map<string, Promise<unknown>>;
40
+ sleep?: (ms: number) => Promise<void>;
41
+ };
42
+ /** The npm SDK's error, whose shape apps (and the goldens) see: not an `Error`. */
43
+ export declare class AirtableError {
44
+ error: string;
45
+ message: string;
46
+ statusCode: number;
47
+ constructor(error: string, message: string, statusCode: number);
48
+ toString(): string;
49
+ }
50
+ export declare function airtableRecordToSdkNames(record: Record<string, any> | undefined, fieldMappings: AirtableFieldMappings | undefined): Record<string, any> | undefined;
51
+ /** A Duration cell (seconds) as HH:mm:ss, keeping seconds and fractions (ZIT-3889). */
52
+ export declare function formatAirtableDuration(durationSeconds: number): string;
53
+ /** One Airtable table call, as classic's dispatch runs it. */
54
+ export declare function executeAirtableCall(context: AirtableContext, methodName: string, params: any): Promise<any>;
@@ -0,0 +1,465 @@
1
+ // Ported from restly's apps/cloudflare-lambda/src/integrations/airtable.ts (`executeAirtableCall`), classic's
2
+ // dispatch, which the Airtable goldens are recorded from, with a fetch client in place of the npm SDK. Keep the two
3
+ // in step. Differences: fields come back by id from every request, so a rename since the lock never unmaps one;
4
+ // the schema is the lock's (`nameMappings`), as the build bakes it; the per-base limiter is gone.
5
+ import { filtersToFormula as buildFiltersFormula } from "./formulas.js";
6
+ const SDK_ID_FIELD = "id";
7
+ const UNIQUE_RECORD_ID_KEY = "recordID";
8
+ const AIRTABLE_BATCH_SIZE = 10;
9
+ /** The npm SDK's error, whose shape apps (and the goldens) see: not an `Error`. */
10
+ export class AirtableError {
11
+ error;
12
+ message;
13
+ statusCode;
14
+ constructor(error, message, statusCode) {
15
+ this.error = error;
16
+ this.message = message;
17
+ this.statusCode = statusCode;
18
+ }
19
+ toString() {
20
+ return [this.message, "(", this.error, ")", this.statusCode ? `[Http code ${this.statusCode}]` : ""].join("");
21
+ }
22
+ }
23
+ // =============================================================================
24
+ // Field mapping
25
+ // =============================================================================
26
+ const sdkNameToFieldId = (fieldMappings) => {
27
+ const map = {};
28
+ for (const [fieldId, info] of Object.entries(fieldMappings ?? {})) {
29
+ if (info?.mapping?.sdkName)
30
+ map[info.mapping.sdkName] = fieldId;
31
+ }
32
+ return map;
33
+ };
34
+ const fieldIdToSdkName = (fieldMappings) => {
35
+ const map = {};
36
+ for (const [fieldId, info] of Object.entries(fieldMappings ?? {})) {
37
+ if (info?.mapping?.sdkName)
38
+ map[fieldId] = info.mapping.sdkName;
39
+ }
40
+ return map;
41
+ };
42
+ function transformFiltersToFieldIds(filters, fieldMappings) {
43
+ if (!filters || !fieldMappings)
44
+ return filters;
45
+ const ids = sdkNameToFieldId(fieldMappings);
46
+ const result = {};
47
+ for (const [key, value] of Object.entries(filters)) {
48
+ result[key === "id" || key === SDK_ID_FIELD ? key : (ids[key] ?? key)] = value;
49
+ }
50
+ return result;
51
+ }
52
+ function transformRecordToFieldIds(record, fieldMappings) {
53
+ if (!record || !fieldMappings)
54
+ return record;
55
+ const ids = sdkNameToFieldId(fieldMappings);
56
+ const result = {};
57
+ for (const [key, value] of Object.entries(record)) {
58
+ if (key === "id" || key === SDK_ID_FIELD || key === UNIQUE_RECORD_ID_KEY)
59
+ continue;
60
+ result[ids[key] ?? key] = value;
61
+ }
62
+ return result;
63
+ }
64
+ export function airtableRecordToSdkNames(record, fieldMappings) {
65
+ if (!record)
66
+ return undefined;
67
+ if (!fieldMappings)
68
+ return record;
69
+ const names = fieldIdToSdkName(fieldMappings);
70
+ const result = {};
71
+ for (const [key, value] of Object.entries(record)) {
72
+ if (key === "id" || key === UNIQUE_RECORD_ID_KEY) {
73
+ result[key] = value;
74
+ continue;
75
+ }
76
+ result[names[key] ?? key] = value;
77
+ }
78
+ return result;
79
+ }
80
+ /** The table's fields as zite.lock records them; fields a legacy lock names no further are left out. */
81
+ const lockSchema = (tableId, table) => {
82
+ if (!table)
83
+ return undefined;
84
+ const fields = Object.entries(table.fields).flatMap(([id, field]) => field.fieldName
85
+ ? [
86
+ {
87
+ id,
88
+ name: field.fieldName,
89
+ type: field.fieldType ?? "singleLineText",
90
+ ...(field.fieldOptions ? { options: field.fieldOptions } : {}),
91
+ },
92
+ ]
93
+ : []);
94
+ return fields.length ? { id: tableId, primaryFieldId: table.primaryFieldId, fields } : undefined;
95
+ };
96
+ // =============================================================================
97
+ // Values
98
+ // =============================================================================
99
+ /** For writes: dates, linked records, attachments, ratings, text. */
100
+ function processColumnValues(columnValues, tableSchema) {
101
+ if (!tableSchema)
102
+ return columnValues;
103
+ const result = { ...columnValues };
104
+ for (const columnId of Object.keys(columnValues)) {
105
+ const field = tableSchema.fields.find((f) => f.id === columnId || f.name === columnId);
106
+ if (!field)
107
+ continue;
108
+ const value = columnValues[columnId];
109
+ if (value === undefined || value === null)
110
+ continue;
111
+ // An empty object means an unmapped field.
112
+ if (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) {
113
+ result[columnId] = undefined;
114
+ continue;
115
+ }
116
+ if (field.type === "multipleRecordLinks") {
117
+ if (!Array.isArray(value))
118
+ result[columnId] = value === "" ? [] : [value];
119
+ }
120
+ else if (field.type === "date" || field.type === "dateTime") {
121
+ if (value === "") {
122
+ result[columnId] = undefined;
123
+ }
124
+ else {
125
+ const dateValue = new Date(value);
126
+ if (isNaN(dateValue.getTime())) {
127
+ throw new Error(`Invalid date provided for field ${field.name}: ${value}`);
128
+ }
129
+ if (field.type === "date") {
130
+ const startOfDay = new Date(dateValue);
131
+ startOfDay.setUTCHours(0, 0, 0, 0);
132
+ result[columnId] = startOfDay.toISOString();
133
+ }
134
+ else {
135
+ result[columnId] = dateValue.toISOString();
136
+ }
137
+ }
138
+ }
139
+ else if (field.type === "multipleAttachments") {
140
+ if (Array.isArray(value) && value.length !== 0) {
141
+ const attachments = value
142
+ .map((v) => {
143
+ if (!v || typeof v !== "object" || !v.url)
144
+ return undefined;
145
+ return { url: v.url, filename: v.filename || v.url.split("/").pop() };
146
+ })
147
+ .filter((v) => v !== undefined);
148
+ if (attachments.length > 0)
149
+ result[columnId] = attachments;
150
+ }
151
+ }
152
+ else if (field.type === "rating") {
153
+ const max = field.options?.max;
154
+ if (max && typeof value === "number" && value > max) {
155
+ throw new Error(`Value ${value} surpasses Airtable max value setting on the rating field "${field.name}"`);
156
+ }
157
+ if (typeof value === "number" && value <= 0)
158
+ result[columnId] = undefined;
159
+ }
160
+ else if (field.type === "singleLineText" || field.type === "multilineText") {
161
+ if (Array.isArray(value))
162
+ result[columnId] = value.join(", ");
163
+ else if (typeof value !== "string")
164
+ result[columnId] = String(value);
165
+ }
166
+ else if (field.type === "barcode") {
167
+ result[columnId] = String(value);
168
+ }
169
+ }
170
+ return result;
171
+ }
172
+ /** A Duration cell (seconds) as HH:mm:ss, keeping seconds and fractions (ZIT-3889). */
173
+ export function formatAirtableDuration(durationSeconds) {
174
+ const duration = Math.abs(durationSeconds);
175
+ const pad = (n) => (n < 10 ? `0${n}` : `${n}`);
176
+ const hours = Math.floor(duration / 3600);
177
+ const minutes = Math.floor((duration % 3600) / 60);
178
+ const seconds = Math.floor(duration % 60);
179
+ const milliseconds = Math.round((duration % 1) * 1000);
180
+ const fraction = milliseconds > 0 ? `.${`${milliseconds}`.padStart(3, "0").replace(/0+$/, "")}` : "";
181
+ return `${durationSeconds < 0 ? "-" : ""}${pad(hours)}:${pad(minutes)}:${pad(seconds)}${fraction}`;
182
+ }
183
+ const isErrorObject = (value) => typeof value === "object" && value !== null && typeof value.error === "string";
184
+ const isNaNValue = (value) => typeof value === "object" && value !== null && value.specialValue === "NaN";
185
+ /** For reads: durations, barcodes, AI text, buttons, broken formulas and rollups. */
186
+ function getRecordFields(rawRecord, tableSchema) {
187
+ const fields = { ...rawRecord.fields };
188
+ if (tableSchema) {
189
+ for (const fieldId of Object.keys(fields)) {
190
+ const value = fields[fieldId];
191
+ const field = tableSchema.fields.find((f) => f.id === fieldId);
192
+ if (!field)
193
+ continue;
194
+ if (field.type === "multilineText" || field.type === "richText") {
195
+ if (value === "\n")
196
+ fields[fieldId] = "";
197
+ }
198
+ else if (field.type === "barcode" && value && typeof value === "object") {
199
+ fields[fieldId] = value.text || "";
200
+ }
201
+ else if (field.type === "duration" && typeof value === "number") {
202
+ fields[fieldId] = formatAirtableDuration(value);
203
+ }
204
+ else if (field.type === "button" && value && typeof value === "object") {
205
+ fields[fieldId] = value.url;
206
+ }
207
+ else if (field.type === "aiText" && value && typeof value === "object") {
208
+ const text = value.value;
209
+ fields[fieldId] = text !== undefined && text !== null && text !== "" ? text : undefined;
210
+ }
211
+ else if (field.type === "formula") {
212
+ const numeric = field.options?.result?.type === "number";
213
+ if ((numeric && typeof value !== "number") || isNaNValue(value) || isErrorObject(value)) {
214
+ fields[fieldId] = undefined;
215
+ }
216
+ }
217
+ else if (field.type === "rollup") {
218
+ const numeric = field.options?.rollup?.type === "number";
219
+ if ((numeric && typeof value !== "number") || isNaNValue(value) || isErrorObject(value) || value === null) {
220
+ fields[fieldId] = undefined;
221
+ }
222
+ }
223
+ }
224
+ }
225
+ return { ...fields, [UNIQUE_RECORD_ID_KEY]: rawRecord.id, id: rawRecord.id };
226
+ }
227
+ // =============================================================================
228
+ // Requests
229
+ // =============================================================================
230
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
231
+ // As the npm SDK raises it (`_checkStatusForError`, `_getErrorForNonObjectBody`).
232
+ const sdkError = async (response) => {
233
+ const status = response.status;
234
+ const body = await response.json().catch(() => undefined);
235
+ const { type, message } = (body?.error ?? {});
236
+ if (status === 401) {
237
+ return new AirtableError("AUTHENTICATION_REQUIRED", "You should provide valid api key to perform this operation", status);
238
+ }
239
+ if (status === 403)
240
+ return new AirtableError("NOT_AUTHORIZED", "You are not authorized to perform this operation", status);
241
+ if (status === 404)
242
+ return new AirtableError("NOT_FOUND", message ?? "Could not find what you are looking for", status);
243
+ if (status === 413)
244
+ return new AirtableError("REQUEST_TOO_LARGE", "Request body is too large", status);
245
+ if (status === 422) {
246
+ return new AirtableError(type ?? "UNPROCESSABLE_ENTITY", message ?? "The operation cannot be processed", status);
247
+ }
248
+ if (status === 429) {
249
+ return new AirtableError("TOO_MANY_REQUESTS", "You have made too many requests in a short period of time. Please retry your request later", status);
250
+ }
251
+ if (status === 500)
252
+ return new AirtableError("SERVER_ERROR", "Try again. If the problem persists, contact support.", status);
253
+ if (status === 503) {
254
+ return new AirtableError("SERVICE_UNAVAILABLE", "The service is temporarily unavailable. Please retry shortly.", status);
255
+ }
256
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
257
+ return new AirtableError("UNEXPECTED_ERROR", "The response from Airtable was invalid JSON. Please try again soon.", status);
258
+ }
259
+ return new AirtableError(type ?? "UNEXPECTED_ERROR", message ?? "An unexpected error occurred", status);
260
+ };
261
+ const MIN_RETRY_DELAY_MS = 200;
262
+ /** One bounded backoff on 429s, as classic's: five attempts, jittered over a doubling delay. */
263
+ async function withBackoff(operation, name, sleep, maxAttempts = 5, startingDelay = 1000, maxDelay = 600_000) {
264
+ let attempt = 0;
265
+ let delay = startingDelay;
266
+ while (attempt < maxAttempts) {
267
+ try {
268
+ return await operation();
269
+ }
270
+ catch (error) {
271
+ const rateLimited = error?.statusCode === 429 ||
272
+ error?.status === 429 ||
273
+ error?.message?.includes("429") ||
274
+ error?.message?.toLowerCase().includes("rate limit");
275
+ if (!rateLimited)
276
+ throw error;
277
+ attempt++;
278
+ if (attempt >= maxAttempts) {
279
+ console.warn(`[Airtable429] ${name} attempt ${attempt}/${maxAttempts}: max retries exhausted, giving up`);
280
+ throw Object.assign(new Error("Airtable rate limit exceeded after maximum retries"), {
281
+ status: 429,
282
+ originalError: error,
283
+ });
284
+ }
285
+ const jittered = MIN_RETRY_DELAY_MS + Math.floor(Math.random() * (delay - MIN_RETRY_DELAY_MS));
286
+ console.warn(`[Airtable429] ${name} attempt ${attempt}/${maxAttempts}: retrying in ${jittered}ms`);
287
+ await sleep(jittered);
288
+ delay = Math.min(delay * 2, maxDelay);
289
+ }
290
+ }
291
+ throw new Error("Unexpected end of Airtable backoff loop");
292
+ }
293
+ const segment = (id) => encodeURIComponent(id);
294
+ // =============================================================================
295
+ // Dispatch
296
+ // =============================================================================
297
+ /** One Airtable table call, as classic's dispatch runs it. */
298
+ export async function executeAirtableCall(context, methodName, params) {
299
+ const tableId = params?.tableId;
300
+ if (!tableId)
301
+ throw new Error("Airtable calls require tableId in params");
302
+ const baseId = context.settings.baseId || params.baseId;
303
+ if (!baseId)
304
+ throw new Error("Airtable calls require baseId (from integration settings or params)");
305
+ const tables = context.settings.nameMappings?.bases?.[baseId]?.tables;
306
+ const fieldMappings = tables?.[tableId]?.fields;
307
+ const tableSchema = lockSchema(tableId, tables?.[tableId]);
308
+ const sleep = context.sleep ?? sleepMs;
309
+ const tablePath = `/v0/${segment(baseId)}/${segment(tableId)}`;
310
+ // A write makes any read this request already made stale.
311
+ const send = async (path, init) => {
312
+ if (init.method !== "GET" && !path.endsWith("/listRecords"))
313
+ context.reads?.clear();
314
+ return context.fetch(path, init);
315
+ };
316
+ const read = (key, load) => {
317
+ if (!context.reads)
318
+ return load();
319
+ const held = context.reads.get(key);
320
+ if (held)
321
+ return held;
322
+ const loading = load();
323
+ context.reads.set(key, loading);
324
+ loading.catch(() => context.reads?.delete(key));
325
+ return loading;
326
+ };
327
+ const getRecord = (recordTableId, recordId) => read(`get:${baseId}:${recordTableId}:${recordId}`, async () => {
328
+ const response = await send(`/v0/${segment(baseId)}/${segment(recordTableId)}/${segment(recordId)}?returnFieldsByFieldId=true`, { method: "GET" });
329
+ if (!response.ok)
330
+ throw await sdkError(response);
331
+ return (await response.json());
332
+ });
333
+ const filterFormula = async (filters) => {
334
+ if (!filters || Object.keys(filters).length === 0)
335
+ return undefined;
336
+ const transformed = transformFiltersToFieldIds(filters, fieldMappings);
337
+ if (!transformed)
338
+ return undefined;
339
+ // A linked-record filter names the record by its primary field, which costs a lookup each.
340
+ const getPrimaryFieldForRecord = async (linkedTableId, recordId) => {
341
+ try {
342
+ const record = await withBackoff(() => getRecord(linkedTableId, recordId), "linkedRecord", sleep);
343
+ const primaryFieldId = tables?.[linkedTableId]?.primaryFieldId;
344
+ const value = primaryFieldId !== undefined ? record.fields[primaryFieldId] : Object.values(record.fields)[0];
345
+ return value !== undefined && value !== null ? String(value) : undefined;
346
+ }
347
+ catch {
348
+ return undefined;
349
+ }
350
+ };
351
+ return buildFiltersFormula({
352
+ filters: transformed,
353
+ tableSchema: tableSchema,
354
+ getPrimaryFieldForRecord,
355
+ });
356
+ };
357
+ const toSdk = (record) => airtableRecordToSdkNames(getRecordFields(record, tableSchema), fieldMappings);
358
+ switch (methodName) {
359
+ case "findOne": {
360
+ const { id, filters } = params;
361
+ if (id) {
362
+ return withBackoff(async () => {
363
+ try {
364
+ return toSdk(await getRecord(tableId, id));
365
+ }
366
+ catch (error) {
367
+ // A missing record is no record; 401 and 403 go up, where the platform refreshes a token.
368
+ if (error?.statusCode === 404)
369
+ return undefined;
370
+ throw error;
371
+ }
372
+ }, "findOne", sleep);
373
+ }
374
+ const filterByFormula = await filterFormula(filters);
375
+ return withBackoff(async () => {
376
+ const list = await listRecords({ maxRecords: 1, ...(filterByFormula ? { filterByFormula } : {}) });
377
+ return list.records.length === 0 ? undefined : toSdk(list.records[0]);
378
+ }, "findOne", sleep);
379
+ }
380
+ case "findAll": {
381
+ const { filters, limit, offset } = params;
382
+ const filterByFormula = await filterFormula(filters);
383
+ return withBackoff(async () => {
384
+ const json = await listRecords({
385
+ ...(filterByFormula ? { filterByFormula } : {}),
386
+ ...(limit ? { pageSize: limit } : {}),
387
+ ...(offset ? { offset } : {}),
388
+ });
389
+ return {
390
+ records: (json.records || []).map(toSdk).filter((r) => r !== undefined),
391
+ offset: json.offset,
392
+ hasMore: json.offset !== undefined,
393
+ };
394
+ }, "findAll", sleep);
395
+ }
396
+ case "create": {
397
+ const { record } = params;
398
+ if (!record)
399
+ return undefined;
400
+ return withBackoff(async () => {
401
+ const fields = processColumnValues(transformRecordToFieldIds(record, fieldMappings) || record, tableSchema);
402
+ const [created] = await writeRecords("POST", [{ fields }]);
403
+ return toSdk(created);
404
+ }, "create", sleep);
405
+ }
406
+ case "bulkCreate": {
407
+ const { records } = params;
408
+ if (!records || records.length === 0)
409
+ return [];
410
+ // Airtable takes at most 10 records a request.
411
+ const prepared = records.map((r) => ({
412
+ fields: processColumnValues(transformRecordToFieldIds(r, fieldMappings) || r, tableSchema),
413
+ }));
414
+ const results = [];
415
+ for (let i = 0; i < prepared.length; i += AIRTABLE_BATCH_SIZE) {
416
+ const created = await withBackoff(() => writeRecords("POST", prepared.slice(i, i + AIRTABLE_BATCH_SIZE)), "bulkCreate", sleep);
417
+ for (const r of created)
418
+ results.push(toSdk(r));
419
+ }
420
+ return results;
421
+ }
422
+ case "update": {
423
+ const { id, record } = params;
424
+ if (!id || !record)
425
+ return undefined;
426
+ return withBackoff(async () => {
427
+ const fields = processColumnValues(transformRecordToFieldIds(record, fieldMappings) || record, tableSchema);
428
+ const [updated] = await writeRecords("PATCH", [{ id, fields }]);
429
+ return { id, fields: toSdk(updated) };
430
+ }, "update", sleep);
431
+ }
432
+ case "delete": {
433
+ const { id } = params;
434
+ if (!id)
435
+ throw new Error("Delete requires id parameter");
436
+ return withBackoff(async () => {
437
+ const response = await send(`${tablePath}/${segment(id)}`, { method: "DELETE" });
438
+ if (!response.ok)
439
+ throw await sdkError(response);
440
+ const deleted = (await response.json());
441
+ return { success: true, id: deleted.id };
442
+ }, "delete", sleep);
443
+ }
444
+ default:
445
+ throw new Error(`Unknown Airtable method: ${methodName}`);
446
+ }
447
+ async function listRecords(body) {
448
+ const request = { returnFieldsByFieldId: true, ...body };
449
+ return read(`list:${baseId}:${tableId}:${JSON.stringify(request)}`, async () => {
450
+ const response = await send(`${tablePath}/listRecords`, { method: "POST", body: request });
451
+ if (!response.ok) {
452
+ const error = new Error(`Airtable API error: ${await response.text()}`);
453
+ error.statusCode = response.status;
454
+ throw error;
455
+ }
456
+ return (await response.json());
457
+ });
458
+ }
459
+ async function writeRecords(method, records) {
460
+ const response = await send(tablePath, { method, body: { records, typecast: true, returnFieldsByFieldId: true } });
461
+ if (!response.ok)
462
+ throw await sdkError(response);
463
+ return (await response.json()).records;
464
+ }
465
+ }