@syncmatters/connector-sdk 1.0.1 → 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.
@@ -10,7 +10,7 @@ meta/Connectors/<ConnectorName>/ # one *.meta.json sidecar per source file
10
10
  - **Entry file**: `<ConnectorName>.mjs`, default-exporting the connector class. Its sidecar has
11
11
  `"type": "connector"` and carries the `connector_metadata` block.
12
12
  - **Module files**: additional `.mjs` files in the same folder, imported by the entry (or by
13
- other modules). Sidecar `"type": "module"`.
13
+ other modules). Sidecar `"type": "module"`. Modules **must** be placed in the same folder as the file that imports the module.
14
14
  - **Resource files**: e.g. a bundled OpenAPI spec (`acme-api.yaml`). Sidecar type `"yaml"` /
15
15
  `"json"`. Load big specs lazily (dynamic `import()`), not at module top level.
16
16
  - **Test harness files**: `mod Test.mjs` (sidecar type `"connectortest"`) and the generated
@@ -50,37 +50,40 @@ switch (version) {
50
50
  break;
51
51
  }
52
52
  default:
53
- throw new SDK.ConnectorError(SDK.ErrorCode.InvalidOptionValue, `Connector version '${version}' not recognized`);
53
+ throw new SDK.ConnectorError(
54
+ SDK.ErrorCode.InvalidOptionValue,
55
+ `Connector version '${version}' not recognized`,
56
+ );
54
57
  }
55
58
  await this.#core.init(args);
56
59
  ```
57
60
 
58
61
  ## Sidecar top-level fields
59
62
 
60
- | Field | Values / meaning |
61
- |---|---|
62
- | `name` | the file name, e.g. `"Acme.mjs"` |
63
- | `type` | `"connector"` (entry) · `"module"` · `"connectortest"` · `"json"` · `"yaml"` |
64
- | `runnable` | `false` for connector files (they are invoked by the platform, not run directly) |
65
- | `engine` | runtime engine version int; leave as scaffolded unless told otherwise |
66
- | `resource_size` | `""` or `"medium"` — runtime resource class hint |
67
- | `cpu_architecture` | normally `""` |
68
- | `module_script_file_paths` | see above |
69
- | `npm_packages` | name→version map of npm packages this file needs at runtime, e.g. `{"box-node-sdk": "2.4.0"}`. The `sm` CLI unions these into the workspace `package.json` so IntelliSense sees them too. Prefer the SDK's built-in http/utilities over adding packages. |
70
- | `connector_metadata` | entry sidecar only — everything below |
63
+ | Field | Values / meaning |
64
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `name` | the file name, e.g. `"Acme.mjs"` |
66
+ | `type` | `"connector"` (entry) · `"module"` · `"connectortest"` · `"json"` · `"yaml"` |
67
+ | `runnable` | `false` for connector files (they are invoked by the platform, not run directly) |
68
+ | `engine` | runtime engine version int; leave as scaffolded unless told otherwise |
69
+ | `resource_size` | `""` or `"medium"` — runtime resource class hint |
70
+ | `cpu_architecture` | normally `""` |
71
+ | `module_script_file_paths` | see above |
72
+ | `npm_packages` | name→version map of npm packages this file needs at runtime, e.g. `{"box-node-sdk": "2.4.0"}`. The `sm` CLI unions these into the workspace `package.json` so IntelliSense sees them too. Prefer the SDK's built-in http/utilities over adding packages. |
73
+ | `connector_metadata` | entry sidecar only — everything below |
71
74
 
72
75
  ## `connector_metadata` reference
73
76
 
74
- | Field | Meaning |
75
- |---|---|
76
- | `categories` | discovery taxonomy, e.g. `["sales"]`, `["file"]`, `["marketing","sales"]` |
77
- | `agent_mode` | `"never"` = runs in cloud · `"always"` = runs on the customer's on-premise agent · `"optional"` = per-connection (agent used when the connection selects one) |
78
- | `oauth2_mode` | `"connector"` = one shared OAuth app for every account · `"connection"` = each connection supplies its own client id/secret. Omit for non-OAuth connectors. |
79
- | `oauth2_provider` | the OAuth provider definition (below) |
80
- | `webhooks_provider` | `{"mode": "custom.perconnection"}` (each connection registers its own webhook) or `{"mode": "custom.perconnector", "url": ..., "webhook_uid": ...}` (one shared endpoint) |
81
- | `settings` | the connection-settings form (below) |
82
- | `icon_url` | connector logo URL |
83
- | `connector_name`, `type_id`, `application_name`, `application_icon_url` | platform/branding ids — usually left as scaffolded |
77
+ | Field | Meaning |
78
+ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
79
+ | `categories` | discovery taxonomy, e.g. `["sales"]`, `["file"]`, `["marketing","sales"]` |
80
+ | `agent_mode` | `"never"` = runs in cloud · `"always"` = runs on the customer's on-premise agent · `"optional"` = per-connection (agent used when the connection selects one) |
81
+ | `oauth2_mode` | `"connector"` = one shared OAuth app for every account · `"connection"` = each connection supplies its own client id/secret. Omit for non-OAuth connectors. |
82
+ | `oauth2_provider` | the OAuth provider definition (below) |
83
+ | `webhooks_provider` | `{"mode": "custom.perconnection"}` (each connection registers its own webhook) or `{"mode": "custom.perconnector", "url": ..., "webhook_uid": ...}` (one shared endpoint) |
84
+ | `settings` | the connection-settings form (below) |
85
+ | `icon_url` | connector logo URL |
86
+ | `connector_name`, `type_id`, `application_name`, `application_icon_url` | platform/branding ids — usually left as scaffolded |
84
87
 
85
88
  ### `settings`
86
89
 
package/docs/05-query.md CHANGED
@@ -124,11 +124,16 @@ candidates }])` — the platform reserves un-mapped matches and returns the sele
124
124
 
