@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,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.