@syncmatters/connector-sdk 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,23 @@
1
1
  # @syncmatters/connector-sdk
2
2
 
3
- TypeScript type definitions for the **SyncMatters Connector SDK**.
3
+ TypeScript type definitions **and the connector authoring guide** for the **SyncMatters
4
+ Connector SDK**.
4
5
 
5
6
  This is a **types-only** package: it provides IntelliSense and type checking when developing
6
7
  SyncMatters connectors locally (for example in VS Code or Cursor). Connector code executes on the
7
8
  SyncMatters platform, which provides the runtime implementation of this SDK.
8
9
 
10
+ ## Authoring guide (humans and AI assistants)
11
+
12
+ The complete guide to building SyncMatters connectors ships inside this package, version-locked
13
+ to the types:
14
+
15
+ - [`docs/connector-authoring.md`](./docs/connector-authoring.md) — start here (cheat sheet +
16
+ index); focused topic files sit alongside it.
17
+
18
+ If you are an AI assistant working in a connector workspace: **read that file before writing
19
+ connector code.** Workspaces created by the `sm` CLI also contain an `AGENTS.md` pointing here.
20
+
9
21
  ## Usage
10
22
 
11
23
  ```js
@@ -28,11 +40,6 @@ export default class MyConnector {
28
40
  Add `"checkJs": true` to your `jsconfig.json` to activate the JSDoc type annotations used in
29
41
  connector `.mjs` files.
30
42
 
31
- ## Legacy package name
32
-
33
- `@ihq/connector-sdk` is the legacy name of this package (IntegrateHQ is now SyncMatters).
34
- Existing connectors importing the legacy name continue to work on-platform.
35
-
36
43
  ## Support
37
44
 
38
45
  - <https://syncmatters.com>
@@ -0,0 +1,135 @@
1
+ # Workspace anatomy and the `*.meta.json` sidecar
2
+
3
+ ## Two parallel trees
4
+
5
+ ```
6
+ files/Connectors/<ConnectorName>/ # source code (what runs on the platform)
7
+ meta/Connectors/<ConnectorName>/ # one *.meta.json sidecar per source file
8
+ ```
9
+
10
+ - **Entry file**: `<ConnectorName>.mjs`, default-exporting the connector class. Its sidecar has
11
+ `"type": "connector"` and carries the `connector_metadata` block.
12
+ - **Module files**: additional `.mjs` files in the same folder, imported by the entry (or by
13
+ other modules). Sidecar `"type": "module"`. Modules **must** be placed in the same folder as the file that imports the module.
14
+ - **Resource files**: e.g. a bundled OpenAPI spec (`acme-api.yaml`). Sidecar type `"yaml"` /
15
+ `"json"`. Load big specs lazily (dynamic `import()`), not at module top level.
16
+ - **Test harness files**: `mod Test.mjs` (sidecar type `"connectortest"`) and the generated
17
+ `mod ObjectTestFeatures.mjs` (type `"module"`). The `"mod "` prefix (with a space) is the
18
+ convention. See [09-testing.md](./09-testing.md).
19
+
20
+ Naming: entry and class-per-file modules are PascalCase (`Acme.mjs`); shared-logic modules are
21
+ kebab-case (`acme-v3-query.mjs`). Version-split connectors use a `-v<N>` suffix per version core.
22
+
23
+ ## Module wiring: `module_script_file_paths`
24
+
25
+ The platform stages exactly the files listed in the sidecars — an import that isn't listed will
26
+ not exist at runtime.
27
+
28
+ - The **entry** file's sidecar lists **every file of the connector, transitively** (all modules
29
+ and resources).
30
+ - Each **module**'s sidecar lists only **its own direct imports**.
31
+
32
+ ```json
33
+ // meta/Connectors/Acme/Acme.mjs.meta.json (entry - lists everything)
34
+ "module_script_file_paths": [
35
+ "Connectors/Acme/acme-core.mjs",
36
+ "Connectors/Acme/acme-query.mjs",
37
+ "Connectors/Acme/acme-api.yaml"
38
+ ]
39
+ ```
40
+
41
+ Import style in code: static `import` for your main module; dynamic `await import("./acme-v3")`
42
+ for version dispatch so only the selected version's code is loaded:
43
+
44
+ ```js
45
+ const version = args.settings.get("connectorVersion") || "1";
46
+ switch (version) {
47
+ case "3": {
48
+ const V3 = await import("./acme-v3");
49
+ this.#core = new V3.AcmeV3();
50
+ break;
51
+ }
52
+ default:
53
+ throw new SDK.ConnectorError(
54
+ SDK.ErrorCode.InvalidOptionValue,
55
+ `Connector version '${version}' not recognized`,
56
+ );
57
+ }
58
+ await this.#core.init(args);
59
+ ```
60
+
61
+ ## Sidecar top-level fields
62
+
63
+ | Field | Values / meaning |
64
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `name` | the file name, e.g. `"Acme.mjs"` |
66
+ | `type` | `"connector"` (entry) · `"module"` · `"connectortest"` · `"json"` · `"yaml"` |
67
+ | `runnable` | `false` for connector files (they are invoked by the platform, not run directly) |
68
+ | `engine` | runtime engine version int; leave as scaffolded unless told otherwise |
69
+ | `resource_size` | `""` or `"medium"` — runtime resource class hint |
70
+ | `cpu_architecture` | normally `""` |
71
+ | `module_script_file_paths` | see above |
72
+ | `npm_packages` | name→version map of npm packages this file needs at runtime, e.g. `{"box-node-sdk": "2.4.0"}`. The `sm` CLI unions these into the workspace `package.json` so IntelliSense sees them too. Prefer the SDK's built-in http/utilities over adding packages. |
73
+ | `connector_metadata` | entry sidecar only — everything below |
74
+
75
+ ## `connector_metadata` reference
76
+
77
+ | Field | Meaning |
78
+ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
79
+ | `categories` | discovery taxonomy, e.g. `["sales"]`, `["file"]`, `["marketing","sales"]` |
80
+ | `agent_mode` | `"never"` = runs in cloud · `"always"` = runs on the customer's on-premise agent · `"optional"` = per-connection (agent used when the connection selects one) |
81
+ | `oauth2_mode` | `"connector"` = one shared OAuth app for every account · `"connection"` = each connection supplies its own client id/secret. Omit for non-OAuth connectors. |
82
+ | `oauth2_provider` | the OAuth provider definition (below) |
83
+ | `webhooks_provider` | `{"mode": "custom.perconnection"}` (each connection registers its own webhook) or `{"mode": "custom.perconnector", "url": ..., "webhook_uid": ...}` (one shared endpoint) |
84
+ | `settings` | the connection-settings form (below) |
85
+ | `icon_url` | connector logo URL |
86
+ | `connector_name`, `type_id`, `application_name`, `application_icon_url` | platform/branding ids — usually left as scaffolded |
87
+
88
+ ### `settings`
89
+
90
+ Keyed by setting id; the values arrive in `init()` via `args.settings.get("<id>")`.
91
+
92
+ ```json
93
+ "settings": {
94
+ "api_key": { "order": "00", "name": "API Key", "type": "string", "control": "singlelinetext", "secret": true },
95
+ "region": { "order": "01", "name": "Region", "type": "string", "control": "select",
96
+ "options": [ { "id": "us", "name": "US", "order": "00" }, { "id": "eu", "name": "EU", "order": "01" } ],
97
+ "default": { "type": "string", "expression": "us" } },
98
+ "connectorVersion": { "order": "02", "name": "Connector Version", "defines_version": true,
99
+ "type": "string", "control": "select",
100
+ "options": [ { "id": "1", "name": "1 (Superseded)", "order": "00" }, { "id": "2", "name": "2 (Recommended)", "order": "01" } ],
101
+ "default": { "type": "string", "expression": "2" } },
102
+ "debug": { "order": "03", "name": "Debug", "advanced": true, "type": "boolean", "control": "singlelinetext" }
103
+ }
104
+ ```
105
+
106
+ Per-setting fields: `order` (two-digit string, display order), `name`, `type` (`string` |
107
+ `boolean`), `control` (`singlelinetext` | `select`), `secret: true` (masked + encrypted),
108
+ `advanced: true` (collapsed by default), `hidden: true`, `readonly: true`, `placeholder`,
109
+ `options` (for `select`), `default: { "type": "string", "expression": "<value>" }`, and
110
+ `defines_version: true` on the setting whose value picks a version core.
111
+
112
+ Conventions worth copying: a secret `api_key`/`clientId`+`clientSecret` pair for credentials; an
113
+ advanced boolean `debug` setting that gates verbose request/response logging; a
114
+ `connectorVersion` select with `defines_version` when you need breaking revisions to coexist.
115
+
116
+ ### `oauth2_provider`
117
+
118
+ Defines the OAuth2 dance the platform performs on the connector's behalf (your code only ever
119
+ sees the resulting token via `args.oauth2Provider` — see
120
+ [03-http-auth-ratelimit.md](./03-http-auth-ratelimit.md)). Fields seen in practice:
121
+ `provider_id`, `grant_type` (`"code"`), `default_scopes`, `client_id`, `has_client_secret`,
122
+ `redirect_uri` (the platform's OAuth callback), `authorize_uri`, `authorize_params`,
123
+ `token_uri`, `refresh_uri`, `introspect_uri`, `authorized_domains`. URIs may template settings,
124
+ e.g. `https://{acme_domain}/oauth2/authorize`. An optional `scope_groups` array drives the
125
+ consent UI: items carry `label` plus `read`/`write`/`full` scope strings and optional
126
+ `read_mandatory: true`.
127
+
128
+ ## Do not edit
129
+
130
+ - `.sm/` (CLI state), and the CLI-generated `package.json` / `jsconfig.json` / `AGENTS.md`
131
+ managed block at the workspace root. They are editor tooling, never pushed.
132
+ - Generated `mod ObjectTestFeatures.mjs` files (regenerate instead).
133
+ - System-connector stubs: if a connector's settings include a hidden
134
+ `systemFlag*` boolean, the real implementation is embedded in the platform runtime and the
135
+ `.mjs` stub is deliberately a no-op — don't implement it.
@@ -0,0 +1,116 @@
1
+ # Connector lifecycle and class shape
2
+
3
+ ## The class
4
+
5
+ The entry file default-exports a class. Only `test()` and `meta()` are required; every other
6
+ method is optional and its presence unlocks a capability (see the table in
7
+ [connector-authoring.md](./connector-authoring.md)).
8
+
9
+ ```js
10
+ import SDK from "@syncmatters/connector-sdk";
11
+
12
+ export default class Acme {
13
+ /** @type {SDK.Logger | undefined} */ #log;
14
+
15
+ /** @param {SDK.InitArgs} args @returns {Promise<void>} */
16
+ async init(args) {
17
+ SDK.verifyType(this);
18
+ this.#log = args.log;
19
+ // read settings, build http client(s), wire OAuth refresh
20
+ }
21
+
22
+ /** @returns {Promise<SDK.TestResult>} */
23
+ async test() {
24
+ /* ... */
25
+ }
26
+
27
+ /** @returns {Promise<SDK.ConnectorMeta>} */
28
+ async meta() {
29
+ /* ... */
30
+ }
31
+ }
32
+ ```
33
+
34
+ `SDK.verifyType(this)` is a no-op whose only job is to make `checkJs` verify the class against
35
+ the `SDK.Connector` interface — always the first line of `init()`.
36
+
37
+ ## `init(args)` — called exactly once per connector instance
38
+
39
+ `SDK.InitArgs` fields:
40
+
41
+ | Field | Type | Use |
42
+ | ----------------------------- | --------------------- | ---------------------------------------------------------------------------------------------- |
43
+ | `id` | `string` | unique id of this connection instance |
44
+ | `name` | `string` | display name of the connection |
45
+ | `identity?` | `string` | system-specific identity if known (e.g. signed-in email) |
46
+ | `mode` | `SDK.ScriptType` | why the instance was created — see below |
47
+ | `log` | `SDK.Logger` | `info(message, props?)` / `error(messageOrError, props?)` to the platform event store |
48
+ | `sourcePath` | `string` | directory holding the connector's staged files |
49
+ | `settings` | `Map<string, any>` | the connection's configured settings (`settings.get("api_key")`) |
50
+ | `oauth2Provider?` | `OAuth2TokenProvider` | present when the connection has a managed OAuth2 token — see [03](./03-http-auth-ratelimit.md) |
51
+ | `webhookUrl?` / `webhookUid?` | `string` | this connection's webhook endpoint / its unique id — see [07](./07-events-webhooks.md) |
52
+
53
+ `init()` should: validate required settings (throw
54
+ `SDK.ConnectorError(SDK.ErrorCode.InvalidOptionValue, ...)` naming the missing setting), stash
55
+ what you need in `#fields`, build the HTTP client(s), and register the OAuth refresh callback.
56
+ Do **not** make network calls in `init()` unless unavoidable — `test()` is where connectivity
57
+ is proven.
58
+
59
+ ### `mode` (`SDK.ScriptType`) — early-out for static modes
60
+
61
+ Values include `"integration"`, `"scriptFile"`, `"parseEvents"`, `"verifyEventIdentity"`,
62
+ `"agentConnection"`, `"syncGroup"`, `"connectorTest"`, `"connectorFeatureGenerate"`,
63
+ `"connectionSemanticTypes"`, `"codeEmbedding"`.
64
+
65
+ Whether event parsing gets a real connection depends on the sidecar's
66
+ `webhooks_provider.mode` (see [07-events-webhooks.md](./07-events-webhooks.md)): with a
67
+ **per-connection** webhook the connector is initialized normally (settings available); with a
68
+ **per-connector** shared endpoint, `parseEvents` runs **statically** — `init()` is called with
69
+ `mode: "parseEvents"` and no settings/token are guaranteed. Shared-endpoint connectors with
70
+ heavy `init()` setup should return early for static modes:
71
+
72
+ ```js
73
+ const isStatic = args.mode === "parseEvents";
74
+ if (isStatic) return; // static parseEvents only needs the http client it builds itself
75
+ ```
76
+
77
+ ## `test()` — prove the credentials
78
+
79
+ Make the cheapest authenticated call the API offers. Catch, don't throw:
80
+
81
+ ```js
82
+ /** @returns {Promise<SDK.TestResult>} */
83
+ async test() {
84
+ try {
85
+ await this.#call({ method: "GET", url: "me" });
86
+ return { success: true };
87
+ } catch (err) {
88
+ return { success: false, message: err instanceof Error ? err.message : String(err), error: /** @type any */ (err) };
89
+ }
90
+ }
91
+ ```
92
+
93
+ `TestResult` = `{ success: boolean; message?: string; error?: CodedError }`.
94
+
95
+ ## `meta()` — see [04-meta-objects-fields.md](./04-meta-objects-fields.md)
96
+
97
+ Return `{ identity?, objects: ObjectMeta[], events? }`. `identity` should be a stable string for
98
+ the connected account (e.g. `` `${org.name}|${user.email}` ``) — the platform uses it to detect a
99
+ connection being re-pointed at a different account.
100
+
101
+ ## Optional lifecycle methods
102
+
103
+ | Method | Contract |
104
+ | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
105
+ | `syncSessionBegin(session)` / `syncSessionEnd(session)` | bracket a sync-group run; implement only if you need to bootstrap internal caching for the whole run |
106
+ | `dispose(options)` | destroy unmanaged resources at process exit — **best-effort only**, may not run on abnormal exit; never rely on it for correctness |
107
+ | `base()` | return the underlying third-party client instance for diagnostic scripts |
108
+ | `optimalBatchSize(options)` | recommend `{ size, batchType? }` per operation (`"upsert"`, `"idsFilter"`, ...); `batchType` is `"atomic"`, `"concurrent"` or `"splitInsertUpdateAtomic"` |
109
+
110
+ ## Multi-version connectors
111
+
112
+ When an API revision would break existing connections, add a `connectorVersion` select setting
113
+ (`defines_version: true`, see [01](./01-workspace-and-meta-json.md)) and split the
114
+ implementation into per-version core modules. The entry class reads the setting in `init()` and
115
+ 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.
@@ -0,0 +1,188 @@
1
+ # HTTP, auth and rate limiting
2
+
3
+ ## The one true HTTP idiom
4
+
5
+ Every request goes through an `SDK.utilities.httpClient` built in `init()` with a rate limiter.
6
+ Prefer this over `fetch`, `axios`, or `node:https` — the SDK client is what the platform monitors,
7
+ retries and rate-limits.
8
+
9
+ ```js
10
+ this.#http = SDK.utilities.httpClient({
11
+ limiter: SDK.utilities.rateLimiter({ type: "rolling", count: 10, ms: 1000 }), // 10 req/sec
12
+ maxRetry: 3,
13
+ retryBackoffMs: 2000,
14
+ defaultHeaders: { "Content-Type": "application/json" },
15
+ });
16
+ ```
17
+
18
+ `rateLimiter` types: `{ type: "rolling", count, ms }` (N requests per window) and
19
+ `{ type: "concurrent", count }` (N in flight). Set them from the API's documented limits.
20
+
21
+ When a window limiter alone is not enough (some APIs also punish burst concurrency), compose
22
+ two limiters:
23
+
24
+ ```js
25
+ const rolling = SDK.utilities.rateLimiter({ type: "rolling", count: 100, ms: 10000 });
26
+ const concurrent = SDK.utilities.rateLimiter({ type: "concurrent", count: 6 });
27
+ const limiter = {
28
+ execute: async (o) => concurrent.execute({ cost: 1, exec: () => rolling.execute(o) }),
29
+ };
30
+ this.#http = SDK.utilities.httpClient({ limiter, maxRetry: 3, retryBackoffMs: 2000 });
31
+ ```
32
+
33
+ Endpoints with their own (stricter) limits get their **own** client — e.g. a search endpoint
34
+ limited to 4 req/s gets a second `httpClient` with a second limiter.
35
+
36
+ ### Requests
37
+
38
+ ```js
39
+ const response = await this.#http.execute({
40
+ method: "GET", // GET | POST | PUT | DELETE | PATCH
41
+ url: "https://api.example.com/v1/contacts",
42
+ headers: { Authorization: `Bearer ${this.#token}` },
43
+ body: undefined, // object bodies are JSON-serialized
44
+ // limiterCost?, fullResponse?, fileResponse?
45
+ });
46
+ ```
47
+
48
+ Non-2xx responses **throw an `HttpError`**: `{ name: "HttpError", code: "HTTP404",
49
+ statusCode, url, responseHeaders, responseBodyString, responseBodyJson, responseBodyBuffer }`.
50
+ Branch on `err.code === "HTTP401"` etc. — see [08-errors.md](./08-errors.md).
51
+
52
+ ### The central call wrapper
53
+
54
+ Every mature connector funnels requests through one private method that (a) prepends the base
55
+ URL, (b) attaches auth, (c) debug-logs when the `debug` setting is on:
56
+
57
+ ```js
58
+ /** @param {SDK.HttpRequest} request */
59
+ async #call(request) {
60
+ if (!request.url.startsWith("https://")) request.url = this.#baseUrl + request.url;
61
+ request.headers = { ...request.headers, Authorization: this.#auth };
62
+ try {
63
+ const response = await this.#http.execute(request);
64
+ this.#debug && this.#log.info(`DEBUG: ${request.method} ${request.url}`, { request, response });
65
+ return response;
66
+ } catch (err) {
67
+ this.#debug && this.#log.info(`DEBUG: ${request.method} ${request.url} failed`, { request, err });
68
+ throw err;
69
+ }
70
+ }
71
+ ```
72
+
73
+ ### Retry policy: `canRetry`
74
+
75
+ `canRetry(err, method, url, httpCode)` returns `true`/`false` — or a **number of milliseconds**
76
+ to wait before that retry. The proven policy shape:
77
+
78
+ ```js
79
+ const canRetry = (err, method, url, httpCode) => {
80
+ if ([400, 403, 404].includes(httpCode)) return false; // client errors don't heal
81
+ if (method === "GET") return true; // reads are idempotent
82
+ if ([429, 502, 504].includes(httpCode)) return true; // throttle/gateway blips
83
+ const msg = String(err.message || "");
84
+ return msg.includes("socket hang up") || msg.includes("ECONNRESET") || msg.includes("timeout");
85
+ };
86
+ ```
87
+
88
+ Non-idempotent writes: only retry when you know the API tolerates it. One real-world subtlety:
89
+ writes that reference a _freshly created picklist option_ can fail transiently while the option
90
+ propagates — returning ~10000 (10s backoff) from `canRetry` for that specific error is the
91
+ established fix.
92
+
93
+ ## Auth patterns
94
+
95
+ ### API key / basic auth (from settings)
96
+
97
+ ```js
98
+ this.#apiKey = args.settings.get("api_key");
99
+ if (!this.#apiKey)
100
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidOptionValue, "Setting 'API Key' is required");
101
+ // basic:
102
+ this.#auth = `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`;
103
+ ```
104
+
105
+ Validate every required setting up front with a clear per-setting message.
106
+
107
+ ### OAuth2 (`args.oauth2Provider`)
108
+
109
+ The platform performs the OAuth dance (per the sidecar's `oauth2_provider` definition) and
110
+ hands your code a live token. Three obligations:
111
+
112
+ ```js
113
+ // 1. take the current token
114
+ this.#oauth2Provider = args.oauth2Provider;
115
+ this.#token = args.oauth2Provider.token;
116
+ // 2. stay current: the platform refreshes in the background
117
+ args.oauth2Provider.onRefresh((token) => {
118
+ this.#token = token;
119
+ });
120
+ ```
121
+
122
+ ```js
123
+ // 3. refresh-on-401: some APIs invalidate sessions early
124
+ } catch (err) {
125
+ if (err.code === "HTTP401") {
126
+ await this.#oauth2Provider.forceRefresh();
127
+ return this.#callInternal(request); // retry ONCE after refresh
128
+ }
129
+ throw err;
130
+ }
131
+ ```
132
+
133
+ Also available: `oauth2Provider.scope` (granted scopes — normalize with
134
+ `.replace(/\+/g, " ").split(" ")` and gate optional objects on the scopes actually granted),
135
+ `introspectToken()` (account metadata — useful for `meta().identity`), `expiry`, `authParams`.
136
+
137
+ `oauth2_mode` in the sidecar decides who owns the OAuth app: `"connector"` (one shared app) or
138
+ `"connection"` (each connection brings its own client id/secret) — see
139
+ [01-workspace-and-meta-json.md](./01-workspace-and-meta-json.md).
140
+
141
+ ### Self-managed auth (when the managed OAuth2 provider doesn't fit)
142
+
143
+ Some APIs use schemes the platform provider can't do. The fleet's established shapes:
144
+
145
+ - **Client-credentials token endpoint you manage yourself** — a small helper that caches the
146
+ token and refreshes ahead of expiry, called from your request wrapper:
147
+
148
+ ```js
149
+ async #token() {
150
+ if (this.#expiresAt < Date.now() + 5 * 60 * 1000) { // refresh 5 min early (clock skew)
151
+ const resp = await this.#http.execute({
152
+ method: "POST", url: this.#tokenUrl,
153
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
154
+ body: new URLSearchParams({ grant_type: "client_credentials",
155
+ client_id: this.#clientId, client_secret: this.#clientSecret }).toString(),
156
+ });
157
+ this.#expiresAt = Date.now() + resp.expires_in * 1000;
158
+ this.#auth = `${resp.token_type} ${resp.access_token}`;
159
+ }
160
+ return this.#auth;
161
+ }
162
+ ```
163
+
164
+ - **Request-signing schemes** (OAuth 1.0a/TBA HMAC signing, NTLM, AWS SigV4) — encapsulate the
165
+ signing in its own module (or a vendor package, see
166
+ [14-vendor-sdks-and-non-http.md](./14-vendor-sdks-and-non-http.md)) exposing the same
167
+ `execute(request)` shape as the SDK http client, so the rest of the connector can't tell the
168
+ difference.
169
+
170
+ ## OpenAPI-spec-driven APIs
171
+
172
+ If the target publishes an OpenAPI 3 spec, bundle it as a resource file and let the SDK build a
173
+ typed client with auth + rate limiting wired in:
174
+
175
+ ```js
176
+ const api = await SDK.OpenApi3.createAsync({
177
+ specPathYaml: `${args.sourcePath}/acme-api.yaml`,
178
+ apiKey: { key: this.#apiKey, securityName: "ApiKeyAuth" }, // or oauth2/basicAuth
179
+ rateLimiterOptions: { type: "rolling", count: 10, ms: 1000 },
180
+ serverOverride: this.#baseUrl,
181
+ httpClientOverrides: { maxRetry: 3, defaultHeaders: { Accept: "application/json" } },
182
+ errLog: args.log,
183
+ });
184
+ // api.apis.<Tag>.<operationId>(...) ; api.httpClient / api.rateLimiter also exposed
185
+ ```
186
+
187
+ Bundled specs are also the proven way to _generate_ `meta()` from the API's schemas at runtime
188
+ — load lazily with `await import()` and map schema properties to `ObjectField[]`.
@@ -0,0 +1,162 @@
1
+ # `meta()` — objects, fields, flags and keys
2
+
3
+ `meta()` is the contract between your connector and everything the platform does with it. Every
4
+ flag is a promise: set `canQueryByCheckpoint: true` and the platform WILL send checkpoint
5
+ queries. Set nothing you haven't implemented.
6
+
7
+ ```js
8
+ /** @returns {Promise<SDK.ConnectorMeta>} */
9
+ async meta() {
10
+ return { identity: await this.#accountIdentity(), objects: await this.#buildObjects() };
11
+ }
12
+ ```
13
+
14
+ `ConnectorMeta` = `{ identity?: string; objects: ObjectMeta[]; events?: { identity?, types: EventTypeMeta[] } }`.
15
+ `objects` is an **array**. (`ObjectMetaSummary`/`ObjectMetaDetail` are deprecated — never use.)
16
+ `events` only when you implement webhooks — see [07-events-webhooks.md](./07-events-webhooks.md).
17
+
18
+ ## `ObjectMeta` — the canonical shape
19
+
20
+ ```js
21
+ {
22
+ id: "contacts", // stable object id (what query/upsert receive as options.id)
23
+ name: "Contacts", // display name
24
+ canQueryList: true, // supports a plain "give me everything" paged query
25
+ canQueryByIds: true, // supports queryOptions.idsFilter
26
+ canQueryByCheckpoint: true, // supports differential (changed-since) queries
27
+ canQueryRandoms: true, // random sample support
28
+ matchRules: ["id", "field_value_equals[ci]"], // match rules the object supports
29
+ canUpsertClean: true, // you implement upsertClean()
30
+ canUpsertFieldOptions: true, // you implement upsertFieldOptions()
31
+ queryFields: [ /* ObjectField[] returned by query */ ],
32
+ upsertFields: [ /* ObjectField[] accepted by upsert */ ],
33
+ deleteFields: [ /* ObjectField[] needed to identify rows to delete */ ],
34
+ queryFilter: { // system-specific extra filters
35
+ rowFilter: [ { id: "modifiedOnOrAfter", type: "string" } ],
36
+ propertyFilter: [ { id: "fields", type: "array", arrayType: "string" } ],
37
+ },
38
+ relationships: [ // enables related-row queries
39
+ { id: "companies-companyId", name: "Company", relObjectId: "companies",
40
+ cardinality: "OneToOne", data: { idField: "companyId" } },
41
+ ],
42
+ isCustom: true, // object is account-specific (a custom object)
43
+ data: { /* anything YOUR connector needs back later - options.meta.data */ },
44
+ }
45
+ ```
46
+
47
+ Strive to align the `queryFields` and `upsertFields` shape (same schema). The `deleteFields` should typically contain a single row key field.
48
+
49
+ Other fields: `settingsMeta`/`settingsValues` (per-object settings), `order` (sort). Omit flags
50
+ rather than setting them `false` — `undefined` and `false` both mean "unsupported", and omitted
51
+ reads cleaner.
52
+
53
+ Only make a capability conditional when the API forces it: e.g. objects whose REST path embeds
54
+ `{parentId}` can't `canQueryList` (there's no "all parents" call) — give them `canQueryByIds`
55
+ and a `relatedFilter` path instead.
56
+
57
+ ## `ObjectField`
58
+
59
+ ```js
60
+ { id: "email", name: "Email", type: "string" }
61
+ ```
62
+
63
+ | Field | Meaning |
64
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `id` | property name as it appears in `row.data` - should be unique across all peer fields |
66
+ | `type` | `"string"` · `"number"` · `"boolean"` · `"object"` · `"array"` · `"file"` · `"any"` |
67
+ | `isKey: true` | this field is the row's unique id — its value becomes `row.meta.key`. Assign this (or `isUserKey`) to exactly one `queryFields`, plus one `upsertFields`, plus one `deleteFields` |
68
+ | `isUserKey: true` | user-supplied natural key that can both add and update — see below |
69
+ | `canMatch: true` | field may be referenced by match rules (set on id/name/email-like fields; apply recursively into nested object fields where meaningful) |
70
+ | `optionValues` | picklist options `[{ id, name?, group? }]` |
71
+ | `canUpdateOptions: true` | new picklist options may be added via `upsertFieldOptions()` |
72
+ | `objectFields` | child fields when `type: "object"` |
73
+ | `arrayType` / `arrayObjectFields` | element type / element fields when `type: "array"` |
74
+ | `constraints` | validation: `mandatory` (`"always"` · `"add"` · `"update"`), `max/min/maxLen/minLen`, `regex`, `email`, `integer`, `decimals`, `trim`, `dt: { format: "iso8601", "unix", ... }` |
75
+ | `isCustom` | account-specific/custom field |
76
+ | `data` | connector-private payload, echoed back in metadata |
77
+
78
+ ### `isKey` vs `isUserKey`
79
+
80
+ - `isKey`: the system-assigned id (`"id"`, `"Id"`). Updates address rows by it. When an upsert
81
+ operation omits the field it should be treated as an add operation. Objects whose identity
82
+ needs MORE than one field (child REST resources like `/parent/{parentId}/notes`) encode the
83
+ parts into a single `$compositeKey` pseudo-field — see
84
+ [11-composite-keys.md](./11-composite-keys.md).
85
+ - `isUserKey`: a natural key the USER supplies (e.g. a property name) that can either add or
86
+ update. It is declared as an **object** field whose children carry the value and the intended
87
+ operation:
88
+
89
+ ```js
90
+ upsertFields.unshift({
91
+ id: "name",
92
+ type: "object",
93
+ isUserKey: true,
94
+ objectFields: [
95
+ { id: "value", name: "Field name", type: "string" },
96
+ {
97
+ id: "operation",
98
+ name: "Upsert operation",
99
+ type: "string",
100
+ optionValues: [{ id: "add" }, { id: "update" }],
101
+ },
102
+ ],
103
+ });
104
+ ```
105
+
106
+ ### Match rules
107
+
108
+ `matchRules` values: `"id"`, `"name[ci]"`, `"email[ci]"`, `"domain[ci]"`,
109
+ `"first_and_last_name[ci]"`, `"field_value_equals[ci]"` (`[ci]` = case-insensitive). Declare
110
+ only rules `query()` actually implements via `matchFilter` — see [05-query.md](./05-query.md).
111
+
112
+ ### Relationships
113
+
114
+ Relationships are defined on the source object and declare that given a row from the source object, the relationship allows row(s) from another object (`relObjectId`) that relate to this row to be collected. The canonical example is collecting the Company record that a Contact relates to.
115
+
116
+ `{ id, name?, relObjectId, cardinality: "OneToOne" | "OneToMany", fkPath?, fkPathOn?, data? }` —
117
+ `id` unique per object; `data` is yours (a common pattern stores the FK field name so `query()`
118
+ can build the related filter). Declaring relationships enables `relatedFilter` queries.
119
+
120
+ ## Building `meta()` maintainably
121
+
122
+ Never hand-maintain a big field list twice. The proven generators, in order of preference:
123
+
124
+ 1. **From the API's describe/metadata endpoint** — fetch object descriptions (in parallel with
125
+ `Promise.all`) and map each API field to an `ObjectField`, switch-casing the API's type
126
+ system into the SDK's. Map reference/lookup fields into `relationships`.
127
+ 2. **From a bundled OpenAPI spec** — load the YAML/JSON lazily and derive objects + fields from
128
+ its schemas.
129
+ 3. **From a sample payload** — `SDK.utilities.fieldsFromJSON(sampleJson)` infers a full
130
+ `ObjectField[]` tree. Its rules: nested objects → `objectFields`; arrays → `arrayType`
131
+ sampled from the first non-null element (arrays of objects → `arrayObjectFields`); empty
132
+ `{}`, `null` and `undefined` → type `"any"`. Great for drafting; review the result.
133
+
134
+ When field lists must be hand-maintained, keep them as **one `Fields<Object>.mjs` module per
135
+ object**, imported by a `MetaData.mjs` that assembles `meta()` — and remember every module
136
+ must appear in the entry sidecar's `module_script_file_paths` (01).
137
+
138
+ Deduplicate anything the API can duplicate (e.g. `[...new Set(referenceTo)]`) and derive
139
+ capability flags from evidence, not optimism — e.g. `canQueryByCheckpoint` only when a
140
+ last-modified filter actually exists on that object.
141
+
142
+ ## How `constraints` are enforced (what `verifyFieldConstraints` does)
143
+
144
+ `upsertClean`'s `verifyFieldConstraints` (06) doesn't just report — several constraints
145
+ **cleanse the value in place**:
146
+
147
+ - `maxLen`: longer strings are truncated to `maxLen - 3` + `"..."` (byte length).
148
+ - `min`/`max`: value parsed via `Number(v)`; out-of-range values are removed.
149
+ - `integer` / `decimals`: `Number(v)` then `Math.round` / `toFixed(decimals)`.
150
+ - `email`: removed when malformed or the TLD is unknown.
151
+ - `trim`: whitespace-trimmed.
152
+ - `dt`: validated/normalized per `format` (`unix`, `unix_ms`, `iso8601`, `yyyy-MM-dd`, ...,
153
+ or `custom` + `pattern`), plus `future`/`past`/`weekday`/`from`/`to`/`rangeYears` checks.
154
+ - `mandatory` (`"always" | "add" | "update"`) raises a `ValueRequired` issue — and
155
+ `mandatoryAccepts: ["null" | "blank_string"]` lets a mandatory field treat `null`/`""` as a
156
+ satisfying value (for APIs where "explicitly blank" is meaningful).
157
+
158
+ ## `identity`
159
+
160
+ Return a stable account identity string, e.g. `` `${org.name}|${user.username}` ``. The platform
161
+ compares it across refreshes to detect a connection re-pointed at a different account. The test
162
+ harness pins it via `expectedIdentity` ([09-testing.md](./09-testing.md)).