125
125
  Receives rows from a source object (`otherObjectId`) that has a defined relationship to this object, and collect the related rows from this object.
126
126
 
127
- `{ otherObjectId, otherRelationshipId, otherRows }`. Resolve the rows from a relationship declared in
128
- `meta()` (your `relationship.data` typically holds the FK field), turn `otherRows` into an id
129
- lookup, and return rows tagged with
127
+ `{ otherObjectId, otherRelationshipId, otherRows }`. DIRECTION: the platform queries the
128
+ relationship's TARGET object and passes the DECLARING object's rows contacts declare
129
+ contact→company, so a related query arrives on `companies` with `otherObjectId: "contacts"`.
130
+ Resolve the relationship from the OTHER object's metadata
131
+ (`await options.objectMeta(related.otherObjectId)` — it is NOT in `options.meta`; your
132
+ `relationship.data` typically holds the FK field name), collect the FK values from
133
+ `otherRows` (they name THIS object's keys), fetch those rows, and return them tagged with
130
134
  `relationship: { srcRowId, srcObjectId: otherObjectId, relationshipId: otherRelationshipId }` —
131
- one row instance per source row it relates to.
135
+ one row instance per source row it relates to. See the worked implementation in
136
+ [12-example-connector.md](./12-example-connector.md).
132
137
 
133
138
  ## Cross-page scratch state: `kvStore`
134
139
 
@@ -11,6 +11,9 @@ space, is the convention):
11
11
  The platform's connector test suite (script type `"connectorTest"`) drives your connector
12
12
  through every capability its `meta()` declares — list/ids/checkpoint/match/related queries,
13
13
  upserts, deletes — using these two files to know what to expect and how to prepare data.
14
+ The complete worked pair for the Acme connector of
15
+ [12-example-connector.md](./12-example-connector.md) is in
16
+ [15-example-test-file.md](./15-example-test-file.md).
14
17
 
15
18
  ## `mod ObjectTestFeatures.mjs` (generated)
16
19
 
@@ -98,8 +101,12 @@ objects.
98
101
 
99
102
  ## Prepare/cleanup hooks and their data types
100
103
 
101
- Every testable feature has a `*Prepare` hook (and usually a `*Cleanup` counterpart)
102
- returning `undefined` from a prepare hook skips that test:
104
+ Every testable feature has a `*Prepare` hook (and usually a `*Cleanup` counterpart). Most
105
+ prepare hooks skip their test when they return `undefined` with two exceptions:
106
+ `listPrepare` returning `undefined` still runs the list test (it just expects some rows), and
107
+ `idsFilterPrepare` returning `undefined` makes the harness find ids via a list query itself.
108
+ To disable those tests use `cannotTestReason` instead
109
+ ([15-example-test-file.md](./15-example-test-file.md) shows the worked semantics):
103
110
 
104
111
  - `listPrepare` / `idsFilterPrepare` / `rowFiltersPrepare` — arrange rows and return what the
