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