@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.
@@ -0,0 +1,156 @@
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 }`. DIRECTION: the platform queries the
128
+ relationship's TARGET object and passes the DECLARING object's rows — contacts declare
129
+ contact→company, so a related query arrives on `companies` with `otherObjectId: "contacts"`.
130
+ Resolve the relationship from the OTHER object's metadata
131
+ (`await options.objectMeta(related.otherObjectId)` — it is NOT in `options.meta`; your
132
+ `relationship.data` typically holds the FK field name), collect the FK values from
133
+ `otherRows` (they name THIS object's keys), fetch those rows, and return them tagged with
134
+ `relationship: { srcRowId, srcObjectId: otherObjectId, relationshipId: otherRelationshipId }` —
135
+ one row instance per source row it relates to. See the worked implementation in
136
+ [12-example-connector.md](./12-example-connector.md).
137
+
138
+ ## Cross-page scratch state: `kvStore`
139
+
140
+ When a query needs working data too big for `queryState` (dedup sets across thousands of
141
+ pages, id→row indexes built once and read per page), stage it in a key-value collection:
142
+
143
+ ```js
144
+ const kv = SDK.utilities.kvStore();
145
+ const seen = await kv.open(`seen-${options.id}`); // get/put/del/iterator
146
+ if (await seen.get(row.meta.key)) continue; // already emitted on an earlier page
147
+ await seen.put(row.meta.key, true);
148
+ ```
149
+
150
+ Keep `queryState` itself small and JSON-serializable; the kv collection carries the bulk.
151
+
152
+ ## Reporting deletions
153
+
154
+ Rows that represent deletions carry `meta: { key, deleted: true }` — used in checkpoint streams
155
+ (when the API exposes deletions) and by `handleParsedEvent`
156
+ ([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.