@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.
@@ -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 (regenerate instead).
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, url, httpCode) => {
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: [ { id: "modifiedOnOrAfter", type: "string" } ],
36
- propertyFilter: [ { id: "fields", type: "array", arrayType: "string" } ],
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
- Only make a capability conditional when the API forces it: e.g. objects whose REST path embeds
54
- `{parentId}` can't `canQueryList` (there's no "all parents" call) give them `canQueryByIds`
55
- and a `relatedFilter` path instead.
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` / `propertyFilter` | your system-specific filters as declared in `meta().queryFilter` |
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 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).
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
 
@@ -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
@@ -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` / `rowFiltersPrepare` — arrange rows and return what the
112
- suite should expect (row-filter testing is easy to forget: provide it when `meta()` exposes
113
- `queryFilter.rowFilter`).
114
- - `checkpointFilterStep1Prepare` / `Step2Prepare` — step 1 seeds data and captures the
115
- checkpoint; step 2 mutates data the differential query must then surface.
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
- ## Local dev loop
131
-
132
- The harness runs platform-side. Locally you get: `sm validate` (workspace structure),
133
- `checkJs` type checking against the SDK types, `sm push` and a platform-side test run. Keep
134
- `test()` cheap and `meta()` deterministic so the suite stays fast.
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. **Objects with `{parentId}`-style paths can't support `canQueryList`** expose
59
- `canQueryByIds` + a relationship for `relatedFilter` traversal instead.
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
- 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)).
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 run test` require — is
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 run test|meta|query` both require a file of sidecar
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 run test` resolves the test
43
- module from the connector's folder and several candidates are ambiguous.
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; never hand-edit it. Note there are **no** `cannotTestReason` / `sameAsObjectId`
49
- entries here: the generator cannot know those, so the fine-grained scoping knobs are applied
50
- from `mod Test.mjs`'s `before()` (below), on top of this file.
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()`** for `canQueryList`, then `idsFilter`, then `checkpointFilter` (05).
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
 
@@ -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.2",
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": "1e322fcb4cda8d7a31d350d575470adeb2402009ea1929274efe27d7ccb7e041",
15
+ "typesContentHash": "b47810ca30415f646b1055512fb4e342e48f3cd7459411c2dabcc7ca29959271",
16
16
  "dependencies": {
17
17
  "@types/node": "*",
18
- "@syncmatters/script-api": "^1.0.1"
18
+ "@syncmatters/script-api": "^1.0.3"
19
19
  }
20
20
  }