@syncmatters/connector-sdk 1.0.1 → 1.0.3
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/docs/01-workspace-and-meta-json.md +35 -24
- package/docs/03-http-auth-ratelimit.md +5 -1
- package/docs/04-meta-objects-fields.md +66 -6
- package/docs/05-query.md +71 -6
- package/docs/06-upsert-delete.md +11 -0
- package/docs/09-testing.md +96 -13
- package/docs/10-style-and-pitfalls.md +18 -2
- package/docs/11-composite-keys.md +12 -4
- package/docs/12-example-connector.md +64 -27
- package/docs/15-example-test-file.md +567 -0
- package/docs/connector-authoring.md +12 -3
- package/lib/connector.d.ts +2 -2
- package/package.json +3 -3
|
@@ -5,6 +5,9 @@ One canonical, end-to-end connector on a single page. Three objects — `compani
|
|
|
5
5
|
business unit + order id) — plus a contact→company relationship, static + API-discovered
|
|
6
6
|
custom fields, and full `query` / `upsert` / `delete`. Every pattern here follows the
|
|
7
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 the CLI's `sm test` require — is
|
|
10
|
+
[15-example-test-file.md](./15-example-test-file.md).
|
|
8
11
|
|
|
9
12
|
The imaginary Acme API: bearer-token REST at `https://api.example.com/v1/`, cursor pagination
|
|
10
13
|
(`?after=<id>`), `modified_since` differential filter, a `/search` endpoint taking field
|
|
@@ -98,6 +101,15 @@ export default class Acme {
|
|
|
98
101
|
canQueryByIds: true,
|
|
99
102
|
canQueryByCheckpoint: true,
|
|
100
103
|
matchRules: ["id", "name[ci]"],
|
|
104
|
+
queryFilter: {
|
|
105
|
+
// system-specific filter users may apply alone OR on top of list/checkpoint
|
|
106
|
+
// queries ('extends') - "vendors changed since the checkpoint" is one query (05)
|
|
107
|
+
rowFilter: [
|
|
108
|
+
{ id: "companyType", name: "Company type", type: "string",
|
|
109
|
+
optionValues: [{ id: "customer" }, { id: "vendor" }, { id: "other" }],
|
|
110
|
+
extends: [{ type: "checkpoint" }, { type: "list" }] },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
101
113
|
queryFields: [
|
|
102
114
|
{ id: "id", name: "ID", type: "string", isKey: true, canMatch: true },
|
|
103
115
|
{ id: "name", name: "Name", type: "string", canMatch: true },
|
|
@@ -211,6 +223,7 @@ export default class Acme {
|
|
|
211
223
|
}
|
|
212
224
|
|
|
213
225
|
if (qo.matchFilter) return await this.#queryMatch(options, qo.matchFilter);
|
|
226
|
+
if (qo.relatedFilter) return await this.#queryRelated(options, qo.relatedFilter);
|
|
214
227
|
// composite-key ids arrive as key STRINGS - parse each and fetch directly (11)
|
|
215
228
|
if (options.id === "salesorders" && qo.idsFilter) {
|
|
216
229
|
return await this.#querySalesOrdersByIds(qo.idsFilter);
|
|
@@ -231,13 +244,14 @@ export default class Acme {
|
|
|
231
244
|
// undefined value = first differential run: return everything (05)
|
|
232
245
|
params.push(`modified_since=${encodeURIComponent(qo.checkpointFilter.value || "1970-01-01T00:00:00Z")}`);
|
|
233
246
|
}
|
|
234
|
-
|
|
235
|
-
|
|
247
|
+
// declared rowFilter (extends list/checkpoint): one more AND constraint when present (05)
|
|
248
|
+
if (options.id === "companies" && qo.rowFilter?.companyType) {
|
|
249
|
+
params.push(`type=${encodeURIComponent(qo.rowFilter.companyType)}`);
|
|
250
|
+
}
|
|
236
251
|
const resp = await this.#call({ method: "GET", url: `${options.id}?${params.join("&")}` });
|
|
237
252
|
|
|
238
253
|
/** @type {SDK.Row[]} */
|
|
239
254
|
let rows = resp.items.map((/** @type {any} */ item) => this.#row(options.id, item));
|
|
240
|
-
if (qo.relatedFilter) rows = this.#tagRelationships(rows, qo.relatedFilter);
|
|
241
255
|
|
|
242
256
|
queryState.after = resp.next_cursor || undefined;
|
|
243
257
|
queryState.rowCount += rows.length;
|
|
@@ -334,35 +348,52 @@ export default class Acme {
|
|
|
334
348
|
}
|
|
335
349
|
}
|
|
336
350
|
|
|
337
|
-
/**
|
|
338
|
-
*
|
|
339
|
-
|
|
340
|
-
|
|
351
|
+
/** Fetch rows of THIS object that other-object rows relate to. DIRECTION MATTERS:
|
|
352
|
+
* the platform queries the relationship's TARGET (here: companies) and passes the
|
|
353
|
+
* DECLARING object's rows (contacts) - so the relationship is resolved from the
|
|
354
|
+
* OTHER object's metadata via options.objectMeta, never options.meta (05).
|
|
355
|
+
* @param {SDK.QueryOptions} options @param {SDK.QueryRelatedFilter} related
|
|
356
|
+
* @returns {Promise<SDK.QueryPage>} */
|
|
357
|
+
async #queryRelated(options, related) {
|
|
358
|
+
const otherMeta = await options.objectMeta(related.otherObjectId);
|
|
359
|
+
const rel = (otherMeta.relationships || []).find(
|
|
360
|
+
(r) => r.id === related.otherRelationshipId,
|
|
361
|
+
);
|
|
341
362
|
if (!rel) {
|
|
342
363
|
throw new SDK.ConnectorError(SDK.ErrorCode.InvalidFilterParameter,
|
|
343
|
-
`object ${
|
|
364
|
+
`object ${related.otherObjectId} has no relationship '${related.otherRelationshipId}'`);
|
|
344
365
|
}
|
|
366
|
+
// the FK lives on the DECLARING object's rows (contact.company_id names a company);
|
|
367
|
+
// rel.data is OUR round-trip payload from meta() (04)
|
|
345
368
|
const fk = /** @type {{idField: string}} */ (rel.data).idField;
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
369
|
+
/** @type {Map<string, SDK.Row[]>} this-object key -> source rows referencing it */
|
|
370
|
+
const wanted = new Map();
|
|
371
|
+
for (const src of related.otherRows) {
|
|
372
|
+
const key = src.data?.[fk] != null ? String(src.data[fk]) : "";
|
|
373
|
+
if (!key) continue; // source row without the FK - skip, never guess (10)
|
|
374
|
+
if (!wanted.has(key)) wanted.set(key, []);
|
|
375
|
+
/** @type {SDK.Row[]} */ (wanted.get(key)).push(src);
|
|
376
|
+
}
|
|
377
|
+
if (wanted.size === 0) return { rows: [], finalPage: true };
|
|
349
378
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
379
|
+
const resp = await this.#call({
|
|
380
|
+
method: "GET",
|
|
381
|
+
url: `${options.id}?ids=${[...wanted.keys()].map(encodeURIComponent).join(",")}`,
|
|
382
|
+
});
|
|
383
|
+
/** @type {SDK.Row[]} */
|
|
384
|
+
const rows = [];
|
|
385
|
+
for (const item of resp.items) {
|
|
386
|
+
const base = this.#row(options.id, item);
|
|
387
|
+
// one row INSTANCE per source row it relates to (05)
|
|
388
|
+
for (const src of wanted.get(base.meta.key) || []) {
|
|
389
|
+
rows.push({ ...base, relationship: {
|
|
390
|
+
srcRowId: src.meta.key,
|
|
391
|
+
srcObjectId: related.otherObjectId,
|
|
392
|
+
relationshipId: related.otherRelationshipId,
|
|
393
|
+
} });
|
|
363
394
|
}
|
|
364
395
|
}
|
|
365
|
-
return
|
|
396
|
+
return { rows, finalPage: true };
|
|
366
397
|
}
|
|
367
398
|
|
|
368
399
|
// ---------------------------------------------------------------- upsert / delete (06)
|
|
@@ -548,8 +579,11 @@ function parseSalesOrderKey(compositeKey) {
|
|
|
548
579
|
verbatim** (never re-encoded) on update/delete.
|
|
549
580
|
4. **The composite `$compositeKey` pseudo-field carries `isKey`** and is stripped from every
|
|
550
581
|
API body; adds supply the parent part (`business_unit`) as a regular mandatory field.
|
|
551
|
-
5.
|
|
552
|
-
|
|
582
|
+
5. **Related queries run on the relationship's TARGET object** — the platform queries
|
|
583
|
+
`companies` passing `contacts` rows, so the relationship is resolved from the OTHER
|
|
584
|
+
object's metadata (`options.objectMeta(otherObjectId)`), never `options.meta`; the FK
|
|
585
|
+
values on the source rows name the queried object's keys. `relationships[].data` is the
|
|
586
|
+
connector's own round-trip payload carrying that FK field name.
|
|
553
587
|
6. **`query()` dispatches filters in one place**, throws `InvalidFilterParameter` for anything
|
|
554
588
|
undeclared, and shares the pagination path across list/ids/checkpoint variants — except
|
|
555
589
|
composite-key `idsFilter`, which parses each key and fetches directly.
|
|
@@ -557,6 +591,9 @@ function parseSalesOrderKey(compositeKey) {
|
|
|
557
591
|
8. **Match queries end with `canUse`** — the platform picks the winners, you just supply
|
|
558
592
|
candidates. `first_and_last_name[ci]` requires BOTH parts; sources missing either are
|
|
559
593
|
skipped, never half-matched.
|
|
594
|
+
9. **The `companyType` rowFilter declares `extends`** — users can apply it alone or ON TOP of
|
|
595
|
+
list/checkpoint queries; in `query()` it is one more AND constraint next to
|
|
596
|
+
`modified_since`, not a separate code path.
|
|
560
597
|
9. **`upsertClean` runs the mutating verifies first, `detectFirstChange` last**, and only the
|
|
561
598
|
object that declares `canUpsertClean` (contacts) is cleaned.
|
|
562
599
|
10. **Upsert output is index-mapped back to input order** and throws rather than guessing when
|