@syncmatters/connector-sdk 1.0.16 → 1.0.17

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.
@@ -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
+ | `affectsMeta: 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,46 @@ 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", "affectsMeta": 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 `affectsMeta: 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 (or with no `discover()` on the entry class,
179
+ and vice versa).
180
+
128
181
  ### `oauth2_provider`
129
182
 
130
183
  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,6 @@ 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.
@@ -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). Omit flags
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`:
@@ -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
- Two workspace facts that bite here:
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,24 @@
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.
84
102
 
85
103
  ## Useful utilities you might otherwise reimplement
86
104
 
@@ -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", "affectsMeta": 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` narrows. A
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.16",
3
+ "version": "1.0.17",
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": "07fac99bb193fe5a9bff764336c8de1815d3729720589c2d893dd45a987e6eab",
15
+ "typesContentHash": "314515b24178a1bb092c58fdf12782eb777f8ad583d83c9837536be02c751490",
16
16
  "dependencies": {
17
17
  "@types/node": "*",
18
18
  "@syncmatters/script-api": "^1.0.17"