@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 +13 -6
- package/docs/01-workspace-and-meta-json.md +135 -0
- package/docs/02-lifecycle-and-shape.md +116 -0
- package/docs/03-http-auth-ratelimit.md +188 -0
- package/docs/04-meta-objects-fields.md +162 -0
- package/docs/05-query.md +156 -0
- package/docs/06-upsert-delete.md +194 -0
- package/docs/07-events-webhooks.md +123 -0
- package/docs/08-errors.md +68 -0
- package/docs/09-testing.md +134 -0
- package/docs/10-style-and-pitfalls.md +100 -0
- package/docs/11-composite-keys.md +164 -0
- package/docs/12-example-connector.md +587 -0
- package/docs/13-file-fields.md +123 -0
- package/docs/14-vendor-sdks-and-non-http.md +115 -0
- package/docs/15-example-test-file.md +565 -0
- package/docs/connector-authoring.md +159 -0
- package/package.json +3 -3
package/docs/05-query.md
ADDED
|
@@ -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.
|