@syncmatters/connector-sdk 1.0.0 → 1.0.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.
@@ -0,0 +1,566 @@
1
+ # Acme — a complete worked connector
2
+
3
+ One canonical, end-to-end connector on a single page. Three objects — `companies`, `contacts`
4
+ (first/last name matching, `upsertClean`) and `salesorders` (a **composite key**:
5
+ business unit + order id) — plus a contact→company relationship, static + API-discovered
6
+ custom fields, and full `query` / `upsert` / `delete`. Every pattern here follows the
7
+ production fleet; copy this shape and adapt it. Topic-file cross-references are in comments.
8
+
9
+ The imaginary Acme API: bearer-token REST at `https://api.example.com/v1/`, cursor pagination
10
+ (`?after=<id>`), `modified_since` differential filter, a `/search` endpoint taking field
11
+ criteria, batch write endpoints for companies/contacts, per-unit sales-order routes
12
+ (`salesorders/{businessUnit}/{orderId}` — order ids are only unique within a business unit),
13
+ and a `/fields` endpoint listing account-specific custom fields.
14
+
15
+ ## `files/Connectors/Acme/Acme.mjs`
16
+
17
+ ```js
18
+ import SDK from "@syncmatters/connector-sdk";
19
+
20
+ /**
21
+ * Acme connector - canonical example.
22
+ * Authoring guide: node_modules/@syncmatters/connector-sdk/docs/connector-authoring.md
23
+ */
24
+ export default class Acme {
25
+ /** @type {SDK.Logger} */ #log;
26
+ /** @type {SDK.HttpClient} */ #http;
27
+ /** @type {string} */ #apiKey = "";
28
+ /** @type {boolean} */ #debug = false;
29
+ #baseUrl = "https://api.example.com/v1/";
30
+ // checkpoint overlap: rows modified while a query runs must be re-read next run (05, 10)
31
+ #checkpointOverlapMs = 5 * 60 * 1000;
32
+
33
+ /** @param {SDK.InitArgs} args @returns {Promise<void>} */
34
+ async init(args) {
35
+ SDK.verifyType(this);
36
+ this.#log = args.log;
37
+ this.#debug = args.settings.get("debug") === true;
38
+
39
+ this.#apiKey = args.settings.get("api_key");
40
+ if (!this.#apiKey) {
41
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidOptionValue, "Setting 'API Key' is required");
42
+ }
43
+
44
+ // all HTTP through one rate-limited client; retry reads and throttle/gateway blips (03)
45
+ this.#http = SDK.utilities.httpClient({
46
+ limiter: SDK.utilities.rateLimiter({ type: "rolling", count: 10, ms: 1000 }),
47
+ maxRetry: 3,
48
+ retryBackoffMs: 2000,
49
+ canRetry: (err, method, url, httpCode) => {
50
+ if ([400, 401, 403, 404].includes(httpCode)) return false;
51
+ if (method === "GET") return true;
52
+ return [429, 502, 504].includes(httpCode);
53
+ },
54
+ defaultHeaders: { "Content-Type": "application/json" },
55
+ });
56
+ }
57
+
58
+ /** Central request wrapper: base URL + auth + debug logging (03). @param {SDK.HttpRequest} request */
59
+ async #call(request) {
60
+ if (!request.url.startsWith("https://")) request.url = this.#baseUrl + request.url;
61
+ request.headers = { ...request.headers, Authorization: `Bearer ${this.#apiKey}` };
62
+ try {
63
+ const response = await this.#http.execute(request);
64
+ this.#debug && this.#log.info(`DEBUG: ${request.method} ${request.url}`, { response });
65
+ return response;
66
+ } catch (err) {
67
+ this.#debug && this.#log.info(`DEBUG: ${request.method} ${request.url} failed`, { err });
68
+ throw err;
69
+ }
70
+ }
71
+
72
+ /** Cheapest authenticated call; catch, never throw (02). @returns {Promise<SDK.TestResult>} */
73
+ async test() {
74
+ try {
75
+ await this.#call({ method: "GET", url: "me" });
76
+ return { success: true };
77
+ } catch (err) {
78
+ return { success: false, message: err instanceof Error ? err.message : String(err) };
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------- meta (04)
83
+
84
+ /** @returns {Promise<SDK.ConnectorMeta>} */
85
+ async meta() {
86
+ const me = await this.#call({ method: "GET", url: "me" });
87
+ const [companyCustom, contactCustom] = await Promise.all([
88
+ this.#customFields("companies"),
89
+ this.#customFields("contacts"),
90
+ ]);
91
+
92
+ /** @type {SDK.ObjectMeta[]} */
93
+ const objects = [
94
+ {
95
+ id: "companies",
96
+ name: "Companies",
97
+ canQueryList: true,
98
+ canQueryByIds: true,
99
+ canQueryByCheckpoint: true,
100
+ matchRules: ["id", "name[ci]"],
101
+ queryFields: [
102
+ { id: "id", name: "ID", type: "string", isKey: true, canMatch: true },
103
+ { id: "name", name: "Name", type: "string", canMatch: true },
104
+ { id: "domain", name: "Domain", type: "string", canMatch: true },
105
+ { id: "created_at", name: "Created", type: "string" },
106
+ ...companyCustom,
107
+ ],
108
+ // upsert accepts the writable statics + customs; the key addresses updates (04)
109
+ upsertFields: [
110
+ { id: "name", name: "Name", type: "string", constraints: { mandatory: "add" } },
111
+ { id: "domain", name: "Domain", type: "string" },
112
+ ...companyCustom,
113
+ { id: "id", type: "string", isKey: true },
114
+ ],
115
+ deleteFields: [{ id: "id", type: "string", isKey: true }],
116
+ },
117
+ {
118
+ id: "contacts",
119
+ name: "Contacts",
120
+ canQueryList: true,
121
+ canQueryByIds: true,
122
+ canQueryByCheckpoint: true,
123
+ canUpsertClean: true, // we implement upsertClean() below (06)
124
+ matchRules: ["id", "email[ci]", "first_and_last_name[ci]"],
125
+ relationships: [
126
+ // enables relatedFilter queries; data.idField is OUR payload, echoed back in
127
+ // options.meta so #relatedWhere below can find the FK (04, 05)
128
+ { id: "companies-company_id", name: "Company", relObjectId: "companies",
129
+ cardinality: "OneToOne", data: { idField: "company_id" } },
130
+ ],
131
+ queryFields: [
132
+ { id: "id", name: "ID", type: "string", isKey: true, canMatch: true },
133
+ { id: "email", name: "Email", type: "string", canMatch: true, constraints: { email: true } },
134
+ { id: "first_name", name: "First Name", type: "string", canMatch: true },
135
+ { id: "last_name", name: "Last Name", type: "string", canMatch: true },
136
+ { id: "company_id", name: "Company ID", type: "string" },
137
+ ...contactCustom,
138
+ ],
139
+ upsertFields: [
140
+ { id: "email", name: "Email", type: "string", constraints: { mandatory: "add", email: true } },
141
+ { id: "first_name", name: "First Name", type: "string" },
142
+ { id: "last_name", name: "Last Name", type: "string" },
143
+ { id: "company_id", name: "Company ID", type: "string" },
144
+ ...contactCustom,
145
+ { id: "id", type: "string", isKey: true },
146
+ ],
147
+ deleteFields: [{ id: "id", type: "string", isKey: true }],
148
+ },
149
+ {
150
+ // COMPOSITE KEY object: order_id is only unique within a business_unit, so the row
151
+ // identity is BOTH - encoded into the $compositeKey pseudo-field (11)
152
+ id: "salesorders",
153
+ name: "Sales Orders",
154
+ canQueryList: true,
155
+ canQueryByIds: true,
156
+ queryFields: [
157
+ { id: "$compositeKey", name: "Composite key", type: "string", isKey: true },
158
+ { id: "business_unit", name: "Business Unit", type: "string" },
159
+ { id: "order_id", name: "Order ID", type: "number" },
160
+ { id: "amount", name: "Amount", type: "number" },
161
+ { id: "status", name: "Status", type: "string" },
162
+ ],
163
+ upsertFields: [
164
+ { id: "business_unit", name: "Business Unit", type: "string", constraints: { mandatory: "add" } },
165
+ { id: "amount", name: "Amount", type: "number" },
166
+ { id: "status", name: "Status", type: "string" },
167
+ { id: "$compositeKey", name: "Composite key", type: "string", isKey: true },
168
+ ],
169
+ deleteFields: [{ id: "$compositeKey", name: "Composite key", type: "string", isKey: true }],
170
+ },
171
+ ];
172
+
173
+ // stable account identity - platform detects re-pointed connections with it (04)
174
+ return { identity: `${me.account_name}|${me.email}`, objects };
175
+ }
176
+
177
+ /** Discover account-specific custom fields via the API. @param {string} objectId @returns {Promise<SDK.ObjectField[]>} */
178
+ async #customFields(objectId) {
179
+ const resp = await this.#call({ method: "GET", url: `${objectId}/fields` });
180
+ /** @type {SDK.ObjectField[]} */
181
+ const fields = [];
182
+ for (const f of resp.fields) {
183
+ /** @type {SDK.ObjectField} */
184
+ const field = { id: f.key, name: f.label, type: "string", isCustom: true };
185
+ switch (f.type) {
186
+ case "number": field.type = "number"; break;
187
+ case "boolean": field.type = "boolean"; break;
188
+ case "picklist":
189
+ field.optionValues = f.options.map((/** @type {{id: string, label?: string}} */ o) =>
190
+ ({ id: o.id, name: o.label }));
191
+ break;
192
+ case "multipicklist":
193
+ field.type = "array";
194
+ field.arrayType = "string";
195
+ break;
196
+ }
197
+ fields.push(field);
198
+ }
199
+ return fields;
200
+ }
201
+
202
+ // ---------------------------------------------------------------- query (05)
203
+
204
+ /** @param {SDK.QueryOptions} options @returns {Promise<SDK.QueryPage>} */
205
+ async query(options) {
206
+ const qo = options.queryOptions || {};
207
+ // fail fast on filters this object never declared (08)
208
+ if (qo.checkpointFilter && !options.meta.canQueryByCheckpoint) {
209
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidFilterParameter,
210
+ `object ${options.id} does not support checkpoint queries`);
211
+ }
212
+
213
+ if (qo.matchFilter) return await this.#queryMatch(options, qo.matchFilter);
214
+ // composite-key ids arrive as key STRINGS - parse each and fetch directly (11)
215
+ if (options.id === "salesorders" && qo.idsFilter) {
216
+ return await this.#querySalesOrdersByIds(qo.idsFilter);
217
+ }
218
+
219
+ const queryState = options.queryState ||
220
+ { after: undefined, limit: 200, rowCount: 0, startTime: Date.now() };
221
+
222
+ const params = [`limit=${queryState.limit}`];
223
+ if (queryState.after) params.push(`after=${encodeURIComponent(queryState.after)}`);
224
+
225
+ if (qo.idsFilter) {
226
+ const ids = qo.idsFilter.filter((id) => id != null && id !== ""); // never emit id=undefined (10)
227
+ if (ids.length === 0) return { rows: [], finalPage: true };
228
+ params.push(`ids=${ids.map(encodeURIComponent).join(",")}`);
229
+ }
230
+ if (qo.checkpointFilter) {
231
+ // undefined value = first differential run: return everything (05)
232
+ params.push(`modified_since=${encodeURIComponent(qo.checkpointFilter.value || "1970-01-01T00:00:00Z")}`);
233
+ }
234
+ if (qo.relatedFilter) params.push(this.#relatedWhere(options, qo.relatedFilter));
235
+
236
+ const resp = await this.#call({ method: "GET", url: `${options.id}?${params.join("&")}` });
237
+
238
+ /** @type {SDK.Row[]} */
239
+ let rows = resp.items.map((/** @type {any} */ item) => this.#row(options.id, item));
240
+ if (qo.relatedFilter) rows = this.#tagRelationships(rows, qo.relatedFilter);
241
+
242
+ queryState.after = resp.next_cursor || undefined;
243
+ queryState.rowCount += rows.length;
244
+
245
+ // respect the caller's limit: trim overflow, then stop (05)
246
+ if (qo.limit != null && queryState.rowCount > qo.limit) {
247
+ rows = rows.slice(0, rows.length - (queryState.rowCount - qo.limit));
248
+ queryState.rowCount = qo.limit;
249
+ }
250
+
251
+ /** @type {SDK.QueryPage} */
252
+ const result = { rows, queryState };
253
+ result.finalPage = rows.length === 0 || !queryState.after ||
254
+ (qo.limit != null && queryState.rowCount >= qo.limit);
255
+
256
+ if (qo.checkpointFilter && result.finalPage) {
257
+ // ALWAYS overlap - emit earlier than "now" so mid-run modifications are re-read (05, 10)
258
+ result.checkpoint = new Date(queryState.startTime - this.#checkpointOverlapMs).toISOString();
259
+ }
260
+ return result;
261
+ }
262
+
263
+ /** One place builds rows, so keys are ALWAYS encoded identically (11).
264
+ * @param {string} objectId @param {any} item @returns {SDK.Row} */
265
+ #row(objectId, item) {
266
+ if (objectId === "salesorders") {
267
+ const key = makeSalesOrderKey(item);
268
+ // expose the key as a field too, so callers can address/inspect it (11)
269
+ return { meta: { key }, data: { ...item, $compositeKey: key } };
270
+ }
271
+ return {
272
+ meta: { key: String(item.id), url: `https://app.example.com/${objectId}/${item.id}` },
273
+ data: item,
274
+ };
275
+ }
276
+
277
+ /** @param {string[]} keys @returns {Promise<SDK.QueryPage>} */
278
+ async #querySalesOrdersByIds(keys) {
279
+ const rows = await Promise.all(keys.filter((k) => k).map(async (key) => {
280
+ const { businessUnit, orderId } = parseSalesOrderKey(key);
281
+ const item = await this.#call({
282
+ method: "GET",
283
+ url: `salesorders/${encodeURIComponent(businessUnit)}/${orderId}`,
284
+ });
285
+ return this.#row("salesorders", item);
286
+ }));
287
+ return { rows, finalPage: true };
288
+ }
289
+
290
+ /** Match by email / first+last name / company name: search candidates, let the platform
291
+ * reserve winners via canUse (05).
292
+ * @param {SDK.QueryOptions} options @param {NonNullable<NonNullable<SDK.QueryOptions["queryOptions"]>["matchFilter"]>} matchFilter
293
+ * @returns {Promise<SDK.QueryPage>} */
294
+ async #queryMatch(options, matchFilter) {
295
+ if (!(options.meta.matchRules || []).includes(matchFilter.rule)) {
296
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidFilterParameter,
297
+ `object ${options.id} does not support match rule '${matchFilter.rule}'`);
298
+ }
299
+ const candidates = [];
300
+ for (const src of matchFilter.srcData) {
301
+ const criteria = this.#matchCriteria(matchFilter.rule, src.match);
302
+ if (!criteria) continue; // src is missing a required match value - skip, never guess (10)
303
+ const resp = await this.#call({
304
+ method: "POST", url: `${options.id}/search`,
305
+ body: { criteria, case_insensitive: true },
306
+ });
307
+ if (resp.items.length > 0) {
308
+ candidates.push({
309
+ srcRowId: src.srcRowId,
310
+ candidates: resp.items.map((/** @type {any} */ item) => this.#row(options.id, item)),
311
+ });
312
+ }
313
+ }
314
+ // canUse reserves un-mapped matches and returns rows with row.relationship populated (05)
315
+ const rows = await matchFilter.canUse(candidates);
316
+ return { rows, finalPage: true };
317
+ }
318
+
319
+ /** Map a match rule to API search criteria; undefined = this src can't be searched.
320
+ * @param {string} rule @param {{id?: string, name?: string, email?: string, firstname?: string, lastname?: string}} match */
321
+ #matchCriteria(rule, match) {
322
+ switch (rule) {
323
+ case "email[ci]":
324
+ return match.email ? { email: match.email } : undefined;
325
+ case "name[ci]":
326
+ return match.name ? { name: match.name } : undefined;
327
+ case "first_and_last_name[ci]":
328
+ // BOTH parts are required - a first name alone must not match (05)
329
+ return match.firstname && match.lastname
330
+ ? { first_name: match.firstname, last_name: match.lastname }
331
+ : undefined;
332
+ default:
333
+ return undefined;
334
+ }
335
+ }
336
+
337
+ /** Resolve the FK declared in meta().relationships via its data payload.
338
+ * @param {SDK.QueryOptions} options @param {SDK.QueryRelatedFilter} related */
339
+ #relatedWhere(options, related) {
340
+ const rel = (options.meta.relationships || []).find((r) => r.id === related.otherRelationshipId);
341
+ if (!rel) {
342
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidFilterParameter,
343
+ `object ${options.id} has no relationship '${related.otherRelationshipId}'`);
344
+ }
345
+ const fk = /** @type {{idField: string}} */ (rel.data).idField;
346
+ const ids = related.otherRows.map((r) => r.meta.key);
347
+ return `${fk}=${ids.map(encodeURIComponent).join(",")}`;
348
+ }
349
+
350
+ /** One row instance per source row it relates to, tagged with the relationship (05).
351
+ * @param {SDK.Row[]} rows @param {SDK.QueryRelatedFilter} related @returns {SDK.Row[]} */
352
+ #tagRelationships(rows, related) {
353
+ const out = [];
354
+ for (const row of rows) {
355
+ for (const src of related.otherRows) {
356
+ if (String(row.data.company_id) === src.meta.key) {
357
+ out.push({ ...row, relationship: {
358
+ srcRowId: src.meta.key,
359
+ srcObjectId: related.otherObjectId,
360
+ relationshipId: related.otherRelationshipId,
361
+ } });
362
+ }
363
+ }
364
+ }
365
+ return out;
366
+ }
367
+
368
+ // ---------------------------------------------------------------- upsert / delete (06)
369
+
370
+ /** Pre-write validation + change detection - called for contacts (canUpsertClean).
371
+ * ORDER MATTERS: the verifies can MUTATE the upsert (cleanse invalid picklist options,
372
+ * resolve labels to ids), so detect the change LAST (06).
373
+ * @param {SDK.UpsertCleanOptions} options @returns {Promise<SDK.UpsertCleanResponse>} */
374
+ async upsertClean(options) {
375
+ const issues = [
376
+ ...(await options.verifyFieldOptions({ cleanAction: "DeleteUpsertField" })),
377
+ ...(await options.verifyFieldConstraints({ timeZone: "UTC" })),
378
+ ];
379
+ const change = options.detectFirstChange();
380
+ return { hasChanges: change !== undefined, change, issues };
381
+ }
382
+
383
+ /** @param {SDK.UpsertOptions} options @returns {Promise<SDK.Row[]>} */
384
+ async upsert(options) {
385
+ if (options.id === "salesorders") return await this.#upsertSalesOrders(options);
386
+
387
+ // split add vs update, batch each, then re-align POSITIONALLY with options.rows (06, 10)
388
+ /** @type {any[]} */ const inserts = [];
389
+ /** @type {any[]} */ const updates = [];
390
+ /** @type {Map<number, {list: "i"|"u", at: number}>} */ const where = new Map();
391
+ options.rows.forEach((row, i) => {
392
+ if (row.id != null) { updates.push(row); where.set(i, { list: "u", at: updates.length - 1 }); }
393
+ else { inserts.push(row); where.set(i, { list: "i", at: inserts.length - 1 }); }
394
+ });
395
+
396
+ const iOut = await this.#writeBatches(options.id, "POST", "batch/create", inserts);
397
+ const uOut = await this.#writeBatches(options.id, "POST", "batch/update", updates);
398
+
399
+ return options.rows.map((_, i) => {
400
+ const w = /** @type {{list: "i"|"u", at: number}} */ (where.get(i));
401
+ const row = w.list === "i" ? iOut[w.at] : uOut[w.at];
402
+ if (!row) {
403
+ // never guess at correlation - misalignment corrupts data silently (10)
404
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidOperation,
405
+ `upsert result for input row ${i} could not be correlated`);
406
+ }
407
+ return row;
408
+ });
409
+ }
410
+
411
+ /** Composite-key writes: parse the key for updates, require the parent part for adds (11).
412
+ * Promise.all over map keeps results positionally aligned with options.rows.
413
+ * @param {SDK.UpsertOptions} options @returns {Promise<SDK.Row[]>} */
414
+ async #upsertSalesOrders(options) {
415
+ return await Promise.all(options.rows.map(async (/** @type {any} */ row) => {
416
+ const compositeKey = row["$compositeKey"];
417
+ if (compositeKey) {
418
+ // UPDATE: decode the key for the URL; the pseudo-field never goes to the API (11)
419
+ const { businessUnit, orderId } = parseSalesOrderKey(compositeKey);
420
+ const body = { ...row };
421
+ delete body["$compositeKey"];
422
+ const result = await this.#call({
423
+ method: "PUT",
424
+ url: `salesorders/${encodeURIComponent(businessUnit)}/${orderId}`,
425
+ body,
426
+ });
427
+ // echo the EXACT key string we received - never re-encode (11)
428
+ return { meta: { key: compositeKey }, data: { ...result, $compositeKey: compositeKey } };
429
+ }
430
+ // ADD: no key yet - the parent part must come as a regular field
431
+ if (row.business_unit == null) {
432
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidApiKey,
433
+ "adding a salesorder requires 'business_unit'");
434
+ }
435
+ const result = await this.#call({
436
+ method: "POST",
437
+ url: `salesorders/${encodeURIComponent(row.business_unit)}`,
438
+ body: row,
439
+ });
440
+ return this.#row("salesorders", result);
441
+ }));
442
+ }
443
+
444
+ /** Batch writes in API-sized chunks; per-row API failures throw ApiReportedError (06, 08).
445
+ * @param {string} objectId @param {"POST"} method @param {string} action @param {any[]} rows
446
+ * @returns {Promise<SDK.Row[]>} */
447
+ async #writeBatches(objectId, method, action, rows) {
448
+ const batchSize = 100;
449
+ /** @type {SDK.Row[]} */ const out = [];
450
+ for (let i = 0; i < rows.length; i += batchSize) {
451
+ const batch = rows.slice(i, i + batchSize);
452
+ const resp = await this.#call({ method, url: `${objectId}/${action}`, body: { inputs: batch } });
453
+ for (const result of resp.results) { // API echoes results in input order
454
+ if (result.error) {
455
+ this.#log.error(`Acme ${action} reported an error`, result);
456
+ throw new SDK.ConnectorError(SDK.ErrorCode.ApiReportedError,
457
+ `Acme ${action} reported an error: ${JSON.stringify(result.error)}`);
458
+ }
459
+ out.push(this.#row(objectId, result));
460
+ }
461
+ }
462
+ return out;
463
+ }
464
+
465
+ /** @param {SDK.DeleteOptions} options @returns {Promise<SDK.Row[]>} */
466
+ async delete(options) {
467
+ if (options.id === "salesorders") {
468
+ return await Promise.all(options.rows.map(async (/** @type {any} */ row) => {
469
+ const key = row["$compositeKey"];
470
+ const { businessUnit, orderId } = parseSalesOrderKey(key);
471
+ await this.#call({
472
+ method: "DELETE",
473
+ url: `salesorders/${encodeURIComponent(businessUnit)}/${orderId}`,
474
+ });
475
+ // echo the exact key back so the platform matches the cached row (11)
476
+ return { meta: { key, deleted: true }, data: { business_unit: businessUnit, order_id: orderId } };
477
+ }));
478
+ }
479
+ const ids = options.rows.map((r) => String(r.id));
480
+ await this.#call({ method: "POST", url: `${options.id}/batch/archive`, body: { ids } });
481
+ return options.rows.map((r) => ({ meta: { key: String(r.id), deleted: true }, data: { id: r.id } }));
482
+ }
483
+ }
484
+
485
+ // ------------------------------------------------------------- composite key helpers (11)
486
+ // salesorders identity = (business_unit, order_id). Encoded as ONE deterministic JSON string
487
+ // with sorted parts - the platform compares keys by string equality, so the same row must
488
+ // produce a byte-identical key on every query, forever. Never reorder or re-format.
489
+
490
+ /** @param {any} data @returns {string} */
491
+ function makeSalesOrderKey(data) {
492
+ if (data.business_unit == null || data.order_id == null) {
493
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidApiKey,
494
+ `cannot build salesorders key: business_unit/order_id missing: ${JSON.stringify(data)}`);
495
+ }
496
+ // property order fixed by construction (sorted): business_unit, then order_id
497
+ return JSON.stringify({ business_unit: data.business_unit, order_id: data.order_id });
498
+ }
499
+
500
+ /** @param {string} compositeKey @returns {{businessUnit: string, orderId: number}} */
501
+ function parseSalesOrderKey(compositeKey) {
502
+ /** @type {any} */
503
+ let parsed;
504
+ try {
505
+ parsed = JSON.parse(compositeKey);
506
+ } catch {
507
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidApiKey,
508
+ `failed to parse salesorders composite key '${compositeKey}'`);
509
+ }
510
+ if (parsed?.business_unit == null || parsed?.order_id == null) {
511
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidApiKey,
512
+ `salesorders composite key '${compositeKey}' did not contain business_unit + order_id`);
513
+ }
514
+ return { businessUnit: String(parsed.business_unit), orderId: Number(parsed.order_id) };
515
+ }
516
+ ```
517
+
518
+ ## The sidecar: `meta/Connectors/Acme/Acme.mjs.meta.json`
519
+
520
+ ```json
521
+ {
522
+ "runnable": false,
523
+ "type": "connector",
524
+ "cpu_architecture": "",
525
+ "engine": 0,
526
+ "resource_size": "medium",
527
+ "name": "Acme.mjs",
528
+ "module_script_file_paths": [],
529
+ "connector_metadata": {
530
+ "categories": ["sales"],
531
+ "agent_mode": "never",
532
+ "settings": {
533
+ "api_key": { "order": "00", "name": "API Key", "type": "string", "control": "singlelinetext", "secret": true },
534
+ "debug": { "order": "01", "name": "Debug", "advanced": true, "type": "boolean", "control": "singlelinetext" }
535
+ }
536
+ }
537
+ }
538
+ ```
539
+
540
+ ## What to notice (the parts assistants most often get wrong)
541
+
542
+ 1. **One `#call` wrapper** owns base URL, auth and debug logging; nothing else touches HTTP.
543
+ 2. **`meta()` is generated, not hand-typed twice** — static fields inline, custom fields
544
+ discovered from the API and spread into BOTH `queryFields` and `upsertFields`; the `isKey`
545
+ field is appended to `upsertFields` so updates can address rows.
546
+ 3. **One `#row()` builds every row**, so keys are always encoded identically — including the
547
+ composite `salesorders` key, which is built by a single `makeSalesOrderKey` and **echoed
548
+ verbatim** (never re-encoded) on update/delete.
549
+ 4. **The composite `$compositeKey` pseudo-field carries `isKey`** and is stripped from every
550
+ API body; adds supply the parent part (`business_unit`) as a regular mandatory field.
551
+ 5. **`relationships[].data` is the connector's own round-trip payload** — `meta()` stores the
552
+ FK field name, `query()` reads it back from `options.meta` when resolving `relatedFilter`.
553
+ 6. **`query()` dispatches filters in one place**, throws `InvalidFilterParameter` for anything
554
+ undeclared, and shares the pagination path across list/ids/checkpoint variants — except
555
+ composite-key `idsFilter`, which parses each key and fetches directly.
556
+ 7. **The checkpoint is `startTime - overlap`**, emitted only on the final page.
557
+ 8. **Match queries end with `canUse`** — the platform picks the winners, you just supply
558
+ candidates. `first_and_last_name[ci]` requires BOTH parts; sources missing either are
559
+ skipped, never half-matched.
560
+ 9. **`upsertClean` runs the mutating verifies first, `detectFirstChange` last**, and only the
561
+ object that declares `canUpsertClean` (contacts) is cleaned.
562
+ 10. **Upsert output is index-mapped back to input order** and throws rather than guessing when
563
+ a result can't be correlated (`Promise.all` over `map` preserves order for the per-row
564
+ sales-order path).
565
+ 11. Every method is JSDoc-typed against `SDK.*`; state lives in `#fields`; the only throw
566
+ types are `ConnectorError` (and propagated `HttpError`s).
@@ -0,0 +1,123 @@
1
+ # File fields
2
+
3
+ Objects can carry file content (documents, attachments, exports) as **fields of
4
+ `type: "file"`** — declared like any other field, requested like any other field, and written
5
+ like any other field. The value of a file field is an `SDK.FileProvider`.
6
+
7
+ ## Declaring
8
+
9
+ ```js
10
+ // meta() - a document object whose content is a file field
11
+ {
12
+ id: "documents",
13
+ name: "Documents",
14
+ canQueryList: true,
15
+ canQueryByIds: true,
16
+ queryFields: [
17
+ { id: "id", name: "ID", type: "string", isKey: true },
18
+ { id: "name", name: "File name", type: "string" },
19
+ { id: "size", name: "Size (bytes)", type: "number" },
20
+ { id: "content", name: "Content", type: "file" },
21
+ ],
22
+ upsertFields: [
23
+ { id: "name", name: "File name", type: "string", constraints: { mandatory: "add" } },
24
+ { id: "content", name: "Content", type: "file" },
25
+ { id: "id", type: "string", isKey: true },
26
+ ],
27
+ }
28
+ ```
29
+
30
+ ## Querying: include the file only when asked
31
+
32
+ File content is expensive — never fetch it unconditionally. The caller signals what it wants
33
+ through `options.queryOptions.fields` (the field paths it expects, see
34
+ [05-query.md](./05-query.md)); include file content **only when a file-typed field is in that
35
+ list**:
36
+
37
+ ```js
38
+ /** does the caller want any of this object's file fields? */
39
+ #wantsField(options, fieldId) {
40
+ const fields = options.queryOptions?.fields;
41
+ if (!fields) return false; // no field selection -> cheap fields only, no file content
42
+ return fields.some((path) => path[0]?.path === fieldId);
43
+ }
44
+
45
+ async query(options) {
46
+ const wantsContent = this.#wantsField(options, "content");
47
+ // ...fetch the row list as usual...
48
+ const rows = [];
49
+ for (const item of resp.items) {
50
+ /** @type {any} */
51
+ const data = { id: item.id, name: item.name, size: item.size };
52
+ if (wantsContent) {
53
+ // fileResponse: true -> the http client hands back an SDK.FileProvider
54
+ data.content = /** @type {SDK.FileProvider} */ (await this.#http.execute({
55
+ method: "GET",
56
+ url: `documents/${item.id}/content`,
57
+ fileResponse: true,
58
+ }));
59
+ }
60
+ rows.push({ meta: { key: String(item.id) }, data });
61
+ }
62
+ // ...
63
+ }
64
+ ```
65
+
66
+ `fileResponse: true` on an `HttpRequest` is the standard way to download into a provider
67
+ without buffering the whole body in memory. To build a provider from something you already
68
+ have, use the constructor variants:
69
+
70
+ ```js
71
+ SDK.utilities.fileProvider({ buffer: someBuffer });
72
+ SDK.utilities.fileProvider({ string: someText });
73
+ SDK.utilities.fileProvider({ file: { path: tempPath, deleteOnClose: true } });
74
+ ```
75
+
76
+ ## Consuming a `FileProvider`
77
+
78
+ | Method | Use |
79
+ | ------------------------------------- | ----------------------------------------------------------------------- |
80
+ | `stream(encoding?)` | a readable stream — the normal way to upload or parse without buffering |
81
+ | `blob(fileName?)` / `formDataValue()` | for APIs consumed via `fetch`-style multipart bodies |
82
+ | `save(path?)` | persist to disk, returns the path (pair with `SDK.utilities.tempFile`) |
83
+ | `length()` | size in bytes (e.g. for a `size` field or Content-Length header) |
84
+ | `close()` | release the resource — **always, in a `finally`** |
85
+
86
+ Parsing structured file content: `SDK.utilities.csvReader({ source: { fileProvider } })`
87
+ (same idea for `xlsxReader`).
88
+
89
+ ## Upserting: the file arrives as a field value
90
+
91
+ The upsert row carries a `FileProvider` at the file field's position. Stream it out and close
92
+ it:
93
+
94
+ ```js
95
+ async upsert(options) {
96
+ return await Promise.all(options.rows.map(async (/** @type {any} */ row) => {
97
+ const file = /** @type {SDK.FileProvider} */ (row.content);
98
+ try {
99
+ const result = await this.#call({
100
+ method: "POST",
101
+ url: `documents${row.id ? `/${row.id}` : ""}`,
102
+ headers: { "Content-Type": "application/octet-stream", "X-File-Name": row.name },
103
+ body: await file.stream(),
104
+ });
105
+ return { meta: { key: String(result.id) }, data: result };
106
+ } finally {
107
+ try { await file.close(); } catch { /* already closed */ }
108
+ }
109
+ }));
110
+ }
111
+ ```
112
+
113
+ ## Gotchas
114
+
115
+ 1. **No `fields` list means no file content.** When the caller doesn't select fields, return
116
+ the cheap scalar fields only — a list query that downloads every document is a platform
117
+ incident waiting to happen.
118
+ 2. **`upsertClean` treats every `FileProvider` as a change** and `confirmChange` cannot veto
119
+ it ([06-upsert-delete.md](./06-upsert-delete.md)) — keep file fields out of cleaned
120
+ payloads or accept the always-write.
121
+ 3. **Close what you open.** Providers can be backed by temp files; `close()` in a `finally`
122
+ on both the query and upsert paths.
123
+