@syncmatters/connector-sdk 1.0.0 → 1.0.1

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.
@@ -0,0 +1,151 @@
1
+ # `query()` — paging, checkpoints and filters
2
+
3
+ ## The lifecycle
4
+
5
+ The platform calls `query()` repeatedly. First call: `options.queryState` is `undefined`.
6
+ Each return's `queryState` is echoed into the next call, until you return `finalPage: true`
7
+ (returning no rows has the same effect).
8
+
9
+ ```js
10
+ /** @param {SDK.QueryOptions} options @returns {Promise<SDK.QueryPage>} */
11
+ async query(options) {
12
+ const queryState = options.queryState || { offset: 0, limit: 200, rowCount: 0, startTime: Date.now() };
13
+
14
+ const resp = await this.#call(this.#buildRequest(options, queryState));
15
+
16
+ /** @type {SDK.Row[]} */
17
+ const rows = resp.records.map((r) => ({ meta: { key: String(r.id) }, data: r }));
18
+
19
+ queryState.offset += rows.length;
20
+ queryState.rowCount += rows.length;
21
+
22
+ /** @type {SDK.QueryPage} */
23
+ const result = { rows, queryState };
24
+ const requestedLimit = options.queryOptions?.limit;
25
+ result.finalPage =
26
+ rows.length === 0 ||
27
+ rows.length < queryState.limit ||
28
+ (requestedLimit != null && queryState.rowCount >= requestedLimit);
29
+
30
+ if (options.queryOptions?.checkpointFilter && result.finalPage) {
31
+ // ALWAYS overlap: emit a checkpoint EARLIER than "now" so rows modified while this
32
+ // query ran are re-read next time. 60s-5min overlap is the established range.
33
+ result.checkpoint = new Date(queryState.startTime - 5 * 60 * 1000).toISOString();
34
+ }
35
+ return result;
36
+ }
37
+ ```
38
+
39
+ Rules that follow from the lifecycle:
40
+
41
+ - `queryState` is yours — an opaque cursor (`offset`, `nextPageToken`, `lastId`, ...). Keep it
42
+ JSON-serializable.
43
+ - Respect `options.queryOptions.limit`: stop at it (`finalPage: true`) and trim overflow rows
44
+ from the last page.
45
+ - Prefer **keyset pagination** (`WHERE id > lastId ORDER BY id`) over offsets when the API
46
+ allows — offset/`after` cursors can skip rows when records are deleted mid-pagination.
47
+
48
+ ## `Row`
49
+
50
+ ```js
51
+ {
52
+ meta: {
53
+ key: "42", // REQUIRED - unique row id; the value of the isKey field, as a string
54
+ deleted: true, // only on rows reporting a deletion
55
+ url: "https://app.example.com/contacts/42", // optional deep link shown in the UI
56
+ previousKeys: ["41"], // optional - prior keys (e.g. after a merge)
57
+ },
58
+ data: { id: 42, email: "a@b.co" }, // the payload; property names = queryFields ids
59
+ file: fileProvider, // LEGACY - do not emit; modern connectors return files as fields of type "file" IN data (13)
60
+ relationship: { srcRowId: "7", srcObjectId: "companies", relationshipId: "companies-companyId" },
61
+ }
62
+ ```
63
+
64
+ ## `QueryOptions`
65
+
66
+ | Field | Meaning |
67
+ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
68
+ | `id` / `meta` | object id being queried / its `ObjectMeta` (with your `data` payload) |
69
+ | `log` | platform logger |
70
+ | `objectMeta(id)` | fetch cached `ObjectMeta` of ANY object on the connection (for related/lookup objects) |
71
+ | `queryState` | your cursor from the previous page (undefined on the first call) |
72
+ | `queryOptions.limit` | max rows the caller wants |
73
+ | `queryOptions.fields` | field paths the caller needs (used to fetch less when the API supports it) |
74
+ | `queryOptions.file` | LEGACY - ignore; modern connectors include file content when `fields` contains a file-typed field's path (13) |
75
+ | `queryOptions.idsFilter` | `string[]` of row keys — return exactly those rows; row with the key not found? omit it, do not report an error |
76
+ | `queryOptions.checkpointFilter` | `{ value?: string }` — rows changed since your last checkpoint (undefined value = first differential run: return everything) |
77
+ | `queryOptions.matchFilter` | find rows matching people/records — below |
78
+ | `queryOptions.relatedFilter` | rows related to another object's rows — below |
79
+ | `queryOptions.randomFilter` | random sample up to `limit` |
80
+ | `queryOptions.rowFilter` / `propertyFilter` | your system-specific filters as declared in `meta().queryFilter` |
81
+
82
+ Throw `SDK.ConnectorError(SDK.ErrorCode.InvalidFilterParameter, ...)` when asked for a filter
83
+ the object didn't declare — it's the single most-thrown error code in the fleet, and it fails
84
+ fast instead of returning wrong data.
85
+
86
+ ### idsFilter
87
+
88
+ ```js
89
+ const ids = options.queryOptions?.idsFilter ?? [];
90
+ if (ids.length > 0) where.push(`id IN (${ids.map((i) => `'${escape(i)}'`).join(",")})`);
91
+ ```
92
+
93
+ Escape values you interpolate into query languages (backslash first, then quote), and skip
94
+ null/empty ids so you never emit `id = 'undefined'`.
95
+
96
+ When the row does not exist on the target system (e.g. deleted), do not report an error (e.g. API returns 404), simply do not return the row.
97
+
98
+ ### checkpointFilter (differential sync)
99
+
100
+ The contract is a round-trip: you emit `checkpoint` (any string — ISO date is conventional) on
101
+ the final page; the platform stores it and hands it back as `checkpointFilter.value` next run.
102
+
103
+ - **Always subtract an overlap window** (60s–5min) from the query's start time when emitting —
104
+ rows modified while your query ran would otherwise be lost forever. Re-reading a few rows is
105
+ free; losing one is a data bug.
106
+
107
+ ### matchFilter
108
+
109
+ `{ rule, selectOrder?, destFieldPath?, srcData: [{ srcRowId, match: { id?, name?, email?, domain?, firstname?, lastname?, custom? } }], canUse }`.
110
+
111
+ For each `srcData` entry, search rows matching per the `rule` (e.g. `"email[ci]"` — compare
112
+ case-insensitively). Collect candidates, then call `await options.matchFilter.canUse([{ srcRowId,
113
+ candidates }])` — the platform reserves un-mapped matches and returns the selected rows with
114
+ `row.relationship` already populated; return those. Skip src entries with empty match values.
115
+
116
+ - **name[ci]** - inspect the `match.name` values and search for rows with the same `name`. The determination of which field holds name is made by the connector, e.g. for a company it may be `companyName`, for a deal it may be `title`.
117
+ - **email[ci]** - inspect the `match.email` values and search for rows with the same `email`. The determination of which field(s) holds email is made by the connector, e.g. for a contact it may search `email` and `alternativeEmails`.
118
+ - **domain[ci]** - inspect the `match.domain` values and search for rows with the same `domain`. The determination of which field(s) holds email is made by the connector, e.g. for a comapny it may search `website`.
119
+ - **first_and_last_name[ci]** - inspect the `match.firstname` and values and `match.lastname` and search for rows with the same name.
120
+ - **id** - inspect the `match.id` value and search in the selected `destFieldPath` (path to the field on the target system) to find rows holding this exact value in this field. This search is case sensitive.
121
+ - **field_value_equals[ci]** - inspect the `match.custom` value and search in the selected `destFieldPath` (path to the field on the target system) to find rows holding this value (case insensitive) in this field.
122
+
123
+ ### relatedFilter
124
+
125
+ Receives rows from a source object (`otherObjectId`) that has a defined relationship to this object, and collect the related rows from this object.
126
+
127
+ `{ otherObjectId, otherRelationshipId, otherRows }`. Resolve the rows from a relationship declared in
128
+ `meta()` (your `relationship.data` typically holds the FK field), turn `otherRows` into an id
129
+ lookup, and return rows tagged with
130
+ `relationship: { srcRowId, srcObjectId: otherObjectId, relationshipId: otherRelationshipId }` —
131
+ one row instance per source row it relates to.
132
+
133
+ ## Cross-page scratch state: `kvStore`
134
+
135
+ When a query needs working data too big for `queryState` (dedup sets across thousands of
136
+ pages, id→row indexes built once and read per page), stage it in a key-value collection:
137
+
138
+ ```js
139
+ const kv = SDK.utilities.kvStore();
140
+ const seen = await kv.open(`seen-${options.id}`); // get/put/del/iterator
141
+ if (await seen.get(row.meta.key)) continue; // already emitted on an earlier page
142
+ await seen.put(row.meta.key, true);
143
+ ```
144
+
145
+ Keep `queryState` itself small and JSON-serializable; the kv collection carries the bulk.
146
+
147
+ ## Reporting deletions
148
+
149
+ Rows that represent deletions carry `meta: { key, deleted: true }` — used in checkpoint streams
150
+ (when the API exposes deletions) and by `handleParsedEvent`
151
+ ([07-events-webhooks.md](./07-events-webhooks.md)).
@@ -0,0 +1,194 @@
1
+ # `upsert()`, `delete()` and write hygiene
2
+
3
+ ## `upsert(options)` → `Promise<SDK.Row[]>`
4
+
5
+ `options` = `{ log, id, meta, objectMeta, rows }`. `rows` are write payloads shaped by your
6
+ `upsertFields`. Return rows in **query-result format** (`{ meta: { key }, data }`) so keys and
7
+ URLs flow back to the caller.
8
+
9
+ **The cardinal rule: the returned array is positionally aligned with `options.rows`.**
10
+ `result[3]` is the outcome of `options.rows[3]`, whatever batching you did internally. The
11
+ proven pattern when the API splits inserts and updates:
12
+
13
+ ```js
14
+ const inserts = [],
15
+ updates = [],
16
+ where = new Map();
17
+ options.rows.forEach((row, i) => {
18
+ if (row.id != null) {
19
+ updates.push(row);
20
+ where.set(i, { list: "u", at: updates.length - 1 });
21
+ } else {
22
+ inserts.push(row);
23
+ where.set(i, { list: "i", at: inserts.length - 1 });
24
+ }
25
+ });
26
+ const uOut = updates.length ? await this.#writeBatch("update", updates) : [];
27
+ const iOut = inserts.length ? await this.#writeBatch("insert", inserts) : [];
28
+ return options.rows.map((_, i) => {
29
+ const w = where.get(i);
30
+ return w.list === "u" ? uOut[w.at] : iOut[w.at];
31
+ });
32
+ ```
33
+
34
+ If the API's batch response can't be correlated back to an input row (no id echo, no ordering
35
+ guarantee), **throw** rather than guess — silent misalignment corrupts data downstream.
36
+
37
+ ### Batching
38
+
39
+ - Slice into the API's documented batch size: `rows.slice(i, i + batchSize)` per request.
40
+ - Separate batchable from non-batchable payloads if the API distinguishes them; run both, then
41
+ re-align by index.
42
+ - Implement `optimalBatchSize()` to tell the platform your preferred sizes per operation
43
+ (`"upsert"`, `"idsFilter"`, ...) — return `{ size, batchType? }` with `batchType` `"atomic"`,
44
+ `"concurrent"` or `"splitInsertUpdateAtomic"`.
45
+
46
+ ### add vs update hints
47
+
48
+ When the caller already knows whether a row exists it stamps a flag on the row (attached via a
49
+ Symbol — invisible to serialization):
50
+
51
+ ```js
52
+ const flags = SDK.utilities.upsertFlags.get(row); // { operation?: "add" | "update" }
53
+ ```
54
+
55
+ ### API-reported failures
56
+
57
+ Inspect the API's per-row results. When a row fails, log the response and throw
58
+ `SDK.ConnectorError(SDK.ErrorCode.ApiReportedError, "<api> <op> reported an error: " + JSON.stringify(detail))`.
59
+ (If your API supports partial success and the platform batch mode is `"atomic"`, failing the
60
+ whole batch by throwing is correct; don't fabricate per-row placeholders.)
61
+
62
+ ## `delete(options)` → `Promise<SDK.Row[]>`
63
+
64
+ `options.rows` carry the identifying fields you declared in `deleteFields` (usually just the
65
+ key). Return the deleted rows with `meta: { key, deleted: true }`.
66
+
67
+ ```js
68
+ async delete(options) {
69
+ const ids = options.rows.map((r) => String(r.id));
70
+ await this.#call({ method: "DELETE", url: `contacts?ids=${ids.join(",")}` });
71
+ return options.rows.map((r) => ({ meta: { key: String(r.id), deleted: true }, data: { id: r.id } }));
72
+ }
73
+ ```
74
+
75
+ ## `upsertClean(options)` — pre-write validation and change detection
76
+
77
+ Declare `canUpsertClean: true` and implement to let the platform validate + diff a row BEFORE
78
+ writing (skipping no-op writes and surfacing issues early). The platform does the heavy
79
+ lifting; the canonical implementation — and the ORDER matters, see below — is:
80
+
81
+ ```js
82
+ /** @param {SDK.UpsertCleanOptions} options @returns {Promise<SDK.UpsertCleanResponse>} */
83
+ async upsertClean(options) {
84
+ const issues = [
85
+ ...(await options.verifyFieldOptions({ cleanAction: "DeleteUpsertField" })),
86
+ ...(await options.verifyFieldConstraints({ timeZone: "UTC" })),
87
+ ];
88
+ const change = options.detectFirstChange();
89
+ return { hasChanges: change !== undefined, change, issues };
90
+ }
91
+ ```
92
+
93
+ - `verifyFieldOptions` validates picklist values against `optionValues`. It can **mutate the
94
+ upsert**: `cleanAction: "DeleteUpsertField"` deletes fields holding invalid options,
95
+ `matchByName` resolves labels to option ids in place, and fields the caller allows may be
96
+ auto-created via your `upsertFieldOptions()` (resolved ids written back into the upsert).
97
+ **Because it mutates, always run the verifies BEFORE `detectFirstChange`** — the production
98
+ fleet does, without exception.
99
+ - `verifyFieldConstraints` enforces your `ObjectField.constraints`.
100
+ - Issues are `{ type, fatal?, fieldPath?, value?, option? }`; `fatal: true` blocks the row.
101
+ - Returning `hasChanges` with no fatal issues queues the row; the platform batches queued rows
102
+ and calls your `upsert()` with them later (clean order = write order; `upsertClean` calls
103
+ are serialized per object). **Any throw inside `upsertClean` marks the row as a fatal
104
+ `RowCleanFailed` and it will not be written.**
105
+ - Your `hasChanges` is not always final: when the caller uses an issues-field strategy, the
106
+ platform writes the issue text onto the row afterwards and flips `hasChanges` to true if
107
+ that text differs from the current row's.
108
+ - **Do not call the destination API from `upsertClean`.** It runs per row on the hot path of
109
+ every sync — validation must work from `meta`, `current` and the upsert alone. (The one
110
+ sanctioned exception: `verifyFieldOptions` may trigger your `upsertFieldOptions()` to
111
+ create missing picklist options.)
112
+ - **Not available everywhere:** on-premise (`agent_mode: "always"`) connectors and C#
113
+ connectors do not support `upsertClean` — don't declare `canUpsertClean` there.
114
+ - For reference, callers (syncs/scripts) drive this per row via
115
+ `connection.upsertClean.<Object>({ upsert, current, batchSize, ... })` and flush the queued
116
+ batch by calling it with no arguments — useful to know when writing integration tests.
117
+
118
+ ### `detectFirstChange(options?)` — exact semantics
119
+
120
+ It walks the **upsert**, comparing against `options.current.data`, and returns the FIRST
121
+ difference as `{ field, oldValue, newValue }` (dot-joined path; non-string values
122
+ JSON-stringified) — or `undefined` when nothing differs. It is a change *detector*, not a
123
+ diff: `change.field` is evidence for logs, never a complete change list.
124
+
125
+ Comparison rules:
126
+
127
+ - **Only upsert properties are compared.** Fields on the current row that the upsert omits
128
+ are ignored — partial updates behave naturally.
129
+ - **The key field never counts as a change** (the `isKey`/`isUserKey` path is skipped).
130
+ - **No `current` row = insert**: it returns the upsert's first property as the change, so
131
+ `hasChanges` is effectively always true for inserts (some connectors shortcut:
132
+ `if (!options.current) return { hasChanges: true, change: { field: "*new*" }, issues };`).
133
+ - **Null-equivalence defaults are ON**: `null`/`undefined` equals `""`, `[]` and `{}`. Disable
134
+ per shape via `overrides: { nullDoesNotEqualEmptyString: true, nullDoesNotEqualEmptyArray:
135
+ true, nullDoesNotEqualEmptyObject: true }` — needed when your API distinguishes "clear this
136
+ field" (null) from an empty string/array.
137
+ - **No type coercion**: `"5" !== 5`, `"true" !== true` (`NaN === NaN` is the one special
138
+ case). If `query()` returns numbers-as-strings but upserts carry numbers, every row reports
139
+ a change — normalize the values, or veto in `confirmChange`.
140
+ - **Arrays are order-insensitive multisets**: each upsert element must find an equal peer in
141
+ the current array. An unmatched upsert element = change (added); an unmatched current
142
+ element = change (removed) unless `overrides.doNotTreatArrayElementRemovalAsChange: true`.
143
+ The reported `field` is the array's own path. Matching is pairwise (O(n·m)) — very large
144
+ arrays get slow.
145
+ - **Hard failures (which fail the whole row, per above)**: nested arrays throw; function
146
+ values throw; circular references throw. Symbols are silently treated as `undefined`.
147
+ - **`FileProvider` values are ALWAYS a change** — no content comparison is attempted, and
148
+ `confirmChange` is not consulted. A file field in a cleaned upsert forces `hasChanges` on
149
+ every run; keep files out of cleaned payloads if that matters.
150
+
151
+ `confirmChange(details)` is a veto callback — return `false` to suppress a detected
152
+ difference. Its limitations are the part nobody guesses:
153
+
154
+ - It receives `{ objectId, fieldPath, fieldMeta?, oldValue, newValue }`, but **`newValue` is
155
+ only populated for primitive-vs-primitive comparisons** — null-transition and
156
+ container-type-mismatch branches pass `newValue: undefined`. Build the callback around
157
+ `fieldPath` + `oldValue`.
158
+ - It is **not consulted** for array add/remove detections or `FileProvider` changes — those
159
+ cannot be vetoed.
160
+ - `fieldMeta` can be `undefined` for nested/array paths.
161
+
162
+ The canonical `confirmChange` use case is API-side value normalization — e.g. an API that
163
+ stores date-only in a datetime field, so the echoed value never equals what was sent:
164
+
165
+ ```js
166
+ const change = options.detectFirstChange({
167
+ confirmChange: (d) =>
168
+ dateOnlyFields.has(d.fieldPath[0]) && typeof d.newValue === "string" && typeof d.oldValue === "string"
169
+ ? d.newValue.slice(0, 10) !== d.oldValue.slice(0, 10) // compare the date part only
170
+ : true,
171
+ });
172
+ ```
173
+
174
+ One more ordering trap: APIs that require full-record PUTs need the current row merged into
175
+ the upsert before writing — **detect first, merge after**, or the merged-in fields make every
176
+ row look changed:
177
+
178
+ ```js
179
+ const change = options.detectFirstChange(); // detect on the caller's intent
180
+ Object.assign(options.upsert, this.#mergeCurrent(options.meta, options.current, options.upsert));
181
+ return { hasChanges: !!change, change, issues };
182
+ ```
183
+
184
+ ## `upsertFieldOptions(options)` — auto-adding picklist values
185
+
186
+ Declare `canUpsertFieldOptions: true` on the object AND `canUpdateOptions: true` on the field.
187
+ `options` = `{ fieldPath, options: [{ id, meta? }], ... }`. Create/update the options via the
188
+ API and return `{ resolvedIds, added, updated, unchanged, unresolved, metaUpdate? }` —
189
+ `metaUpdate` lets you return just the changed field metadata so the platform skips a full
190
+ `meta()` refresh.
191
+
192
+ Real-world trap: many APIs are _eventually consistent_ about new picklist options — an upsert
193
+ referencing a just-created option can fail for ~10-20 seconds. Handle it in your HTTP
194
+ `canRetry` with a long backoff for that specific error ([03](./03-http-auth-ratelimit.md)).
@@ -0,0 +1,123 @@
1
+ # Events and webhooks
2
+
3
+ Two sidecar modes (`connector_metadata.webhooks_provider`, see
4
+ [01-workspace-and-meta-json.md](./01-workspace-and-meta-json.md)):
5
+
6
+ - `{ "mode": "custom.perconnection" }` — each connection registers its own webhook with the
7
+ external system. `init()` receives `args.webhookUrl` / `args.webhookUid` for this connection.
8
+ The incoming `webhookUid` identifies the connection, so `parseEvents` runs on a **fully
9
+ initialized instance**: settings are available, and `eventIdentity` is simply
10
+ `options.payload.webhookUid`.
11
+ - `{ "mode": "custom.perconnector", "url": ..., "webhook_uid": ... }` — one shared endpoint for
12
+ ALL connections. The platform cannot know which connection a payload belongs to, so
13
+ `parseEvents` runs **statically** (no connection settings); your code must derive
14
+ `eventIdentity` from the payload itself (e.g. an account/portal id), and each connection's
15
+ `meta().events.identity` must return the matching value so events route correctly.
16
+
17
+ Declare the event types you emit in `meta()`:
18
+
19
+ ```js
20
+ events: {
21
+ types: [
22
+ { id: "record.updated", name: "Record updated" },
23
+ // dataFields (ObjectField[]) optionally describes the event payload's schema,
24
+ // so triggers can map event data; [] is common when the payload is free-form
25
+ { id: "record.deleted", name: "Record deleted", dataFields: [] },
26
+ ];
27
+ }
28
+ ```
29
+
30
+ Note that the actual webhook UID varues are system generated, you do not manually specify them here.
31
+
32
+ ## `parseEvents(options)` → `{ events: SDK.ParsedEvent[] }`
33
+
34
+ The execution context follows the `webhooks_provider.mode`:
35
+
36
+ - **`custom.perconnection` → instance-based.** The connector is initialized for the target
37
+ connection first, so `parseEvents` can use everything `init()` set up — including a
38
+ settings-derived signing secret (e.g. a `webhookSecretKey` setting) for signature checks.
39
+ - **`custom.perconnector` → static.** `init()` runs with `mode: "parseEvents"` and no
40
+ connection settings are guaranteed. Structure `init()` to early-return for that mode and
41
+ have `parseEvents` build anything it needs (e.g. its own `httpClient`) itself
42
+ ([02-lifecycle-and-shape.md](./02-lifecycle-and-shape.md)); verify signatures with the
43
+ platform-managed `verifyHash` or a key obtainable from the payload — never from settings,
44
+ which are absent here.
45
+
46
+ ```js
47
+ /** @param {SDK.ParseEventsOptions} options @returns {Promise<SDK.ParsedEvents>} */
48
+ async parseEvents(options) {
49
+ /** @type {SDK.ParsedEvent[]} */
50
+ const events = [];
51
+ if (!options.payload) return { events };
52
+
53
+ const bodyText = options.payload.body.toString("utf-8"); // body is a Buffer
54
+ const raw = JSON.parse(bodyText);
55
+
56
+ if (!(await this.#signatureValid(options, bodyText))) return { events }; // drop, don't throw
57
+
58
+ events.push({
59
+ // per-connection mode: the webhookUid IS the connection identity.
60
+ // per-connector (shared endpoint) mode: derive this from the payload instead
61
+ // (e.g. String(raw.accountId)) and return the same value from meta().events.identity.
62
+ eventIdentity: options.payload.webhookUid,
63
+ typeId: String(raw.type || "").toLowerCase(), // must match an id from meta().events.types
64
+ timestamp: raw.occurredAt ? Date.parse(raw.occurredAt) : Date.now(),
65
+ uniqueId: raw.eventId, // de-dup key (~48h window) - use when available
66
+ object: raw.recordId ? { id: "contacts", key: String(raw.recordId) } : undefined,
67
+ data: raw,
68
+ });
69
+ return { events };
70
+ }
71
+ ```
72
+
73
+ `options.payload` = `{ webhookUid, uri, method, headers: {[name]: string[]}, body: Buffer }`.
74
+
75
+ ### Verifying signatures
76
+
77
+ **Always verify** before emitting events — a webhook endpoint is unauthenticated input.
78
+
79
+ - Platform-managed key: `await options.verifyHash({ strategy: "OAuth2.Sha256.Hex", expression, expected })`
80
+ — the platform computes the hash with a server-side key and returns `{ success }`.
81
+ - Settings-derived secret (per-connection mode only — settings exist there): HMAC the raw body
82
+ with the secret the user configured (e.g. a `webhookSecretKey` setting captured in `init()`),
83
+ compare against the signature header.
84
+ - Manual HMAC with a key obtainable from the payload (works in static mode too):
85
+
86
+ ```js
87
+ const expected = options.payload.headers["x-content-signature"]?.[0];
88
+ const crypto = await import("crypto");
89
+ const computed = crypto.createHmac("sha256", signingKey).update(bodyText).digest("base64");
90
+ if (expected !== computed) return { events: [] };
91
+ ```
92
+
93
+ Failed verification: return no events (and optionally log) — throwing turns an attacker's junk
94
+ into your error noise.
95
+
96
+ ## `handleParsedEvent(options)` — keep the cache current
97
+
98
+ After your events fire, the platform calls `handleParsedEvent` per affected object so the
99
+ cached data can be updated without a full re-query:
100
+
101
+ ```js
102
+ /** @param {SDK.HandleParsedEventOptions} options */
103
+ async handleParsedEvent(options) {
104
+ if (options.event.id === "record.deleted" && options.event.key) {
105
+ await options.reportChanges({
106
+ id: options.id,
107
+ page: { rows: [ { meta: { key: options.event.key, deleted: true } } ], finalPage: true },
108
+ });
109
+ }
110
+ // updated-record events: either fetch the row and report it, or report nothing and let the
111
+ // next differential query pick it up - fetching is better for near-real-time triggers.
112
+ }
113
+ ```
114
+
115
+ `options.event` = `{ id, timestamp, key?, data? }`; `reportChanges({ id, page })` takes a normal
116
+ `QueryPage`.
117
+
118
+ ## Flow summary
119
+
120
+ external system → platform webhook endpoint → your `parseEvents` (verify, emit
121
+ `ParsedEvent[]`) → platform de-dups (`uniqueId`), resolves connections (`eventIdentity`),
122
+ fires triggers (`typeId`) → your `handleParsedEvent` per changed object → `reportChanges`
123
+ updates the cache.
@@ -0,0 +1,68 @@
1
+ # Error handling
2
+
3
+ ## `SDK.ConnectorError`
4
+
5
+ Every platform-visible failure is a `ConnectorError`:
6
+
7
+ ```js
8
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidOptionValue, "Setting 'API Key' is required");
9
+ throw new SDK.ConnectorError(SDK.ErrorCode.InvalidOptionValue, "supplied option not valid",
10
+ { issues: [{ objectId: "deals", fieldId: "sales_rep", option: "Jane Doe" }] }); // optional data payload
11
+ ```
12
+
13
+ Never throw bare strings or plain `Error` for expected failure modes — the `code` is what the
14
+ platform (and support tooling) branches on. The one exception: `test()` **catches** and returns
15
+ `{ success: false, message, error }` instead of throwing
16
+ ([02-lifecycle-and-shape.md](./02-lifecycle-and-shape.md)).
17
+
18
+ ## Choosing an `ErrorCode`
19
+
20
+ Ordered by how often the production fleet actually uses them — the top four cover ~85% of
21
+ throws:
22
+
23
+ | Code | Throw when |
24
+ |---|---|
25
+ | `InvalidFilterParameter` | asked to run a query/filter/match rule the object didn't declare or with malformed filter values. Fail fast — never silently return unfiltered data |
26
+ | `InvalidOptionValue` | a required connection setting is missing/invalid, or an option/parameter value is not permitted |
27
+ | `InvalidOperation` | "this should never happen" internal-state violations |
28
+ | `InvalidObjectId` | `options.id` names an object this connector doesn't expose |
29
+ | `ApiReportedError` | the external API returned a failure in its response body (include the API's payload in the message) |
30
+ | `InvalidRecordId` | a row key doesn't exist / is malformed |
31
+ | `NotImplimented` | an operation/feature is deliberately unsupported. **Note the spelling — `NotImplimented` is the canonical enum member**, do not "fix" it |
32
+ | `InvalidFieldId` | unknown field/property path, or an option label that can't be resolved |
33
+ | `InvalidApiKey` / `InvalidUserName` / `InvalidPassword` | the specific credential the API rejected |
34
+ | `ApiTokenGenerationTerminated` | token acquisition/refresh was aborted |
35
+ | `ApiUrlNotSupported` / `InvalidFilePath` / `InvalidCacheFilter` / ... | as named — see the `ErrorCode` enum in the type definitions for the full list |
36
+
37
+ ## `HttpError` (thrown by the SDK http client)
38
+
39
+ Non-2xx responses from `httpClient.execute` throw:
40
+
41
+ ```js
42
+ { name: "HttpError", code: "HTTP429", statusCode: 429, url,
43
+ responseHeaders, // lowercased names
44
+ responseBodyString, responseBodyJson, responseBodyBuffer, cause }
45
+ ```
46
+
47
+ Idioms:
48
+
49
+ ```js
50
+ } catch (err) {
51
+ if (err.code === "HTTP401") { /* force OAuth refresh, retry once - see 03 */ }
52
+ if (err.code === "HTTP404") { return { rows: [], finalPage: true }; } // when 404 means "no rows"
53
+ throw err; // let unexpected errors surface with full context
54
+ }
55
+ ```
56
+
57
+ Prefer the client's built-in retry (`maxRetry` + `canRetry` + `retryBackoffMs`,
58
+ [03-http-auth-ratelimit.md](./03-http-auth-ratelimit.md)) over hand-rolled retry loops.
59
+ Translate an `HttpError` into a `ConnectorError` only when you can say something more precise
60
+ (e.g. a 401 during `test()` → `InvalidApiKey`); otherwise let it propagate — it already carries
61
+ the URL, status and response body.
62
+
63
+ ## Logging
64
+
65
+ `options.log` / `args.log` writes to the platform event store: `log.info(message, props?)`,
66
+ `log.error(messageOrError, props?)`. Log the API's raw response alongside errors you throw for
67
+ supportability, and gate verbose request/response logging behind an advanced `debug` setting —
68
+ never log secrets or tokens.
@@ -0,0 +1,127 @@
1
+ # The connector test harness
2
+
3
+ Two files per connector, both living beside the entry file (the `"mod "` prefix, with the
4
+ space, is the convention):
5
+
6
+ | File | Sidecar `type` | Origin |
7
+ | ---------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
8
+ | `mod ObjectTestFeatures.mjs` | `"module"` | **generated** from a live test connection's cached metadata — never hand-edit |
9
+ | `mod Test.mjs` | `"connectortest"` (its sidecar lists `mod ObjectTestFeatures.mjs` in `module_script_file_paths`) | hand-written `TestFeatureProvider` |
10
+
11
+ The platform's connector test suite (script type `"connectorTest"`) drives your connector
12
+ through every capability its `meta()` declares — list/ids/checkpoint/match/related queries,
13
+ upserts, deletes — using these two files to know what to expect and how to prepare data.
14
+
15
+ ## `mod ObjectTestFeatures.mjs` (generated)
16
+
17
+ Declares, per object, which features the suite should exercise and which fields are usable:
18
+
19
+ ```js
20
+ /** Do not edit. This file is automatically generated using cached metadata from a test connection. */
21
+ import API from "@syncmatters/script-api";
22
+ const toJsonPath = (p) => API.utilities.json.stringToJsonValuePath(p, "all");
23
+ const toJsonPaths = (ps) => API.utilities.json.stringsToJsonValuePaths(ps, "all");
24
+
25
+ export const objectFeatures = {
26
+ contacts: {
27
+ query: {
28
+ list: true,
29
+ idsFilter: true,
30
+ checkpointFilter: true,
31
+ matchRules: [{ rule: "email[ci]" }],
32
+ canMatchFields: toJsonPaths(["id", "email", "name"]),
33
+ relationshipsTo: [{ id: "companies-companyId", relObjectId: "companies" }],
34
+ },
35
+ upsert: { canUpsert: true, key: { path: toJsonPath("id") }, canUpsertClean: true },
36
+ delete: { canDelete: true, key: { path: toJsonPath("id") } },
37
+ },
38
+ };
39
+ ```
40
+
41
+ It is emitted by the platform's object-features generator from `ObjectMeta[]` cached on a test
42
+ connection — regenerate after `meta()` changes rather than editing.
43
+
44
+ ## `mod Test.mjs` (hand-written)
45
+
46
+ Implements `SDKTest.TestFeatureProvider` (types from `@syncmatters/script-api/sdk-test`):
47
+
48
+ ```js
49
+ import API from "@syncmatters/script-api";
50
+ import SDKTest from "@syncmatters/script-api/sdk-test";
51
+ import { objectFeatures } from "./mod ObjectTestFeatures";
52
+
53
+ /** @typedef {API.connections.acme_test.Connection} TestConnection */
54
+
55
+ export default class Test {
56
+ expectedIdentity = "Acme Sandbox|integration@example.com"; // must equal meta().identity
57
+ /** @type {TestConnection | undefined} */ #connection;
58
+
59
+ async before(ctx) {
60
+ this.#connection = /** @type any */ (ctx.connection);
61
+ await this.#cleanupTestData(); // delete leftovers from crashed runs
62
+ }
63
+ objectIds() {
64
+ return Object.keys(objectFeatures);
65
+ }
66
+ objectFeatures(id) {
67
+ return objectFeatures[id];
68
+ }
69
+
70
+ // prepare hooks - create/arrange the data a given test needs, return what it should expect:
71
+ // listPrepare, idsFilterPrepare, checkpointFilterStep1Prepare / Step2Prepare,
72
+ // matchFilterPrepare, relatedFilterPrepare, upsertPrepare
73
+ }
74
+ ```
75
+
76
+ Established conventions:
77
+
78
+ - **Typed test connection**: `API.connections.<connection_name>.Connection` gives you typed
79
+ `query`/`upsert`/`delete` accessors for driving real data:
80
+ `for await (const row of this.#connection.query.contacts({...})) {...}`,
81
+ `await this.#connection.upsert.contacts([{ id, name }])`.
82
+ - **Test-data hygiene**: prefix every created record (`const testRecordPrefix = "sm_test";`)
83
+ and have `before()` sweep and delete `sm_test*` leftovers, so crashed runs never pollute the
84
+ sandbox account.
85
+ - **Scoping out an object**: set `features.cannotTestReason = "<why>"` on an object's features
86
+ instead of deleting it from the generated file.
87
+ - `expectedIdentity` pins the account: if the sandbox credentials get re-pointed, the suite
88
+ fails loudly instead of trashing another account's data.
89
+
90
+ ## Fine-grained feature control
91
+
92
+ `cannotTestReason` has per-feature variants when only part of an object is untestable:
93
+ `query.listCannotTestReason`, `idsFilterCannotTestReason`, `checkpointFilterCannotTestReason`,
94
+ `matchRulesCannotTestReason` (whole or per-rule index), `relationshipsToCannotTestReason`
95
+ (whole or per-relationship index). The `*SameAsObjectId` variants ("this feature behaves
96
+ exactly like object X — test it there") avoid duplicating slow coverage across near-identical
97
+ objects.
98
+
99
+ ## Prepare/cleanup hooks and their data types
100
+
101
+ Every testable feature has a `*Prepare` hook (and usually a `*Cleanup` counterpart) —
102
+ returning `undefined` from a prepare hook skips that test:
103
+
104
+ - `listPrepare` / `idsFilterPrepare` / `rowFiltersPrepare` — arrange rows and return what the
105
+ suite should expect (row-filter testing is easy to forget: provide it when `meta()` exposes
106
+ `queryFilter.rowFilter`).
107
+ - `checkpointFilterStep1Prepare` / `Step2Prepare` — step 1 seeds data and captures the
108
+ checkpoint; step 2 mutates data the differential query must then surface.
109
+ - `matchFilterPrepare` — returns `MatchData` including `expectedMatchRowIds`.
110
+ - `upsertPrepare` — returns `UpsertData`; use `withIssueSimulators` / `UpsertIssueSimulator`
111
+ (`verifyFields`, expected issue `type` + `fatal`) to deliberately exercise your
112
+ `upsertClean` issue reporting.
113
+ - delete features support soft-delete verification — `isDeleted: { path, valueWhenDeleted }`
114
+ checks a flag field instead of row absence, and `afterDeleteMaxIndexWaitTimeMs` tolerates
115
+ APIs whose deletions surface asynchronously.
116
+
117
+ ## Coverage bar
118
+
119
+ Target **80%+ code coverage** (statements/branches/functions/lines) from the platform's
120
+ connector test runs — reports are downloadable per run and retained ~90 days. Structure
121
+ `mod Test.mjs` so every object with declared capabilities is exercised rather than scoped out.
122
+
123
+ ## Local dev loop
124
+
125
+ The harness runs platform-side. Locally you get: `sm validate` (workspace structure),
126
+ `checkJs` type checking against the SDK types, `sm push` and a platform-side test run. Keep
127
+ `test()` cheap and `meta()` deterministic so the suite stays fast.