@syncmatters/connector-sdk 1.0.2 → 1.0.4
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/docs/01-workspace-and-meta-json.md +9 -1
- package/docs/03-http-auth-ratelimit.md +5 -1
- package/docs/04-meta-objects-fields.md +66 -6
- package/docs/05-query.md +70 -10
- package/docs/06-upsert-delete.md +12 -0
- package/docs/09-testing.md +97 -10
- package/docs/10-style-and-pitfalls.md +18 -2
- package/docs/11-composite-keys.md +12 -4
- package/docs/12-example-connector.md +17 -1
- package/docs/15-example-test-file.md +8 -6
- package/docs/connector-authoring.md +9 -1
- package/lib/connector.d.ts +2 -2
- package/package.json +3 -3
|
@@ -38,6 +38,12 @@ not exist at runtime.
|
|
|
38
38
|
]
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
> `sm push` pushes `module_script_file_paths` changes (the CLI maps the paths to platform
|
|
42
|
+
> file ids from the workspace). Every referenced file must be tracked — a reference the CLI
|
|
43
|
+
> can't map holds the whole wiring change back with a warning, so imports never half-wire.
|
|
44
|
+
> A new module and the sidecar edits that wire it can go in ONE push: files are created
|
|
45
|
+
> first, wiring follows automatically.
|
|
46
|
+
|
|
41
47
|
Import style in code: static `import` for your main module; dynamic `await import("./acme-v3")`
|
|
42
48
|
for version dispatch so only the selected version's code is loaded:
|
|
43
49
|
|
|
@@ -129,7 +135,9 @@ consent UI: items carry `label` plus `read`/`write`/`full` scope strings and opt
|
|
|
129
135
|
|
|
130
136
|
- `.sm/` (CLI state), and the CLI-generated `package.json` / `jsconfig.json` / `AGENTS.md`
|
|
131
137
|
managed block at the workspace root. They are editor tooling, never pushed.
|
|
132
|
-
- Generated `mod ObjectTestFeatures.mjs` files
|
|
138
|
+
- Generated `mod ObjectTestFeatures.mjs` files — regenerate with `sm connector features`
|
|
139
|
+
(or the web UI's feature-generate action) after `meta()` changes; new relationships/match
|
|
140
|
+
rules are not tested until regenerated ([09-testing.md](./09-testing.md)).
|
|
133
141
|
- System-connector stubs: if a connector's settings include a hidden
|
|
134
142
|
`systemFlag*` boolean, the real implementation is embedded in the platform runtime and the
|
|
135
143
|
`.mjs` stub is deliberately a no-op — don't implement it.
|
|
@@ -76,7 +76,7 @@ async #call(request) {
|
|
|
76
76
|
to wait before that retry. The proven policy shape:
|
|
77
77
|
|
|
78
78
|
```js
|
|
79
|
-
const canRetry = (err, method,
|
|
79
|
+
const canRetry = (err, method, _url, httpCode) => {
|
|
80
80
|
if ([400, 403, 404].includes(httpCode)) return false; // client errors don't heal
|
|
81
81
|
if (method === "GET") return true; // reads are idempotent
|
|
82
82
|
if ([429, 502, 504].includes(httpCode)) return true; // throttle/gateway blips
|
|
@@ -85,6 +85,10 @@ const canRetry = (err, method, url, httpCode) => {
|
|
|
85
85
|
};
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
The parameters are positional — keep every position up to the last one you read, and prefix
|
|
89
|
+
the ones you don't use with `_` (as with `_url` above, or `(_err, method, _url, httpCode)` in
|
|
90
|
+
a shorter policy) so `checkJs` doesn't flag them as unused.
|
|
91
|
+
|
|
88
92
|
Non-idempotent writes: only retry when you know the API tolerates it. One real-world subtlety:
|
|
89
93
|
writes that reference a _freshly created picklist option_ can fail transiently while the option
|
|
90
94
|
propagates — returning ~10000 (10s backoff) from `canRetry` for that specific error is the
|
|
@@ -31,9 +31,13 @@ async meta() {
|
|
|
31
31
|
queryFields: [ /* ObjectField[] returned by query */ ],
|
|
32
32
|
upsertFields: [ /* ObjectField[] accepted by upsert */ ],
|
|
33
33
|
deleteFields: [ /* ObjectField[] needed to identify rows to delete */ ],
|
|
34
|
-
queryFilter: { // system-specific extra filters
|
|
35
|
-
rowFilter: [
|
|
36
|
-
|
|
34
|
+
queryFilter: { // system-specific extra filters (rowFilter only - propertyFilter is deprecated)
|
|
35
|
+
rowFilter: [
|
|
36
|
+
{ id: "companyType", name: "Company type", type: "string",
|
|
37
|
+
optionValues: [{ id: "customer" }, { id: "vendor" }, { id: "other" }],
|
|
38
|
+
// where users may combine this filter with the standard filters (05):
|
|
39
|
+
extends: [{ type: "checkpoint" }, { type: "list" }] },
|
|
40
|
+
],
|
|
37
41
|
},
|
|
38
42
|
relationships: [ // enables related-row queries
|
|
39
43
|
{ id: "companies-companyId", name: "Company", relObjectId: "companies",
|
|
@@ -50,9 +54,9 @@ Other fields: `settingsMeta`/`settingsValues` (per-object settings), `order` (so
|
|
|
50
54
|
rather than setting them `false` — `undefined` and `false` both mean "unsupported", and omitted
|
|
51
55
|
reads cleaner.
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
Set flags only for behaviour you implemented — but investigate the workaround patterns below
|
|
58
|
+
before concluding a capability is impossible. "The API has no list-all endpoint" is not the
|
|
59
|
+
same as "no list support". See [the query capability bar](#the-query-capability-bar).
|
|
56
60
|
|
|
57
61
|
## `ObjectField`
|
|
58
62
|
|
|
@@ -109,6 +113,11 @@ upsertFields.unshift({
|
|
|
109
113
|
`"first_and_last_name[ci]"`, `"field_value_equals[ci]"` (`[ci]` = case-insensitive). Declare
|
|
110
114
|
only rules `query()` actually implements via `matchFilter` — see [05-query.md](./05-query.md).
|
|
111
115
|
|
|
116
|
+
**Commonly forgotten:** an object that supports `idsFilter` can ALWAYS offer
|
|
117
|
+
`"field_value_equals[ci]"` — with `canMatch: true` on its id field, the match is just a
|
|
118
|
+
lookup of the supplied value by id. There is no reason for such an object to declare no
|
|
119
|
+
match rules at all; add this floor even when the API has no search endpoint.
|
|
120
|
+
|
|
112
121
|
### Relationships
|
|
113
122
|
|
|
114
123
|
Relationships are defined on the source object and declare that given a row from the source object, the relationship allows row(s) from another object (`relObjectId`) that relate to this row to be collected. The canonical example is collecting the Company record that a Contact relates to.
|
|
@@ -117,6 +126,57 @@ Relationships are defined on the source object and declare that given a row from
|
|
|
117
126
|
`id` unique per object; `data` is yours (a common pattern stores the FK field name so `query()`
|
|
118
127
|
can build the related filter). Declaring relationships enables `relatedFilter` queries.
|
|
119
128
|
|
|
129
|
+
**Which object declares it?** The one whose row you START from — not necessarily the child:
|
|
130
|
+
|
|
131
|
+
- _Child holds a lookup to its parent_ (Contact → Company): declare on the **child**
|
|
132
|
+
(`contacts` gets `relObjectId: "companies"`, `OneToOne`). Related queries then arrive on
|
|
133
|
+
`companies`.
|
|
134
|
+
- _Parent owns children with no child list endpoint_ (a Flow owns its Executions and the API
|
|
135
|
+
only exposes `/flows/{id}/executions`): declare on the **parent**
|
|
136
|
+
(`flows` gets `relObjectId: "executions"`, `OneToMany`). Related queries then arrive on the
|
|
137
|
+
**child** (`executions`, with `otherObjectId: "flows"`) — that is how consumers reach child
|
|
138
|
+
rows at all, so pair it with `canQueryByIds` on the child
|
|
139
|
+
([05-query.md](./05-query.md#relatedfilter) has the direction rules and a checklist).
|
|
140
|
+
|
|
141
|
+
Declaring it on the wrong object doesn't fail here — it fails later, when
|
|
142
|
+
`relatedFilter` hands your `query()` keys of the wrong object. Decide direction at `meta()` time.
|
|
143
|
+
|
|
144
|
+
## The query capability bar
|
|
145
|
+
|
|
146
|
+
Syncs are built from these capabilities — an object missing one is an object syncs can only
|
|
147
|
+
partially use. **For every object, strive to support all five; omit a flag only after the
|
|
148
|
+
workaround patterns below have failed**, and note why in a comment so the next author doesn't
|
|
149
|
+
re-investigate:
|
|
150
|
+
|
|
151
|
+
1. `canQueryList`
|
|
152
|
+
2. `canQueryByIds`
|
|
153
|
+
3. `canQueryByCheckpoint`
|
|
154
|
+
4. `matchRules` (+ `matchFilter` in `query()`)
|
|
155
|
+
5. `relationships` (+ `relatedFilter` in `query()`)
|
|
156
|
+
|
|
157
|
+
The recurring "the API is awkward" shapes, and the patterns that beat them:
|
|
158
|
+
|
|
159
|
+
- **No cross-parent list endpoint → fan-out list.** Child resources reachable only via
|
|
160
|
+
`/parents/{id}/children` can still `canQueryList`: enumerate the parents (cache their ids in
|
|
161
|
+
`queryState` or a `kvStore`), then page each parent's children in turn. Pair it with an
|
|
162
|
+
optional parent-id `rowFilter` (`extends: [{ type: "list" }, { type: "checkpoint" }]`) so
|
|
163
|
+
callers can scope to one parent and skip the fan-out.
|
|
164
|
+
- **No "changed-since" cursor → checkpoint from what the API has.** A created/modified date
|
|
165
|
+
param (`DateCreatedFrom`, `modified_since`, an OData `$filter`) mapped from
|
|
166
|
+
`checkpointFilter.value` is a legitimate checkpoint implementation — for insert-only
|
|
167
|
+
objects a creation-date high-water mark is exactly right. Keep the overlap rule (05).
|
|
168
|
+
- **Match via whatever search the API offers.** `matchRules` don't need a dedicated search
|
|
169
|
+
API — a filter param on the list endpoint (`To=`, `ContentName=`, `$filter=email eq ...`)
|
|
170
|
+
implements `email[ci]`/`name[ci]` fine. Fetch candidates, compare case-insensitively
|
|
171
|
+
yourself, hand them to `matchFilter.canUse`. And the floor: `canQueryByIds` alone already
|
|
172
|
+
implements `field_value_equals[ci]` on the id field (see Match rules above).
|
|
173
|
+
- **Parent-scoped children → relationship on the PARENT** (see
|
|
174
|
+
[Relationships](#relationships)) so `relatedFilter` traversal works — in ADDITION to
|
|
175
|
+
fan-out list, not instead of it.
|
|
176
|
+
|
|
177
|
+
The flags-are-promises rule ([10](./10-style-and-pitfalls.md)) still holds: implement first,
|
|
178
|
+
then declare. The bar is about how hard to try before giving up, not about optimistic flags.
|
|
179
|
+
|
|
120
180
|
## Building `meta()` maintainably
|
|
121
181
|
|
|
122
182
|
Never hand-maintain a big field list twice. The proven generators, in order of preference:
|
package/docs/05-query.md
CHANGED
|
@@ -77,7 +77,7 @@ Rules that follow from the lifecycle:
|
|
|
77
77
|
| `queryOptions.matchFilter` | find rows matching people/records — below |
|
|
78
78
|
| `queryOptions.relatedFilter` | rows related to another object's rows — below |
|
|
79
79
|
| `queryOptions.randomFilter` | random sample up to `limit` |
|
|
80
|
-
| `queryOptions.rowFilter`
|
|
80
|
+
| `queryOptions.rowFilter` | your system-specific filters as declared in `meta().queryFilter.rowFilter` — may arrive WITH a standard filter when declared `extends` (below) |
|
|
81
81
|
|
|
82
82
|
Throw `SDK.ConnectorError(SDK.ErrorCode.InvalidFilterParameter, ...)` when asked for a filter
|
|
83
83
|
the object didn't declare — it's the single most-thrown error code in the fleet, and it fails
|
|
@@ -104,6 +104,47 @@ the final page; the platform stores it and hands it back as `checkpointFilter.va
|
|
|
104
104
|
rows modified while your query ran would otherwise be lost forever. Re-reading a few rows is
|
|
105
105
|
free; losing one is a data bug.
|
|
106
106
|
|
|
107
|
+
### rowFilter — user-parameterized augmentation of the standard filters
|
|
108
|
+
|
|
109
|
+
The system-specific filters you declared in `meta().queryFilter.rowFilter`
|
|
110
|
+
([04](./04-meta-objects-fields.md)) arrive as `queryOptions.rowFilter` — an object keyed by
|
|
111
|
+
your declared filter ids. Each declaration's **`extends`** array is the contract for
|
|
112
|
+
COMBINING them with the standard filters:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
// meta(): let users scope companies by type, alone or on top of list/checkpoint queries
|
|
116
|
+
queryFilter: {
|
|
117
|
+
rowFilter: [
|
|
118
|
+
{ id: "companyType", name: "Company type", type: "string",
|
|
119
|
+
optionValues: [{ id: "customer" }, { id: "vendor" }, { id: "other" }],
|
|
120
|
+
extends: [{ type: "checkpoint" }, { type: "list" }] },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`extends: [{ type: "checkpoint" | "list" | "match", mandatory? }]` declares which standard
|
|
126
|
+
filters this rowFilter may arrive WITH — so "vendors changed since the checkpoint" is ONE
|
|
127
|
+
query: `queryOptions.checkpointFilter` and `queryOptions.rowFilter.companyType` together,
|
|
128
|
+
AND semantics. `mandatory: true` means the standard filter never arrives on this object
|
|
129
|
+
without the rowFilter (e.g. a child object whose list endpoint requires a parent id). Without
|
|
130
|
+
`extends` the filter stands alone.
|
|
131
|
+
|
|
132
|
+
In `query()`, treat each declared rowFilter as one more constraint alongside whatever
|
|
133
|
+
standard filter drives the query:
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
const companyType = options.queryOptions?.rowFilter?.companyType;
|
|
137
|
+
if (companyType) params.push(`type=${encodeURIComponent(companyType)}`); // AND with modified_since etc.
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
and keep throwing `InvalidFilterParameter` for filters the object didn't declare. The test
|
|
141
|
+
contract has a `rowFiltersPrepare` hook, but the suite does not exercise rowFilters yet —
|
|
142
|
+
cover yours with a direct query in another prepare hook
|
|
143
|
+
([09-testing.md](./09-testing.md)).
|
|
144
|
+
|
|
145
|
+
> `queryFilter.propertyFilter` is **deprecated** — the standard `fields` filter supersedes it.
|
|
146
|
+
> Don't declare it in new connectors ([10](./10-style-and-pitfalls.md)).
|
|
147
|
+
|
|
107
148
|
### matchFilter
|
|
108
149
|
|
|
109
150
|
`{ rule, selectOrder?, destFieldPath?, srcData: [{ srcRowId, match: { id?, name?, email?, domain?, firstname?, lastname?, custom? } }], canUse }`.
|
|
@@ -125,15 +166,34 @@ candidates }])` — the platform reserves un-mapped matches and returns the sele
|
|
|
125
166
|
Receives rows from a source object (`otherObjectId`) that has a defined relationship to this object, and collect the related rows from this object.
|
|
126
167
|
|
|
127
168
|
`{ otherObjectId, otherRelationshipId, otherRows }`. DIRECTION: the platform queries the
|
|
128
|
-
relationship's TARGET object and passes the DECLARING object's rows — contacts
|
|
129
|
-
contact→company, so a related query arrives on `companies` with
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
`
|
|
133
|
-
`
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
169
|
+
relationship's TARGET object (`relObjectId`) and passes the DECLARING object's rows — contacts
|
|
170
|
+
declare contact→company, so a related query arrives on `companies` with
|
|
171
|
+
`otherObjectId: "contacts"`. The same rule covers the inverse, parent-owns-children shape:
|
|
172
|
+
when the PARENT declares `flows → executions` (`OneToMany`, because the API only exposes
|
|
173
|
+
`/flows/{id}/executions`), the related query arrives on the CHILD (`executions`) with
|
|
174
|
+
`otherObjectId: "flows"` and `otherRows` = flow rows — your child `query()` reads each parent
|
|
175
|
+
row's key and calls the nested endpoint. Which object declares the relationship is a `meta()`
|
|
176
|
+
decision — see [04-meta-objects-fields.md](./04-meta-objects-fields.md#relationships).
|
|
177
|
+
|
|
178
|
+
The implementation checklist (each step has been gotten wrong in a real connector):
|
|
179
|
+
|
|
180
|
+
1. Declare the relationship on the SOURCE object in `meta()` (the object rows start from —
|
|
181
|
+
child for lookups, parent for owned children).
|
|
182
|
+
2. Handle `queryOptions.relatedFilter` in the TARGET object's `query()` (`relObjectId`'s) —
|
|
183
|
+
not the declaring object's.
|
|
184
|
+
3. Resolve the relationship definition from the OTHER object's metadata:
|
|
185
|
+
`await options.objectMeta(related.otherObjectId)` then find `otherRelationshipId` in its
|
|
186
|
+
`relationships`. It is NOT in `options.meta` — that is this object's metadata, which does
|
|
187
|
+
not declare the relationship. (`relationship.data` typically holds the FK field name.)
|
|
188
|
+
4. Collect the linking values from `otherRows` — for a lookup they are FK values naming THIS
|
|
189
|
+
object's keys; for parent-owned children they are the parent keys your nested endpoint
|
|
190
|
+
needs.
|
|
191
|
+
5. Return the related rows tagged with
|
|
192
|
+
`relationship: { srcRowId, srcObjectId: otherObjectId, relationshipId: otherRelationshipId }` —
|
|
193
|
+
one row instance per source row it relates to (the same related row appears once per
|
|
194
|
+
source row).
|
|
195
|
+
|
|
196
|
+
See the worked implementation in [12-example-connector.md](./12-example-connector.md).
|
|
137
197
|
|
|
138
198
|
## Cross-page scratch state: `kvStore`
|
|
139
199
|
|
package/docs/06-upsert-delete.md
CHANGED
|
@@ -59,6 +59,18 @@ Inspect the API's per-row results. When a row fails, log the response and throw
|
|
|
59
59
|
(If your API supports partial success and the platform batch mode is `"atomic"`, failing the
|
|
60
60
|
whole batch by throwing is correct; don't fabricate per-row placeholders.)
|
|
61
61
|
|
|
62
|
+
### Insert-only objects
|
|
63
|
+
|
|
64
|
+
Some objects create but never update — messages that send on create, execution/run records,
|
|
65
|
+
audit entries. Implement the insert path normally and throw
|
|
66
|
+
`SDK.ConnectorError(SDK.ErrorCode.NotImplimented, ...)` for rows that would update (a row
|
|
67
|
+
arriving with its key set). Two consequences: the object's test spec declares
|
|
68
|
+
`upsert.insertOnly` so the harness tests the insert path and skips the update phase —
|
|
69
|
+
`Passed (insert only)` — see
|
|
70
|
+
[09-testing.md](./09-testing.md#insert-only--immutable-objects); and if creating a row has
|
|
71
|
+
billable or externally visible side effects (a real message send), make sure test connections
|
|
72
|
+
use sandbox/test credentials or a no-send setting.
|
|
73
|
+
|
|
62
74
|
## `delete(options)` → `Promise<SDK.Row[]>`
|
|
63
75
|
|
|
64
76
|
`options.rows` carry the identifying fields you declared in `deleteFields` (usually just the
|
package/docs/09-testing.md
CHANGED
|
@@ -108,12 +108,26 @@ prepare hooks skip their test when they return `undefined` — with two exceptio
|
|
|
108
108
|
To disable those tests use `cannotTestReason` instead
|
|
109
109
|
([15-example-test-file.md](./15-example-test-file.md) shows the worked semantics):
|
|
110
110
|
|
|
111
|
-
- `listPrepare` / `idsFilterPrepare`
|
|
112
|
-
|
|
113
|
-
`queryFilter.rowFilter
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
- `listPrepare` / `idsFilterPrepare` — arrange rows and return what the suite should expect.
|
|
112
|
+
(`rowFiltersPrepare` exists in the contract but the suite does NOT run rowFilter tests yet —
|
|
113
|
+
when `meta()` exposes `queryFilter.rowFilter`, cover it yourself with a direct query in a
|
|
114
|
+
hook you already implement.)
|
|
115
|
+
- `checkpointFilterStep1Prepare` / `Step2Prepare` — step 1 returns `expectedRowIds` for a
|
|
116
|
+
query from your chosen starting checkpoint (`nextCheckpoint`); the harness runs it and
|
|
117
|
+
captures the checkpoint the query emits. Step 2 receives that checkpoint
|
|
118
|
+
(`{ meta, step1TestData, checkpointReceived }`) and returns the `expectedRowIds` a query
|
|
119
|
+
FROM it must surface. Mutating data between the steps is one way to produce step-2 rows —
|
|
120
|
+
but not required: on APIs where you can't create rows in-test, pick a step-1 checkpoint far
|
|
121
|
+
enough back that existing rows fall on both sides (remember your connector's overlap window
|
|
122
|
+
re-surfaces recent rows in step 2 — include them in `expectedRowIds`).
|
|
116
123
|
- `matchFilterPrepare` — returns `MatchData` including `expectedMatchRowIds`.
|
|
124
|
+
- `relatedFilterPrepare({ fromObjectMeta, toObjectMeta, fromRelationshipId })` —
|
|
125
|
+
`fromObjectMeta` is the object where the relationship is DECLARED (the parent, in the
|
|
126
|
+
parent-owns-children shape), `toObjectMeta` is its `relObjectId`. Return
|
|
127
|
+
`{ srcRowId, expectedRelatedRowIds, ... }[]` where `srcRowId` is a row of the DECLARING
|
|
128
|
+
object; the harness fetches it via `idsFilter`, then queries the related object with
|
|
129
|
+
`relatedFilter` and compares against `expectedRelatedRowIds`. The test spec side
|
|
130
|
+
(`relationshipsTo`) also lives on the declaring object's features.
|
|
117
131
|
- `upsertPrepare` — returns `UpsertData`; use `withIssueSimulators` / `UpsertIssueSimulator`
|
|
118
132
|
(`verifyFields`, expected issue `type` + `fatal`) to deliberately exercise your
|
|
119
133
|
`upsertClean` issue reporting.
|
|
@@ -121,14 +135,87 @@ To disable those tests use `cannotTestReason` instead
|
|
|
121
135
|
checks a flag field instead of row absence, and `afterDeleteMaxIndexWaitTimeMs` tolerates
|
|
122
136
|
APIs whose deletions surface asynchronously.
|
|
123
137
|
|
|
138
|
+
## What each test needs from your sandbox data
|
|
139
|
+
|
|
140
|
+
Requirements that only surface as cryptic failures when unmet:
|
|
141
|
+
|
|
142
|
+
- **idsFilter needs two distinct ids.** `idsFilterPrepare` should return `{ id1, id2 }`; ids
|
|
143
|
+
you omit are collected via a list query when the object `canQueryList` — an object with only
|
|
144
|
+
one row (or no list support and no prepare hook) fails with `"No test data"`. Each id is
|
|
145
|
+
then queried alone and must return exactly that one row.
|
|
146
|
+
- **The upsert test is insert THEN update, twice over.** `upsertPrepare` is called twice; both
|
|
147
|
+
rows are inserted, re-queried by `idsFilter` and compared on `verifyFields` (which must be
|
|
148
|
+
non-null on the payload), then BOTH are updated via the declared `upsert.key` and re-queried
|
|
149
|
+
again. Objects whose rows can never update declare `upsert.insertOnly` — see below.
|
|
150
|
+
- **relatedFilterPrepare needs a declaring-object row that actually has related rows** —
|
|
151
|
+
`expectedRelatedRowIds` must be non-empty. In the parent-owns-children shape that means a
|
|
152
|
+
parent with at least one child in the sandbox (create one in `before()` when the API allows;
|
|
153
|
+
otherwise seed the sandbox account once and document it in the test file).
|
|
154
|
+
- **Checkpoint step 2 does not have to create data** — see the hook notes above.
|
|
155
|
+
|
|
156
|
+
## Insert-only / immutable objects
|
|
157
|
+
|
|
158
|
+
Some objects create rows but never update them: messages that send on create and are
|
|
159
|
+
immutable after (`update` correctly throws `NotImplimented`), execution/run records, audit
|
|
160
|
+
logs. Declare that contract on the test spec and the harness tests exactly what exists:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
// in mod Test.mjs's before(), over the generated spec (same place as cannotTestReason)
|
|
164
|
+
objectFeatures.messages.upsert.insertOnly = true;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
With `upsert.insertOnly` the upsert test runs its insert + query-back + `verifyFields`
|
|
168
|
+
phases as normal and SKIPS the update phase, reporting **`Passed (insert only)`** — a full
|
|
169
|
+
pass, with the result string honest about what was covered. `upsertPrepare` then only needs
|
|
170
|
+
`insert` and `verifyFields` (no `update` data). The `upsert.key` spec is still required —
|
|
171
|
+
the inserted rows are re-queried by id. Requires a platform runner that understands the
|
|
172
|
+
flag; older runners fail such objects with `"No test data for update"`.
|
|
173
|
+
|
|
174
|
+
Escalate to the blunter knobs only when the situation is stricter than "no update":
|
|
175
|
+
|
|
176
|
+
- `upsert.cannotTestReason` (with a REAL explanation) when even CREATING rows is off-limits —
|
|
177
|
+
a client's production system, or a billable/externally visible side effect with no safe
|
|
178
|
+
path. The object reports `Not tested` + reason and the suite result is `Partial`.
|
|
179
|
+
(`delete.cannotTestReason` likewise when rows cannot be deleted.)
|
|
180
|
+
- **Billable or externally visible side effects** (a real SMS/WhatsApp send, an email) must
|
|
181
|
+
not fire from tests: point the test connection at sandbox/test credentials (e.g. Twilio's
|
|
182
|
+
test credentials with magic numbers), or gate sends behind a connector setting the test
|
|
183
|
+
connection sets (a "suppress sends" / no-send mode) — then `insertOnly` still gives the
|
|
184
|
+
insert path real coverage safely.
|
|
185
|
+
|
|
124
186
|
## Coverage bar
|
|
125
187
|
|
|
126
188
|
Target **80%+ code coverage** (statements/branches/functions/lines) from the platform's
|
|
127
189
|
connector test runs — reports are downloadable per run and retained ~90 days. Structure
|
|
128
190
|
`mod Test.mjs` so every object with declared capabilities is exercised rather than scoped out.
|
|
129
191
|
|
|
130
|
-
##
|
|
131
|
-
|
|
132
|
-
The harness
|
|
133
|
-
|
|
134
|
-
|
|
192
|
+
## Running the suite
|
|
193
|
+
|
|
194
|
+
The harness always executes platform-side. What each entry point actually runs:
|
|
195
|
+
|
|
196
|
+
| Command | What runs |
|
|
197
|
+
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
198
|
+
| `sm test` | The FULL suite — `connection.test()` + `metaRefresh()` + every object feature above. Identical to the web UI's connector test. |
|
|
199
|
+
| `sm test --only connection` | `connection.test()` only — credentials check, nothing else. The harness has NOT run. |
|
|
200
|
+
| `sm test --only meta` | `connection.metaRefresh()` only. |
|
|
201
|
+
| `sm test --only query` | Every object's plain list query only — no ids/checkpoint/match/related, no writes. |
|
|
202
|
+
| Web UI (connector → test results → run) | Same engine, feature selection via the run dialog. |
|
|
203
|
+
|
|
204
|
+
Do not mistake a passing `--only connection` or `--only query` for suite coverage — only the
|
|
205
|
+
full run exercises the prepare hooks and write features. `sm test` needs a developer token
|
|
206
|
+
with the `scripts:read`, `connections:read` and `scripts:execute` scopes; a 403 from the CLI
|
|
207
|
+
names the missing one. The dev loop around it: `sm validate` (workspace structure), `checkJs`
|
|
208
|
+
type checking against the SDK types, `sm push`, then `sm test`. Keep `test()` cheap and
|
|
209
|
+
`meta()` deterministic so the suite stays fast.
|
|
210
|
+
|
|
211
|
+
Two workspace facts that bite here:
|
|
212
|
+
|
|
213
|
+
- `mod ObjectTestFeatures.mjs` is **generated** — regenerate it with
|
|
214
|
+
`sm connector features` (or the web UI's feature-generate action) after `meta()` changes;
|
|
215
|
+
new relationships/match rules will NOT be tested until you do, because the generated
|
|
216
|
+
`relationshipsTo`/`matchRules` spec still reflects the old metadata. The loop is:
|
|
217
|
+
edit `meta()` → `sm push` → `sm connector features` → `sm test`.
|
|
218
|
+
- The typed test connection (the `API.connections.<connection_name>.Connection` `@typedef` in
|
|
219
|
+
the `mod Test.mjs` example above) resolves from **generated** typings:
|
|
220
|
+
`types/connections/*.d.ts` in a CLI workspace (`sm types` refreshes them; `sm pull` runs it
|
|
221
|
+
automatically) — never hand-write these.
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
`/** @param {SDK.InitArgs} args @returns {Promise<void>} */`. Fields:
|
|
7
7
|
`/** @type {SDK.HttpClient | undefined} */ #http;`. Complex local types: `@typedef` blocks at
|
|
8
8
|
the end of the file. Escape hatches (`/** @type any */ (x)`, `@ts-ignore`) sparingly.
|
|
9
|
+
- **Prefix unused positional parameters with `_`** — callback signatures like
|
|
10
|
+
`canRetry(err, method, url, httpCode)` are positional, so a policy that only reads
|
|
11
|
+
`method`/`httpCode` still declares the earlier slots: `(_err, method, _url, httpCode)`.
|
|
12
|
+
The `_` prefix is what keeps `checkJs`/editors from flagging them as unused.
|
|
9
13
|
- **Private `#fields`** for all connector state (`#log`, `#http`, `#token`, `#settings`).
|
|
10
14
|
- First line of `init()`: `SDK.verifyType(this);`.
|
|
11
15
|
- **Double quotes**, semicolons, CRLF line endings.
|
|
@@ -22,6 +26,7 @@
|
|
|
22
26
|
| `cacheRefreshOptions()` / `RefreshSpec` / `QueryPage.cacheState` | checkpoint queries ([05-query.md](./05-query.md)) |
|
|
23
27
|
| `RateLimiter.take()` | `rateLimiter.execute({ cost, exec })` — or just pass the limiter to `httpClient` |
|
|
24
28
|
| `new SDK.HttpClient(...)` / `new SDK.RateLimiter(...)` classes | `SDK.utilities.httpClient(...)` / `SDK.utilities.rateLimiter(...)` |
|
|
29
|
+
| `queryFilter.propertyFilter` / `queryOptions.propertyFilter` | the standard `fields` filter (`queryOptions.fields`) — don't declare propertyFilter in new connectors |
|
|
25
30
|
|
|
26
31
|
## The trap list (each one has bitten a real connector)
|
|
27
32
|
|
|
@@ -55,8 +60,12 @@
|
|
|
55
60
|
11. **`NotImplimented` is spelled that way.** The enum member (value `CONN_NOT_IMPLEMENTED`)
|
|
56
61
|
and several long-standing message strings use the misspelling — it is load-bearing; match
|
|
57
62
|
it exactly.
|
|
58
|
-
12. **
|
|
59
|
-
|
|
63
|
+
12. **Don't give up on `canQueryList`/`canQueryByCheckpoint` for `{parentId}`-scoped
|
|
64
|
+
objects.** The naive reading ("no list-all endpoint → no list") under-delivers: fan out
|
|
65
|
+
over the parents, offer a parent-id `rowFilter` (`extends` list/checkpoint), and map a
|
|
66
|
+
creation/modified-date param to the checkpoint — see
|
|
67
|
+
[the query capability bar](./04-meta-objects-fields.md#the-query-capability-bar). Declare
|
|
68
|
+
the parent relationship as well (`relatedFilter` traversal), not instead.
|
|
60
69
|
13. **Don't log secrets.** Debug logging of requests/responses must be gated behind an
|
|
61
70
|
advanced `debug` setting, and auth headers/tokens should be excluded or masked.
|
|
62
71
|
14. **A hidden `systemFlag*` setting means the connector is a runtime-embedded stub** — the
|
|
@@ -65,6 +74,13 @@
|
|
|
65
74
|
only when `queryOptions.fields` asks for it ([13-file-fields.md](./13-file-fields.md)) —
|
|
66
75
|
the `queryFile`/`queryOptions.file`/`Row.file` trio in older connectors is legacy; don't
|
|
67
76
|
copy it.
|
|
77
|
+
16. **A relationship declared on the wrong object fails far from the mistake.** Declare it on
|
|
78
|
+
the object rows START from — the child for a lookup (contact→company), the PARENT for
|
|
79
|
+
owned children with no child list endpoint (flow→executions). Get it backwards and
|
|
80
|
+
`relatedFilter` arrives on the wrong object carrying the wrong kind of keys — which
|
|
81
|
+
typically surfaces as a composite-key parse error or "row not found", nowhere near
|
|
82
|
+
`meta()`. Direction rules: [04](./04-meta-objects-fields.md#relationships),
|
|
83
|
+
[05](./05-query.md#relatedfilter).
|
|
68
84
|
|
|
69
85
|
## Useful utilities you might otherwise reimplement
|
|
70
86
|
|
|
@@ -23,6 +23,11 @@ loudly on garbage, and self-documents which part is which. (A delimiter join lik
|
|
|
23
23
|
`5678:1234` is acceptable only when the parts can never contain the delimiter — and it's
|
|
24
24
|
harder to validate; prefer JSON.)
|
|
25
25
|
|
|
26
|
+
The property names need not be field paths — any fixed, self-describing ids satisfy the rules,
|
|
27
|
+
e.g. the API's own resource ids: `{"execution_sid":"FN...","flow_sid":"FW..."}`. What is
|
|
28
|
+
non-negotiable is that the property SET and ORDER are fixed: sort the names, always emit every
|
|
29
|
+
part, so the same row encodes byte-identically forever.
|
|
30
|
+
|
|
26
31
|
> Changing the encoding of an existing object is a breaking change: every cached row's key
|
|
27
32
|
> stops matching and the platform sees an entirely new set of rows. Pick the encoding once.
|
|
28
33
|
|
|
@@ -44,10 +49,13 @@ not conflict with real fielf identifiers on the object. When the caller maps val
|
|
|
44
49
|
discrete key fields AND supplies `$compositeKey`, cross-check them against the parsed key parts
|
|
45
50
|
and throw on mismatch; when the discrete fields are absent, populate them from the parsed key.
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
(`{ id: "parentId", type: "number"
|
|
50
|
-
|
|
52
|
+
`{parentId}`-path objects have no "fetch everything" endpoint, but that does NOT mean no
|
|
53
|
+
`canQueryList`: fan out over the parents (enumerate them, page each parent's children), offer
|
|
54
|
+
a parent-id `rowFilter` (`{ id: "parentId", type: "number", extends: [{ type: "list" }, { type: "checkpoint" }] }`)
|
|
55
|
+
so callers can scope to one parent, AND declare the relationship on the parent so rows also
|
|
56
|
+
arrive via `relatedFilter` — see
|
|
57
|
+
[the query capability bar](./04-meta-objects-fields.md#the-query-capability-bar) and
|
|
58
|
+
[05](./05-query.md).
|
|
51
59
|
|
|
52
60
|
## The helper pair
|
|
53
61
|
|
|
@@ -6,7 +6,7 @@ business unit + order id) — plus a contact→company relationship, static + AP
|
|
|
6
6
|
custom fields, and full `query` / `upsert` / `delete`. Every pattern here follows the
|
|
7
7
|
production fleet; copy this shape and adapt it. Topic-file cross-references are in comments.
|
|
8
8
|
Its test-file counterpart — the `mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair the
|
|
9
|
-
platform test suite and `sm
|
|
9
|
+
platform test suite and the CLI's `sm test` require — is
|
|
10
10
|
[15-example-test-file.md](./15-example-test-file.md).
|
|
11
11
|
|
|
12
12
|
The imaginary Acme API: bearer-token REST at `https://api.example.com/v1/`, cursor pagination
|
|
@@ -101,6 +101,15 @@ export default class Acme {
|
|
|
101
101
|
canQueryByIds: true,
|
|
102
102
|
canQueryByCheckpoint: true,
|
|
103
103
|
matchRules: ["id", "name[ci]"],
|
|
104
|
+
queryFilter: {
|
|
105
|
+
// system-specific filter users may apply alone OR on top of list/checkpoint
|
|
106
|
+
// queries ('extends') - "vendors changed since the checkpoint" is one query (05)
|
|
107
|
+
rowFilter: [
|
|
108
|
+
{ id: "companyType", name: "Company type", type: "string",
|
|
109
|
+
optionValues: [{ id: "customer" }, { id: "vendor" }, { id: "other" }],
|
|
110
|
+
extends: [{ type: "checkpoint" }, { type: "list" }] },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
104
113
|
queryFields: [
|
|
105
114
|
{ id: "id", name: "ID", type: "string", isKey: true, canMatch: true },
|
|
106
115
|
{ id: "name", name: "Name", type: "string", canMatch: true },
|
|
@@ -235,6 +244,10 @@ export default class Acme {
|
|
|
235
244
|
// undefined value = first differential run: return everything (05)
|
|
236
245
|
params.push(`modified_since=${encodeURIComponent(qo.checkpointFilter.value || "1970-01-01T00:00:00Z")}`);
|
|
237
246
|
}
|
|
247
|
+
// declared rowFilter (extends list/checkpoint): one more AND constraint when present (05)
|
|
248
|
+
if (options.id === "companies" && qo.rowFilter?.companyType) {
|
|
249
|
+
params.push(`type=${encodeURIComponent(qo.rowFilter.companyType)}`);
|
|
250
|
+
}
|
|
238
251
|
const resp = await this.#call({ method: "GET", url: `${options.id}?${params.join("&")}` });
|
|
239
252
|
|
|
240
253
|
/** @type {SDK.Row[]} */
|
|
@@ -578,6 +591,9 @@ function parseSalesOrderKey(compositeKey) {
|
|
|
578
591
|
8. **Match queries end with `canUse`** — the platform picks the winners, you just supply
|
|
579
592
|
candidates. `first_and_last_name[ci]` requires BOTH parts; sources missing either are
|
|
580
593
|
skipped, never half-matched.
|
|
594
|
+
9. **The `companyType` rowFilter declares `extends`** — users can apply it alone or ON TOP of
|
|
595
|
+
list/checkpoint queries; in `query()` it is one more AND constraint next to
|
|
596
|
+
`modified_since`, not a separate code path.
|
|
581
597
|
9. **`upsertClean` runs the mutating verifies first, `detectFirstChange` last**, and only the
|
|
582
598
|
object that declares `canUpsertClean` (contacts) is cleaned.
|
|
583
599
|
10. **Upsert output is index-mapped back to input order** and throws rather than guessing when
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The test-file counterpart to [12-example-connector.md](./12-example-connector.md): the complete
|
|
4
4
|
`mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair for the Acme connector, on one page. The
|
|
5
|
-
platform's connector test suite AND `sm
|
|
5
|
+
platform's connector test suite AND the CLI's `sm test` both require a file of sidecar
|
|
6
6
|
type `"connectortest"` in the connector's folder — **a connector without one cannot be
|
|
7
7
|
run-tested at all**. Harness conventions and hook semantics live in
|
|
8
8
|
[09-testing.md](./09-testing.md); this page shows them applied end to end against Acme's three
|
|
@@ -39,15 +39,17 @@ generated module via `module_script_file_paths`:
|
|
|
39
39
|
}
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Keep exactly one `"connectortest"` file per connector folder — `sm
|
|
43
|
-
module from the connector's
|
|
42
|
+
Keep exactly one `"connectortest"` file per connector folder — `sm test` resolves the test
|
|
43
|
+
module from the folder beside the connector's entry file, and several candidates are ambiguous
|
|
44
|
+
(`<Name>.test.mjs` wins when present).
|
|
44
45
|
|
|
45
46
|
## `files/Connectors/Acme/mod ObjectTestFeatures.mjs` (generated)
|
|
46
47
|
|
|
47
48
|
Generated from the sandbox connection's cached `meta()` — regenerate it whenever `meta()`
|
|
48
|
-
changes
|
|
49
|
-
|
|
50
|
-
from `mod Test.mjs`'s
|
|
49
|
+
changes (`sm connector features`, or the web UI's feature-generate action); never hand-edit
|
|
50
|
+
it. Note there are **no** `cannotTestReason` / `sameAsObjectId` entries here: the generator
|
|
51
|
+
cannot know those, so the fine-grained scoping knobs are applied from `mod Test.mjs`'s
|
|
52
|
+
`before()` (below), on top of this file.
|
|
51
53
|
|
|
52
54
|
```js
|
|
53
55
|
/**
|
|
@@ -147,12 +147,20 @@ Implementing more methods unlocks more platform capability:
|
|
|
147
147
|
3. **Make `test()` real**: cheapest authenticated call the API offers (02).
|
|
148
148
|
4. **Describe objects** in `meta()` (04). Start with one object and its `isKey` field; grow from
|
|
149
149
|
there. `SDK.utilities.fieldsFromJSON(sample)` can draft field lists from a sample response.
|
|
150
|
-
5. **Implement `query()
|
|
150
|
+
5. **Implement `query()` capabilities in order**: list → ids → checkpoint → match → related
|
|
151
|
+
(05). For EVERY object aim for all five — syncs are built from them. When the API is
|
|
152
|
+
awkward (parent-scoped paths, no changed-since cursor), work the patterns in
|
|
153
|
+
[the query capability bar](./04-meta-objects-fields.md#the-query-capability-bar) (fan-out
|
|
154
|
+
list, `rowFilter` + `extends`, creation-date checkpoints, match via search params) before
|
|
155
|
+
leaving a flag unset.
|
|
151
156
|
6. **Implement writes** if needed: `upsert()`, `delete()` (06).
|
|
152
157
|
7. **Validate + push**: `sm validate`, `sm status` / `sm diff`, `sm push -m "message"`.
|
|
153
158
|
A push 409 means the platform copy changed: `sm pull`, merge, push again — never force.
|
|
154
159
|
8. **Test harness**: add `mod Test.mjs` (+ generated `mod ObjectTestFeatures.mjs`) so the
|
|
155
160
|
platform's connector test suite can exercise your objects (09; worked example: 15).
|
|
161
|
+
9. **Run it**: `sm test` = the full suite (identical to the web UI's connector test — including
|
|
162
|
+
write features, so use a sandbox connection); `--only connection|meta|query` narrows. A
|
|
163
|
+
passing `--only` run is NOT suite coverage (09).
|
|
156
164
|
|
|
157
165
|
## Support
|
|
158
166
|
|
package/lib/connector.d.ts
CHANGED
|
@@ -116,9 +116,9 @@ export interface QueryOptions {
|
|
|
116
116
|
matchFilter?: QueryMatchFilter;
|
|
117
117
|
/** collect records changed since a particular checkpoint */
|
|
118
118
|
checkpointFilter?: QueryCheckpointFilter;
|
|
119
|
-
/** system specific row filters */
|
|
119
|
+
/** system specific row filters; allowed values are defined in meta.queryFilter.rowFilter */
|
|
120
120
|
rowFilter?: any;
|
|
121
|
-
/** system specific property filters */
|
|
121
|
+
/** @deprecated (use standard fields filter) system specific property filters; allowed values are defined in meta.queryFilter.propertyFilter */
|
|
122
122
|
propertyFilter?: any;
|
|
123
123
|
};
|
|
124
124
|
queryState?: any;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncmatters/connector-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "TypeScript type definitions for the SyncMatters connector SDK (types only - connectors execute on the SyncMatters platform)",
|
|
5
5
|
"types": "./index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"author": "SyncMatters",
|
|
14
14
|
"homepage": "https://syncmatters.com",
|
|
15
|
-
"typesContentHash": "
|
|
15
|
+
"typesContentHash": "b47810ca30415f646b1055512fb4e342e48f3cd7459411c2dabcc7ca29959271",
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@types/node": "*",
|
|
18
|
-
"@syncmatters/script-api": "^1.0.
|
|
18
|
+
"@syncmatters/script-api": "^1.0.3"
|
|
19
19
|
}
|
|
20
20
|
}
|