@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.
@@ -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
- if (qo.relatedFilter) params.push(this.#relatedWhere(options, qo.relatedFilter));
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
- /** 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);
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 ${options.id} has no relationship '${related.otherRelationshipId}'`);
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
- const ids = related.otherRows.map((r) => r.meta.key);
347
- return `${fk}=${ids.map(encodeURIComponent).join(",")}`;
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
- /** 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
- }
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 out;
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. **`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`.
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