105
112
  suite should expect (row-filter testing is easy to forget: provide it when `meta()` exposes
@@ -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 `sm run 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
@@ -211,6 +214,7 @@ export default class Acme {
211
214
  }
212
215
 
213
216
  if (qo.matchFilter) return await this.#queryMatch(options, qo.matchFilter);
217
+ if (qo.relatedFilter) return await this.#queryRelated(options, qo.relatedFilter);
214
218
  // composite-key ids arrive as key STRINGS - parse each and fetch directly (11)
215
219
  if (options.id === "salesorders" && qo.idsFilter) {
216
220
  return await this.#querySalesOrdersByIds(qo.idsFilter);
@@ -231,13 +235,10 @@ export default class Acme {
231
235
  // undefined value = first differential run: return everything (05)
232
236
  params.push(`modified_since=${encodeURIComponent(qo.checkpointFilter.value || "1970-01-01T00:00:00Z")}`);
233
237
  }
234
- if (qo.relatedFilter) params.push(this.#relatedWhere(options, qo.relatedFilter));
235
-
236
238
  const resp = await this.#call({ method: "GET", url: `${options.id}?${params.join("&")}` });
237
239
 
238
240
  /** @type {SDK.Row[]} */
239
241
  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
 
242
243
  queryState.after = resp.next_cursor || undefined;
243
244
  queryState.rowCount += rows.length;
@@ -334,35 +335,52 @@ export default class Acme {
334
335
  }
335
336
  }
336
337
 
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);
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
+ );
341
349
  if (!rel) {
342
350
  throw new SDK.ConnectorError(SDK.ErrorCode.InvalidFilterParameter,
343
- `object ${options.id} has no relationship '${related.otherRelationshipId}'`);
351
+ `object ${related.otherObjectId} has no relationship '${related.otherRelationshipId}'`);
344
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)
345
355
  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
- }
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 };
349
365
 
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
- }
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
+ } });
363
381
  }
364
382
  }
365
- return out;
383
+ return { rows, finalPage: true };
366
384
  }
367
385
 
368
386
  // ---------------------------------------------------------------- upsert / delete (06)
@@ -548,8 +566,11 @@ function parseSalesOrderKey(compositeKey) {
548
566
  verbatim** (never re-encoded) on update/delete.
549
567
  4. **The composite `$compositeKey` pseudo-field carries `isKey`** and is stripped from every
550
568
  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`.
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.
553
574
  6. **`query()` dispatches filters in one place**, throws `InvalidFilterParameter` for anything
554
575
  undeclared, and shares the pagination path across list/ids/checkpoint variants — except
555
576
  composite-key `idsFilter`, which parses each key and fetches directly.
@@ -0,0 +1,565 @@
1
+ # Acme — the worked test-file pair
2
+
3
+ The test-file counterpart to [12-example-connector.md](./12-example-connector.md): the complete
4
+ `mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair for the Acme connector, on one page. The
5
+ platform's connector test suite AND `sm run test|meta|query` both require a file of sidecar
6
+ type `"connectortest"` in the connector's folder — **a connector without one cannot be
7
+ run-tested at all**. Harness conventions and hook semantics live in
8
+ [09-testing.md](./09-testing.md); this page shows them applied end to end against Acme's three
9
+ objects: `companies`, `contacts` (email + first/last-name matching, `upsertClean`, custom
10
+ fields) and `salesorders` (the `$compositeKey` composite-key object).
11
+
12
+ Two files, two sidecars. `mod Test.mjs`'s sidecar is type `"connectortest"` and wires in the
13
+ generated module via `module_script_file_paths`:
14
+
15
+ `meta/Connectors/Acme/mod ObjectTestFeatures.mjs.meta.json`
16
+
17
+ ```json
18
+ {
19
+ "runnable": false,
20
+ "type": "module",
21
+ "cpu_architecture": "",
22
+ "engine": 0,
23
+ "resource_size": "medium",
24
+ "name": "mod ObjectTestFeatures.mjs"
25
+ }
26
+ ```
27
+
28
+ `meta/Connectors/Acme/mod Test.mjs.meta.json`
29
+
30
+ ```json
31
+ {
32
+ "runnable": false,
33
+ "type": "connectortest",
34
+ "cpu_architecture": "",
35
+ "engine": 0,
36
+ "resource_size": "medium",
37
+ "name": "mod Test.mjs",
38
+ "module_script_file_paths": ["Connectors/Acme/mod ObjectTestFeatures.mjs"]
39
+ }
40
+ ```
41
+
42
+ Keep exactly one `"connectortest"` file per connector folder — `sm run test` resolves the test
43
+ module from the connector's folder and several candidates are ambiguous.
44
+
45
+ ## `files/Connectors/Acme/mod ObjectTestFeatures.mjs` (generated)
46
+
47
+ Generated from the sandbox connection's cached `meta()` — regenerate it whenever `meta()`
48
+ changes; never hand-edit it. Note there are **no** `cannotTestReason` / `sameAsObjectId`
49
+ entries here: the generator cannot know those, so the fine-grained scoping knobs are applied
50
+ from `mod Test.mjs`'s `before()` (below), on top of this file.
51
+
52
+ ```js
53
+ /**
54
+ * Do not edit. This file is automatically generated using cached metadata from a test connection.
55
+ *
56
+ * This automatically generated object feature list can be extended in your main test module file; connector metadata
57
+ * does not contain sufficient detail to fully describe all connector features.
58
+ */
59
+ import API from "@syncmatters/script-api";
60
+ import SDKTest from "@syncmatters/script-api/sdk-test";
61
+ const toJsonPath = (/** @type string */ fieldPath) => API.utilities.json.stringToJsonValuePath(fieldPath, "all");
62
+ const toJsonPaths = (/** @type string[] */ fieldPaths) => API.utilities.json.stringsToJsonValuePaths(fieldPaths, "all");
63
+
64
+ /** @type Record<string, SDKTest.ObjectFeatures> */
65
+ export const objectFeatures = {
66
+ companies: {
67
+ query: {
68
+ list: true,
69
+ idsFilter: true,
70
+ checkpointFilter: true,
71
+ matchRules: [{"rule":"id"},{"rule":"name[ci]"}],
72
+ canMatchFields: toJsonPaths(["id", "name", "domain"])
73
+ },
74
+ upsert: {
75
+ canUpsert: true,
76
+ key: {
77
+ path: toJsonPath("id")
78
+ },
79
+ constraints: [{"constraints":{"mandatory":"add"},"fieldPath":[{"path":"name"}]}]
80
+ },
81
+ delete: {
82
+ canDelete: true,
83
+ key: {
84
+ path: toJsonPath("id")
85
+ }
86
+ }
87
+ },
88
+ contacts: {
89
+ query: {
90
+ list: true,
91
+ idsFilter: true,
92
+ checkpointFilter: true,
93
+ matchRules: [{"rule":"id"},{"rule":"email[ci]"},{"rule":"first_and_last_name[ci]"}],
94
+ canMatchFields: toJsonPaths(["id", "email", "first_name", "last_name"]),
95
+ relationshipsTo: [{"id":"companies-company_id","relObjectId":"companies"}]
96
+ },
97
+ upsert: {
98
+ canUpsert: true,
99
+ key: {
100
+ path: toJsonPath("id")
101
+ },
102
+ canUpsertClean: true,
103
+ constraints: [{"constraints":{"mandatory":"add","email":true},"fieldPath":[{"path":"email"}]}]
104
+ },
105
+ delete: {
106
+ canDelete: true,
107
+ key: {
108
+ path: toJsonPath("id")
109
+ }
110
+ }
111
+ },
112
+ salesorders: {
113
+ query: {
114
+ list: true,
115
+ idsFilter: true
116
+ },
117
+ upsert: {
118
+ canUpsert: true,
119
+ key: {
120
+ path: toJsonPath("$compositeKey")
121
+ },
122
+ constraints: [{"constraints":{"mandatory":"add"},"fieldPath":[{"path":"business_unit"}]}]
123
+ },
124
+ delete: {
125
+ canDelete: true,
126
+ key: {
127
+ path: toJsonPath("$compositeKey")
128
+ }
129
+ }
130
+ }
131
+ };
132
+ ```
133
+
134
+ ## `files/Connectors/Acme/mod Test.mjs` (hand-written)
135
+
136
+ Implements `SDKTest.TestFeatureProvider`. Every field/type below is from
137
+ `@syncmatters/script-api/sdk-test`; topic-file cross-references are in comments.
138
+
139
+ ```js
140
+ import API from "@syncmatters/script-api";
141
+ import SDKTest from "@syncmatters/script-api/sdk-test";
142
+ // the auto-generated feature list - regenerate after meta() changes, never hand-edit (09)
143
+ import { objectFeatures } from "./mod ObjectTestFeatures";
144
+
145
+ // Typed test connection: typed query/queryOne/upsert/delete accessors over the sandbox
146
+ // connection. "acme_test" is YOUR test connection's name. (Fleet files that predate the
147
+ // @syncmatters packages import the same types from the legacy "@ihq/script-api" scope -
148
+ // new code always imports @syncmatters/*.)
149
+ /** @typedef {API.connections.acme_test.Connection} TestConnection */
150
+ /** @typedef {TestConnection["upsert"]} Upsert */
151
+ /** @typedef {TestConnection["queryOne"]} Query */
152
+ // With the ^^above^^ typedefs:
153
+ // - upsert row type for "contacts": {Parameters<Upsert["contacts"]>[0][0]}
154
+ // - query result row type for "contacts": {Awaited<ReturnType<Query["contacts"]>>}
155
+
156
+ // prefix EVERY record the tests create, so leftovers from crashed runs stay findable
157
+ const testRecordPrefix = "sm_test";
158
+
159
+ /** @implements SDKTest.TestFeatureProvider */
160
+ export default class TestFeatureProvider {
161
+ // must equal the sandbox connection's meta().identity - pins the account, so re-pointed
162
+ // credentials fail loudly instead of trashing another account's data (09, 12)
163
+ /** @type SDKTest.TestFeatureProvider["expectedIdentity"] */
164
+ expectedIdentity = "Acme Sandbox|integration@example.com";
165
+
166
+ /** @type API.Logger */
167
+ #log;
168
+ /** @type TestConnection */
169
+ #connection;
170
+ /** @type string */
171
+ #fixtureCompanyId = ""; // shared parent row for contact test data
172
+
173
+ /** @type SDKTest.TestFeatureProvider["before"] */
174
+ async before(ctx) {
175
+ this.#log = ctx.log;
176
+ this.#connection = /** @type any */ (ctx.connection);
177
+
178
+ // === extend the GENERATED features here - never by editing the generated file (09).
179
+ // contacts delete runs the exact same batch/archive code path as companies delete,
180
+ // so test it once:
181
+ if (objectFeatures.contacts.delete) {
182
+ objectFeatures.contacts.delete.sameAsObjectId = "companies";
183
+ }
184
+ // per-rule scoping: Acme's /search cannot search by record id - the "id" rule resolves
185
+ // via get-by-id, which the idsFilter test already covers:
186
+ for (const objectId of ["companies", "contacts"]) {
187
+ const idRule = (objectFeatures[objectId].query?.matchRules || []).find((r) => r.rule === "id");
188
+ if (idRule) idRule.cannotTestReason = "id matches resolve via get-by-id; covered by the idsFilter test";
189
+ }
190
+ // Other scoping knobs, when you need them:
191
+ // objectFeatures.salesorders.cannotTestReason = "<why the whole object cannot be tested>";
192
+ // objectFeatures.salesorders.delete.cannotTestReason = "<why just this feature cannot>";
193
+ // If Acme's archive endpoint kept returning archived rows (a soft delete), you would
194
+ // declare the flag field instead of expecting row absence, and allow for slow indexes:
195
+ // objectFeatures.companies.delete.isDeleted = { path: [{ path: "archived" }], valueWhenDeleted: true };
196
+ // objectFeatures.companies.delete.afterDeleteMaxIndexWaitTimeMs = 30000;
197
+
198
+ // === sweep sm_test* leftovers from crashed runs
199
+ await this.#sweepTestData();
200
+
201
+ // === shared fixtures
202
+ const company = await this.#connection.queryOne.companies();
203
+ if (!company) throw new Error("Sandbox has no companies - seed at least one before testing");
204
+ this.#fixtureCompanyId = company.meta.key;
205
+ }
206
+
207
+ /** @type SDKTest.TestFeatureProvider["after"] */
208
+ async after() {
209
+ await this.#sweepTestData();
210
+ }
211
+
212
+ /** @type SDKTest.TestFeatureProvider["objectIds"] */
213
+ async objectIds() {
214
+ return Object.keys(objectFeatures);
215
+ }
216
+
217
+ /** @type SDKTest.TestFeatureProvider["objectFeatures"] */
218
+ async objectFeatures(objectId) {
219
+ return objectFeatures[objectId];
220
+ }
221
+
222
+ /** @type SDKTest.TestFeatureProvider["listPrepare"] */
223
+ async listPrepare(options) {
224
+ // returning undefined here would mean "run the list, just verify SOME rows come back";
225
+ // a small pageSize forces real pagination through Acme's cursor (05)
226
+ switch (options.meta.id) {
227
+ case "salesorders":
228
+ return { pageSize: 25, fields: [[{ path: "$compositeKey" }], [{ path: "status" }]] };
229
+ default:
230
+ return { pageSize: 25 };
231
+ }
232
+ }
233
+
234
+ /** @type SDKTest.TestFeatureProvider["idsFilterPrepare"] */
235
+ async idsFilterPrepare(options) {
236
+ // list-capable objects can skip this hook - undefined tells the harness to find two
237
+ // ids via a list query itself
238
+ if (options.meta.id !== "salesorders") return undefined;
239
+
240
+ // implemented for salesorders to make composite keys concrete: the ids ARE the
241
+ // connector's JSON key strings (row.meta.key), e.g.
242
+ // '{"business_unit":"emea","order_id":1041}'
243
+ // NEVER hand-assemble them - collect real rows and reuse their keys verbatim (11, 12)
244
+ /** @type SDKTest.IdsFilterData */
245
+ const result = { id1: "", id2: "" };
246
+ for await (const order of this.#connection.query.salesorders()) {
247
+ if (!result.id1) {
248
+ result.id1 = order.meta.key;
249
+ } else if (order.meta.key !== result.id1) {
250
+ result.id2 = order.meta.key;
251
+ break;
252
+ }
253
+ }
254
+ return result;
255
+ }
256
+
257
+ /** @type SDKTest.TestFeatureProvider["checkpointFilterStep1Prepare"] */
258
+ async checkpointFilterStep1Prepare(options) {
259
+ // step 1: pick a starting checkpoint and report every row the differential query
260
+ // should return for it - then guarantee at least one by seeding a row
261
+ const start = new Date(Date.now() - 60000).toISOString();
262
+ /** @type SDKTest.CheckpointStep1Data */
263
+ const result = { nextCheckpoint: start, expectedRowIds: [] };
264
+
265
+ // a shared sandbox is never quiet: rows already modified inside the window are
266
+ // expected too, so collect them rather than assuming zero
267
+ // @ts-ignore - objectId is dynamic
268
+ for await (const row of this.#connection.query[options.meta.id]({ checkpointFilter: { value: start } })) {
269
+ result.expectedRowIds.push(row.meta.key);
270
+ }
271
+ if (result.expectedRowIds.length === 0) {
272
+ result.expectedRowIds.push(await this.#createTestRow(options.meta.id));
273
+ }
274
+ return result;
275
+ }
276
+
277
+ /** @type SDKTest.TestFeatureProvider["checkpointFilterStep2Prepare"] */
278
+ async checkpointFilterStep2Prepare(options) {
279
+ // step 2: the harness hands back the checkpoint the CONNECTOR emitted in step 1;
280
+ // mutate the data and report what the second differential query must surface
281
+ /** @type SDKTest.CheckpointStep2Data */
282
+ const result = { expectedRowIds: [] };
283
+
284
+ // checkpoints are emitted with an overlap window (05, 12), so step-1 rows may
285
+ // legitimately reappear - include everything currently inside the window...
286
+ // @ts-ignore - objectId is dynamic
287
+ for await (const row of this.#connection.query[options.meta.id]({ checkpointFilter: { value: options.checkpointReceived } })) {
288
+ result.expectedRowIds.push(row.meta.key);
289
+ }
290
+ // ...then make one fresh modification the query MUST pick up
291
+ result.expectedRowIds.push(await this.#createTestRow(options.meta.id));
292
+ return result;
293
+ }
294
+
295
+ /** @type SDKTest.TestFeatureProvider["matchFilterPrepare"] */
296
+ async matchFilterPrepare(options) {
297
+ const uid = SDKTest.makeUid();
298
+ /** @type SDKTest.MatchData[] */
299
+ const results = [];
300
+
301
+ switch (options.meta.id) {
302
+ case "companies": {
303
+ if (options.rule !== "name[ci]") return undefined; // undefined = rule cannot be tested
304
+ const name = `${testRecordPrefix} Match Co ${uid}`;
305
+ const [company] = await this.#connection.upsert.companies([{ name }]);
306
+ results.push({
307
+ // a [ci] rule: deliberately change the case - the match must still land
308
+ srcData: [{ srcRowId: "src-1", match: { name: name.toUpperCase() } }],
309
+ destFieldPath: [{ path: "name" }],
310
+ expectedMatchRowIds: [company.meta.key],
311
+ });
312
+ break;
313
+ }
314
+ case "contacts": {
315
+ const email = `${testRecordPrefix}.${uid}@example.com`;
316
+ const first = `${testRecordPrefix}-First-${uid}`;
317
+ const last = `Last-${uid}`;
318
+ const [contact] = await this.#connection.upsert.contacts([
319
+ { email, first_name: first, last_name: last, company_id: this.#fixtureCompanyId },
320
+ ]);
321
+ switch (options.rule) {
322
+ case "email[ci]": {
323
+ results.push({
324
+ srcData: [{ srcRowId: "src-1", match: { email: email.toUpperCase() } }],
325
+ destFieldPath: [{ path: "email" }],
326
+ expectedMatchRowIds: [contact.meta.key],
327
+ });
328
+ break;
329
+ }
330
+ case "first_and_last_name[ci]": {
331
+ // RowMatchData carries the parts as firstname/lastname - the rule needs BOTH
332
+ results.push({
333
+ srcData: [{ srcRowId: "src-1", match: { firstname: first.toUpperCase(), lastname: last.toUpperCase() } }],
334
+ expectedMatchRowIds: [contact.meta.key],
335
+ });
336
+ break;
337
+ }
338
+ default:
339
+ return undefined;
340
+ }
341
+ break;
342
+ }
343
+ default:
344
+ return undefined;
345
+ }
346
+ return results;
347
+ }
348
+
349
+ /** @type SDKTest.TestFeatureProvider["matchFilterCleanup"] */
350
+ async matchFilterCleanup(options) {
351
+ // the rows we created ARE the expected matches - delete them
352
+ const keys = options.testData.flatMap((d) => d.expectedMatchRowIds);
353
+ await this.#deleteRows(options.meta.id, keys);
354
+ }
355
+
356
+ /** @type SDKTest.TestFeatureProvider["relatedFilterPrepare"] */
357
+ async relatedFilterPrepare(options) {
358
+ // contacts declares relationship "companies-company_id" (12). The harness will
359
+ // 1) collect the 'from' contact via idsFilter (srcRowId), then 2) query companies with
360
+ // relatedFilter { otherObjectId: "contacts", otherRelationshipId, otherRows: [contact] }
361
+ // and expect EXACTLY expectedRelatedRowIds back.
362
+ if (options.fromObjectMeta.id !== "contacts" || options.fromRelationshipId !== "companies-company_id") {
363
+ return undefined;
364
+ }
365
+ const uid = SDKTest.makeUid();
366
+ const [company] = await this.#connection.upsert.companies([{ name: `${testRecordPrefix} Rel Co ${uid}` }]);
367
+ const [contact] = await this.#connection.upsert.contacts([
368
+ {
369
+ email: `${testRecordPrefix}.rel.${uid}@example.com`,
370
+ first_name: testRecordPrefix,
371
+ last_name: `Rel ${uid}`,
372
+ company_id: company.meta.key, // the FK that drives the relationship
373
+ },
374
+ ]);
375
+ // OneToOne cardinality: exactly one related company expected
376
+ return [{ srcRowId: contact.meta.key, expectedRelatedRowIds: [company.meta.key] }];
377
+ }
378
+
379
+ /** @type SDKTest.TestFeatureProvider["relatedFilterCleanup"] */
380
+ async relatedFilterCleanup(options) {
381
+ for (const testData of options.testDatas) {
382
+ await this.#deleteRows("contacts", [testData.srcRowId]); // child first...
383
+ await this.#deleteRows("companies", testData.expectedRelatedRowIds); // ...then parent
384
+ }
385
+ }
386
+
387
+ /** @type SDKTest.TestFeatureProvider["upsertPrepare"] */
388
+ async upsertPrepare(options) {
389
+ // This hook also feeds the DELETE test: there is no deletePrepare - the harness calls
390
+ // upsertPrepare twice, inserts both rows, deletes one and verifies exactly that one
391
+ // is gone. Keep inserts repeatable and unique (makeUid).
392
+ const uid = SDKTest.makeUid();
393
+ /** @type SDKTest.UpsertData */
394
+ const result = { insert: {}, update: {}, verifyFields: [], issueSimulators: [] };
395
+
396
+ switch (options.meta.id) {
397
+ case "companies": {
398
+ /** @type Parameters<Upsert["companies"]>[0][0] */
399
+ const insert = { name: `${testRecordPrefix} Co ${uid}`, domain: `${uid}.example.com` };
400
+ /** @type Parameters<Upsert["companies"]>[0][0] */
401
+ const update = { domain: `${uid}-updated.example.com` };
402
+ result.insert = insert;
403
+ result.update = update;
404
+ // the harness re-queries the row after each write and compares these paths
405
+ result.verifyFields.push([{ path: "domain" }]);
406
+ break;
407
+ }
408
+ case "contacts": {
409
+ /** @type Parameters<Upsert["contacts"]>[0][0] */
410
+ const insert = {
411
+ email: `${testRecordPrefix}.${uid}@example.com`,
412
+ first_name: testRecordPrefix,
413
+ last_name: `Contact ${uid}`,
414
+ company_id: this.#fixtureCompanyId,
415
+ };
416
+ /** @type Parameters<Upsert["contacts"]>[0][0] */
417
+ const update = { last_name: `Contact ${uid} updated` };
418
+ result.insert = insert;
419
+ result.update = update;
420
+ result.verifyFields.push([{ path: "last_name" }]);
421
+
422
+ // contacts declares canUpsertClean (12): when asked, supply deliberately-broken
423
+ // inserts stating EXACTLY which issue upsertClean must report and whether it is
424
+ // fatal. The harness applies each simulator's fields over the insert row.
425
+ if (options.withIssueSimulators) {
426
+ result.issueSimulators = [
427
+ // email is mandatory on add -> ValueRequired, fatal
428
+ { type: "ValueRequired", upsert: { email: undefined }, fatal: true },
429
+ // an invalid email is cleansed (the field is REMOVED)... which then violates
430
+ // the mandatory-on-add constraint, so this issue escalates to fatal
431
+ { type: "EmailInvalid", upsert: { email: "not-an-email" }, fatal: true },
432
+ // unknown option on a sandbox custom picklist field: upsertClean's
433
+ // cleanAction "DeleteUpsertField" drops the field and the row still
434
+ // writes -> NOT fatal (06, 12)
435
+ { type: "OptionNotFound", upsert: { t_shirt_size: "no-such-option" }, fatal: false },
436
+ ];
437
+ }
438
+ break;
439
+ }
440
+ case "salesorders": {
441
+ // composite-key object: the ADD carries the parent part (business_unit) as a
442
+ // plain mandatory field - there is NO key yet. The harness copies the key the
443
+ // insert response returns ($compositeKey) onto the update row for us (11, 12).
444
+ // "emea" is an existing business unit on the Acme sandbox (units are fixtures).
445
+ /** @type Parameters<Upsert["salesorders"]>[0][0] */
446
+ const insert = { business_unit: "emea", amount: 42, status: "draft" };
447
+ /** @type Parameters<Upsert["salesorders"]>[0][0] */
448
+ const update = { status: "confirmed" };
449
+ result.insert = insert;
450
+ result.update = update;
451
+ result.verifyFields.push([{ path: "status" }]);
452
+ break;
453
+ }
454
+ }
455
+ return result;
456
+ }
457
+
458
+ /** @type SDKTest.TestFeatureProvider["upsertCleanup"] */
459
+ async upsertCleanup(options) {
460
+ // the harness hands back the rows it inserted - delete them right away. This is the
461
+ // ONLY cleanup path for salesorders test rows: they have no prefixable text field,
462
+ // so the after() sweep cannot find them.
463
+ await this.#deleteRows(options.meta.id, options.inserted.map((r) => r.meta.key));
464
+ }
465
+
466
+ // === private helpers ======================================================
467
+
468
+ /** Create one prefixed row; creating counts as a modification for checkpoint tests.
469
+ * @param {string} objectId @returns {Promise<string>} the new row's key */
470
+ async #createTestRow(objectId) {
471
+ const uid = SDKTest.makeUid();
472
+ switch (objectId) {
473
+ case "companies": {
474
+ const [row] = await this.#connection.upsert.companies([{ name: `${testRecordPrefix} Co ${uid}` }]);
475
+ return row.meta.key;
476
+ }
477
+ case "contacts": {
478
+ const [row] = await this.#connection.upsert.contacts([
479
+ {
480
+ email: `${testRecordPrefix}.${uid}@example.com`,
481
+ first_name: testRecordPrefix,
482
+ last_name: `Contact ${uid}`,
483
+ company_id: this.#fixtureCompanyId,
484
+ },
485
+ ]);
486
+ return row.meta.key;
487
+ }
488
+ default:
489
+ throw new Error(`#createTestRow not implemented for ${objectId}`);
490
+ }
491
+ }
492
+
493
+ /** Delete rows by key. @param {string} objectId @param {string[]} keys */
494
+ async #deleteRows(objectId, keys) {
495
+ if (keys.length === 0) return;
496
+ switch (objectId) {
497
+ case "companies":
498
+ await this.#connection.delete.companies(keys.map((id) => ({ id })));
499
+ break;
500
+ case "contacts":
501
+ await this.#connection.delete.contacts(keys.map((id) => ({ id })));
502
+ break;
503
+ case "salesorders":
504
+ // composite-key deletes address rows by the SAME key string queries returned (11)
505
+ await this.#connection.delete.salesorders(keys.map((k) => ({ "$compositeKey": k })));
506
+ break;
507
+ }
508
+ }
509
+
510
+ /** Sweep sm_test* leftovers - children (contacts) before parents (companies). */
511
+ async #sweepTestData() {
512
+ /** @type string[] */
513
+ const staleContacts = [];
514
+ for await (const row of this.#connection.query.contacts()) {
515
+ if (String(row.data.email || "").startsWith(`${testRecordPrefix}.`)) staleContacts.push(row.meta.key);
516
+ }
517
+ /** @type string[] */
518
+ const staleCompanies = [];
519
+ for await (const row of this.#connection.query.companies()) {
520
+ if (String(row.data.name || "").startsWith(testRecordPrefix)) staleCompanies.push(row.meta.key);
521
+ }
522
+ if (staleContacts.length || staleCompanies.length) {
523
+ this.#log.info(`Sweeping ${staleContacts.length} test contact(s), ${staleCompanies.length} test company(ies)`);
524
+ }
525
+ await this.#deleteRows("contacts", staleContacts);
526
+ await this.#deleteRows("companies", staleCompanies);
527
+ }
528
+ }
529
+ ```
530
+
531
+ ## What to notice (the parts assistants most often get wrong)
532
+
533
+ 1. **Every created record is prefixed** (`sm_test`) and `before()`/`after()` sweep the
534
+ prefix — a crashed run never pollutes the sandbox, and the next run self-heals. Objects
535
+ with no prefixable text field (salesorders) must be cleaned in `upsertCleanup` via
536
+ `options.inserted` instead.
537
+ 2. **`expectedIdentity` pins the account** — it must equal the sandbox connection's
538
+ `meta().identity` string exactly; re-pointed credentials then fail loudly instead of
539
+ letting the suite write into a real account.
540
+ 3. **`undefined` from a prepare hook is a decision, not a default.** For
541
+ `checkpointFilterStep1/2Prepare`, `matchFilterPrepare`, `relatedFilterPrepare`,
542
+ `rowFiltersPrepare` and `upsertPrepare` it means "this cannot be tested" (the feature is
543
+ skipped); but for `listPrepare` it means "run the list and just verify some rows", and for
544
+ `idsFilterPrepare` it means "find two ids via a list query". Returning `undefined` by
545
+ accident silently drops coverage.
546
+ 4. **`mod ObjectTestFeatures.mjs` is generated — regenerate, don't edit.** Scoping
547
+ (`cannotTestReason`, `sameAsObjectId` and their per-feature/per-rule/per-relationship
548
+ variants) is layered on from `mod Test.mjs`'s `before()`, so a regeneration never loses it.
549
+ 5. **Composite-key objects use the connector's own key strings.** Test ids come from
550
+ `row.meta.key` verbatim — never hand-assembled; the upsert `insert` has no key at all (the
551
+ parent part travels as a regular mandatory field) and the harness copies the returned
552
+ `$compositeKey` onto the `update` row.
553
+ 6. **Issue simulators are how `upsertClean` gets exercised.** Each simulator names the exact
554
+ `UpsertIssueType` and its fatality, and cleansing cascades: an invalid email on a
555
+ mandatory-on-add field is removed by the cleanse, which then trips `ValueRequired` — so
556
+ the `EmailInvalid` issue is reported fatal.
557
+ 7. **There is no `deletePrepare`.** The delete test calls `upsertPrepare` twice, inserts both
558
+ rows, deletes one and verifies exactly that one disappeared — so inserts must be
559
+ repeatable and unique. Soft-deleting APIs declare
560
+ `delete.isDeleted { path, valueWhenDeleted }` (plus `afterDeleteMaxIndexWaitTimeMs`)
561
+ instead of expecting row absence.
562
+ 8. **Checkpoint tests assume a noisy sandbox.** Both steps collect everything already inside
563
+ the differential window into `expectedRowIds` before seeding one guaranteed change, and
564
+ step 2 expects step-1 rows to reappear because the connector's checkpoint deliberately
565
+ overlaps (05, 12).
@@ -25,9 +25,10 @@ locally — the local workspace is for editing, type-checking and pushing via th
25
25
  | [09-testing.md](./09-testing.md) | the test harness: `mod Test.mjs` + `mod ObjectTestFeatures.mjs` |
26
26
  | [10-style-and-pitfalls.md](./10-style-and-pitfalls.md) | style rules, deprecated APIs to avoid, known traps — skim this once regardless |
27
27
  | [11-composite-keys.md](./11-composite-keys.md) | objects with multi-part identity (child REST resources, junction rows) — key encoding across query/upsert/delete |
28
- | [12-example-connector.md](./12-example-connector.md) | **the canonical worked example** — a complete three-object connector (relationships, matching, composite keys, custom fields, upsertClean, query/upsert/delete) on one page |
28
+ | [12-example-connector.md](./12-example-connector.md) | **the canonical worked connector** — a complete three-object connector (relationships, matching, composite keys, custom fields, upsertClean, query/upsert/delete) on one page; its test-file counterpart is 15 |
29
29
  | [13-file-fields.md](./13-file-fields.md) | objects carrying file content — fields of `type: "file"`, FileProvider, fields-driven inclusion (do NOT copy the fleet's legacy `row.file`) |
30
30
  | [14-vendor-sdks-and-non-http.md](./14-vendor-sdks-and-non-http.md) | vendor npm SDKs (googleapis, AWS, box), database/on-prem `agent_mode: "always"` connectors, SOAP/XML, self-managed rate limiting |
31
+ | [15-example-test-file.md](./15-example-test-file.md) | **the canonical worked test file** — the complete `mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair for the Acme connector of 12 (TestFeatureProvider, prepare/cleanup hooks, issue simulators, feature scoping) |
31
32
 
