@syncmatters/connector-sdk 1.0.18 → 1.0.20

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.
@@ -34,6 +34,7 @@ export const objectFeatures = {
34
34
  matchRules: [{ rule: "email[ci]" }],
35
35
  canMatchFields: toJsonPaths(["id", "email", "name"]),
36
36
  relationshipsTo: [{ id: "companies-companyId", relObjectId: "companies" }],
37
+ key: { path: toJsonPath("id") }, // the query field whose value becomes row.meta.key
37
38
  },
38
39
  upsert: { canUpsert: true, key: { path: toJsonPath("id") }, canUpsertClean: true },
39
40
  delete: { canDelete: true, key: { path: toJsonPath("id") } },
@@ -72,7 +73,8 @@ export default class Test {
72
73
 
73
74
  // prepare hooks - create/arrange the data a given test needs, return what it should expect:
74
75
  // listPrepare, idsFilterPrepare, checkpointFilterStep1Prepare / Step2Prepare,
75
- // matchFilterPrepare, relatedFilterPrepare, upsertPrepare
76
+ // matchFilterPrepare, relatedFilterPrepare, rowFiltersPrepare, upsertPrepare
77
+ // and, for what is not shaped like a feature, customChecks() (below)
76
78
  }
77
79
  ```
78
80
 
@@ -109,9 +111,14 @@ To disable those tests use `cannotTestReason` instead
109
111
  ([15-example-test-file.md](./15-example-test-file.md) shows the worked semantics):
110
112
 
111
113
  - `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.)
114
+ - `rowFiltersPrepare` — one `RowFilterData { rowFilter, expectedRowIds, fields?, expect? }` per
115
+ case, for the filters `meta()` declares in `queryFilter.rowFilter`. The harness queries each
116
+ with its `rowFilter` (and `fields`), in order, and the rows must be exactly `expectedRowIds`;
117
+ the first case that fails decides the object's `rowFilter` status (`Row not returned`,
118
+ `Returned unexpected row`, or the `expect` failures below), details under `rowFilter`. Row
119
+ filters have no `ObjectFeatures` flag, so a suite without the hook (or one that returns
120
+ `undefined`) reads `Not tested` with the reason and the object is NOT made Partial; with
121
+ neither the hook nor declared row filters the status is `Not supported`.
115
122
  - `checkpointFilterStep1Prepare` / `Step2Prepare` — the two-step round-trip test of your
