@syncmatters/connector-sdk 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/docs/01-workspace-and-meta-json.md +135 -0
- package/docs/02-lifecycle-and-shape.md +116 -0
- package/docs/03-http-auth-ratelimit.md +188 -0
- package/docs/04-meta-objects-fields.md +162 -0
- package/docs/05-query.md +156 -0
- package/docs/06-upsert-delete.md +194 -0
- package/docs/07-events-webhooks.md +123 -0
- package/docs/08-errors.md +68 -0
- package/docs/09-testing.md +134 -0
- package/docs/10-style-and-pitfalls.md +100 -0
- package/docs/11-composite-keys.md +164 -0
- package/docs/12-example-connector.md +587 -0
- package/docs/13-file-fields.md +123 -0
- package/docs/14-vendor-sdks-and-non-http.md +115 -0
- package/docs/15-example-test-file.md +565 -0
- package/docs/connector-authoring.md +159 -0
- package/package.json +3 -3
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# File fields
|
|
2
|
+
|
|
3
|
+
Objects can carry file content (documents, attachments, exports) as **fields of
|
|
4
|
+
`type: "file"`** — declared like any other field, requested like any other field, and written
|
|
5
|
+
like any other field. The value of a file field is an `SDK.FileProvider`.
|
|
6
|
+
|
|
7
|
+
## Declaring
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// meta() - a document object whose content is a file field
|
|
11
|
+
{
|
|
12
|
+
id: "documents",
|
|
13
|
+
name: "Documents",
|
|
14
|
+
canQueryList: true,
|
|
15
|
+
canQueryByIds: true,
|
|
16
|
+
queryFields: [
|
|
17
|
+
{ id: "id", name: "ID", type: "string", isKey: true },
|
|
18
|
+
{ id: "name", name: "File name", type: "string" },
|
|
19
|
+
{ id: "size", name: "Size (bytes)", type: "number" },
|
|
20
|
+
{ id: "content", name: "Content", type: "file" },
|
|
21
|
+
],
|
|
22
|
+
upsertFields: [
|
|
23
|
+
{ id: "name", name: "File name", type: "string", constraints: { mandatory: "add" } },
|
|
24
|
+
{ id: "content", name: "Content", type: "file" },
|
|
25
|
+
{ id: "id", type: "string", isKey: true },
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Querying: include the file only when asked
|
|
31
|
+
|
|
32
|
+
File content is expensive — never fetch it unconditionally. The caller signals what it wants
|
|
33
|
+
through `options.queryOptions.fields` (the field paths it expects, see
|
|
34
|
+
[05-query.md](./05-query.md)); include file content **only when a file-typed field is in that
|
|
35
|
+
list**:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
/** does the caller want any of this object's file fields? */
|
|
39
|
+
#wantsField(options, fieldId) {
|
|
40
|
+
const fields = options.queryOptions?.fields;
|
|
41
|
+
if (!fields) return false; // no field selection -> cheap fields only, no file content
|
|
42
|
+
return fields.some((path) => path[0]?.path === fieldId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async query(options) {
|
|
46
|
+
const wantsContent = this.#wantsField(options, "content");
|
|
47
|
+
// ...fetch the row list as usual...
|
|
48
|
+
const rows = [];
|
|
49
|
+
for (const item of resp.items) {
|
|
50
|
+
/** @type {any} */
|
|
51
|
+
const data = { id: item.id, name: item.name, size: item.size };
|
|
52
|
+
if (wantsContent) {
|
|
53
|
+
// fileResponse: true -> the http client hands back an SDK.FileProvider
|
|
54
|
+
data.content = /** @type {SDK.FileProvider} */ (await this.#http.execute({
|
|
55
|
+
method: "GET",
|
|
56
|
+
url: `documents/${item.id}/content`,
|
|
57
|
+
fileResponse: true,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
rows.push({ meta: { key: String(item.id) }, data });
|
|
61
|
+
}
|
|
62
|
+
// ...
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`fileResponse: true` on an `HttpRequest` is the standard way to download into a provider
|
|
67
|
+
without buffering the whole body in memory. To build a provider from something you already
|
|
68
|
+
have, use the constructor variants:
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
SDK.utilities.fileProvider({ buffer: someBuffer });
|
|
72
|
+
SDK.utilities.fileProvider({ string: someText });
|
|
73
|
+
SDK.utilities.fileProvider({ file: { path: tempPath, deleteOnClose: true } });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Consuming a `FileProvider`
|
|
77
|
+
|
|
78
|
+
| Method | Use |
|
|
79
|
+
| ------------------------------------- | ----------------------------------------------------------------------- |
|
|
80
|
+
| `stream(encoding?)` | a readable stream — the normal way to upload or parse without buffering |
|
|
81
|
+
| `blob(fileName?)` / `formDataValue()` | for APIs consumed via `fetch`-style multipart bodies |
|
|
82
|
+
| `save(path?)` | persist to disk, returns the path (pair with `SDK.utilities.tempFile`) |
|
|
83
|
+
| `length()` | size in bytes (e.g. for a `size` field or Content-Length header) |
|
|
84
|
+
| `close()` | release the resource — **always, in a `finally`** |
|
|
85
|
+
|
|
86
|
+
Parsing structured file content: `SDK.utilities.csvReader({ source: { fileProvider } })`
|
|
87
|
+
(same idea for `xlsxReader`).
|
|
88
|
+
|
|
89
|
+
## Upserting: the file arrives as a field value
|
|
90
|
+
|
|
91
|
+
The upsert row carries a `FileProvider` at the file field's position. Stream it out and close
|
|
92
|
+
it:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
async upsert(options) {
|
|
96
|
+
return await Promise.all(options.rows.map(async (/** @type {any} */ row) => {
|
|
97
|
+
const file = /** @type {SDK.FileProvider} */ (row.content);
|
|
98
|
+
try {
|
|
99
|
+
const result = await this.#call({
|
|
100
|
+
method: "POST",
|
|
101
|
+
url: `documents${row.id ? `/${row.id}` : ""}`,
|
|
102
|
+
headers: { "Content-Type": "application/octet-stream", "X-File-Name": row.name },
|
|
103
|
+
body: await file.stream(),
|
|
104
|
+
});
|
|
105
|
+
return { meta: { key: String(result.id) }, data: result };
|
|
106
|
+
} finally {
|
|
107
|
+
try { await file.close(); } catch { /* already closed */ }
|
|
108
|
+
}
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Gotchas
|
|
114
|
+
|
|
115
|
+
1. **No `fields` list means no file content.** When the caller doesn't select fields, return
|
|
116
|
+
the cheap scalar fields only — a list query that downloads every document is a platform
|
|
117
|
+
incident waiting to happen.
|
|
118
|
+
2. **`upsertClean` treats every `FileProvider` as a change** and `confirmChange` cannot veto
|
|
119
|
+
it ([06-upsert-delete.md](./06-upsert-delete.md)) — keep file fields out of cleaned
|
|
120
|
+
payloads or accept the always-write.
|
|
121
|
+
3. **Close what you open.** Providers can be backed by temp files; `close()` in a `finally`
|
|
122
|
+
on both the query and upsert paths.
|
|
123
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Vendor SDKs and non-HTTP connectors
|
|
2
|
+
|
|
3
|
+
Not every system speaks plain REST/JSON. When the right client is a vendor npm package
|
|
4
|
+
(`googleapis`, `box-node-sdk`, AWS SDK), a database driver (`pg`, `tedious`), or a protocol
|
|
5
|
+
library (`ssh2` for SFTP, `soap` for WSDL services), the SDK's `httpClient` leaves the picture
|
|
6
|
+
— and with it the rate limiting, retries and monitoring it provided. This topic is how the
|
|
7
|
+
fleet fills those gaps.
|
|
8
|
+
|
|
9
|
+
## Declaring the dependency
|
|
10
|
+
|
|
11
|
+
List the package in the entry sidecar's `npm_packages` (name → exact version,
|
|
12
|
+
[01-workspace-and-meta-json.md](./01-workspace-and-meta-json.md)); the platform installs it at
|
|
13
|
+
runtime and the `sm` CLI adds it to the workspace `package.json` so IntelliSense resolves it.
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
"npm_packages": { "box-node-sdk": "2.4.0" }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Vendor packages are often untyped or loosely typed under `checkJs`: isolate them behind one
|
|
20
|
+
`#client` field and use `@ts-ignore` sparingly at the boundary, not throughout your code.
|
|
21
|
+
|
|
22
|
+
## Wiring the client in `init()`
|
|
23
|
+
|
|
24
|
+
Build the vendor client from settings, exactly where you would have built `httpClient`:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { google } from "googleapis";
|
|
28
|
+
|
|
29
|
+
async init(args) {
|
|
30
|
+
SDK.verifyType(this);
|
|
31
|
+
let auth;
|
|
32
|
+
if (args.settings.get("service_account_json")) {
|
|
33
|
+
// vendor wants a key FILE: stage the setting's content through a FileProvider
|
|
34
|
+
const keyFile = await SDK.utilities.fileProvider({
|
|
35
|
+
string: args.settings.get("service_account_json"),
|
|
36
|
+
}).save();
|
|
37
|
+
auth = new google.auth.GoogleAuth({ keyFile, scopes: ["https://www.googleapis.com/auth/drive"] });
|
|
38
|
+
} else {
|
|
39
|
+
// platform-managed OAuth2: feed the token in AND keep it fresh (03)
|
|
40
|
+
auth = new google.auth.OAuth2();
|
|
41
|
+
auth.setCredentials({ access_token: args.oauth2Provider.token });
|
|
42
|
+
args.oauth2Provider.onRefresh((token) => auth.setCredentials({ access_token: token }));
|
|
43
|
+
}
|
|
44
|
+
this.#drive = google.drive({ version: "v3", auth });
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The two patterns to copy: **`oauth2Provider.token` + `onRefresh` feed the vendor auth
|
|
49
|
+
object** (the platform refreshes; you re-inject), and **file-based credentials are staged via
|
|
50
|
+
`fileProvider({string}).save()`** rather than written by hand.
|
|
51
|
+
|
|
52
|
+
Rarely-used sub-clients load lazily with a dynamic import, so cold starts stay fast:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
const STS = await import("@aws-sdk/client-sts");
|
|
56
|
+
const identity = await new STS.STSClient(config).send(new STS.GetCallerIdentityCommand());
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Rate limiting without `httpClient`
|
|
60
|
+
|
|
61
|
+
`httpClient` normally owns throttling. With a vendor client, wrap **every** call in a
|
|
62
|
+
rate limiter yourself:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
this.#limiter = SDK.utilities.rateLimiter({ type: "rolling", count: 10, ms: 1000 });
|
|
66
|
+
|
|
67
|
+
/** all vendor calls flow through here - the vendor SDK does not respect our limits */
|
|
68
|
+
#callApi(exec) {
|
|
69
|
+
return this.#limiter.execute({ exec });
|
|
70
|
+
}
|
|
71
|
+
// usage:
|
|
72
|
+
const resp = await this.#callApi(() => this.#drive.files.list({ pageSize: 200 }));
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Retries are also yours now: vendor SDKs have their own retry options (configure them to match
|
|
76
|
+
[03-http-auth-ratelimit.md](./03-http-auth-ratelimit.md)'s policy) — don't stack a hand-rolled
|
|
77
|
+
retry loop on top of one you didn't disable. And map vendor errors into `ConnectorError`s at
|
|
78
|
+
the same boundary ([08-errors.md](./08-errors.md)); a raw vendor stack trace helps nobody.
|
|
79
|
+
|
|
80
|
+
## The database-connector archetype (`agent_mode: "always"`)
|
|
81
|
+
|
|
82
|
+
Databases and on-premise systems are a connector shape of their own:
|
|
83
|
+
|
|
84
|
+
- **`agent_mode: "always"`** in the sidecar — the connector runs on the customer's on-premise
|
|
85
|
+
agent, because the database is not reachable from the cloud
|
|
86
|
+
([01-workspace-and-meta-json.md](./01-workspace-and-meta-json.md)).
|
|
87
|
+
- **No HTTP at all** — the driver connection (`pg`, `tedious`) is built in `init()` purely
|
|
88
|
+
from discrete settings: host, port, database, credentials, TLS/timeout options.
|
|
89
|
+
- **`meta()` comes from schema introspection** — query the information schema / catalog for
|
|
90
|
+
tables and columns, map column types to `ObjectField` types, primary keys to `isKey`
|
|
91
|
+
(composite PKs → [11-composite-keys.md](./11-composite-keys.md)), foreign keys to
|
|
92
|
+
`relationships`.
|
|
93
|
+
- **`query()` pages with real cursors** — a streaming query held open across pages: keep the
|
|
94
|
+
live cursor in a `Map` keyed by an id stored in your `queryState`, feed rows out per page,
|
|
95
|
+
and close it on `finalPage` (and in `dispose()` as a best-effort backstop).
|
|
96
|
+
- **Escape SQL values** exactly as [10-style-and-pitfalls.md](./10-style-and-pitfalls.md)
|
|
97
|
+
demands for any string-interpolated query language — or better, use the driver's
|
|
98
|
+
parameterized queries.
|
|
99
|
+
|
|
100
|
+
Note: on-premise (`agent_mode: "always"`) connectors do not support `upsertClean`
|
|
101
|
+
([06-upsert-delete.md](./06-upsert-delete.md)).
|
|
102
|
+
|
|
103
|
+
## XML and SOAP systems
|
|
104
|
+
|
|
105
|
+
- SOAP/WSDL: the `soap` npm package, with the WSDL files bundled as workspace resource files
|
|
106
|
+
(listed in `module_script_file_paths`) and loaded from `args.sourcePath`.
|
|
107
|
+
- Plain XML responses over HTTP: keep `httpClient` and parse with
|
|
108
|
+
`await SDK.utilities.xmlParse({ string: responseBody })` — no extra dependency needed.
|
|
109
|
+
|
|
110
|
+
## C# connectors (out of scope)
|
|
111
|
+
|
|
112
|
+
A small number of connectors are C# scripts (`.csx`, `agent_mode: "always"`) for
|
|
113
|
+
Windows/COM-interop targets. They use a different SDK surface and carry hard limitations (no
|
|
114
|
+
OAuth2 provider, no webhooks, no `upsertClean`, single file, no npm packages). Do not apply
|
|
115
|
+
this guide's JavaScript patterns to them — they are built by the platform team.
|