32
33
  ## The 60-second version
33
34
 
@@ -151,7 +152,7 @@ Implementing more methods unlocks more platform capability:
151
152
  7. **Validate + push**: `sm validate`, `sm status` / `sm diff`, `sm push -m "message"`.
152
153
  A push 409 means the platform copy changed: `sm pull`, merge, push again — never force.
153
154
  8. **Test harness**: add `mod Test.mjs` (+ generated `mod ObjectTestFeatures.mjs`) so the
154
- platform's connector test suite can exercise your objects (09).
155
+ platform's connector test suite can exercise your objects (09; worked example: 15).
155
156
 
156
157
  ## Support
157
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncmatters/connector-sdk",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "TypeScript type definitions for the SyncMatters connector SDK (types only - connectors execute on the SyncMatters platform)",
5
5
  "types": "./index.d.ts",
6
6
  "exports": {
@@ -12,7 +12,7 @@
12
12
  "license": "MIT",
13
13
  "author": "SyncMatters",
14
14
  "homepage": "https://syncmatters.com",
15
- "typesContentHash": "285fcc040f1a62f7472910cf06536de4f33574a6f82c5ac405aa51a6b0797174",
15
+ "typesContentHash": "1e322fcb4cda8d7a31d350d575470adeb2402009ea1929274efe27d7ccb7e041",
16
16
  "dependencies": {
17
17
  "@types/node": "*",
18
18
  "@syncmatters/script-api": "^1.0.1"