116
123
  differential contract; see [Checkpoint testing](#checkpoint-testing-what-the-two-steps-prove)
117
124
  below, it is the most-misimplemented pair.
@@ -132,6 +139,235 @@ To disable those tests use `cannotTestReason` instead
132
139
  checks a flag field instead of row absence, and `afterDeleteMaxIndexWaitTimeMs` tolerates
133
140
  APIs whose deletions surface asynchronously.
134
141
 
142
+ ## Expected values and errors: the `expect` clause
143
+
144
+ The data every prepare hook returns (`ListData`, `IdsFilterData`, `CheckpointStep1Data`,
145
+ `CheckpointStep2Data`, `MatchData`, `RelationshipData`, `RowFilterData`) takes an optional `expect`, for what a
146
+ row-id comparison cannot prove: that a value comes back in the right form, or that a call fails
147
+ the way it must.
148
+
149
+ ```js
150
+ /** @type {SDKTest.ListData} */
151
+ const listData = {
152
+ pageSize: 2,
153
+ expect: {
154
+ rows: {
155
+ // "*" applies to every row the list returns
156
+ "*": [
157
+ { path: toJsonPath("RowVer"), matches: "^[0-9a-f]{16}$" }, // rowversion as 16 hex chars
158
+ { path: toJsonPath("Guid"), matches: "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" },
159
+ { path: toJsonPath("Never_Set"), absent: true }, // a column the connector must not echo
160
+ ],
161
+ // a row's own meta.key: these win over "*" on the same path
162
+ [alphaKey]: [{ path: toJsonPath("Amount"), equals: 10.5 }],
163
+ },
164
+ },
165
+ };
166
+
167
+ /** @type {SDKTest.IdsFilterData} */
168
+ const idsFilterData = {
169
+ id1: "not-a-number",
170
+ id2: "also-not",
171
+ // the connector must reject these ids; success is a failure of the test
172
+ expect: { error: { code: "SCRIPT_INVALID_INDEX_FILTER_VALUE", messagePattern: "not an integer" } },
173
+ };
174
+ ```
175
+
176
+ **`rows`** maps a row's `meta.key` (or `"*"` for every row) to assertions on `row.data` by path.
177
+ Each assertion is one of:
178
+
179
+ | Matcher | Passes when |
180
+ | ---------------------------------------- | ---------------------------------------------------------------------------------------------- |
181
+ | `equals: value` | the value equals, compared per the field's declared type (below) |
182
+ | `matches: "regex"` | the value (as a string) matches the regex source; for hex, GUIDs and formats |
183
+ | `approx: { value, tolerance }` | the value is a number within `tolerance` of `value` |
184
+ | `present: true` | the path is in the row and its value is not `null` |
185
+ | `absent: true` | the path is not in the row at all; a `null` value is present and fails |
186
+
187
+ `equals` compares by the declared type of the field at the path in the object's
188
+ `queryFields`:
189
+
190
+ - `string` exactly (`"12"` does not equal `12`), `number` numerically (`"20.250"` equals
191
+ `20.25`, so a decimal's scale does not matter), `boolean` strictly, `object` and `array`
192
+ structurally (key order ignored, element order kept).
193
+ - A date: a field with `constraints.dt`, or a `format` naming a date (`date`, `date-time`,
194
+ `datetime`, `iso8601`, `unix`, `unix_ms`, a `yyyy-MM-dd`-style pattern), compares as an
195
+ instant at the precision its format implies: days for date-only formats, seconds for
196
+ `unix` and `yyyy-MM-dd HH:mm:ss`, milliseconds otherwise. So `"2026-09-27T10:00:00+02:00"`
197
+ equals `"2026-09-27T08:00:00Z"`.
198
+ - A path with no declared field compares structurally.
199
+
200
+ The details say which comparison each assertion used (`number`, `instant (seconds)`,
201
+ `structural (no declared field)`...).
202
+
203
+ A keyed row that the query did not return is a mismatch. The list reads one page plus one row
204
+ (`pageSize + 1`), so key only rows inside that read, or use `"*"`. If you pass `fields`, include
205
+ the paths you assert on — except `absent` ones. The query only returns what it asked for.
206
+
207
+ **`error`** makes the feature pass only when the operation throws, and throws like this: `code`
208
+ must equal the error's `code` (a `ConnectorError`/`ScriptError` code), and `messagePattern` is a
209
+ regex over its message. With neither, any error passes. The actual error — or, when the call
210
+ succeeded, the rows it returned — is recorded in the details either way. What must fail:
211
+
212
+ | Data | The operation `expect.error` applies to |
213
+ | --------------------- | ----------------------------------------------------------------------------------------------- |
214
+ | `ListData` | the list query |
215
+ | `IdsFilterData` | one query for `[id1, id2]`, which replaces the usual three. `id1`/`id2` must come from the hook; the harness does not look up ids from a list for an expected error |
216
+ | `CheckpointStep1Data` | the step 1 query. Step 2 is then skipped, since there is no emitted checkpoint |
217
+ | `CheckpointStep2Data` | the step 2 query, from the checkpoint step 1 emitted |
218
+ | `MatchData` | that match query |
219
+ | `RelationshipData` | the related query (the 'from' row is still fetched by `idsFilter` and must exist) |
220
+ | `RowFilterData` | that row-filter query |
221
+
222
+ `expectedRowIds` / `expectedMatchRowIds` / `expectedRelatedRowIds` may be empty when `error` is
223
+ set.
224
+
225
+ A timeout is the typical row-filter case: SQL Server's statement filter with a `WAITFOR` and a
226
+ `requestTimeoutOverride` shorter than the wait must fail with the driver's timeout, not hang or
227
+ return rows:
228
+
229
+ ```js
230
+ /** @type {SDKTest.RowFilterData} */
231
+ const timeoutCase = {
232
+ rowFilter: { where: { clause: "1 = 1; WAITFOR DELAY '00:00:05'" }, requestTimeoutOverride: 1000 },
233
+ expectedRowIds: [],
234
+ expect: { error: { code: "ETIMEOUT" } },
235
+ };
236
+ ```
237
+
238
+ **Upsert**: `UpsertData.expect` is `{ insert?, update? }`, each an assertion list for this
239
+ data's own row as queried back after that phase. `upsertPrepare` runs once per row, and the
240
+ row's key is only assigned by the insert, so there is nothing to key by. The asserted paths are
241
+ added to the query-back's fields for you. `verifyFields` stays the shorthand for "what I wrote
242
+ comes back". Use `expect` for what the test did NOT write: identity and computed columns,
243
+ defaults, server timestamps, rowversions. For example, `update:
244
+ [{ path: toJsonPath("RowVer"), matches: ... }]` checks a value the update moved. There is no
245
+ `error` on upsert: `issueSimulators` already express an upsert that must fail. `update` is not
246
+ run for `insertOnly` objects, and the delete test (which reuses `upsertPrepare`) ignores
247
+ `expect`.
248
+
249
+ Failures land in the feature's existing status and details:
250
+
251
+ - **`Failed: Expected values did not match`**, with one error detail per assertion that did not
252
+ hold: the row key, the path, the expected form, the actual value and how they were
253
+ compared. Oversized values are truncated under the usual caps, not dropped.
254
+ - **`Failed: Expected error did not occur`**, when the call succeeded or failed differently.
255
+
256
+ Match rules and relationships report these on the rule or relationship result, and roll up as
257
+ `One or more rules/filters have issues`.
258
+
259
+ Compute expectations at prepare time from data your suite seeded (`before()` or the hook
260
+ itself), never by reading the connector under test. Asserting a returned value's shape is also
261
+ the right test of a type mapping: it proves the connector's behaviour, where a metadata check
262
+ would only prove its declaration.
263
+
264
+ ## `query.key`: the rows' key is the field tagged as the key
265
+
266
+ [04](./04-meta-objects-fields.md) makes `isKey` a contract: the tagged query field's value
267
+ becomes `row.meta.key`, and exactly one query field carries `isKey` (or `isUserKey`). The
268
+ generator declares that field as `query.key { path, expectUserKey? }`, found the same way as
269
+ `upsert.key`. Regenerate `mod ObjectTestFeatures.mjs` to pick it up. A hand-authored spec can
270
+ declare it too. When it is declared:
271
+
272
+ - the **fieldMetadata** feature checks that the field at `path` is tagged `isKey` (`isUserKey`
273
+ when `expectUserKey`) and that no other query field carries either tag (`More than one query
274
+ field tagged with isKey or isUserKey`);
275
+ - the **list** and **idsFilter** features check that every returned row's `meta.key` equals
276
+ `String()` of that field's value (for a user key, of its `value`). The first mismatch fails
277
+ the feature as `Failed: Expected values did not match`, with a detail that says it is the key
278
+ check (`key check (query.key, not a value assertion)`). A query that names its `fields` also
279
+ gets the key path added.
280
+
281
+ This catches a connector that keys its rows on one column and tags another, which otherwise
282
+ only a sync finds. A spec without `query.key` (anything generated before it existed) skips
283
+ these checks.
284
+
285
+ ## Custom checks: `customChecks()`
286
+
287
+ Some tests are not shaped like an object feature: a free statement with typed parameters,
288
+ 500 sequential statements to prove connections are released, a connection-level timeout, an API
289
+ quirk. They go in `customChecks()`, which returns named checks the harness runs **after the
290
+ feature tests, in declaration order**, each with its own status, duration and details:
291
+
292
+ ```js
293
+ /** @type {SDKTest.TestFeatureProvider["customChecks"]} */
294
+ async customChecks() {
295
+ return [
296
+ {
297
+ id: "exec-typed-parameters", // stable: keys the result and details, and is what --check names
298
+ name: "$Exec binds typed parameters",
299
+ objectId: "$Exec", // gated like a feature; omit for a connection-level check
300
+ timeoutMs: 30000, // default 60 000
301
+ run: async ({ connection, meta, record }) => {
302
+ const rows = await collect(connection.query["$Exec"]({ rowFilter: { statement: "SELECT @p0 AS v", parameters: [{ name: "p0", sqlType: "int", value: 42 }] } }));
303
+ record("returned", { rows }); // a detail on the check, under the usual caps
304
+ if (rows[0]?.data.v !== 42) throw new Error(`expected 42, got ${rows[0]?.data.v}`);
305
+ },
306
+ },
307
+ ];
308
+ }
309
+ ```
310
+
311
+ - **Status**: `Passed`, `Failed: <message>` (the first line of what `run` threw; the full error
312
+ is kept as the check's `fatalError`), or `Not tested` with `notTestedReason`. Results are
313
+ `CustomCheckResult { id, name, status, notTestedReason?, durationMs, fatalError? }`, on the
314
+ object's result for a check with an `objectId` and on the run's for one without.
315
+ - **Details**: `record(message, data)` adds a detail under `details["check:<id>"]`; the harness
316
+ adds one saying how the check ended. Same caps and ~48-hour retention as every feature's.
317
+ - **Selection**: a check with an `objectId` whose object is not on the connection (not selected
318
+ on a two-phase connection, or not returned by `meta()`) reads `Not tested` with the reason —
319
+ it does not fail. The object need not be one of `objectIds()`; if it is, the harness's object
320
+ check fails the run first, as it does for any tested object. A check without an `objectId` is
321
+ connection-level and always runs. `ctx.meta` is the check object's metadata.
322
+ - **Timeout**: `timeoutMs` (default 60 000) is capped at the run's remaining budget when the run
323
+ has one. A check still running at its timeout fails (`Failed: Timed out after <n>ms`), what it
324
+ records afterwards is dropped, and the next check runs.
325
+ - **Roll-up**: a failed check fails its object and the run; a check not tested makes a Passed
326
+ object Partial and keeps a run that would pass at `Partial: Not all features/objects can be
327
+ tested`.
328
+ - **Which checks a run reaches**: all of them on a full run; with `--object`, the
329
+ connection-level checks plus the selected objects'; none with `--only
330
+ connection|meta|discover|query`; exactly the named ones with `--check <id>` (repeatable),
331
+ whatever else the run tests. An id the suite does not declare fails as `No such check in the
332
+ test suite`.
333
+
334
+ A check is the escape hatch, not the default. If the same check turns up in a second connector,
335
+ it belongs in the harness as a feature; and a value assertion on rows a feature already fetches
336
+ belongs in that feature's `expect`, where it reports on the feature.
337
+
338
+ ## Fixtures on two-phase connections: fixed names, seeded in `before()`
339
+
340
+ A two-phase test connection collects only the objects it selected. So a fixture object — a
341
+ sandbox table, a custom object — must exist before discovery and be selected before the run.
342
+ **`before()` seeds rows, never objects.** Creating a table in `before()` cannot work: the run's
343
+ refresh covers only the selection, and the harness checks every object to test against the
344
+ connection right after it.
345
+
346
+ The procedure:
347
+
348
+ 1. Create the fixtures once, with fixed names (the SQLServer suite's `dbo.sm_test_*` pattern).
349
+ 2. Run `sm connector test --only discover`.
350
+ 3. Select the fixtures on the test connection.
351
+ 4. Run the suite.
352
+
353
+ A create-a-fresh-table-per-run pattern (`SMTST<random>`) cannot be ported to the harness.
354
+
355
+ `before()` already has the connection and runs before the harness's own object check. A suite
356
+ that wants a clearer message than "objects not on the connection" checks its own fixtures
357
+ there:
358
+
359
+ ```js
360
+ async before({ connection }) {
361
+ const missing = FIXTURES.filter((id) => !connection.objectIds.includes(id));
362
+ if (missing.length > 0) {
363
+ throw new Error(`fixtures not on the connection: ${missing.join(", ")} - create them, run --only discover, select them, run again`);
364
+ }
365
+ }
366
+ ```
367
+
368
+ A thrown `before()` is the run's fatal error, message included, so this fails the run before
369
+ any feature runs.
370
+
135
371
  ## Checkpoint testing: what the two steps prove
136
372
 
137
373
  The checkpoint contract is a ROUND-TRIP ([05](./05-query.md)): the platform stores whatever
@@ -242,25 +478,31 @@ The harness always executes platform-side. What each entry point actually runs:
242
478
 
243
479
  | Command | What runs |
244
480
  | ---------------------------- | ----------------------------------------------------------------------------------------------- |
245
- | `sm test` | The FULL suite — `connection.test()` + `metaRefresh()` + every object feature above. Identical to the web UI's connector test. |
246
- | `sm test --only connection` | `connection.test()` only — credentials check, nothing else. The harness has NOT run. |
247
- | `sm test --only meta` | `connection.metaRefresh()` only — for a connection with a selection, that is `meta()` over the selected objects. |
248
- | `sm test --only discover` | `connection.discover()` only (two-phase connectors) — reports the catalogue size, which settings it returned overrides for, and how long it took, then refreshes the selection and checks every selected object is in the catalogue. |
249
- | `sm test --only query` | Every object's plain list query only — no ids/checkpoint/match/related, no writes. |
481
+ | `sm connector test` | The FULL suite — `connection.test()` + `metaRefresh()` + every object feature above + every custom check. Identical to the web UI's connector test. |
482
+ | `sm connector test --only connection` | `connection.test()` only — credentials check, nothing else. The harness has NOT run. |
483
+ | `sm connector test --only meta` | `connection.metaRefresh()` only — for a connection with a selection, that is `meta()` over the selected objects. |
484
+ | `sm connector test --only discover` | `connection.discover()` only (two-phase connectors) — reports the catalogue size, which settings it returned overrides for, and how long it took, then refreshes the selection and checks every selected object is in the catalogue. |
485
+ | `sm connector test --only query` | Every object's plain list query only — no ids/checkpoint/match/related, no writes, no custom checks. |
486
+ | `sm connector test --object <id>` | The full suite for the named object(s), plus the connection-level custom checks and those object(s)' checks. |
487
+ | `sm connector test --check <id>` | Only the named custom check(s) (repeat the flag); no object features unless `--object` is given too. The fast loop for one failing check. |
250
488
  | Web UI (connector → test results → run) | Same engine, feature selection via the run dialog. |
251
489
 
490
+ (The old short name `sm test` now prints the replacement and exits 2.)
491
+
252
492
  Each feature test records the inputs it ran with and what the connector returned — pass or
253
493
  fail — onto the saved result as diagnostic `details` (the ids an `idsFilter` ran with and the
254
- rows returned; expected vs actual rows per match rule / relationship; upserted rows and their
255
- query-back). Review them on the web UI's test results screen instead of re-running with extra
494
+ rows returned; expected vs actual rows per match rule / relationship / row filter; upserted rows
495
+ and their query-back; what each custom check recorded). The web UI shows custom checks in a
496
+ "Custom checks" table under each object and under the connection, and `sm connector test`
497
+ prints one line per check after the object lines. Review them on the web UI's test results screen instead of re-running with extra
256
498
  logging. Payloads over the size budget are dropped and flagged `truncated`; details hold real
257
499
  connection data and are scrubbed ~48 hours after the run (statuses are kept).
258
500
 
259
501
  Do not mistake a passing `--only connection` or `--only query` for suite coverage — only the
260
- full run exercises the prepare hooks and write features. `sm test` needs a developer token
502
+ full run exercises the prepare hooks and write features. `sm connector test` needs a developer token
261
503
  with the `scripts:read`, `connections:read` and `scripts:execute` scopes; a 403 from the CLI
262
504
  names the missing one. The dev loop around it: `sm validate` (workspace structure), `checkJs`
263
- type checking against the SDK types, `sm push`, then `sm test`. Keep `test()` cheap and
505
+ type checking against the SDK types, `sm push`, then `sm connector test`. Keep `test()` cheap and
264
506
  `meta()` deterministic so the suite stays fast.
265
507
 
266
508
  Three workspace facts that bite here:
@@ -278,8 +520,79 @@ Three workspace facts that bite here:
278
520
  `sm connector features` (or the web UI's feature-generate action) after `meta()` changes;
279
521
  new relationships/match rules will NOT be tested until you do, because the generated
280
522
  `relationshipsTo`/`matchRules` spec still reflects the old metadata. The loop is:
281
- edit `meta()` → `sm push` → `sm connector features` → `sm test`.
523
+ edit `meta()` → `sm push` → `sm connector features` → `sm connector test`.
282
524
  - The typed test connection (the `API.connections.<connection_name>.Connection` `@typedef` in
283
525
  the `mod Test.mjs` example above) resolves from **generated** typings:
284
526
  `types/connections/*.d.ts` in a CLI workspace (`sm types` refreshes them; `sm pull` runs it
285
527
  automatically) — never hand-write these.
528
+
529
+ ## Migrating an old test module to `mod Test.mjs`
530
+
531
+ Many connectors still carry a second suite in `files/Scripts/Tests/mod <Conn>.mjs`, run by the
532
+ loop in `_tst Connector.mjs`: every `test*` method is called, logged as Success or Fail, and the
533
+ run throws at the end. The harness replaces it. Port each method to the first of these that
534
+ fits, and delete the old module and its `_tst` entry when the last method has moved:
535
+
536
+ 1. **An existing feature.** Most methods (list, by id, by ids, by last-modified, match rules,
537
+ related rows, insert/update, the `test_*_metadata` methods) are already what the harness
538
+ tests. Port them as prepare data (`listPrepare`, `idsFilterPrepare`, ...), not as checks;
539
+ metadata methods are covered by the generated spec and the `fieldMetadata` feature.
540
+ 2. **An `expect` clause.** A method that checks a returned value's form (a rowversion as hex, a
541
+ GUID, a decimal's value, a column that must not be echoed) or a call that must fail (a
542
+ timeout, a rejected id, a `*_not_exist` query) becomes `expect.rows` or `expect.error` on the
543
+ feature data that makes that call.
544
+ 3. **A custom check**, for what is left: a method that is not shaped like a feature.
545
+
546
+ Porting a method to a custom check:
547
+
548
+ ```js
549
+ // old: files/Scripts/Tests/mod SQLServer v2.mjs
550
+ async testExec() {
551
+ const rows = await collect(this.conn.query["$Exec"]({ rowFilter: { statement: "SELECT 1 AS one" } }));
552
+ if (rows[0].data.one !== 1) throw new Error("exec did not return 1");
553
+ this.log.info("exec ok");
554
+ }
555
+
556
+ // new: in mod Test.mjs
557
+ async customChecks() {
558
+ return [
559
+ {
560
+ id: "exec",
561
+ name: "$Exec returns a statement's rows",
562
+ objectId: "$Exec",
563
+ run: async ({ connection, record }) => {
564
+ const rows = await collect(connection.query["$Exec"]({ rowFilter: { statement: "SELECT 1 AS one" } }));
565
+ record("returned", { rows });
566
+ if (rows[0]?.data.one !== 1) throw new Error(`exec returned ${JSON.stringify(rows[0]?.data)}`);
567
+ },
568
+ },
569
+ ];
570
+ }
571
+ ```
572
+
573
+ - The method body moves into `run`; `this.conn` becomes `ctx.connection` (or the suite's own
574
+ typed connection from `before()`), `this.log.info` of what you want to review later becomes
575
+ `ctx.record`, and the `throw` stays: throwing is how a check fails.
576
+ - Give it a stable `id` (the old method name without `test` is a good one) and, when it
577
+ exercises one object, that `objectId`, so an unselected object reads Not tested instead of
578
+ failing.
579
+ - `onlyTest*` / `skipTest*` renames are gone: `--check <id>` runs one check, and a check that
580
+ must not run is deleted or given a reason in its own code, not renamed.
581
+ - A method that loops or times (500 sequential statements, a large read) keeps its loop; put
582
+ the timing in `record`, not in a pass/fail threshold, and set `timeoutMs` above what it needs.
583
+ - Setup the old module did in its constructor or `before()` moves to the suite's `before()`,
584
+ which runs once for features and checks alike. **Seed rows there, never objects**.
585
+
586
+ What cannot be ported:
587
+
588
+ - **Creating a table, custom object or other schema per test** (the `SMTST<random>` pattern).
589
+ A two-phase test connection only collects the objects it selected, so an object made during
590
+ the run is never on it. Use a fixed fixture instead ([above](#fixtures-on-two-phase-connections-fixed-names-seeded-in-before)):
591
+ create it once with a fixed name, discover, select it, and let the check or feature seed rows.
592
+ Tests that only prove "a table can be created" go.
593
+ - **Anything whose pass/fail is a timing threshold.** A threshold needs a per-environment budget
594
+ the harness does not have; record the timing as a detail instead.
595
+ - **Methods that depend on another method having run first.** Checks run in declaration order,
596
+ but each must stand alone (a `--check` run may run only one); share state through `before()`.
597
+ - **Platform tests** (`mod ScriptAPI`, `Storage`, `Webhook`, `Drives`, ...) are not connector
598
+ tests and stay where they are, with `_tst Main.mjs`.
@@ -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 the CLI's `sm test` both require a file of sidecar
5
+ platform's connector test suite AND the CLI's `sm connector 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,7 +39,7 @@ generated module via `module_script_file_paths`:
39
39
  }
40
40
  ```
41
41
 
42
- Keep exactly one `"connectortest"` file per connector folder — `sm test` resolves the test
42
+ Keep exactly one `"connectortest"` file per connector folder — `sm connector test` resolves the test
43
43
  module from the folder beside the connector's entry file, and several candidates are ambiguous
44
44
  (`<Name>.test.mjs` wins when present).
45
45
 
@@ -71,7 +71,10 @@ export const objectFeatures = {
71
71
  idsFilter: true,
72
72
  checkpointFilter: true,
73
73
  matchRules: [{"rule":"id"},{"rule":"name[ci]"}],
74
- canMatchFields: toJsonPaths(["id", "name", "domain"])
74
+ canMatchFields: toJsonPaths(["id", "name", "domain"]),
75
+ key: {
76
+ path: toJsonPath("id")
77
+ }
75
78
  },
76
79
  upsert: {
77
80
  canUpsert: true,
@@ -94,7 +97,10 @@ export const objectFeatures = {
94
97
  checkpointFilter: true,
95
98
  matchRules: [{"rule":"id"},{"rule":"email[ci]"},{"rule":"first_and_last_name[ci]"}],
96
99
  canMatchFields: toJsonPaths(["id", "email", "first_name", "last_name"]),
97
- relationshipsTo: [{"id":"companies-company_id","relObjectId":"companies"}]
100
+ relationshipsTo: [{"id":"companies-company_id","relObjectId":"companies"}],
101
+ key: {
102
+ path: toJsonPath("id")
103
+ }
98
104
  },
99
105
  upsert: {
100
106
  canUpsert: true,
@@ -114,7 +120,10 @@ export const objectFeatures = {
114
120
  salesorders: {
115
121
  query: {
116
122
  list: true,
117
- idsFilter: true
123
+ idsFilter: true,
124
+ key: {
125
+ path: toJsonPath("$compositeKey")
126
+ }
118
127
  },
119
128
  upsert: {
120
129
  canUpsert: true,
@@ -171,6 +180,8 @@ export default class TestFeatureProvider {
171
180
  #connection;
172
181
  /** @type string */
173
182
  #fixtureCompanyId = ""; // shared parent row for contact test data
183
+ /** @type string */
184
+ #fixtureVendorId = ""; // the sandbox's one vendor company, for the companyType row filter
174
185
 
175
186
  /** @type SDKTest.TestFeatureProvider["before"] */
176
187
  async before(ctx) {
@@ -204,6 +215,10 @@ export default class TestFeatureProvider {
204
215
  const company = await this.#connection.queryOne.companies();
205
216
  if (!company) throw new Error("Sandbox has no companies - seed at least one before testing");
206
217
  this.#fixtureCompanyId = company.meta.key;
218
+ for await (const row of this.#connection.query.companies()) {
219
+ if (row.data.name === "sm_fixture Vendor") this.#fixtureVendorId = row.meta.key;
220
+ }
221
+ if (!this.#fixtureVendorId) throw new Error("Sandbox fixture 'sm_fixture Vendor' is missing - create it once as a vendor");
207
222
  }
208
223
 
209
224
  /** @type SDKTest.TestFeatureProvider["after"] */
@@ -227,7 +242,17 @@ export default class TestFeatureProvider {
227
242
  // a small pageSize forces real pagination through Acme's cursor (05)
228
243
  switch (options.meta.id) {
229
244
  case "salesorders":
230
- return { pageSize: 25, fields: [[{ path: "$compositeKey" }], [{ path: "status" }]] };
245
+ return {
246
+ pageSize: 25,
247
+ fields: [[{ path: "$compositeKey" }], [{ path: "status" }]],
248
+ // expected values (09): "*" asserts on every row the list returns - here the key
249
+ // encoding (11), sorted JSON with the parent part first. No row-id bookkeeping needed.
250
+ expect: {
251
+ rows: {
252
+ "*": [{ path: [{ path: "$compositeKey" }], matches: '^\\{"business_unit":".+","order_id":\\d+\\}$' }],
253
+ },
254
+ },
255
+ };
231
256
  default:
232
257
  return { pageSize: 25 };
233
258
  }
@@ -407,6 +432,14 @@ export default class TestFeatureProvider {
407
432
  // the payload it just sent - so every verify field must be present (non-null) in
408
433
  // BOTH insert and update, and the update should carry a CHANGED value (09)
409
434
  result.verifyFields.push([{ path: "domain" }]);
435
+ // ...and expect covers what the test did NOT write: Acme assigns id and created_at.
436
+ // The row's key is unknown until the insert, so these name the phase, not a key (09)
437
+ result.expect = {
438
+ insert: [
439
+ { path: [{ path: "id" }], matches: "^\\d+$" },
440
+ { path: [{ path: "created_at" }], present: true },
441
+ ],
442
+ };
410
443
  break;
411
444
  }
412
445
  case "contacts": {
@@ -467,6 +500,57 @@ export default class TestFeatureProvider {
467
500
  await this.#deleteRows(options.meta.id, options.inserted.map((r) => r.meta.key));
468
501
  }
469
502
 
503
+ /** @type SDKTest.TestFeatureProvider["rowFiltersPrepare"] */
504
+ async rowFiltersPrepare(options) {
505
+ // companies declares one row filter, companyType (12). Acme's API cannot set a company's
506
+ // type, so the sandbox holds a fixed fixture instead: exactly one vendor, named
507
+ // "sm_fixture Vendor", created once and never touched by the suite (the fixed-fixture rule, 09).
508
+ // before() looked its key up; every other sandbox company is a customer.
509
+ if (options.meta.id !== "companies") return undefined;
510
+ return [
511
+ {
512
+ rowFilter: { companyType: "vendor" },
513
+ expectedRowIds: [this.#fixtureVendorId],
514
+ expect: { rows: { "*": [{ path: [{ path: "name" }], equals: "sm_fixture Vendor" }] } },
515
+ },
516
+ ];
517
+ }
518
+
519
+ /** @type SDKTest.TestFeatureProvider["customChecks"] */
520
+ async customChecks() {
521
+ // what is not shaped like an object feature; each check reports its own status (09)
522
+ return [
523
+ {
524
+ // connection-level (no objectId): always runs
525
+ id: "sequential-tests",
526
+ name: "20 sequential test() calls all succeed",
527
+ run: async ({ record }) => {
528
+ const started = Date.now();
529
+ for (let i = 0; i < 20; i++) {
530
+ const result = await this.#connection.test();
531
+ if (!result.success) throw new Error(`test() call ${i + 1} failed: ${result.message}`);
532
+ }
533
+ record("20 test() calls", { durationMs: Date.now() - started }); // timing is a detail, not a threshold
534
+ },
535
+ },
536
+ {
537
+ // object-level: reads Not tested, not Failed, if contacts is not selected on the connection
538
+ id: "contacts-page-walk",
539
+ name: "Listing three pages of contacts returns no duplicate keys",
540
+ objectId: "contacts",
541
+ timeoutMs: 120000,
542
+ run: async ({ record }) => {
543
+ const seen = new Set();
544
+ for await (const row of this.#connection.query.contacts({ limit: 300 })) {
545
+ if (seen.has(row.meta.key)) throw new Error(`key ${row.meta.key} returned twice`);
546
+ seen.add(row.meta.key);
547
+ }
548
+ record(`${seen.size} distinct contacts`);
549
+ },
550
+ },
551
+ ];
552
+ }
553
+
470
554
  // === private helpers ======================================================
471
555
 
472
556
  /** Create one prefixed row; creating counts as a modification for checkpoint tests.
@@ -543,9 +627,26 @@ Nothing in this pair changes when a connector gains `discover()`
543
627
  regenerates `mod ObjectTestFeatures.mjs` without it — it warns about the catalogue entries the
544
628
  connection did not select, so read that output rather than assuming a shrunken generated file is
545
629
  the connector's doing. Widen the test connection's selection rather than scoping the object out;
546
- `sm test --only discover` ([09](./09-testing.md#running-the-suite)) checks the catalogue against
630
+ `sm connector test --only discover` ([09](./09-testing.md#running-the-suite)) checks the catalogue against
547
631
  the selection and prints what is missing.
548
632
 
633
+ The same rule applies to fixture objects a suite owns, such as a sandbox table or a custom
634
+ object. Create them once with fixed names, discover, select them, and have `before()` seed rows
635
+ only, never objects ([09](./09-testing.md#fixtures-on-two-phase-connections-fixed-names-seeded-in-before)).
636
+ A suite that wants its own message checks the fixtures first:
637
+
638
+ ```js
639
+ const FIXTURES = ["companies", "contacts", "salesorders"];
640
+
641
+ async before({ connection }) {
642
+ const missing = FIXTURES.filter((id) => !connection.objectIds.includes(id));
643
+ if (missing.length > 0) {
644
+ throw new Error(`fixtures not on the connection: ${missing.join(", ")} - create them, run --only discover, select them, run again`);
645
+ }
646
+ // ...then seed rows as above
647
+ }
648
+ ```
649
+
549
650
  ## What to notice (the parts assistants most often get wrong)
550
651
 
551
652
  1. **Every created record is prefixed** (`sm_test`) and `before()`/`after()` sweep the
@@ -581,3 +682,20 @@ the selection and prints what is missing.
581
682
  the differential window into `expectedRowIds` before seeding one guaranteed change, and
582
683
  step 2 expects step-1 rows to reappear because the connector's checkpoint deliberately
583
684
  overlaps (05, 12).
685
+ 9. **Row ids prove which rows came back; `expect` proves what is in them.** Value fidelity
686
+ (a key encoding, a server-assigned id or timestamp, a column that must not be echoed) goes
687
+ in the prepare data's `expect` clause, computed from data the suite seeded. A call that must
688
+ fail (ids the API rejects, an invalid checkpoint) is `expect.error` on that feature's data.
689
+ `equals` compares by the field's declared type, and a date compares as an instant (09).
690
+ 10. **`query.key` is generated, like `upsert.key`.** It names the field whose value IS
691
+ `row.meta.key`, and the list and idsFilter tests check every row against it. If the
692
+ generated file lacks it, it predates the check: regenerate.
693
+ 11. **Row filters are tested from a fixed fixture.** `rowFiltersPrepare` has to name the exact
694
+ rows a filter returns, so it needs a sandbox whose answer it knows: here one vendor company
695
+ created once and looked up in `before()`, never created by the run. Without the hook
696
+ companies' row filter reads `Not tested`, which does not make the object Partial.
697
+ 12. **Custom checks are for what no feature expresses**, and each reports on its own:
698
+ `sequential-tests` on the connection, `contacts-page-walk` under contacts (Not tested, not
699
+ Failed, if contacts is not selected). Throwing fails a check; `record` puts what you want to
700
+ review later on the result; timings are details, never thresholds. Rerun one with
701
+ `sm connector test Acme --check contacts-page-walk`.
@@ -28,13 +28,13 @@ queryable.
28
28
  | [06-upsert-delete.md](./06-upsert-delete.md) | implementing `upsert()`, `delete()`, `upsertClean()`, `upsertFieldOptions()`, batching |
29
29
  | [07-events-webhooks.md](./07-events-webhooks.md) | webhooks: `parseEvents()`, signature verification (and the usually-skipped `handleParsedEvent()`) |
30
30
  | [08-errors.md](./08-errors.md) | error handling — `ConnectorError`, `ErrorCode` selection, `HttpError` |
31
- | [09-testing.md](./09-testing.md) | the test harness: `mod Test.mjs` + `mod ObjectTestFeatures.mjs` |
31
+ | [09-testing.md](./09-testing.md) | the test harness: `mod Test.mjs` + `mod ObjectTestFeatures.mjs`, expected values/errors (`expect`), `query.key`, fixtures |
32
32
  | [10-style-and-pitfalls.md](./10-style-and-pitfalls.md) | style rules, deprecated APIs to avoid, known traps — skim this once regardless |
33
33
  | [11-composite-keys.md](./11-composite-keys.md) | objects with multi-part identity (child REST resources, junction rows) — key encoding across query/upsert/delete |
34
34
  | [12-example-connector.md](./12-example-connector.md) | **the canonical worked connector** — a complete three-object connector (relationships, matching, composite keys, custom fields, upsertClean, query/upsert/delete) on one page; its test-file counterpart is 15 |
35
35
  | [13-file-fields.md](./13-file-fields.md) | objects carrying file content — fields of `type: "file"`, FileProvider, fields-driven inclusion (do NOT copy the fleet's legacy `row.file`) |
36
36
  | [14-vendor-sdks-and-non-http.md](./14-vendor-sdks-and-non-http.md) | vendor npm SDKs (googleapis, AWS, box), database/on-prem `agent_mode: "always"` connectors, SOAP/XML, self-managed rate limiting |
37
- | [15-example-test-file.md](./15-example-test-file.md) | **the canonical worked test file** — the complete `mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair for the Acme connector of 12 (TestFeatureProvider, prepare/cleanup hooks, issue simulators, feature scoping) |
37
+ | [15-example-test-file.md](./15-example-test-file.md) | **the canonical worked test file** — the complete `mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair for the Acme connector of 12 (TestFeatureProvider, prepare/cleanup hooks, issue simulators, expected values, feature scoping) |
38
38
  | [16-the-end-game-syncs.md](./16-the-end-game-syncs.md) | **why any of this exists** — how the sync pipeline consumes each connector capability, and what silently degrades when one is missing. Read once before designing `meta()` |
39
39
  | [17-user-help-pages.md](./17-user-help-pages.md) | writing the connector's customer help pages — they ship IN the connector (`Connectors/<Name>/help/*.md`, sidecar type `"markdown"`); the house structure, frontmatter, and example conventions |
40
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncmatters/connector-sdk",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
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": "2d30d816e8bb71a9fe2d9390945f4aa62c8eff1f4f86e69ec8f965e8a42cd6c8",
15
+ "typesContentHash": "376c97b772af51a69f5155aff7c80362062a74710e0672b3dd0582679b5cfffb",
16
16
  "dependencies": {
17
17
  "@types/node": "*",
18
- "@syncmatters/script-api": "^1.0.19"
18
+ "@syncmatters/script-api": "^1.0.21"
19
19
  }
20
20
  }