@syncmatters/connector-sdk 1.0.16 → 1.0.18
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 +83 -0
- package/docs/02-lifecycle-and-shape.md +63 -1
- package/docs/04-meta-objects-fields.md +66 -1
- package/docs/07-events-webhooks.md +9 -0
- package/docs/09-testing.md +12 -2
- package/docs/10-style-and-pitfalls.md +32 -0
- package/docs/12-example-connector.md +80 -0
- package/docs/14-vendor-sdks-and-non-http.md +18 -0
- package/docs/15-example-test-file.md +14 -0
- package/docs/17-user-help-pages.md +11 -2
- package/docs/connector-authoring.md +8 -2
- package/package.json +3 -3
|
@@ -87,6 +87,7 @@ await this.#core.init(args);
|
|
|
87
87
|
| `oauth2_mode` | `"connector"` = one shared OAuth app for every account · `"connection"` = each connection supplies its own client id/secret. Omit for non-OAuth connectors. |
|
|
88
88
|
| `oauth2_provider` | the OAuth provider definition (below) |
|
|
89
89
|
| `webhooks_provider` | `{"mode": "custom.perconnection"}` (each connection registers its own webhook) or `{"mode": "custom.perconnector", "url": ..., "webhook_uid": ...}` (one shared endpoint) |
|
|
90
|
+
| `discovery` | `true` = two-phase connection setup: the entry class implements `discover()` and the form collects credentials first, then the settings and objects discovery returns (below) |
|
|
90
91
|
| `settings` | the connection-settings form (below) |
|
|
91
92
|
| `icon_url` | connector logo URL |
|
|
92
93
|
| `connector_name`, `type_id`, `application_name`, `application_icon_url` | platform/branding ids — usually left as scaffolded |
|
|
@@ -117,6 +118,18 @@ Per-setting fields: `order` (two-digit string, display order), `name`, `type` (`
|
|
|
117
118
|
`options` (for `select` only — not used for `timezone`), `default: { "type": "string", "expression": "<value>" }`, and
|
|
118
119
|
`defines_version: true` on the setting whose value picks a version core.
|
|
119
120
|
|
|
121
|
+
A connection setting accepts the same descriptive fields an object setting does — they were
|
|
122
|
+
always stored, they were simply never written down here:
|
|
123
|
+
|
|
124
|
+
| Field | Meaning |
|
|
125
|
+
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| `description` | help text shown under the input — the place to say where the value comes from |
|
|
127
|
+
| `group_id` | renders the setting inside a named group |
|
|
128
|
+
| `mandatory: true` | the form blocks save until the setting has a value |
|
|
129
|
+
| `display_if` / `display_if_setting` / `display_if_values` | conditional rendering (`displayIfHasValue` is the deprecated spelling, still accepted) |
|
|
130
|
+
| `prerequisite: true` | two-phase connectors only — see below |
|
|
131
|
+
| `affects_meta: true` | `meta()` reads this setting (a scope, a checkpoint column, an API version), so changing its value marks the connection's cached metadata stale until the next full refresh. Independent of `discovery`; implied by `prerequisite`; no effect on a `metaRefreshDisabled` connector |
|
|
132
|
+
|
|
120
133
|
Use `control: "timezone"` for IANA time zone pickers (e.g. fleet `time_zone` setting). The stored
|
|
121
134
|
value is a plain string (`"America/New_York"`, `"UTC"`, …); the UI renders a searchable list of
|
|
122
135
|
IANA zones — do not list zones in `options`.
|
|
@@ -125,6 +138,76 @@ Conventions worth copying: a secret `api_key`/`clientId`+`clientSecret` pair for
|
|
|
125
138
|
advanced boolean `debug` setting that gates verbose request/response logging; a
|
|
126
139
|
`connectorVersion` select with `defines_version` when you need breaking revisions to coexist.
|
|
127
140
|
|
|
141
|
+
### Two-phase setup: `discovery` and `prerequisite`
|
|
142
|
+
|
|
143
|
+
Most connectors' settings are the same for every tenant, and a static `options` list covers the
|
|
144
|
+
selects. When the real choices are tenant-specific — which databases, companies, subsidiaries or
|
|
145
|
+
objects THIS account has — set `"discovery": true` and implement `discover()`
|
|
146
|
+
([02-lifecycle-and-shape.md](./02-lifecycle-and-shape.md)). The form then has two steps:
|
|
147
|
+
|
|
148
|
+
1. Settings marked `"prerequisite": true` — everything needed to reach the tenant, and the only
|
|
149
|
+
settings `discover()` gets. **Continue** saves the connection and runs `test()` then `discover()`.
|
|
150
|
+
2. Everything else, rendered from the sidecar definition with the per-setting overrides
|
|
151
|
+
`discover()` returned merged over it, plus the object picker built from the catalogue.
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
"connector_metadata": {
|
|
155
|
+
"discovery": true,
|
|
156
|
+
"settings": {
|
|
157
|
+
"host": { "order": "00", "name": "Host", "type": "string", "control": "singlelinetext", "mandatory": true, "prerequisite": true },
|
|
158
|
+
"username": { "order": "01", "name": "User name", "type": "string", "control": "singlelinetext", "mandatory": true, "prerequisite": true },
|
|
159
|
+
"password": { "order": "02", "name": "Password", "type": "string", "control": "singlelinetext", "secret": true, "mandatory": true, "prerequisite": true },
|
|
160
|
+
"database": { "order": "03", "name": "Database", "type": "string", "control": "singlelinetext", "mandatory": true, "prerequisite": true,
|
|
161
|
+
"description": "The catalogue lists this database's tables and views." },
|
|
162
|
+
"schema": { "order": "04", "name": "Schema", "type": "string", "control": "select", "affects_meta": true,
|
|
163
|
+
"description": "Restrict the catalogue to one schema; the options come from discovery." },
|
|
164
|
+
"timeZone": { "order": "05", "name": "Time Zone", "type": "string", "control": "timezone" }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Rules worth knowing before you mark anything:
|
|
170
|
+
|
|
171
|
+
- `defines_version: true` implies `prerequisite: true` (the version core has to be chosen before
|
|
172
|
+
anything can be discovered); `prerequisite: true` in turn implies `affects_meta: true`.
|
|
173
|
+
- Changing a `prerequisite` setting on a live connection marks the stored catalogue stale — the
|
|
174
|
+
previous result is kept and the user is offered a re-collect. Nothing is queued automatically.
|
|
175
|
+
- A setting with no `options`, in the sidecar or in the overrides, renders as free text. The
|
|
176
|
+
overrides may also supply `default`, `mandatory`, `readonly`, `hidden` and `description`.
|
|
177
|
+
- `prerequisite` is ignored when `discovery` is absent, and `sm validate` rejects
|
|
178
|
+
`"discovery": true` with no `prerequisite` setting. It warns when it finds no `discover()` on
|
|
179
|
+
the entry class (and vice versa): the flag is what the platform reads, so a flagged class
|
|
180
|
+
without the method fails loudly in the wizard with `NotImplimented` rather than being treated
|
|
181
|
+
as single-phase.
|
|
182
|
+
|
|
183
|
+
#### A version that cannot discover: `"discovery": false` on its option
|
|
184
|
+
|
|
185
|
+
`discovery` is connector-wide, but on a multi-version connector one version core may be unable to
|
|
186
|
+
produce a catalogue (a superseded API). Mark that version's option instead of throwing:
|
|
187
|
+
|
|
188
|
+
```json
|
|
189
|
+
"version": {
|
|
190
|
+
"order": "01", "name": "Version", "type": "string", "control": "select", "defines_version": true,
|
|
191
|
+
"default": { "type": "string", "expression": "3b" },
|
|
192
|
+
"options": [
|
|
193
|
+
{ "id": "1", "name": "Superseded - legacy API (v1)", "order": "00", "discovery": false },
|
|
194
|
+
{ "id": "3b", "name": "3b (Recommended)", "order": "03" }
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
A connection is two-phase when `connector_metadata.discovery` is `true` **and** no `prerequisite`
|
|
200
|
+
(or `defines_version`) select setting has its selected option marked `"discovery": false`. An
|
|
201
|
+
option without the field inherits the connector's flag, so a new version is two-phase with
|
|
202
|
+
nothing written; the only thing you ever write is the exception, next to the "Superseded" label.
|
|
203
|
+
A connection on a marked version gets the single-phase form, `discover()` is never called, and
|
|
204
|
+
its metadata is collected on first use like a connector without discovery. The platform reads the
|
|
205
|
+
connection's selected value, not the setting's `default`. Changing a live connection's version
|
|
206
|
+
across the line keeps its cached objects either way: moving to a two-phase version turns them
|
|
207
|
+
into the selection; moving to a marked version clears the selection, so the next refresh collects
|
|
208
|
+
everything. `sm validate` rejects the field on a setting that is not a prerequisite, and on the
|
|
209
|
+
setting's default option.
|
|
210
|
+
|
|
128
211
|
### `oauth2_provider`
|
|
129
212
|
|
|
130
213
|
Defines the OAuth2 dance the platform performs on the connector's behalf (your code only ever
|
|
@@ -56,6 +56,13 @@ what you need in `#fields`, build the HTTP client(s), and register the OAuth ref
|
|
|
56
56
|
Do **not** make network calls in `init()` unless unavoidable — `test()` is where connectivity
|
|
57
57
|
is proven.
|
|
58
58
|
|
|
59
|
+
On a two-phase connector (`discovery: true`, see
|
|
60
|
+
[01](./01-workspace-and-meta-json.md#two-phase-setup-discovery-and-prerequisite)) the first
|
|
61
|
+
`init()` of a connection's life runs with only the `prerequisite` settings entered, so **never
|
|
62
|
+
throw in `init()` for a setting that is not marked `prerequisite`** — discovery would fail before
|
|
63
|
+
it could offer the user the value. Validate those where they are used (`test()`, `meta()`,
|
|
64
|
+
`query()`), as you already do for optional settings.
|
|
65
|
+
|
|
59
66
|
### `mode` (`SDK.ScriptType`) — early-out for static modes
|
|
60
67
|
|
|
61
68
|
Values include `"integration"`, `"scriptFile"`, `"parseEvents"`, `"verifyEventIdentity"`,
|
|
@@ -92,6 +99,55 @@ async test() {
|
|
|
92
99
|
|
|
93
100
|
`TestResult` = `{ success: boolean; message?: string; error?: CodedError }`.
|
|
94
101
|
|
|
102
|
+
`test()` is the credential gate in both phases of a two-phase connection: the platform runs it
|
|
103
|
+
before `discover()` (with only the `prerequisite` settings present — it must tolerate every other
|
|
104
|
+
setting being absent) and again before the first `meta()` with the full settings, where the
|
|
105
|
+
business-rule checks an options list cannot express still belong.
|
|
106
|
+
|
|
107
|
+
## `discover()` — the tenant's catalogue and settings (optional)
|
|
108
|
+
|
|
109
|
+
Implement `discover()` when the remaining settings and the object list depend on the tenant:
|
|
110
|
+
which databases, companies or subsidiaries this account has, which tables it can see. It runs
|
|
111
|
+
once the user has entered the `prerequisite` settings and returns the choices the rest of the
|
|
112
|
+
form is built from, so a value that used to be free text the user could get silently wrong
|
|
113
|
+
becomes a select of what actually exists.
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
/** @param {SDK.DiscoverOptions} options @returns {Promise<SDK.DiscoverResult>} */
|
|
117
|
+
async discover(options) {
|
|
118
|
+
const [schemas, tables] = await Promise.all([this.#listSchemas(), this.#listTables()]);
|
|
119
|
+
options.log.info(`Discovered ${tables.length} table(s)`);
|
|
120
|
+
return {
|
|
121
|
+
// per-setting overrides, merged over the sidecar definition (01)
|
|
122
|
+
settingsMeta: {
|
|
123
|
+
schema: { options: schemas.map((s) => ({ id: s.name })), mandatory: true },
|
|
124
|
+
},
|
|
125
|
+
// the catalogue: every object this tenant could describe, WITHOUT field detail
|
|
126
|
+
objects: tables.map((t) => ({ id: t.id, name: t.name, default: t.isBase })),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`DiscoverOptions` is `{ log }` — nothing else. `DiscoverResult` is
|
|
132
|
+
`{ settingsMeta?, objects: SDK.DiscoveredObject[], data? }`, where a `DiscoveredObject` is the
|
|
133
|
+
`id`, `name`, `order`, `isCustom`, `data` and `default` subset of `ObjectMeta` (same ids, same
|
|
134
|
+
meanings, no fields) and `data` is a connector-private payload handed back to `meta()` through
|
|
135
|
+
`options.discovery()` ([04](./04-meta-objects-fields.md#metaoptions--selection-aware-refresh)).
|
|
136
|
+
`default: true` pre-ticks an object in the picker on a new connection.
|
|
137
|
+
|
|
138
|
+
The contract:
|
|
139
|
+
|
|
140
|
+
- **Stateless and idempotent.** Every call returns the complete catalogue and overrides for the
|
|
141
|
+
tenant; nothing about earlier results is passed in. It runs on a new connection, when a
|
|
142
|
+
`prerequisite` setting changes, and whenever a user asks to check for new objects.
|
|
143
|
+
- **Cheap.** Catalogue endpoints, not per-object sampling — the user is sitting in front of the
|
|
144
|
+
form. The platform gives it 5 minutes and treats a timeout as a discovery failure.
|
|
145
|
+
- **An empty catalogue is a failure, not a result**, and so is a partial one: throw rather than
|
|
146
|
+
return a short list, or the picker will offer to deselect objects that merely failed to list.
|
|
147
|
+
Partial success belongs in an omitted override (say why in that setting's `description`).
|
|
148
|
+
- Identity is not part of it — `meta().identity` remains the only identity.
|
|
149
|
+
- It is never called in static modes (`parseEvents`, `verifyEventIdentity`).
|
|
150
|
+
|
|
95
151
|
## `meta()` — see [04-meta-objects-fields.md](./04-meta-objects-fields.md)
|
|
96
152
|
|
|
97
153
|
Return `{ identity?, objects: ObjectMeta[], events? }`. `identity` should be a stable string for
|
|
@@ -113,4 +169,10 @@ When an API revision would break existing connections, add a `connectorVersion`
|
|
|
113
169
|
(`defines_version: true`, see [01](./01-workspace-and-meta-json.md)) and split the
|
|
114
170
|
implementation into per-version core modules. The entry class reads the setting in `init()` and
|
|
115
171
|
delegates every SDK method to the selected core (`this.#core.query(options)` etc.), loading the
|
|
116
|
-
core with a dynamic `await import("./acme-v3")` so unselected versions never load.
|
|
172
|
+
core with a dynamic `await import("./acme-v3")` so unselected versions never load. `discover()`
|
|
173
|
+
delegates like the rest: a `defines_version` setting is implicitly `prerequisite`, so the version
|
|
174
|
+
is chosen before discovery runs and each core can discover its own way. A core that cannot
|
|
175
|
+
discover at all (a superseded API) is marked on its option with `"discovery": false`
|
|
176
|
+
([01](./01-workspace-and-meta-json.md#a-version-that-cannot-discover-discovery-false-on-its-option)):
|
|
177
|
+
connections on that version stay single-phase and `discover()` is never called for them. Keep a
|
|
178
|
+
`NotImplimented` throw in that core's `discover()` as the backstop.
|
|
@@ -15,6 +15,69 @@ async meta() {
|
|
|
15
15
|
`objects` is an **array**. (`ObjectMetaSummary`/`ObjectMetaDetail` are deprecated — never use.)
|
|
16
16
|
`events` only when you implement webhooks — see [07-events-webhooks.md](./07-events-webhooks.md).
|
|
17
17
|
|
|
18
|
+
## `MetaOptions` — selection-aware refresh
|
|
19
|
+
|
|
20
|
+
`meta()` takes an optional argument. A connector that ignores it keeps working exactly as it
|
|
21
|
+
always has: the platform filters the full result to whatever the connection selected. Declare
|
|
22
|
+
the parameter when describing every object is expensive — a hundred views parsed, every table
|
|
23
|
+
described — and you would rather describe only the ones the connection uses.
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
/** @param {SDK.MetaOptions} [options] @returns {Promise<SDK.ConnectorMeta>} */
|
|
27
|
+
async meta(options) {
|
|
28
|
+
const wanted = options?.refreshObjectIds ?? options?.selectedObjectIds; // undefined = everything
|
|
29
|
+
const tables = (await this.#listTables()).filter((t) => !wanted || wanted.includes(t.id));
|
|
30
|
+
return { identity: await this.#accountIdentity(), objects: await this.#describe(tables) };
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Field | Meaning |
|
|
35
|
+
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
|
36
|
+
| `log` | `SDK.Logger` for this call |
|
|
37
|
+
| `selectedObjectIds?` | the objects the connection has selected. `undefined` = all (a connector with no `discover()`, or an older platform) |
|
|
38
|
+
| `refreshObjectIds?` | the subset to (re)build — objects just added to the selection. `undefined` = all selected |
|
|
39
|
+
| `objectMeta(id)` | `Promise<ObjectMeta \| undefined>` — the cached metadata for any object, `undefined` when nothing is cached |
|
|
40
|
+
| `events()` | `Promise<ConnectorMeta["events"] \| undefined>` — the cached event types, for merging on a partial call |
|
|
41
|
+
| `discovery()` | `Promise<SDK.DiscoverResult \| undefined>` — the stored catalogue, including your own `data`. Fetched only when you call it |
|
|
42
|
+
|
|
43
|
+
Read it as `options?.selectedObjectIds`: `options` is `undefined` on an older platform, and
|
|
44
|
+
`selectedObjectIds` is `undefined` whenever the connection has no selection. An empty selection
|
|
45
|
+
is `[]`, not absent.
|
|
46
|
+
|
|
47
|
+
What the platform does with the result:
|
|
48
|
+
|
|
49
|
+
- Every returned object that is in the selection is upserted, **whether or not it was asked
|
|
50
|
+
for** — adding object A may legitimately change object B (a new relationship, say). Objects
|
|
51
|
+
outside the selection are ignored with a log line.
|
|
52
|
+
- A selected object your result omits keeps its cached entry — its fields, its user-edited
|
|
53
|
+
per-object settings and its refresh time are all left alone. Only a selection change evicts
|
|
54
|
+
an object, and the connector is not involved in that.
|
|
55
|
+
- Without a selection the old rule still stands: every cached object your result omits is
|
|
56
|
+
deleted. That is why a connector that ignores `options` is safe, and why a connector that
|
|
57
|
+
scopes its result MUST only do so from `options`.
|
|
58
|
+
- `identity` is returned on every call, partial or not, and must equal the pinned identity or
|
|
59
|
+
the refresh is rejected as a re-pointed connection (see [`identity`](#identity)). A connector
|
|
60
|
+
whose identity is scoped by a discovered setting appends it: `` `${user}|${host}|${database}` ``.
|
|
61
|
+
|
|
62
|
+
### `default` and `unavailable`
|
|
63
|
+
|
|
64
|
+
Two optional `ObjectMeta` properties exist for the selection:
|
|
65
|
+
|
|
66
|
+
- `default: true` — pre-tick this object in the picker on a new connection. It is also a
|
|
67
|
+
`DiscoveredObject` field, so the catalogue can carry it before any metadata exists (02).
|
|
68
|
+
- `unavailable: "deleted" | "unauthorized"` — a selected object you can no longer describe,
|
|
69
|
+
because it is gone from the tenant or the API user may no longer read it. Return the object
|
|
70
|
+
with the flag set instead of omitting it: the platform keeps the cached copy, shows the state
|
|
71
|
+
and leaves the decision to deselect to the user. Throwing, by contrast, fails the whole
|
|
72
|
+
refresh.
|
|
73
|
+
|
|
74
|
+
### Relationships reach objects the connection did not select
|
|
75
|
+
|
|
76
|
+
Keep emitting every relationship the object has — the selection is the user's, and trimming
|
|
77
|
+
metadata to match it hides the traversal from the sync designer. A `relatedFilter` query against
|
|
78
|
+
an object that is not selected fails with `InvalidObjectId`, which is the honest outcome; the
|
|
79
|
+
platform warns the user at deselect time, naming the syncs that would break.
|
|
80
|
+
|
|
18
81
|
## `ObjectMeta` — the canonical shape
|
|
19
82
|
|
|
20
83
|
```js
|
|
@@ -50,7 +113,9 @@ async meta() {
|
|
|
50
113
|
|
|
51
114
|
Strive to align the `queryFields` and `upsertFields` shape (same schema). The `deleteFields` should typically contain a single row key field.
|
|
52
115
|
|
|
53
|
-
Other fields: `settingsMeta`/`settingsValues` (per-object settings), `order` (sort)
|
|
116
|
+
Other fields: `settingsMeta`/`settingsValues` (per-object settings), `order` (sort), and
|
|
117
|
+
`default`/`unavailable` for connections that select their objects
|
|
118
|
+
([above](#default-and-unavailable)). Omit flags
|
|
54
119
|
rather than setting them `false` — `undefined` and `false` both mean "unsupported", and omitted
|
|
55
120
|
reads cleaner.
|
|
56
121
|
|
|
@@ -29,6 +29,15 @@ events: {
|
|
|
29
29
|
|
|
30
30
|
Note that the actual webhook UID varues are system generated, you do not manually specify them here.
|
|
31
31
|
|
|
32
|
+
**Partial metadata refreshes leave events alone.** When a connection selects its objects, the
|
|
33
|
+
platform may call `meta()` for only some of them (`options.refreshObjectIds`, see
|
|
34
|
+
[04-meta-objects-fields.md](./04-meta-objects-fields.md#metaoptions--selection-aware-refresh)).
|
|
35
|
+
Omitting `events` from such a result keeps the cached event types and their identity — the right
|
|
36
|
+
default, since event types rarely depend on which objects are selected. Return `events` only when
|
|
37
|
+
you mean to replace the whole set; if you need to add to it on a partial call, merge over what is
|
|
38
|
+
already cached with `await options.events()`. Per-connector static webhook modes are unaffected:
|
|
39
|
+
`parseEvents` still runs statically and never triggers discovery.
|
|
40
|
+
|
|
32
41
|
## `parseEvents(options)` → `{ events: SDK.ParsedEvent[] }`
|
|
33
42
|
|
|
34
43
|
The execution context follows the `webhooks_provider.mode`:
|
package/docs/09-testing.md
CHANGED
|
@@ -244,7 +244,8 @@ The harness always executes platform-side. What each entry point actually runs:
|
|
|
244
244
|
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
245
245
|
| `sm test` | The FULL suite — `connection.test()` + `metaRefresh()` + every object feature above. Identical to the web UI's connector test. |
|
|
246
246
|
| `sm test --only connection` | `connection.test()` only — credentials check, nothing else. The harness has NOT run. |
|
|
247
|
-
| `sm test --only meta` | `connection.metaRefresh()` only.
|
|
247
|
+
| `sm test --only meta` | `connection.metaRefresh()` only — for a connection with a selection, that is `meta()` over the selected objects. |
|
|
248
|
+
| `sm test --only discover` | `connection.discover()` only (two-phase connectors) — reports the catalogue size, which settings it returned overrides for, and how long it took, then refreshes the selection and checks every selected object is in the catalogue. |
|
|
248
249
|
| `sm test --only query` | Every object's plain list query only — no ids/checkpoint/match/related, no writes. |
|
|
249
250
|
| Web UI (connector → test results → run) | Same engine, feature selection via the run dialog. |
|
|
250
251
|
|
|
@@ -262,7 +263,16 @@ names the missing one. The dev loop around it: `sm validate` (workspace structur
|
|
|
262
263
|
type checking against the SDK types, `sm push`, then `sm test`. Keep `test()` cheap and
|
|
263
264
|
`meta()` deterministic so the suite stays fast.
|
|
264
265
|
|
|
265
|
-
|
|
266
|
+
Three workspace facts that bite here:
|
|
267
|
+
|
|
268
|
+
- **A two-phase test connection must SELECT every object the test module covers.** On a
|
|
269
|
+
connector with `discover()` the connection caches only its selected objects, so
|
|
270
|
+
`sm connector features` generates from those alone and the suite can only test those — an
|
|
271
|
+
object in `mod Test.mjs` that nobody selected reports "not present on connection". The CLI
|
|
272
|
+
says which ids are missing rather than skipping them quietly; fix it in the connection's
|
|
273
|
+
object picker, not in the test file. `sm connector features` also warns about catalogue
|
|
274
|
+
entries the connection did not select, which is the reminder to widen the test connection
|
|
275
|
+
when `meta()` grows a new object.
|
|
266
276
|
|
|
267
277
|
- `mod ObjectTestFeatures.mjs` is **generated** — regenerate it with
|
|
268
278
|
`sm connector features` (or the web UI's feature-generate action) after `meta()` changes;
|
|
@@ -81,6 +81,38 @@
|
|
|
81
81
|
typically surfaces as a composite-key parse error or "row not found", nowhere near
|
|
82
82
|
`meta()`. Direction rules: [04](./04-meta-objects-fields.md#relationships),
|
|
83
83
|
[05](./05-query.md#relatedfilter).
|
|
84
|
+
17. **Returning a partial catalogue from `discover()` is worse than failing.** When some
|
|
85
|
+
catalogue call errors or a permission is missing, THROW. A short list looks like a tenant
|
|
86
|
+
that lost objects: the picker offers to deselect them and the user acts on it. Same for an
|
|
87
|
+
empty catalogue — the platform treats it as a failure. Partial success is expressed by
|
|
88
|
+
omitting a setting's override (and saying why in that setting's `description`), never by
|
|
89
|
+
trimming `objects` ([02](./02-lifecycle-and-shape.md#discover--the-tenants-catalogue-and-settings-optional)).
|
|
90
|
+
18. **`meta(options)` must work when `options` is `undefined`.** `options` is absent on an older
|
|
91
|
+
platform, and `options.selectedObjectIds` is absent whenever the connection has no
|
|
92
|
+
selection — always read it as `options?.selectedObjectIds`. A `meta()` that indexes
|
|
93
|
+
`options` directly fails the very first refresh of every connection that has no selection
|
|
94
|
+
yet, which includes every existing connection on the day you add the parameter. And scope
|
|
95
|
+
the result ONLY from what `options` says: without a selection the platform still deletes
|
|
96
|
+
every cached object the result omits
|
|
97
|
+
([04](./04-meta-objects-fields.md#metaoptions--selection-aware-refresh)).
|
|
98
|
+
19. **Throwing in `init()` for a setting that is not `prerequisite`** breaks two-phase setup:
|
|
99
|
+
the first `init()` of a connection's life runs with only the prerequisite settings entered,
|
|
100
|
+
so a connector that demands the rest up front fails discovery before the user can be shown
|
|
101
|
+
the values ([02](./02-lifecycle-and-shape.md)). Validate them where they are used.
|
|
102
|
+
20. **An unmarked version whose core cannot discover fails loudly in the wizard.** That is the
|
|
103
|
+
design: with `"discovery": true` every version is two-phase unless its option says
|
|
104
|
+
`"discovery": false`, so a new connection on an unmarked legacy version hits the core's
|
|
105
|
+
`NotImplimented` throw on Continue (shown inline, with Retry) instead of being silently
|
|
106
|
+
downgraded. Mark the option
|
|
107
|
+
([01](./01-workspace-and-meta-json.md#a-version-that-cannot-discover-discovery-false-on-its-option)).
|
|
108
|
+
Marking the setting's default option is a `sm validate` error: every new connection would
|
|
109
|
+
be single-phase.
|
|
110
|
+
21. **Reading another object's metadata through `objectMeta()` works on legacy connections and
|
|
111
|
+
fails on selected ones.** Keep an object self-contained: put what its own query/upsert needs
|
|
112
|
+
on its `data`; treat any other object's metadata as optional and fail with a clear
|
|
113
|
+
`ConnectorError` when it is absent. A two-phase connection caches only the objects the user
|
|
114
|
+
selected, so a companion object the parent reads its association ids or aliases from may
|
|
115
|
+
simply not be there.
|
|
84
116
|
|
|
85
117
|
## Useful utilities you might otherwise reimplement
|
|
86
118
|
|
|
@@ -572,6 +572,86 @@ function parseSalesOrderKey(compositeKey) {
|
|
|
572
572
|
}
|
|
573
573
|
```
|
|
574
574
|
|
|
575
|
+
## If Acme were two-phase: `discover()` and a scoped `meta()`
|
|
576
|
+
|
|
577
|
+
Acme as written is single-phase, which is right for it: two settings, three objects, every
|
|
578
|
+
account the same. Suppose instead that an Acme account exposes its own set of business units and
|
|
579
|
+
its own custom objects — the values the user has to type today (the business unit) and the
|
|
580
|
+
objects worth caching are then tenant-specific, which is exactly what `discover()` is for
|
|
581
|
+
([02](./02-lifecycle-and-shape.md#discover--the-tenants-catalogue-and-settings-optional)).
|
|
582
|
+
Three deltas, nothing else changes.
|
|
583
|
+
|
|
584
|
+
**Sidecar** — turn the flow on, mark what discovery needs, and add the setting it fills in:
|
|
585
|
+
|
|
586
|
+
```json
|
|
587
|
+
"connector_metadata": {
|
|
588
|
+
"categories": ["sales"],
|
|
589
|
+
"agent_mode": "never",
|
|
590
|
+
"discovery": true,
|
|
591
|
+
"settings": {
|
|
592
|
+
"api_key": { "order": "00", "name": "API Key", "type": "string", "control": "singlelinetext", "secret": true, "mandatory": true, "prerequisite": true },
|
|
593
|
+
"businessUnit": { "order": "01", "name": "Business Unit", "type": "string", "control": "select", "affects_meta": true,
|
|
594
|
+
"description": "Sales orders are scoped to this unit; the options come from your account." },
|
|
595
|
+
"debug": { "order": "02", "name": "Debug", "advanced": true, "type": "boolean", "control": "singlelinetext" }
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
**`discover()`** — catalogue endpoints only, no per-object sampling:
|
|
601
|
+
|
|
602
|
+
```js
|
|
603
|
+
/** Tenant catalogue + the options for businessUnit (02). @param {SDK.DiscoverOptions} options @returns {Promise<SDK.DiscoverResult>} */
|
|
604
|
+
async discover(options) {
|
|
605
|
+
const [units, catalogue] = await Promise.all([
|
|
606
|
+
this.#call({ method: "GET", url: "businessunits" }),
|
|
607
|
+
this.#call({ method: "GET", url: "objects" }),
|
|
608
|
+
]);
|
|
609
|
+
options.log.info(`Acme account exposes ${catalogue.items.length} object(s)`);
|
|
610
|
+
return {
|
|
611
|
+
settingsMeta: {
|
|
612
|
+
businessUnit: {
|
|
613
|
+
options: units.items.map((/** @type any */ u) => ({ id: String(u.id), name: u.name })),
|
|
614
|
+
mandatory: true,
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
// no field detail here - just the ids meta() will be asked to describe.
|
|
618
|
+
// 'default' pre-ticks the three built-ins in the picker on a new connection
|
|
619
|
+
objects: catalogue.items.map((/** @type any */ o) => ({
|
|
620
|
+
id: o.id, name: o.label, isCustom: o.custom === true, default: o.custom !== true,
|
|
621
|
+
})),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
**`meta(options)`** — scope the expensive work, not just the result. Acme's cost is one
|
|
627
|
+
`/fields` call per object, so the filter goes in front of those, and the `objects` array is
|
|
628
|
+
filtered on the way out:
|
|
629
|
+
|
|
630
|
+
```js
|
|
631
|
+
/** @param {SDK.MetaOptions} [options] @returns {Promise<SDK.ConnectorMeta>} */
|
|
632
|
+
async meta(options) {
|
|
633
|
+
const me = await this.#call({ method: "GET", url: "me" });
|
|
634
|
+
// undefined = no selection (legacy connection, or an older platform): describe everything
|
|
635
|
+
const scope = options?.refreshObjectIds ?? options?.selectedObjectIds;
|
|
636
|
+
const wanted = (/** @type string */ id) => !scope || scope.includes(id);
|
|
637
|
+
const [companyCustom, contactCustom] = await Promise.all([
|
|
638
|
+
wanted("companies") ? this.#customFields("companies") : [],
|
|
639
|
+
wanted("contacts") ? this.#customFields("contacts") : [],
|
|
640
|
+
]);
|
|
641
|
+
|
|
642
|
+
/** @type {SDK.ObjectMeta[]} */
|
|
643
|
+
const objects = [ /* ...unchanged... */ ];
|
|
644
|
+
|
|
645
|
+
// identity is returned on EVERY call, partial or not, and must still match the pin (04)
|
|
646
|
+
return { identity: `${me.account_name}|${me.email}`, objects: objects.filter((o) => wanted(o.id)) };
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
Note what does NOT change: the relationships stay on every object even when the other end is
|
|
651
|
+
unselected, `test()` still runs before both `discover()` and `meta()`, and `init()` still
|
|
652
|
+
throws for a missing `api_key` — it is `prerequisite`, so it is always there — but it must
|
|
653
|
+
not start throwing for `businessUnit`, which is only entered after discovery.
|
|
654
|
+
|
|
575
655
|
## What to notice (the parts assistants most often get wrong)
|
|
576
656
|
|
|
577
657
|
1. **One `#call` wrapper** owns base URL, auth and debug logging; nothing else touches HTTP.
|
|
@@ -96,10 +96,28 @@ Databases and on-premise systems are a connector shape of their own:
|
|
|
96
96
|
- **Escape SQL values** exactly as [10-style-and-pitfalls.md](./10-style-and-pitfalls.md)
|
|
97
97
|
demands for any string-interpolated query language — or better, use the driver's
|
|
98
98
|
parameterized queries.
|
|
99
|
+
- **`discover()` earns its keep here** — the database name, the schema and the table list are
|
|
100
|
+
exactly the settings a static sidecar cannot know. Set `"discovery": true`, mark the
|
|
101
|
+
connection settings (host, credentials, database) `prerequisite`, and return the schemas as
|
|
102
|
+
`settingsMeta` options and the tables/views as the catalogue from a handful of catalog
|
|
103
|
+
queries ([01](./01-workspace-and-meta-json.md#two-phase-setup-discovery-and-prerequisite),
|
|
104
|
+
[02](./02-lifecycle-and-shape.md#discover--the-tenants-catalogue-and-settings-optional)).
|
|
105
|
+
`meta({ selectedObjectIds })` then describes only the tables the connection uses instead of
|
|
106
|
+
every table in the database, which is the difference between a refresh of seconds and one of
|
|
107
|
+
minutes.
|
|
99
108
|
|
|
100
109
|
Note: on-premise (`agent_mode: "always"`) connectors do not support `upsertClean`
|
|
101
110
|
([06-upsert-delete.md](./06-upsert-delete.md)).
|
|
102
111
|
|
|
112
|
+
Everything in this guide applies unchanged on the agent: `discover()` and `meta(options)` are
|
|
113
|
+
plain calls with JSON in and JSON out, and the host-provided accessors on `MetaOptions`
|
|
114
|
+
(`objectMeta`, `events`, `discovery`) are proxied across the boundary the way
|
|
115
|
+
`QueryOptions.objectMeta` already is — `await options.discovery()` costs a round trip, so call
|
|
116
|
+
it once and keep the result, and don't call it at all if you don't need it. The one thing that
|
|
117
|
+
does differ is version skew: an agent runtime too old to honour `MetaOptions` fails the refresh
|
|
118
|
+
with "agent upgrade required" rather than quietly collecting every object, so a customer on a
|
|
119
|
+
stale agent sees a clear error instead of a slow one.
|
|
120
|
+
|
|
103
121
|
## XML and SOAP systems
|
|
104
122
|
|
|
105
123
|
- SOAP/WSDL: the `soap` npm package, with the WSDL files bundled as workspace resource files
|
|
@@ -532,6 +532,20 @@ export default class TestFeatureProvider {
|
|
|
532
532
|
}
|
|
533
533
|
```
|
|
534
534
|
|
|
535
|
+
## If Acme were two-phase: the test connection's selection
|
|
536
|
+
|
|
537
|
+
Nothing in this pair changes when a connector gains `discover()`
|
|
538
|
+
([12](./12-example-connector.md#if-acme-were-two-phase-discover-and-a-scoped-meta)) — but the
|
|
539
|
+
**test connection** does. A connection that selects its objects caches only those, so
|
|
540
|
+
`objectIds()` above returns three ids only if the sandbox connection has `companies`,
|
|
541
|
+
`contacts` AND `salesorders` ticked in its object picker. Miss one and it reports
|
|
542
|
+
`Failed: Object from test spec not present on connection`, and `sm connector features`
|
|
543
|
+
regenerates `mod ObjectTestFeatures.mjs` without it — it warns about the catalogue entries the
|
|
544
|
+
connection did not select, so read that output rather than assuming a shrunken generated file is
|
|
545
|
+
the connector's doing. Widen the test connection's selection rather than scoping the object out;
|
|
546
|
+
`sm test --only discover` ([09](./09-testing.md#running-the-suite)) checks the catalogue against
|
|
547
|
+
the selection and prints what is missing.
|
|
548
|
+
|
|
535
549
|
## What to notice (the parts assistants most often get wrong)
|
|
536
550
|
|
|
537
551
|
1. **Every created record is prefixed** (`sm_test`) and `before()`/`after()` sweep the
|
|
@@ -59,10 +59,19 @@ Sections, in this order (omit ones that don't apply):
|
|
|
59
59
|
doc plus a one-line click-path ("Configure → Dev Center → API Credential Management →
|
|
60
60
|
Create New API Key").
|
|
61
61
|
3. **Connection settings** — one bullet per setting, `*(sensitive)*` on secrets, defaults and
|
|
62
|
-
allowed values stated; `### Advanced settings` for the advanced group.
|
|
62
|
+
allowed values stated; `### Advanced settings` for the advanced group. On a two-phase
|
|
63
|
+
connector (`connector_metadata.discovery`, see
|
|
64
|
+
[01-workspace-and-meta-json.md](./01-workspace-and-meta-json.md#two-phase-setup-discovery-and-prerequisite))
|
|
65
|
+
split the list the way the form does: the `prerequisite` settings the customer enters to
|
|
66
|
+
reach their tenant, then the settings the connector fills in for them — say where the
|
|
67
|
+
options come from ("the databases your login can see") rather than listing values that
|
|
68
|
+
differ per account, and name the ones that mark metadata stale when changed.
|
|
63
69
|
4. **Supported objects** — link to the vendor's API reference, then the table
|
|
64
70
|
`| Name | Operations ^1 | Description |` with the `^1 Q=Query, U=Upsert, D=Delete`
|
|
65
|
-
footnote. Note that the live per-connection list is in the connection UI.
|
|
71
|
+
footnote. Note that the live per-connection list is in the connection UI. On a two-phase
|
|
72
|
+
connector the table is the catalogue the connector CAN describe; the connection caches only
|
|
73
|
+
the objects the customer selected, so also say how to add one later (the object picker's
|
|
74
|
+
"check for new objects" re-runs discovery) and that metadata is refreshed for the selection.
|
|
66
75
|
5. **Capabilities & limits** — rate limits, timestamp formats, API quirks, insert-only
|
|
67
76
|
objects, pagination oddities. Start with the standard callout:
|
|
68
77
|
> In a Sync, source filtering and collecting only changed records (incremental/checkpoint)
|
|
@@ -21,7 +21,7 @@ queryable.
|
|
|
21
21
|
| File | Read when you are... |
|
|
22
22
|
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
23
23
|
| [01-workspace-and-meta-json.md](./01-workspace-and-meta-json.md) | adding files, wiring modules, editing any `*.meta.json` sidecar, declaring settings/OAuth/webhooks/npm packages |
|
|
24
|
-
| [02-lifecycle-and-shape.md](./02-lifecycle-and-shape.md) | starting a connector; implementing `init`/`test`/`meta`; anything about `InitArgs` |
|
|
24
|
+
| [02-lifecycle-and-shape.md](./02-lifecycle-and-shape.md) | starting a connector; implementing `init`/`test`/`discover`/`meta`; anything about `InitArgs` |
|
|
25
25
|
| [03-http-auth-ratelimit.md](./03-http-auth-ratelimit.md) | making HTTP calls, rate limiting, retries, API keys, basic auth, OAuth2 |
|
|
26
26
|
| [04-meta-objects-fields.md](./04-meta-objects-fields.md) | writing `meta()` — objects, fields, flags, keys, relationships, filters |
|
|
27
27
|
| [05-query.md](./05-query.md) | implementing `query()` — paging, checkpoints, ids/match/related filters, `Row` |
|
|
@@ -119,6 +119,8 @@ Implementing more methods unlocks more platform capability:
|
|
|
119
119
|
| You implement | The platform can |
|
|
120
120
|
| --------------------------------------- | ------------------------------------------------------------------- |
|
|
121
121
|
| `test()` + `meta()` (**required**) | validate connections, show objects/fields in the UI |
|
|
122
|
+
| `discover()` (+ `"discovery": true`) | two-phase connection setup: offer the tenant's real settings options and let the customer pick the objects to cache (01, 02) |
|
|
123
|
+
| `meta(options)` (the optional argument) | refresh metadata for only the selected objects instead of every object, every time (04) |
|
|
122
124
|
| `query()` | read data — lists, differential syncs, lookups |
|
|
123
125
|
| `upsert()` / `delete()` | write data |
|
|
124
126
|
| `upsertClean()` | pre-validate + change-detect rows before writing (`canUpsertClean`) |
|
|
@@ -155,6 +157,9 @@ Implementing more methods unlocks more platform capability:
|
|
|
155
157
|
3. **Make `test()` real**: cheapest authenticated call the API offers (02).
|
|
156
158
|
4. **Describe objects** in `meta()` (04). Start with one object and its `isKey` field; grow from
|
|
157
159
|
there. `SDK.utilities.fieldsFromJSON(sample)` can draft field lists from a sample response.
|
|
160
|
+
If the settings or the object list depend on the tenant — a database, a company, a
|
|
161
|
+
subsidiary, a hundred views of which a connection uses six — add `discover()` and
|
|
162
|
+
`"discovery": true` (02, 01) and take `meta(options)`'s selection into account (04).
|
|
158
163
|
5. **Implement `query()` capabilities in order**: list → ids → checkpoint → match → related
|
|
159
164
|
(05). For EVERY object aim for all five — syncs are built from them. When the API is
|
|
160
165
|
awkward (parent-scoped paths, no changed-since cursor), work the patterns in
|
|
@@ -167,7 +172,8 @@ Implementing more methods unlocks more platform capability:
|
|
|
167
172
|
8. **Test harness**: add `mod Test.mjs` (+ generated `mod ObjectTestFeatures.mjs`) so the
|
|
168
173
|
platform's connector test suite can exercise your objects (09; worked example: 15).
|
|
169
174
|
9. **Run it**: `sm test` = the full suite (identical to the web UI's connector test — including
|
|
170
|
-
write features, so use a sandbox connection); `--only connection|meta|query`
|
|
175
|
+
write features, so use a sandbox connection); `--only connection|meta|query|discover`
|
|
176
|
+
narrows. A
|
|
171
177
|
passing `--only` run is NOT suite coverage (09).
|
|
172
178
|
10. **Document it**: write the customer help pages IN the connector —
|
|
173
179
|
`Connectors/<Name>/help/index.md` + `help/scripting.md` (sidecar type `"markdown"`),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncmatters/connector-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
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": "2d30d816e8bb71a9fe2d9390945f4aa62c8eff1f4f86e69ec8f965e8a42cd6c8",
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@types/node": "*",
|
|
18
|
-
"@syncmatters/script-api": "^1.0.
|
|
18
|
+
"@syncmatters/script-api": "^1.0.19"
|
|
19
19
|
}
|
|
20
20
|
}
|