@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
|
@@ -0,0 +1,567 @@
|
|
|
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 the CLI's `sm test` 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 test` resolves the test
|
|
43
|
+
module from the folder beside the connector's entry file, and several candidates are ambiguous
|
|
44
|
+
(`<Name>.test.mjs` wins when present).
|
|
45
|
+
|
|
46
|
+
## `files/Connectors/Acme/mod ObjectTestFeatures.mjs` (generated)
|
|
47
|
+
|
|
48
|
+
Generated from the sandbox connection's cached `meta()` — regenerate it whenever `meta()`
|
|
49
|
+
changes (`sm connector features`, or the web UI's feature-generate action); never hand-edit
|
|
50
|
+
it. Note there are **no** `cannotTestReason` / `sameAsObjectId` entries here: the generator
|
|
51
|
+
cannot know those, so the fine-grained scoping knobs are applied from `mod Test.mjs`'s
|
|
52
|
+
`before()` (below), on top of this file.
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
/**
|
|
56
|
+
* Do not edit. This file is automatically generated using cached metadata from a test connection.
|
|
57
|
+
*
|
|
58
|
+
* This automatically generated object feature list can be extended in your main test module file; connector metadata
|
|
59
|
+
* does not contain sufficient detail to fully describe all connector features.
|
|
60
|
+
*/
|
|
61
|
+
import API from "@syncmatters/script-api";
|
|
62
|
+
import SDKTest from "@syncmatters/script-api/sdk-test";
|
|
63
|
+
const toJsonPath = (/** @type string */ fieldPath) => API.utilities.json.stringToJsonValuePath(fieldPath, "all");
|
|
64
|
+
const toJsonPaths = (/** @type string[] */ fieldPaths) => API.utilities.json.stringsToJsonValuePaths(fieldPaths, "all");
|
|
65
|
+
|
|
66
|
+
/** @type Record<string, SDKTest.ObjectFeatures> */
|
|
67
|
+
export const objectFeatures = {
|
|
68
|
+
companies: {
|
|
69
|
+
query: {
|
|
70
|
+
list: true,
|
|
71
|
+
idsFilter: true,
|
|
72
|
+
checkpointFilter: true,
|
|
73
|
+
matchRules: [{"rule":"id"},{"rule":"name[ci]"}],
|
|
74
|
+
canMatchFields: toJsonPaths(["id", "name", "domain"])
|
|
75
|
+
},
|
|
76
|
+
upsert: {
|
|
77
|
+
canUpsert: true,
|
|
78
|
+
key: {
|
|
79
|
+
path: toJsonPath("id")
|
|
80
|
+
},
|
|
81
|
+
constraints: [{"constraints":{"mandatory":"add"},"fieldPath":[{"path":"name"}]}]
|
|
82
|
+
},
|
|
83
|
+
delete: {
|
|
84
|
+
canDelete: true,
|
|
85
|
+
key: {
|
|
86
|
+
path: toJsonPath("id")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
contacts: {
|
|
91
|
+
query: {
|
|
92
|
+
list: true,
|
|
93
|
+
idsFilter: true,
|
|
94
|
+
checkpointFilter: true,
|
|
95
|
+
matchRules: [{"rule":"id"},{"rule":"email[ci]"},{"rule":"first_and_last_name[ci]"}],
|
|
96
|
+
canMatchFields: toJsonPaths(["id", "email", "first_name", "last_name"]),
|
|
97
|
+
relationshipsTo: [{"id":"companies-company_id","relObjectId":"companies"}]
|
|
98
|
+
},
|
|
99
|
+
upsert: {
|
|
100
|
+
canUpsert: true,
|
|
101
|
+
key: {
|
|
102
|
+
path: toJsonPath("id")
|
|
103
|
+
},
|
|
104
|
+
canUpsertClean: true,
|
|
105
|
+
constraints: [{"constraints":{"mandatory":"add","email":true},"fieldPath":[{"path":"email"}]}]
|
|
106
|
+
},
|
|
107
|
+
delete: {
|
|
108
|
+
canDelete: true,
|
|
109
|
+
key: {
|
|
110
|
+
path: toJsonPath("id")
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
salesorders: {
|
|
115
|
+
query: {
|
|
116
|
+
list: true,
|
|
117
|
+
idsFilter: true
|
|
118
|
+
},
|
|
119
|
+
upsert: {
|
|
120
|
+
canUpsert: true,
|
|
121
|
+
key: {
|
|
122
|
+
path: toJsonPath("$compositeKey")
|
|
123
|
+
},
|
|
124
|
+
constraints: [{"constraints":{"mandatory":"add"},"fieldPath":[{"path":"business_unit"}]}]
|
|
125
|
+
},
|
|
126
|
+
delete: {
|
|
127
|
+
canDelete: true,
|
|
128
|
+
key: {
|
|
129
|
+
path: toJsonPath("$compositeKey")
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## `files/Connectors/Acme/mod Test.mjs` (hand-written)
|
|
137
|
+
|
|
138
|
+
Implements `SDKTest.TestFeatureProvider`. Every field/type below is from
|
|
139
|
+
`@syncmatters/script-api/sdk-test`; topic-file cross-references are in comments.
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
import API from "@syncmatters/script-api";
|
|
143
|
+
import SDKTest from "@syncmatters/script-api/sdk-test";
|
|
144
|
+
// the auto-generated feature list - regenerate after meta() changes, never hand-edit (09)
|
|
145
|
+
import { objectFeatures } from "./mod ObjectTestFeatures";
|
|
146
|
+
|
|
147
|
+
// Typed test connection: typed query/queryOne/upsert/delete accessors over the sandbox
|
|
148
|
+
// connection. "acme_test" is YOUR test connection's name. (Fleet files that predate the
|
|
149
|
+
// @syncmatters packages import the same types from the legacy "@ihq/script-api" scope -
|
|
150
|
+
// new code always imports @syncmatters/*.)
|
|
151
|
+
/** @typedef {API.connections.acme_test.Connection} TestConnection */
|
|
152
|
+
/** @typedef {TestConnection["upsert"]} Upsert */
|
|
153
|
+
/** @typedef {TestConnection["queryOne"]} Query */
|
|
154
|
+
// With the ^^above^^ typedefs:
|
|
155
|
+
// - upsert row type for "contacts": {Parameters<Upsert["contacts"]>[0][0]}
|
|
156
|
+
// - query result row type for "contacts": {Awaited<ReturnType<Query["contacts"]>>}
|
|
157
|
+
|
|
158
|
+
// prefix EVERY record the tests create, so leftovers from crashed runs stay findable
|
|
159
|
+
const testRecordPrefix = "sm_test";
|
|
160
|
+
|
|
161
|
+
/** @implements SDKTest.TestFeatureProvider */
|
|
162
|
+
export default class TestFeatureProvider {
|
|
163
|
+
// must equal the sandbox connection's meta().identity - pins the account, so re-pointed
|
|
164
|
+
// credentials fail loudly instead of trashing another account's data (09, 12)
|
|
165
|
+
/** @type SDKTest.TestFeatureProvider["expectedIdentity"] */
|
|
166
|
+
expectedIdentity = "Acme Sandbox|integration@example.com";
|
|
167
|
+
|
|
168
|
+
/** @type API.Logger */
|
|
169
|
+
#log;
|
|
170
|
+
/** @type TestConnection */
|
|
171
|
+
#connection;
|
|
172
|
+
/** @type string */
|
|
173
|
+
#fixtureCompanyId = ""; // shared parent row for contact test data
|
|
174
|
+
|
|
175
|
+
/** @type SDKTest.TestFeatureProvider["before"] */
|
|
176
|
+
async before(ctx) {
|
|
177
|
+
this.#log = ctx.log;
|
|
178
|
+
this.#connection = /** @type any */ (ctx.connection);
|
|
179
|
+
|
|
180
|
+
// === extend the GENERATED features here - never by editing the generated file (09).
|
|
181
|
+
// contacts delete runs the exact same batch/archive code path as companies delete,
|
|
182
|
+
// so test it once:
|
|
183
|
+
if (objectFeatures.contacts.delete) {
|
|
184
|
+
objectFeatures.contacts.delete.sameAsObjectId = "companies";
|
|
185
|
+
}
|
|
186
|
+
// per-rule scoping: Acme's /search cannot search by record id - the "id" rule resolves
|
|
187
|
+
// via get-by-id, which the idsFilter test already covers:
|
|
188
|
+
for (const objectId of ["companies", "contacts"]) {
|
|
189
|
+
const idRule = (objectFeatures[objectId].query?.matchRules || []).find((r) => r.rule === "id");
|
|
190
|
+
if (idRule) idRule.cannotTestReason = "id matches resolve via get-by-id; covered by the idsFilter test";
|
|
191
|
+
}
|
|
192
|
+
// Other scoping knobs, when you need them:
|
|
193
|
+
// objectFeatures.salesorders.cannotTestReason = "<why the whole object cannot be tested>";
|
|
194
|
+
// objectFeatures.salesorders.delete.cannotTestReason = "<why just this feature cannot>";
|
|
195
|
+
// If Acme's archive endpoint kept returning archived rows (a soft delete), you would
|
|
196
|
+
// declare the flag field instead of expecting row absence, and allow for slow indexes:
|
|
197
|
+
// objectFeatures.companies.delete.isDeleted = { path: [{ path: "archived" }], valueWhenDeleted: true };
|
|
198
|
+
// objectFeatures.companies.delete.afterDeleteMaxIndexWaitTimeMs = 30000;
|
|
199
|
+
|
|
200
|
+
// === sweep sm_test* leftovers from crashed runs
|
|
201
|
+
await this.#sweepTestData();
|
|
202
|
+
|
|
203
|
+
// === shared fixtures
|
|
204
|
+
const company = await this.#connection.queryOne.companies();
|
|
205
|
+
if (!company) throw new Error("Sandbox has no companies - seed at least one before testing");
|
|
206
|
+
this.#fixtureCompanyId = company.meta.key;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @type SDKTest.TestFeatureProvider["after"] */
|
|
210
|
+
async after() {
|
|
211
|
+
await this.#sweepTestData();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @type SDKTest.TestFeatureProvider["objectIds"] */
|
|
215
|
+
async objectIds() {
|
|
216
|
+
return Object.keys(objectFeatures);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @type SDKTest.TestFeatureProvider["objectFeatures"] */
|
|
220
|
+
async objectFeatures(objectId) {
|
|
221
|
+
return objectFeatures[objectId];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @type SDKTest.TestFeatureProvider["listPrepare"] */
|
|
225
|
+
async listPrepare(options) {
|
|
226
|
+
// returning undefined here would mean "run the list, just verify SOME rows come back";
|
|
227
|
+
// a small pageSize forces real pagination through Acme's cursor (05)
|
|
228
|
+
switch (options.meta.id) {
|
|
229
|
+
case "salesorders":
|
|
230
|
+
return { pageSize: 25, fields: [[{ path: "$compositeKey" }], [{ path: "status" }]] };
|
|
231
|
+
default:
|
|
232
|
+
return { pageSize: 25 };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @type SDKTest.TestFeatureProvider["idsFilterPrepare"] */
|
|
237
|
+
async idsFilterPrepare(options) {
|
|
238
|
+
// list-capable objects can skip this hook - undefined tells the harness to find two
|
|
239
|
+
// ids via a list query itself
|
|
240
|
+
if (options.meta.id !== "salesorders") return undefined;
|
|
241
|
+
|
|
242
|
+
// implemented for salesorders to make composite keys concrete: the ids ARE the
|
|
243
|
+
// connector's JSON key strings (row.meta.key), e.g.
|
|
244
|
+
// '{"business_unit":"emea","order_id":1041}'
|
|
245
|
+
// NEVER hand-assemble them - collect real rows and reuse their keys verbatim (11, 12)
|
|
246
|
+
/** @type SDKTest.IdsFilterData */
|
|
247
|
+
const result = { id1: "", id2: "" };
|
|
248
|
+
for await (const order of this.#connection.query.salesorders()) {
|
|
249
|
+
if (!result.id1) {
|
|
250
|
+
result.id1 = order.meta.key;
|
|
251
|
+
} else if (order.meta.key !== result.id1) {
|
|
252
|
+
result.id2 = order.meta.key;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @type SDKTest.TestFeatureProvider["checkpointFilterStep1Prepare"] */
|
|
260
|
+
async checkpointFilterStep1Prepare(options) {
|
|
261
|
+
// step 1: pick a starting checkpoint and report every row the differential query
|
|
262
|
+
// should return for it - then guarantee at least one by seeding a row
|
|
263
|
+
const start = new Date(Date.now() - 60000).toISOString();
|
|
264
|
+
/** @type SDKTest.CheckpointStep1Data */
|
|
265
|
+
const result = { nextCheckpoint: start, expectedRowIds: [] };
|
|
266
|
+
|
|
267
|
+
// a shared sandbox is never quiet: rows already modified inside the window are
|
|
268
|
+
// expected too, so collect them rather than assuming zero
|
|
269
|
+
// @ts-ignore - objectId is dynamic
|
|
270
|
+
for await (const row of this.#connection.query[options.meta.id]({ checkpointFilter: { value: start } })) {
|
|
271
|
+
result.expectedRowIds.push(row.meta.key);
|
|
272
|
+
}
|
|
273
|
+
if (result.expectedRowIds.length === 0) {
|
|
274
|
+
result.expectedRowIds.push(await this.#createTestRow(options.meta.id));
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** @type SDKTest.TestFeatureProvider["checkpointFilterStep2Prepare"] */
|
|
280
|
+
async checkpointFilterStep2Prepare(options) {
|
|
281
|
+
// step 2: the harness hands back the checkpoint the CONNECTOR emitted in step 1;
|
|
282
|
+
// mutate the data and report what the second differential query must surface
|
|
283
|
+
/** @type SDKTest.CheckpointStep2Data */
|
|
284
|
+
const result = { expectedRowIds: [] };
|
|
285
|
+
|
|
286
|
+
// checkpoints are emitted with an overlap window (05, 12), so step-1 rows may
|
|
287
|
+
// legitimately reappear - include everything currently inside the window...
|
|
288
|
+
// @ts-ignore - objectId is dynamic
|
|
289
|
+
for await (const row of this.#connection.query[options.meta.id]({ checkpointFilter: { value: options.checkpointReceived } })) {
|
|
290
|
+
result.expectedRowIds.push(row.meta.key);
|
|
291
|
+
}
|
|
292
|
+
// ...then make one fresh modification the query MUST pick up
|
|
293
|
+
result.expectedRowIds.push(await this.#createTestRow(options.meta.id));
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** @type SDKTest.TestFeatureProvider["matchFilterPrepare"] */
|
|
298
|
+
async matchFilterPrepare(options) {
|
|
299
|
+
const uid = SDKTest.makeUid();
|
|
300
|
+
/** @type SDKTest.MatchData[] */
|
|
301
|
+
const results = [];
|
|
302
|
+
|
|
303
|
+
switch (options.meta.id) {
|
|
304
|
+
case "companies": {
|
|
305
|
+
if (options.rule !== "name[ci]") return undefined; // undefined = rule cannot be tested
|
|
306
|
+
const name = `${testRecordPrefix} Match Co ${uid}`;
|
|
307
|
+
const [company] = await this.#connection.upsert.companies([{ name }]);
|
|
308
|
+
results.push({
|
|
309
|
+
// a [ci] rule: deliberately change the case - the match must still land
|
|
310
|
+
srcData: [{ srcRowId: "src-1", match: { name: name.toUpperCase() } }],
|
|
311
|
+
destFieldPath: [{ path: "name" }],
|
|
312
|
+
expectedMatchRowIds: [company.meta.key],
|
|
313
|
+
});
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
case "contacts": {
|
|
317
|
+
const email = `${testRecordPrefix}.${uid}@example.com`;
|
|
318
|
+
const first = `${testRecordPrefix}-First-${uid}`;
|
|
319
|
+
const last = `Last-${uid}`;
|
|
320
|
+
const [contact] = await this.#connection.upsert.contacts([
|
|
321
|
+
{ email, first_name: first, last_name: last, company_id: this.#fixtureCompanyId },
|
|
322
|
+
]);
|
|
323
|
+
switch (options.rule) {
|
|
324
|
+
case "email[ci]": {
|
|
325
|
+
results.push({
|
|
326
|
+
srcData: [{ srcRowId: "src-1", match: { email: email.toUpperCase() } }],
|
|
327
|
+
destFieldPath: [{ path: "email" }],
|
|
328
|
+
expectedMatchRowIds: [contact.meta.key],
|
|
329
|
+
});
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "first_and_last_name[ci]": {
|
|
333
|
+
// RowMatchData carries the parts as firstname/lastname - the rule needs BOTH
|
|
334
|
+
results.push({
|
|
335
|
+
srcData: [{ srcRowId: "src-1", match: { firstname: first.toUpperCase(), lastname: last.toUpperCase() } }],
|
|
336
|
+
expectedMatchRowIds: [contact.meta.key],
|
|
337
|
+
});
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
default:
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
default:
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
return results;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** @type SDKTest.TestFeatureProvider["matchFilterCleanup"] */
|
|
352
|
+
async matchFilterCleanup(options) {
|
|
353
|
+
// the rows we created ARE the expected matches - delete them
|
|
354
|
+
const keys = options.testData.flatMap((d) => d.expectedMatchRowIds);
|
|
355
|
+
await this.#deleteRows(options.meta.id, keys);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @type SDKTest.TestFeatureProvider["relatedFilterPrepare"] */
|
|
359
|
+
async relatedFilterPrepare(options) {
|
|
360
|
+
// contacts declares relationship "companies-company_id" (12). The harness will
|
|
361
|
+
// 1) collect the 'from' contact via idsFilter (srcRowId), then 2) query companies with
|
|
362
|
+
// relatedFilter { otherObjectId: "contacts", otherRelationshipId, otherRows: [contact] }
|
|
363
|
+
// and expect EXACTLY expectedRelatedRowIds back.
|
|
364
|
+
if (options.fromObjectMeta.id !== "contacts" || options.fromRelationshipId !== "companies-company_id") {
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
const uid = SDKTest.makeUid();
|
|
368
|
+
const [company] = await this.#connection.upsert.companies([{ name: `${testRecordPrefix} Rel Co ${uid}` }]);
|
|
369
|
+
const [contact] = await this.#connection.upsert.contacts([
|
|
370
|
+
{
|
|
371
|
+
email: `${testRecordPrefix}.rel.${uid}@example.com`,
|
|
372
|
+
first_name: testRecordPrefix,
|
|
373
|
+
last_name: `Rel ${uid}`,
|
|
374
|
+
company_id: company.meta.key, // the FK that drives the relationship
|
|
375
|
+
},
|
|
376
|
+
]);
|
|
377
|
+
// OneToOne cardinality: exactly one related company expected
|
|
378
|
+
return [{ srcRowId: contact.meta.key, expectedRelatedRowIds: [company.meta.key] }];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @type SDKTest.TestFeatureProvider["relatedFilterCleanup"] */
|
|
382
|
+
async relatedFilterCleanup(options) {
|
|
383
|
+
for (const testData of options.testDatas) {
|
|
384
|
+
await this.#deleteRows("contacts", [testData.srcRowId]); // child first...
|
|
385
|
+
await this.#deleteRows("companies", testData.expectedRelatedRowIds); // ...then parent
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @type SDKTest.TestFeatureProvider["upsertPrepare"] */
|
|
390
|
+
async upsertPrepare(options) {
|
|
391
|
+
// This hook also feeds the DELETE test: there is no deletePrepare - the harness calls
|
|
392
|
+
// upsertPrepare twice, inserts both rows, deletes one and verifies exactly that one
|
|
393
|
+
// is gone. Keep inserts repeatable and unique (makeUid).
|
|
394
|
+
const uid = SDKTest.makeUid();
|
|
395
|
+
/** @type SDKTest.UpsertData */
|
|
396
|
+
const result = { insert: {}, update: {}, verifyFields: [], issueSimulators: [] };
|
|
397
|
+
|
|
398
|
+
switch (options.meta.id) {
|
|
399
|
+
case "companies": {
|
|
400
|
+
/** @type Parameters<Upsert["companies"]>[0][0] */
|
|
401
|
+
const insert = { name: `${testRecordPrefix} Co ${uid}`, domain: `${uid}.example.com` };
|
|
402
|
+
/** @type Parameters<Upsert["companies"]>[0][0] */
|
|
403
|
+
const update = { domain: `${uid}-updated.example.com` };
|
|
404
|
+
result.insert = insert;
|
|
405
|
+
result.update = update;
|
|
406
|
+
// the harness re-queries the row after each write and compares these paths
|
|
407
|
+
result.verifyFields.push([{ path: "domain" }]);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case "contacts": {
|
|
411
|
+
/** @type Parameters<Upsert["contacts"]>[0][0] */
|
|
412
|
+
const insert = {
|
|
413
|
+
email: `${testRecordPrefix}.${uid}@example.com`,
|
|
414
|
+
first_name: testRecordPrefix,
|
|
415
|
+
last_name: `Contact ${uid}`,
|
|
416
|
+
company_id: this.#fixtureCompanyId,
|
|
417
|
+
};
|
|
418
|
+
/** @type Parameters<Upsert["contacts"]>[0][0] */
|
|
419
|
+
const update = { last_name: `Contact ${uid} updated` };
|
|
420
|
+
result.insert = insert;
|
|
421
|
+
result.update = update;
|
|
422
|
+
result.verifyFields.push([{ path: "last_name" }]);
|
|
423
|
+
|
|
424
|
+
// contacts declares canUpsertClean (12): when asked, supply deliberately-broken
|
|
425
|
+
// inserts stating EXACTLY which issue upsertClean must report and whether it is
|
|
426
|
+
// fatal. The harness applies each simulator's fields over the insert row.
|
|
427
|
+
if (options.withIssueSimulators) {
|
|
428
|
+
result.issueSimulators = [
|
|
429
|
+
// email is mandatory on add -> ValueRequired, fatal
|
|
430
|
+
{ type: "ValueRequired", upsert: { email: undefined }, fatal: true },
|
|
431
|
+
// an invalid email is cleansed (the field is REMOVED)... which then violates
|
|
432
|
+
// the mandatory-on-add constraint, so this issue escalates to fatal
|
|
433
|
+
{ type: "EmailInvalid", upsert: { email: "not-an-email" }, fatal: true },
|
|
434
|
+
// unknown option on a sandbox custom picklist field: upsertClean's
|
|
435
|
+
// cleanAction "DeleteUpsertField" drops the field and the row still
|
|
436
|
+
// writes -> NOT fatal (06, 12)
|
|
437
|
+
{ type: "OptionNotFound", upsert: { t_shirt_size: "no-such-option" }, fatal: false },
|
|
438
|
+
];
|
|
439
|
+
}
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case "salesorders": {
|
|
443
|
+
// composite-key object: the ADD carries the parent part (business_unit) as a
|
|
444
|
+
// plain mandatory field - there is NO key yet. The harness copies the key the
|
|
445
|
+
// insert response returns ($compositeKey) onto the update row for us (11, 12).
|
|
446
|
+
// "emea" is an existing business unit on the Acme sandbox (units are fixtures).
|
|
447
|
+
/** @type Parameters<Upsert["salesorders"]>[0][0] */
|
|
448
|
+
const insert = { business_unit: "emea", amount: 42, status: "draft" };
|
|
449
|
+
/** @type Parameters<Upsert["salesorders"]>[0][0] */
|
|
450
|
+
const update = { status: "confirmed" };
|
|
451
|
+
result.insert = insert;
|
|
452
|
+
result.update = update;
|
|
453
|
+
result.verifyFields.push([{ path: "status" }]);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** @type SDKTest.TestFeatureProvider["upsertCleanup"] */
|
|
461
|
+
async upsertCleanup(options) {
|
|
462
|
+
// the harness hands back the rows it inserted - delete them right away. This is the
|
|
463
|
+
// ONLY cleanup path for salesorders test rows: they have no prefixable text field,
|
|
464
|
+
// so the after() sweep cannot find them.
|
|
465
|
+
await this.#deleteRows(options.meta.id, options.inserted.map((r) => r.meta.key));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// === private helpers ======================================================
|
|
469
|
+
|
|
470
|
+
/** Create one prefixed row; creating counts as a modification for checkpoint tests.
|
|
471
|
+
* @param {string} objectId @returns {Promise<string>} the new row's key */
|
|
472
|
+
async #createTestRow(objectId) {
|
|
473
|
+
const uid = SDKTest.makeUid();
|
|
474
|
+
switch (objectId) {
|
|
475
|
+
case "companies": {
|
|
476
|
+
const [row] = await this.#connection.upsert.companies([{ name: `${testRecordPrefix} Co ${uid}` }]);
|
|
477
|
+
return row.meta.key;
|
|
478
|
+
}
|
|
479
|
+
case "contacts": {
|
|
480
|
+
const [row] = await this.#connection.upsert.contacts([
|
|
481
|
+
{
|
|
482
|
+
email: `${testRecordPrefix}.${uid}@example.com`,
|
|
483
|
+
first_name: testRecordPrefix,
|
|
484
|
+
last_name: `Contact ${uid}`,
|
|
485
|
+
company_id: this.#fixtureCompanyId,
|
|
486
|
+
},
|
|
487
|
+
]);
|
|
488
|
+
return row.meta.key;
|
|
489
|
+
}
|
|
490
|
+
default:
|
|
491
|
+
throw new Error(`#createTestRow not implemented for ${objectId}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Delete rows by key. @param {string} objectId @param {string[]} keys */
|
|
496
|
+
async #deleteRows(objectId, keys) {
|
|
497
|
+
if (keys.length === 0) return;
|
|
498
|
+
switch (objectId) {
|
|
499
|
+
case "companies":
|
|
500
|
+
await this.#connection.delete.companies(keys.map((id) => ({ id })));
|
|
501
|
+
break;
|
|
502
|
+
case "contacts":
|
|
503
|
+
await this.#connection.delete.contacts(keys.map((id) => ({ id })));
|
|
504
|
+
break;
|
|
505
|
+
case "salesorders":
|
|
506
|
+
// composite-key deletes address rows by the SAME key string queries returned (11)
|
|
507
|
+
await this.#connection.delete.salesorders(keys.map((k) => ({ "$compositeKey": k })));
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Sweep sm_test* leftovers - children (contacts) before parents (companies). */
|
|
513
|
+
async #sweepTestData() {
|
|
514
|
+
/** @type string[] */
|
|
515
|
+
const staleContacts = [];
|
|
516
|
+
for await (const row of this.#connection.query.contacts()) {
|
|
517
|
+
if (String(row.data.email || "").startsWith(`${testRecordPrefix}.`)) staleContacts.push(row.meta.key);
|
|
518
|
+
}
|
|
519
|
+
/** @type string[] */
|
|
520
|
+
const staleCompanies = [];
|
|
521
|
+
for await (const row of this.#connection.query.companies()) {
|
|
522
|
+
if (String(row.data.name || "").startsWith(testRecordPrefix)) staleCompanies.push(row.meta.key);
|
|
523
|
+
}
|
|
524
|
+
if (staleContacts.length || staleCompanies.length) {
|
|
525
|
+
this.#log.info(`Sweeping ${staleContacts.length} test contact(s), ${staleCompanies.length} test company(ies)`);
|
|
526
|
+
}
|
|
527
|
+
await this.#deleteRows("contacts", staleContacts);
|
|
528
|
+
await this.#deleteRows("companies", staleCompanies);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
## What to notice (the parts assistants most often get wrong)
|
|
534
|
+
|
|
535
|
+
1. **Every created record is prefixed** (`sm_test`) and `before()`/`after()` sweep the
|
|
536
|
+
prefix — a crashed run never pollutes the sandbox, and the next run self-heals. Objects
|
|
537
|
+
with no prefixable text field (salesorders) must be cleaned in `upsertCleanup` via
|
|
538
|
+
`options.inserted` instead.
|
|
539
|
+
2. **`expectedIdentity` pins the account** — it must equal the sandbox connection's
|
|
540
|
+
`meta().identity` string exactly; re-pointed credentials then fail loudly instead of
|
|
541
|
+
letting the suite write into a real account.
|
|
542
|
+
3. **`undefined` from a prepare hook is a decision, not a default.** For
|
|
543
|
+
`checkpointFilterStep1/2Prepare`, `matchFilterPrepare`, `relatedFilterPrepare`,
|
|
544
|
+
`rowFiltersPrepare` and `upsertPrepare` it means "this cannot be tested" (the feature is
|
|
545
|
+
skipped); but for `listPrepare` it means "run the list and just verify some rows", and for
|
|
546
|
+
`idsFilterPrepare` it means "find two ids via a list query". Returning `undefined` by
|
|
547
|
+
accident silently drops coverage.
|
|
548
|
+
4. **`mod ObjectTestFeatures.mjs` is generated — regenerate, don't edit.** Scoping
|
|
549
|
+
(`cannotTestReason`, `sameAsObjectId` and their per-feature/per-rule/per-relationship
|
|
550
|
+
variants) is layered on from `mod Test.mjs`'s `before()`, so a regeneration never loses it.
|
|
551
|
+
5. **Composite-key objects use the connector's own key strings.** Test ids come from
|
|
552
|
+
`row.meta.key` verbatim — never hand-assembled; the upsert `insert` has no key at all (the
|
|
553
|
+
parent part travels as a regular mandatory field) and the harness copies the returned
|
|
554
|
+
`$compositeKey` onto the `update` row.
|
|
555
|
+
6. **Issue simulators are how `upsertClean` gets exercised.** Each simulator names the exact
|
|
556
|
+
`UpsertIssueType` and its fatality, and cleansing cascades: an invalid email on a
|
|
557
|
+
mandatory-on-add field is removed by the cleanse, which then trips `ValueRequired` — so
|
|
558
|
+
the `EmailInvalid` issue is reported fatal.
|
|
559
|
+
7. **There is no `deletePrepare`.** The delete test calls `upsertPrepare` twice, inserts both
|
|
560
|
+
rows, deletes one and verifies exactly that one disappeared — so inserts must be
|
|
561
|
+
repeatable and unique. Soft-deleting APIs declare
|
|
562
|
+
`delete.isDeleted { path, valueWhenDeleted }` (plus `afterDeleteMaxIndexWaitTimeMs`)
|
|
563
|
+
instead of expecting row absence.
|
|
564
|
+
8. **Checkpoint tests assume a noisy sandbox.** Both steps collect everything already inside
|
|
565
|
+
the differential window into `expectedRowIds` before seeding one guaranteed change, and
|
|
566
|
+
step 2 expects step-1 rows to reappear because the connector's checkpoint deliberately
|
|
567
|
+
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
|
|
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
|
|
|
@@ -146,12 +147,20 @@ Implementing more methods unlocks more platform capability:
|
|
|
146
147
|
3. **Make `test()` real**: cheapest authenticated call the API offers (02).
|
|
147
148
|
4. **Describe objects** in `meta()` (04). Start with one object and its `isKey` field; grow from
|
|
148
149
|
there. `SDK.utilities.fieldsFromJSON(sample)` can draft field lists from a sample response.
|
|
149
|
-
5. **Implement `query()
|
|
150
|
+
5. **Implement `query()` capabilities in order**: list → ids → checkpoint → match → related
|
|
151
|
+
(05). For EVERY object aim for all five — syncs are built from them. When the API is
|
|
152
|
+
awkward (parent-scoped paths, no changed-since cursor), work the patterns in
|
|
153
|
+
[the query capability bar](./04-meta-objects-fields.md#the-query-capability-bar) (fan-out
|
|
154
|
+
list, `rowFilter` + `extends`, creation-date checkpoints, match via search params) before
|
|
155
|
+
leaving a flag unset.
|
|
150
156
|
6. **Implement writes** if needed: `upsert()`, `delete()` (06).
|
|
151
157
|
7. **Validate + push**: `sm validate`, `sm status` / `sm diff`, `sm push -m "message"`.
|
|
152
158
|
A push 409 means the platform copy changed: `sm pull`, merge, push again — never force.
|
|
153
159
|
8. **Test harness**: add `mod Test.mjs` (+ generated `mod ObjectTestFeatures.mjs`) so the
|
|
154
|
-
platform's connector test suite can exercise your objects (09).
|
|
160
|
+
platform's connector test suite can exercise your objects (09; worked example: 15).
|
|
161
|
+
9. **Run it**: `sm test` = the full suite (identical to the web UI's connector test — including
|
|
162
|
+
write features, so use a sandbox connection); `--only connection|meta|query` narrows. A
|
|
163
|
+
passing `--only` run is NOT suite coverage (09).
|
|
155
164
|
|
|
156
165
|
## Support
|
|
157
166
|
|
package/lib/connector.d.ts
CHANGED
|
@@ -116,9 +116,9 @@ export interface QueryOptions {
|
|
|
116
116
|
matchFilter?: QueryMatchFilter;
|
|
117
117
|
/** collect records changed since a particular checkpoint */
|
|
118
118
|
checkpointFilter?: QueryCheckpointFilter;
|
|
119
|
-
/** system specific row filters */
|
|
119
|
+
/** system specific row filters; allowed values are defined in meta.queryFilter.rowFilter */
|
|
120
120
|
rowFilter?: any;
|
|
121
|
-
/** system specific property filters */
|
|
121
|
+
/** @deprecated (use standard fields filter) system specific property filters; allowed values are defined in meta.queryFilter.propertyFilter */
|
|
122
122
|
propertyFilter?: any;
|
|
123
123
|
};
|
|
124
124
|
queryState?: any;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncmatters/connector-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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,9 +12,9 @@
|
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"author": "SyncMatters",
|
|
14
14
|
"homepage": "https://syncmatters.com",
|
|
15
|
-
"typesContentHash": "
|
|
15
|
+
"typesContentHash": "e11f85e549e818527babaa7304ecca045f11b7e9fc6070519ecdd0d433e9081c",
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@types/node": "*",
|
|
18
|
-
"@syncmatters/script-api": "^1.0.
|
|
18
|
+
"@syncmatters/script-api": "^1.0.2"
|
|
19
19
|
}
|
|
20
20
|
}
|