@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.
- package/README.md +13 -6
- package/docs/01-workspace-and-meta-json.md +132 -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 +151 -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 +127 -0
- package/docs/10-style-and-pitfalls.md +100 -0
- package/docs/11-composite-keys.md +164 -0
- package/docs/12-example-connector.md +566 -0
- package/docs/13-file-fields.md +123 -0
- package/docs/14-vendor-sdks-and-non-http.md +115 -0
- package/docs/connector-authoring.md +158 -0
- package/package.json +3 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Style rules, deprecated APIs, and the trap list
|
|
2
|
+
|
|
3
|
+
## Style (workspace `.mjs` connector code)
|
|
4
|
+
|
|
5
|
+
- **JSDoc types everywhere** — workspaces run TypeScript's `checkJs`. Method signatures:
|
|
6
|
+
`/** @param {SDK.InitArgs} args @returns {Promise<void>} */`. Fields:
|
|
7
|
+
`/** @type {SDK.HttpClient | undefined} */ #http;`. Complex local types: `@typedef` blocks at
|
|
8
|
+
the end of the file. Escape hatches (`/** @type any */ (x)`, `@ts-ignore`) sparingly.
|
|
9
|
+
- **Private `#fields`** for all connector state (`#log`, `#http`, `#token`, `#settings`).
|
|
10
|
+
- First line of `init()`: `SDK.verifyType(this);`.
|
|
11
|
+
- **Double quotes**, semicolons, CRLF line endings.
|
|
12
|
+
- Imports: `import SDK from "@syncmatters/connector-sdk";` (and `@syncmatters/script-api` in test files).
|
|
13
|
+
- One central request wrapper per API; separate `httpClient` instances per rate-limit domain.
|
|
14
|
+
- The platform copy is the source of truth — `sm push` early, `sm pull` before big edits, and a
|
|
15
|
+
push 409 means merge, never force.
|
|
16
|
+
|
|
17
|
+
## Deprecated — do not use, migrate away on contact
|
|
18
|
+
|
|
19
|
+
| Deprecated | Use instead |
|
|
20
|
+
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `ObjectMetaSummary` / `ObjectMetaDetail` (objects with a `detail()` callback) | flat `ObjectMeta[]` in `meta().objects` — the summary form doesn't work on on-premise agents |
|
|
22
|
+
| `cacheRefreshOptions()` / `RefreshSpec` / `QueryPage.cacheState` | checkpoint queries ([05-query.md](./05-query.md)) |
|
|
23
|
+
| `RateLimiter.take()` | `rateLimiter.execute({ cost, exec })` — or just pass the limiter to `httpClient` |
|
|
24
|
+
| `new SDK.HttpClient(...)` / `new SDK.RateLimiter(...)` classes | `SDK.utilities.httpClient(...)` / `SDK.utilities.rateLimiter(...)` |
|
|
25
|
+
|
|
26
|
+
## The trap list (each one has bitten a real connector)
|
|
27
|
+
|
|
28
|
+
1. **Checkpoint without overlap loses rows.** Emit `checkpoint = startTime - overlap`
|
|
29
|
+
(60s–5min), never `Date.now()` at completion. Rows modified during the run must be re-read
|
|
30
|
+
next run.
|
|
31
|
+
2. **Offset/`after` cursors skip rows when records are deleted mid-pagination.** Prefer keyset
|
|
32
|
+
pagination (`WHERE id > lastId ORDER BY id`) when the API allows it.
|
|
33
|
+
3. **Upsert results misaligned with input rows corrupt data silently.** Keep an index map when
|
|
34
|
+
splitting insert/update batches; throw if an API response row can't be correlated back.
|
|
35
|
+
4. **A rolling rate limiter alone may still trip 429s** — some APIs also punish concurrency.
|
|
36
|
+
Compose a `concurrent` limiter with the `rolling` one
|
|
37
|
+
([03-http-auth-ratelimit.md](./03-http-auth-ratelimit.md)).
|
|
38
|
+
5. **Freshly created picklist options are eventually consistent** — upserts referencing them
|
|
39
|
+
can fail for ~10-20s. Special-case that error in `canRetry` with a long backoff.
|
|
40
|
+
6. **String-interpolated query languages need escaping** — escape backslash first, then the
|
|
41
|
+
quote character; skip null/empty values so you never emit `id = 'undefined'`.
|
|
42
|
+
7. **External APIs return duplicates where you least expect** (e.g. reference-target lists).
|
|
43
|
+
`[...new Set(values)]` before mapping into metadata.
|
|
44
|
+
8. **`meta()` flags are promises.** Declaring `canQueryByCheckpoint`/`matchRules` you didn't
|
|
45
|
+
implement produces runtime failures in someone else's sync. Derive flags from evidence
|
|
46
|
+
(does the object actually have a last-modified filter?).
|
|
47
|
+
9. **`parseEvents`' context depends on the webhook mode.** With a `custom.perconnector`
|
|
48
|
+
(shared-endpoint) webhook it runs statically: guard `init()` with an early return for
|
|
49
|
+
`mode === "parseEvents"`, never assume settings/tokens exist there, and derive
|
|
50
|
+
`eventIdentity` from the payload. Only `custom.perconnection` connectors get a fully
|
|
51
|
+
initialized instance (settings-derived signing secrets are legal there). See
|
|
52
|
+
[07-events-webhooks.md](./07-events-webhooks.md).
|
|
53
|
+
10. **Unverified webhooks are attacker input.** Verify the signature and return zero events on
|
|
54
|
+
mismatch — don't throw.
|
|
55
|
+
11. **`NotImplimented` is spelled that way.** The enum member (value `CONN_NOT_IMPLEMENTED`)
|
|
56
|
+
and several long-standing message strings use the misspelling — it is load-bearing; match
|
|
57
|
+
it exactly.
|
|
58
|
+
12. **Objects with `{parentId}`-style paths can't support `canQueryList`** — expose
|
|
59
|
+
`canQueryByIds` + a relationship for `relatedFilter` traversal instead.
|
|
60
|
+
13. **Don't log secrets.** Debug logging of requests/responses must be gated behind an
|
|
61
|
+
advanced `debug` setting, and auth headers/tokens should be excluded or masked.
|
|
62
|
+
14. **A hidden `systemFlag*` setting means the connector is a runtime-embedded stub** — the
|
|
63
|
+
`.mjs` in the workspace is deliberately a no-op; don't implement it.
|
|
64
|
+
15. **Files are fields now.** Declare file content as a field of `type: "file"` and include it
|
|
65
|
+
only when `queryOptions.fields` asks for it ([13-file-fields.md](./13-file-fields.md)) —
|
|
66
|
+
the `queryFile`/`queryOptions.file`/`Row.file` trio in older connectors is legacy; don't
|
|
67
|
+
copy it.
|
|
68
|
+
|
|
69
|
+
## Useful utilities you might otherwise reimplement
|
|
70
|
+
|
|
71
|
+
The two you will use constantly (they appear in nearly every fleet connector):
|
|
72
|
+
|
|
73
|
+
- `SDK.utilities.clone(value)` — deep clone. Use before mutating anything you received or
|
|
74
|
+
return (metadata, rows, options) — shared references are how callers get corrupted.
|
|
75
|
+
- `SDK.utilities.tryGet(() => deeply.nested.maybe.missing)` — returns the value or
|
|
76
|
+
`undefined`, never throws. The idiom for prodding uncertain API payloads:
|
|
77
|
+
`const sig = tryGet(() => options.payload.headers["x-signature"][0]);`
|
|
78
|
+
|
|
79
|
+
Frequently used:
|
|
80
|
+
|
|
81
|
+
- `dateFormat(date, format)` / `dateParse(text, format)` / `dateTransform` — checkpoint and
|
|
82
|
+
API date math without hand-rolled parsing.
|
|
83
|
+
- `arrayOfStr(value)` / `arrayOfNum` / `setOfStr` / `setOfNum` — normalize comma/CSV-ish
|
|
84
|
+
settings values ("a, b,c" → `["a","b","c"]` / a `Set`).
|
|
85
|
+
- `emailCleanse(email)` / `emailIsPublic` / `domainParse` / `nameParse` — normalize match-key
|
|
86
|
+
values before comparing (pairs with `matchRules`).
|
|
87
|
+
- `xmlParse({ string })` — XML APIs without adding a dependency.
|
|
88
|
+
- `kvStore()` — scratch key-value collections for cross-page state (05) or import staging (06).
|
|
89
|
+
- `fileProvider({ string | buffer | file: { path, deleteOnClose } })` — wrap content as a
|
|
90
|
+
`FileProvider` (13); `tempFile`/`tempDir` for staging.
|
|
91
|
+
- `fieldsFromJSON(sample)` — draft `ObjectField[]` from a sample payload (04).
|
|
92
|
+
- `upsertFlags.get(row)` — the caller's add/update hint (06).
|
|
93
|
+
|
|
94
|
+
Also available (see the type definitions): `sleep`, `htmlToText`, `csvReader`/`csvWriter`,
|
|
95
|
+
`xlsxReader`/`xlsxWriter`, `gzip`/`zip`, `json.*` (lossless numbers, `valuesReader`/
|
|
96
|
+
`valuesWriter` path-based field access, `changesChecker`), and `serveApi` — which hosts a
|
|
97
|
+
temporary dynamic endpoint from a script:
|
|
98
|
+
`SDK.utilities.serveApi({ path, apiKey, handler: async (req, res) => res.send(200, body) })`
|
|
99
|
+
returns a URL under `/dynamic/{path}`; callers must pass `?apikey=<key>`; the endpoint is
|
|
100
|
+
torn down when the script ends (`req` = `{ params, body, headers, method, path, url }`).
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Composite keys
|
|
2
|
+
|
|
3
|
+
Some objects have no single-field identity. The classic case is a **child REST resource** —
|
|
4
|
+
`/companies/{parentId}/notes/{id}` — where a note's `id` is only unique _within_ its company;
|
|
5
|
+
addressing a row needs both parts. Other cases: association/junction rows (two record ids) and
|
|
6
|
+
multi-part natural keys. This topic is cross-cutting: the same convention drives `meta()`,
|
|
7
|
+
`query()`, `upsert()` and `delete()`.
|
|
8
|
+
|
|
9
|
+
## The rule
|
|
10
|
+
|
|
11
|
+
`Row.meta.key` is **one opaque string** — the platform has no concept of a multi-part key. A
|
|
12
|
+
composite key is therefore _encoded into_ the key string, and the encoding must be:
|
|
13
|
+
|
|
14
|
+
1. **Deterministic** — the platform compares keys by string equality (cache identity, ids
|
|
15
|
+
filters, dedup). The same row must produce a byte-identical key on every query, forever.
|
|
16
|
+
Sort the parts; never rely on API response property order.
|
|
17
|
+
2. **Parseable** — `upsert()` and `delete()` receive the key string back and must decode it to
|
|
18
|
+
rebuild the API path.
|
|
19
|
+
|
|
20
|
+
**The fleet-standard encoding is a JSON object with the parts keyed by field path, in sorted
|
|
21
|
+
order**: `{"company.id":5678,"id":1234}`. JSON survives any characters in the values, fails
|
|
22
|
+
loudly on garbage, and self-documents which part is which. (A delimiter join like
|
|
23
|
+
`5678:1234` is acceptable only when the parts can never contain the delimiter — and it's
|
|
24
|
+
harder to validate; prefer JSON.)
|
|
25
|
+
|
|
26
|
+
> Changing the encoding of an existing object is a breaking change: every cached row's key
|
|
27
|
+
> stops matching and the platform sees an entirely new set of rows. Pick the encoding once.
|
|
28
|
+
|
|
29
|
+
## The `$compositeKey` pseudo-field
|
|
30
|
+
|
|
31
|
+
The key parts live in separate row properties, so declare a **pseudo-field** that carries the
|
|
32
|
+
encoded key and mark IT as the key — by convention named `$compositeKey` (the `$` prefix
|
|
33
|
+
signals "not a real API field"):
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
// meta() for a child object
|
|
37
|
+
queryFields.push({ id: "$compositeKey", name: "Composite key", type: "string", isKey: true });
|
|
38
|
+
upsertFields.push({ id: "$compositeKey", name: "Composite key", type: "string", isKey: true });
|
|
39
|
+
deleteFields = [{ id: "$compositeKey", type: "string", name: "Composite key", isKey: true }];
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
(`$compositeKey` is a commonly used identifier; the primary requirement is that the identifier
|
|
43
|
+
not conflict with real fielf identifiers on the object. When the caller maps values into the
|
|
44
|
+
discrete key fields AND supplies `$compositeKey`, cross-check them against the parsed key parts
|
|
45
|
+
and throw on mismatch; when the discrete fields are absent, populate them from the parsed key.
|
|
46
|
+
|
|
47
|
+
Because `{parentId}`-path objects have no "fetch everything" endpoint, they usually can't
|
|
48
|
+
declare `canQueryList` without a parent filter — declare a `rowFilter` entry
|
|
49
|
+
(`{ id: "parentId", type: "number" }`) and/or a relationship to the parent so rows arrive via
|
|
50
|
+
`relatedFilter` (see [04](./04-meta-objects-fields.md), [05](./05-query.md)).
|
|
51
|
+
|
|
52
|
+
## The helper pair
|
|
53
|
+
|
|
54
|
+
Keep encode/decode in one place — every operation uses both:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
/** Deterministic composite key: parts sorted by field path. */
|
|
58
|
+
function makeRowKey(keyFieldPaths, data) {
|
|
59
|
+
const keyObj = {};
|
|
60
|
+
for (const fieldPath of [...keyFieldPaths].sort((a, b) =>
|
|
61
|
+
a.join(".").localeCompare(b.join(".")),
|
|
62
|
+
)) {
|
|
63
|
+
const value = valueAtPath(data, fieldPath);
|
|
64
|
+
if (typeof value !== "number") {
|
|
65
|
+
// ids are numbers in this API - validate hard
|
|
66
|
+
throw new SDK.ConnectorError(
|
|
67
|
+
SDK.ErrorCode.InvalidApiKey,
|
|
68
|
+
`cannot create composite key: '${fieldPath.join(".")}' is not a number, got '${value}'`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
keyObj[fieldPath.join(".")] = value;
|
|
72
|
+
}
|
|
73
|
+
return JSON.stringify(keyObj);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseCompositeKey(objectId, compositeKey) {
|
|
77
|
+
let id, parentId;
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(compositeKey);
|
|
80
|
+
id = parsed?.id;
|
|
81
|
+
parentId = parsed?.["company.id"];
|
|
82
|
+
} catch {
|
|
83
|
+
throw new SDK.ConnectorError(
|
|
84
|
+
SDK.ErrorCode.InvalidApiKey,
|
|
85
|
+
`failed to parse composite key '${compositeKey}' for object '${objectId}'`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (id == null || parentId == null) {
|
|
89
|
+
throw new SDK.ConnectorError(
|
|
90
|
+
SDK.ErrorCode.InvalidApiKey,
|
|
91
|
+
`composite key '${compositeKey}' for '${objectId}' did not contain a valid id / parentId`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return { id, parentId };
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Per operation
|
|
99
|
+
|
|
100
|
+
### `query()`
|
|
101
|
+
|
|
102
|
+
- `meta.key = makeRowKey(...)` for every row, and also expose it in the data:
|
|
103
|
+
`row.data["$compositeKey"] = key` — so the key is visible and usable as a field.
|
|
104
|
+
- **The row data must contain everything the key encodes.** If the API response omits the
|
|
105
|
+
parent id (common: you fetched `/companies/5678/notes`, so the notes don't repeat it),
|
|
106
|
+
inject it into each row (`apiRow.parentId = parentIdFilter`) before building the key.
|
|
107
|
+
- List queries on a child object run per parent: require `rowFilter.parentId` (validate its
|
|
108
|
+
type, throw `InvalidFilterParameter` otherwise) or arrive via `relatedFilter` from the
|
|
109
|
+
parent object.
|
|
110
|
+
- `idsFilter` delivers composite key **strings** — parse each one and fetch
|
|
111
|
+
`/companies/{parentId}/notes/{id}` (in parallel, under your rate limiter).
|
|
112
|
+
|
|
113
|
+
### `upsert()`
|
|
114
|
+
|
|
115
|
+
- An **update** row carries `$compositeKey`: parse it, build the API path from the decoded
|
|
116
|
+
parts, and **remove `$compositeKey` from the body you send** — it is not a real API field.
|
|
117
|
+
- An **add** row has no key yet: require the parent id as a regular upsert field and fail
|
|
118
|
+
clearly when it's missing:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
const compositeKey = row["$compositeKey"];
|
|
122
|
+
let id, parentId;
|
|
123
|
+
if (compositeKey) {
|
|
124
|
+
({ id, parentId } = parseCompositeKey(options.id, compositeKey));
|
|
125
|
+
delete row["$compositeKey"];
|
|
126
|
+
} else {
|
|
127
|
+
id = row.id;
|
|
128
|
+
parentId = valueAtPath(row, parentIdFieldPath);
|
|
129
|
+
}
|
|
130
|
+
if (parentId == null) {
|
|
131
|
+
throw new SDK.ConnectorError(
|
|
132
|
+
SDK.ErrorCode.InvalidApiKey,
|
|
133
|
+
`'${options.id}' is a child resource - adding/updating requires a '$compositeKey' or a parent id`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- The parent id belongs in the **URL**, not the request body.
|
|
139
|
+
- Returned rows: `meta.key` must be the composite key — `makeRowKey` over the API's echo (add)
|
|
140
|
+
or the exact string you received (update).
|
|
141
|
+
|
|
142
|
+
### `delete()`
|
|
143
|
+
|
|
144
|
+
Parse the key, DELETE against the decoded path, and echo the **exact key string you
|
|
145
|
+
received** back in `meta: { key, deleted: true }` — the platform matches the cached row by
|
|
146
|
+
string equality, so re-encoding risks a mismatch. Include the decoded parts in `data`.
|
|
147
|
+
|
|
148
|
+
## Gotchas
|
|
149
|
+
|
|
150
|
+
1. **Determinism is everything.** Sorted parts, stable stringification, no locale/format
|
|
151
|
+
drift. A key that encodes differently across runs makes every row look new.
|
|
152
|
+
2. **Echo, don't re-encode.** When an operation received a key string, return that exact
|
|
153
|
+
string — parse for the API call, but never rebuild the key from parsed parts unless you
|
|
154
|
+
must.
|
|
155
|
+
3. **Never send `$compositeKey` to the API.** Strip it from bodies after parsing.
|
|
156
|
+
4. **The special case where the child's id IS the parent id** (a 1:1 sub-resource like
|
|
157
|
+
`/companies/{parentId}/billing`) needs no composite key — the row's key is the parent id;
|
|
158
|
+
just map it into the URL slot.
|
|
159
|
+
5. **`upsertClean` skips the key field automatically** ([06](./06-upsert-delete.md)) — that
|
|
160
|
+
applies to `$compositeKey` because it carries `isKey`; the underlying id/parent fields, if
|
|
161
|
+
also present as upsert fields, are compared like any other field.
|
|
162
|
+
6. **Match/related queries must produce the same keys.** When you build rows from a related
|
|
163
|
+
lookup (`relatedFilter`) or reconstruct a row to satisfy `idsFilter`, run the same
|
|
164
|
+
`makeRowKey` — a second code path with its own encoding is how caches get corrupted.
|