@datarails-ai/dr-cli 1.6.86 → 1.6.87
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/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
|
|
|
8
8
|
// package.json
|
|
9
9
|
var package_default = {
|
|
10
10
|
name: "@datarails-ai/dr-cli",
|
|
11
|
-
version: "1.6.
|
|
11
|
+
version: "1.6.87",
|
|
12
12
|
description: "Datarails CLI",
|
|
13
13
|
type: "module",
|
|
14
14
|
engines: {
|
|
@@ -38,7 +38,7 @@ var package_default = {
|
|
|
38
38
|
postinstall: "node scripts/postinstall.mjs"
|
|
39
39
|
},
|
|
40
40
|
dependencies: {
|
|
41
|
-
"@datarails-ai/dr-appspace-cli": "0.10.
|
|
41
|
+
"@datarails-ai/dr-appspace-cli": "0.10.13",
|
|
42
42
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
43
43
|
"@napi-rs/keyring": "^1.3.0",
|
|
44
44
|
commander: "^12.1.0",
|
|
@@ -67,7 +67,7 @@ var package_default = {
|
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
// src/buildInfo.ts
|
|
70
|
-
var BUILD_TIME = "2026-10-01T08:
|
|
70
|
+
var BUILD_TIME = "2026-10-01T08:55:18.726Z";
|
|
71
71
|
function formatBuildTime(iso) {
|
|
72
72
|
if (iso === "") return "";
|
|
73
73
|
const d = new Date(iso);
|
package/dist/scopes-map.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"cliVersion": "1.6.
|
|
3
|
+
"cliVersion": "1.6.87",
|
|
4
4
|
"_contract": "Resolve a command path through `aliases` (canonical prefix + alias segment -> canonical path) before any lookup. A path listed in `pendingCommands`, or whose first segment is a key of `pending`, is UNDECIDED, not unrestricted: fail closed or skip it, and never read a path's absence from `commands` as 'no requirement'. An entry carrying `denied` is UNAVAILABLE to any AGENT-SCOPED credential — one whose `scopes` claim carries at least one `resource:action` entry; a claim that is absent, empty, capability-only or wholly unparseable is UNRESTRICTED, and this mark does not apply to it — whatever else that entry says: it is not a ceiling and it is not always-available, its `denied.reason` says why and `denied.until` names the ticket that lifts it, and the absence of `requires` on it must NEVER be read as 'no requirement'. Only an entry in `commands` with an empty `requires` means always-available.",
|
|
5
5
|
"commands": {
|
|
6
6
|
"login": {
|
|
@@ -8,9 +8,10 @@ description: Use when a vendor's connector code should be shared across Datarail
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
10
|
A lib is the vendor's code written once: `lib/<vendor>/` owns the request, auth, pagination and the
|
|
11
|
-
file format, and each org's connector is two thin files that
|
|
12
|
-
optional parameters. The worked example is
|
|
13
|
-
|
|
11
|
+
file format, built on the SDK kit's generic pieces, and each org's connector is two thin files that
|
|
12
|
+
**pass their field names** plus a few optional parameters. The worked example is
|
|
13
|
+
`lib/ramp/dataqueries/select_query.py`; find its thin connectors with
|
|
14
|
+
`grep -rl "lib.ramp.dataqueries.select_query" --include=*.py us us2 uk ca`. Read both first.
|
|
14
15
|
|
|
15
16
|
**REQUIRED:** `datarails-connectors-onboard-connector` for everything around the code: branch,
|
|
16
17
|
publish, datasource, filebox, binding. Its memory, streaming and 4xx rules apply to lib code too.
|
|
@@ -20,7 +21,7 @@ publish, datasource, filebox, binding. Its memory, streaming and 4xx rules apply
|
|
|
20
21
|
```
|
|
21
22
|
lib/<vendor>/
|
|
22
23
|
__init__.py what the lib holds: each module and its output, no one who uses it
|
|
23
|
-
_core.py output-neutral:
|
|
24
|
+
_core.py output-neutral vendor code on kit pieces: credential keys, token mint, client setup, paging
|
|
24
25
|
connections/base_connection.py the one connection: auth only, no scope or other per-customer value
|
|
25
26
|
dataqueries/select_query.py the generic engine
|
|
26
27
|
tests/test_select_query.py the engine, on synthetic data
|
|
@@ -38,7 +39,7 @@ The connector's thin dataquery subclasses `SelectQuery` and sets only data:
|
|
|
38
39
|
| Attribute | Meaning |
|
|
39
40
|
|---|---|
|
|
40
41
|
| `PATH` | endpoint under the vendor base URL |
|
|
41
|
-
| `WINDOW` | the endpoint's from/to parameter names, filled from the Sync-query's `startDate`/`endDate
|
|
42
|
+
| `WINDOW` | the endpoint's from/to parameter names, filled from the Sync-query's `startDate`/`endDate` (read them with `kit.dates.date_window`); omit for undated datasets |
|
|
42
43
|
| `COLUMNS` | `(label, source)` pairs in file order |
|
|
43
44
|
|
|
44
45
|
Sources are small functions in the engine module: `field("a.b.c")` (dotted path; a missing step is
|
|
@@ -58,9 +59,13 @@ parameter fails in `connect()` before the vendor is called.
|
|
|
58
59
|
(Workato), in code, docstrings, comments, test names, fixture data or test ids. Test data is
|
|
59
60
|
synthetic. Per-customer values (columns, category or custom-field names) live only in thin files.
|
|
60
61
|
Before committing, grep the lib diff for the customer's name, domain and org id.
|
|
61
|
-
- **Reuse before adding.** Use
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
- **Reuse before adding.** Use the SDK kit first (`from dr_integrations_sdk import kit`: logger, query
|
|
63
|
+
vars, HTTP client, CSV writer, paging, auth, dates), then `_core` helpers and an existing connection
|
|
64
|
+
that fits; never a second copy. Name modules after what they do, never after who uses them. Hand the
|
|
65
|
+
user a list of lib improvements you noticed (duplicated helpers, code that belongs in `_core`, a
|
|
66
|
+
hand-written copy of a kit helper), done in their own PR. The kit comes from the runner image at
|
|
67
|
+
runtime, not from `.sdk-ref`: publish a kit-based lib connector only once each hook's first log line
|
|
68
|
+
in that datacenter reads `INFO:dr.sdk: hook=… kit=<version>`.
|
|
64
69
|
- **Never change what another org lands.** A change to a module other orgs import must be proven
|
|
65
70
|
output-neutral: their tests pass, and for text-only edits, each module's AST with docstrings stripped
|
|
66
71
|
equals `origin/main`'s. A lib merge cuts new versions of every connector that bundles it; none moves
|
|
@@ -68,7 +73,11 @@ parameter fails in `connect()` before the vendor is called.
|
|
|
68
73
|
- **Take the file format from a real landed file, not from memory.** Download the file the old
|
|
69
74
|
integration landed (`dr filebox download <fb> --doc-version <id>`) and profile it: BOM, line endings,
|
|
70
75
|
trailing newline, how empty and null cells are written, when values are quoted, number and boolean
|
|
71
|
-
forms, timestamp formats. The engine's cell writer reproduces exactly that.
|
|
76
|
+
forms, timestamp formats. The engine's cell writer reproduces exactly that. `kit.csv.CsvWriter` gives
|
|
77
|
+
the header-once, one-block-per-page writer and takes `csv.writer` dialect kwargs
|
|
78
|
+
(`lineterminator="\n"`, `quoting=`), but writes each value as `csv.writer` does (`str()`, `None` as an
|
|
79
|
+
empty cell), so the engine still pre-formats every cell the way the landed file shows (`true`, not
|
|
80
|
+
`True`).
|
|
72
81
|
- **Credentials resolve at runtime from the datasource** (`get_credentials`), never from code, tests
|
|
73
82
|
or logs (`tech/security.md` §4). Check a migrated credential's shape without printing it; a stored
|
|
74
83
|
header value may carry its scheme (`Basic <base64>`).
|
|
@@ -88,6 +97,6 @@ the same fetched records through the old and new code and require identical byte
|
|
|
88
97
|
| Customer column list or category names as the lib default | Empty default in the lib; the thin file sets them |
|
|
89
98
|
| "A thin connector needs no test" | A thin file that declares columns tests its header and its org-specific sources |
|
|
90
99
|
| One lib connection per scope, named after an output variant (`flat`) | One parameterized connection; the thin file sets `SCOPE` |
|
|
91
|
-
| `
|
|
100
|
+
| `CsvWriter` defaults (CRLF, `str(True)`) for the file | Pass the landed file's dialect (`lineterminator="\n"`) and pre-format cells the way it shows (`""`, `true`) |
|
|
92
101
|
| Artifact test that lists each client's folder | Discover thin connectors by glob on `from lib.<vendor>.` |
|
|
93
|
-
| A page cap that logs and returns | The cap raises `PermanentError
|
|
102
|
+
| A page cap that logs and returns | The cap raises `PermanentError` (`kit.paging` does at `max_pages`); test landing exactly on it and exceeding it |
|
|
@@ -92,8 +92,11 @@ CONNECTION = "<conn>"
|
|
|
92
92
|
- **Reuse the lib's code before adding any.** A new variant still reuses what the lib already has: the
|
|
93
93
|
helpers in `_core.py` and the lib's one connection. `lib/ramp` has a single
|
|
94
94
|
`connections/base_connection.py`; a thin connection subclasses it and sets `SCOPE`, never a copy. Name a new variant after its output,
|
|
95
|
-
never after the tool or customer it came from.
|
|
96
|
-
|
|
95
|
+
never after the tool or customer it came from. Generic helpers (logger, query vars, retrying client,
|
|
96
|
+
CSV writer, paging) belong in the SDK kit (*Write the connector*), not in `_core`; `_core` holds only
|
|
97
|
+
vendor-specific code. While you're in the lib, list what could be shared or
|
|
98
|
+
cleaned up (a helper two variants duplicate, a pattern that belongs in `_core`, a hand-written copy of a
|
|
99
|
+
kit helper) and give that list to
|
|
97
100
|
the user as lib suggestions. Make those changes in their own PR unless the user asks otherwise.
|
|
98
101
|
- **The lib is shared: it never names a client or holds a client's config or data.** No customer or org
|
|
99
102
|
id in its code, docstrings or tests; no client-specific default (a customer's accounting-category
|
|
@@ -160,14 +163,15 @@ filter ignore any path whose fourth component isn't `connections`/`dataqueries`
|
|
|
160
163
|
change neither republishes nor burns a version.
|
|
161
164
|
|
|
162
165
|
Two files, both importing from `dr_integrations_sdk`. Keep imports within the flavor's `allowlist.txt`
|
|
163
|
-
(stdlib always ok; base flavor = httpx + tenacity + dateutil
|
|
166
|
+
(stdlib always ok; base flavor = httpx + tenacity + dateutil; the SDK's `kit`, below, is part of
|
|
167
|
+
`dr_integrations_sdk`, so every flavor allows it). No manifest — name = `<vendor>__<dq>`,
|
|
164
168
|
runtime_flavor = the `RUNTIME_FLAVOR` constant, version = the git tag CI assigns on merge (a monotonic
|
|
165
169
|
`vN` per connector — NOT a content hash). **No lib (Step 0)? Start from a `samples/` template — pick by
|
|
166
170
|
the SOURCE's shape:**
|
|
167
171
|
|
|
168
172
|
| Vendor shape | Copy | It teaches |
|
|
169
173
|
|---|---|---|
|
|
170
|
-
| Flat: one endpoint you page through (`/invoices?offset=`) | **`samples/sample_http_vendor/`** — the golden path | auth gate, the three config channels, `get_query_class` dispatch, the
|
|
174
|
+
| Flat: one endpoint you page through (`/invoices?offset=`) | **`samples/sample_http_vendor/`** — the golden path, built on the kit | auth gate, the three config channels, `get_query_class` dispatch, the kit logger, HTTP client (non-retryable 4xx → `PermanentError`), CSV writer and cursor paging, and both pagination styles |
|
|
171
175
|
| **Fan-out / N+1**: you must drill DOWN to reach rows (`batches → headers → lines`, `invoices → lines`, `accounts → transactions`) | **`samples/sample_streaming_fanout/`** | everything above **plus** how to fetch a lot of rows concurrently while holding almost none — the bounded worker pool + bounded queue that keeps peak memory flat under the sandbox's 512 MB ceiling (see *Memory* below) |
|
|
172
176
|
| **Any shape, and a second org is likely** — write it as a lib | **`lib/sample_vendor/`** + **`samples/sample_lib_connector/`** | the lib layout: output-neutral code once in `_core.py`, one-hop literals, a page cap that raises, the thin reference-only connector and a customer extension, and the lib's tests (including a runner-style artifact test) |
|
|
173
177
|
|
|
@@ -183,6 +187,29 @@ Those carry org-specific config, may lag the current SDK/runtime contract, and c
|
|
|
183
187
|
drift — the samples are the single maintained source of truth. At most read an existing one to sanity-check
|
|
184
188
|
one behavior; never use it as the starting point to copy from.
|
|
185
189
|
|
|
190
|
+
**Shared helpers come from the SDK's connector kit — never copy them, from a sample or anywhere else.**
|
|
191
|
+
A template gives you the structure; the logger, config, retry, CSV and paging code comes from
|
|
192
|
+
`from dr_integrations_sdk import kit` (import the package, not `kit.csv`/`kit.logging` by name — they
|
|
193
|
+
would shadow the stdlib). `sample_http_vendor` uses it; `sample_streaming_fanout` and `lib/sample_vendor`
|
|
194
|
+
still carry hand-written `_get_logger` / `_query_vars` / retry helpers, so swap those for the kit when you
|
|
195
|
+
copy them. Reference: dr-connectors-runner `docs/connector-kit.md`.
|
|
196
|
+
|
|
197
|
+
| Need | Kit |
|
|
198
|
+
|---|---|
|
|
199
|
+
| a logger whose lines survive the sandbox | `kit.logging.get_logger("<vendor>__<dq>")` |
|
|
200
|
+
| the merged config channels, required variables | `kit.config.query_vars(req)`, `kit.config.require(vars, *names, context=)` (raises `PermanentError` naming each missing one) |
|
|
201
|
+
| vendor text in a log line or an error | `kit.redact.safe_snippet` / `log_safe` (redacts credentials); `one_line` for ids and cursors |
|
|
202
|
+
| HTTP with retry and 4xx classification | `kit.http.client(...)`, an `httpx.AsyncClient` (every httpx kwarg passes through): retries 429 (honouring `Retry-After`), 500/502/503/504 and transport errors; 401/403 raise `AuthError`, any other 4xx or an unfollowed redirect `PermanentError`; a 429/5xx still failing after the last attempt raises `httpx.HTTPStatusError` (runtime retry). `raise_for_status=False` hands 4xx back to you |
|
|
203
|
+
| the CSV header once, one block per page | `kit.csv.CsvWriter(fields).blocks(req, pages)`; `.rows` counts rows written |
|
|
204
|
+
| paging that fails loudly | `kit.paging.cursor_pages` / `offset_pages`; `kit.paging.waves(items, worker, concurrency=)` |
|
|
205
|
+
| credentials and tokens | `kit.auth.credential(creds, *keys, vendor=)`, `client_credentials` / `password_grant` → `Token.header()`, `basic_header`, `bearer_header` |
|
|
206
|
+
| a date window | `kit.dates.date_window(vars, start_keys=, end_keys=, required=)`, `DateWindow.chunks(days)`, `parse_date` |
|
|
207
|
+
|
|
208
|
+
**Publish a kit connector only where the kit is live.** At runtime the kit comes from the **runner
|
|
209
|
+
image**, not from `.sdk-ref` (that pins only what CI tests against). An image with the kit prints
|
|
210
|
+
`INFO:dr.sdk: hook=… correlation_id=… kit=<version>` as each hook's first log line; until that line
|
|
211
|
+
shows in the target datacenter, a connector that imports `kit` fails at import there.
|
|
212
|
+
|
|
186
213
|
**Batching & output — the non-negotiable pattern (get this right FIRST, before writing fetch).**
|
|
187
214
|
|
|
188
215
|
**Step 0 — decide the read strategy by the source's batching support:**
|
|
@@ -206,11 +233,9 @@ Do NOT return `has_more` for an accumulated single output (e.g. a CSV). `write_o
|
|
|
206
233
|
month landed only the final page's rows, with no header. Correct pattern — one `fetch()`, one `write_output`, stream
|
|
207
234
|
the header once then one block per page (`query.py:60-67`), `has_more=False`:
|
|
208
235
|
```python
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
yield csv_rows_block(FIELDS, page) # one Azure block per page — memory stays flat
|
|
213
|
-
return HookResult(output_blob_key=await self.write_output(req, blocks()), has_more=False)
|
|
236
|
+
writer = kit.csv.CsvWriter(FIELDS) # header on batch 0 only, then one Azure block per page
|
|
237
|
+
pages = kit.paging.cursor_pages(page) # page(cursor) -> (items, next_cursor): loop the cursor INTERNALLY
|
|
238
|
+
return HookResult(output_blob_key=await self.write_output(req, writer.blocks(req, pages)), has_more=False)
|
|
214
239
|
```
|
|
215
240
|
|
|
216
241
|
**Memory — the 512 MB ceiling, and the one rule: never hold anything that scales with ROW COUNT.**
|
|
@@ -248,14 +273,14 @@ Four rules, each of which that bug broke:
|
|
|
248
273
|
from, and only a generator that does the work gets it.
|
|
249
274
|
2. **`asyncio.gather` over the whole work set IS the accumulation.** It returns only when every
|
|
250
275
|
coroutine finishes, and returns every result at once — there is no streaming variant. Bounded
|
|
251
|
-
waves
|
|
276
|
+
waves over a handful of **pages** are fine (`kit.paging.waves`; results are bounded); `gather` over
|
|
252
277
|
every **parent** in a fan-out is a buffer wearing a concurrency costume. Use N worker tasks
|
|
253
278
|
draining a work queue and pushing finished chunks into a **bounded** `asyncio.Queue` — the
|
|
254
279
|
bound is what makes `await queue.put(...)` block when the uploader falls behind, carrying
|
|
255
280
|
backpressure from Azure all the way to the vendor request.
|
|
256
281
|
3. **Rows become bytes when their page arrives, then are forgotten.** Measured per row: dict 846 B,
|
|
257
|
-
tuple 550 B, pre-joined CSV line 289 B — but the right answer is to keep none. `csv.
|
|
258
|
-
|
|
282
|
+
tuple 550 B, pre-joined CSV line 289 B — but the right answer is to keep none. `kit.csv.CsvWriter`
|
|
283
|
+
encodes each page (dict or tuple rows) as it arrives; hold no rows past that. The only legitimate accumulation is the **work list** (which
|
|
259
284
|
parents exist), bounded by parent count, not row count — and keep it as small tuples of the
|
|
260
285
|
fields you need, never the vendor's response objects (journal_lines retained ~4,700 verbose
|
|
261
286
|
Oracle header dicts to read 5 fields from each).
|
|
@@ -288,7 +313,8 @@ Two more sandbox ceilings worth knowing, both undocumented elsewhere:
|
|
|
288
313
|
(`_LINE_PAGE_LIMIT` etc.) exist so a broken cursor can't loop forever — but a cap that logs a
|
|
289
314
|
warning and returns what it has lands a **partial file that looks complete**. That is how
|
|
290
315
|
journal_lines silently dropped 113,171 rows of a customer's month and turned into a reconciliation
|
|
291
|
-
escalation. Caps must `raise PermanentError
|
|
316
|
+
escalation. Caps must `raise PermanentError` — `kit.paging.cursor_pages` / `offset_pages` (`max_pages=`)
|
|
317
|
+
do, and also fail on a cursor that does not advance and on a vendor that caps its page size below yours.
|
|
292
318
|
|
|
293
319
|
Keep that single step under the runner's **2700s (45 min) hook timeout** (`HOOK_TIMEOUT_S` in
|
|
294
320
|
`dr-connectors-runtime/src/runtime/activities.py`, applied **per hook**, so `fetch` gets the whole
|
|
@@ -297,15 +323,17 @@ A timeout **blocks instead of retrying** — the budget is a property of the hoo
|
|
|
297
323
|
of the moment, so a re-run would do the same too-slow work and fail again. One kill, one reported
|
|
298
324
|
failure. Levers, in order: (1) a
|
|
299
325
|
**larger vendor page size** — but the vendor may cap it (**some vendors reject `pagesize` above their own limit; a legacy
|
|
300
|
-
recipe used 1000**), so probe before relying on it and
|
|
301
|
-
|
|
302
|
-
`fetch` — for a FLAT source, waves
|
|
303
|
-
stopping at the first short/empty page
|
|
326
|
+
recipe used 1000**), so probe before relying on it and make vendor 4xx visible — `kit.http.client`'s
|
|
327
|
+
`PermanentError` names the status and a redacted body (a bare `raise_for_status` leaves no trace); (2) **bounded concurrency** inside the one
|
|
328
|
+
`fetch` — for a FLAT source, `kit.paging.waves(offsets, worker, concurrency=N)` with N≈3–5 over offset pages,
|
|
329
|
+
yielded in page order, stopping at the first short/empty page — only over a stable sort order, or pages
|
|
330
|
+
shift under you (a cursor API cannot be fanned out); for a **fan-out** source, a bounded worker pool feeding a
|
|
304
331
|
bounded queue instead (`samples/sample_streaming_fanout/`) — gather over every parent both buffers
|
|
305
332
|
the whole result (see *Memory*) and stalls fast parents behind slow ones in the same wave, which
|
|
306
|
-
measured 376s vs 192s on the same data. Either way **with per-page retry**
|
|
307
|
-
drop/429/5xx on that one
|
|
308
|
-
waves
|
|
333
|
+
measured 376s vs 192s on the same data. Either way **with per-page retry** since vendors throttle under
|
|
334
|
+
concurrency — `kit.http.client` retries a transient drop/429/5xx on that one request, not the whole month.
|
|
335
|
+
`waves` propagates the first failure, so a vendor that answers a page *beyond* the last one with an error
|
|
336
|
+
rather than an empty page needs that one status handled in the worker. Wave
|
|
309
337
|
concurrency is what took a 76-page GL month from ~388s sequential (timeout) to ~177s. (3) only as a
|
|
310
338
|
last resort, narrow the
|
|
311
339
|
sync's date window so each run is smaller. `has_more`/`next_cursor` are reserved for when Phase-2
|
|
@@ -316,12 +344,15 @@ multi-batch assembly lands — until then treat them as unusable for a single ac
|
|
|
316
344
|
**fail-fast auth gate** — prove auth here, once. Set module-level `RUNTIME_FLAVOR = "<flavor>"` and
|
|
317
345
|
`CONNECTION = <the class>`:
|
|
318
346
|
```python
|
|
319
|
-
from dr_integrations_sdk import DataIntegrationConnection, SyncRequest
|
|
347
|
+
from dr_integrations_sdk import DataIntegrationConnection, SyncRequest, kit
|
|
320
348
|
RUNTIME_FLAVOR = "base"
|
|
349
|
+
log = kit.logging.get_logger("<vendor>__<dq>")
|
|
321
350
|
class MyConnection(DataIntegrationConnection):
|
|
322
351
|
async def connect(self, req: SyncRequest):
|
|
323
352
|
creds = await self.get_credentials(req) # {"apikey"/"clientId"...} from the datasource
|
|
324
|
-
|
|
353
|
+
api_key = kit.auth.credential(creds, "apikey", vendor="<vendor>") # PermanentError naming the keys, never a KeyError
|
|
354
|
+
# mint a token (kit.auth.client_credentials / password_grant) if the vendor needs one
|
|
355
|
+
log.info("connect: credential resolved") # never the secret
|
|
325
356
|
return Session(...)
|
|
326
357
|
CONNECTION = MyConnection # the runtime object (a class), NOT a string
|
|
327
358
|
```
|
|
@@ -329,16 +360,25 @@ multi-batch assembly lands — until then treat them as unusable for a single ac
|
|
|
329
360
|
session (never re-resolving creds) and returns a `HookResult`. Set `CONNECTION = "<conn>"` (a string
|
|
330
361
|
naming the sibling connection file) and `def get_query_class(name)`:
|
|
331
362
|
```python
|
|
332
|
-
from dr_integrations_sdk import DataQuery, HookResult, SyncRequest
|
|
363
|
+
from dr_integrations_sdk import DataQuery, HookResult, SyncRequest, kit
|
|
333
364
|
CONNECTION = "<conn>" # -> connections/<conn>.py, same vendor (a STRING here)
|
|
365
|
+
log = kit.logging.get_logger("<vendor>__<dq>")
|
|
366
|
+
FIELDS = ["id", "name", "amount"]
|
|
334
367
|
class MyQuery(DataQuery):
|
|
335
368
|
async def fetch(self, session, req: SyncRequest) -> HookResult:
|
|
336
|
-
|
|
369
|
+
q = kit.config.query_vars(req) # public_config ∪ query_config ∪ filebox Sync-query (parsed from its JSON text), platform keys dropped
|
|
337
370
|
# Merge order matters: the Sync-query is applied LAST and wins any key collision. Selectors
|
|
338
371
|
# (symbol/account/entity) live there so operators can see them in the UI; query_config is
|
|
339
372
|
# plumbing only (base_url, caps). Never define one key in both — see step 3c.
|
|
340
|
-
|
|
341
|
-
|
|
373
|
+
(base_url,) = kit.config.require(q, "base_url", context="<vendor> <dq>")
|
|
374
|
+
writer = kit.csv.CsvWriter(FIELDS)
|
|
375
|
+
async with kit.http.client(base_url=base_url, headers=session.headers(), logger=log) as http:
|
|
376
|
+
async def page(cursor):
|
|
377
|
+
body = (await http.get("/rows", params={"cursor": cursor} if cursor else {})).json()
|
|
378
|
+
return body["items"], body.get("next_cursor")
|
|
379
|
+
key = await self.write_output(req, writer.blocks(req, kit.paging.cursor_pages(page)))
|
|
380
|
+
log.info("fetch done rows=%d", writer.rows)
|
|
381
|
+
return HookResult(output_blob_key=key, has_more=False)
|
|
342
382
|
def get_query_class(name: str) -> type[DataQuery]: # name = req.config["objectType"]; single-dataset -> return the sole class
|
|
343
383
|
return MyQuery
|
|
344
384
|
```
|
|
@@ -396,7 +436,8 @@ The repo-root `conftest.py` carries the whole harness, so the test itself stays
|
|
|
396
436
|
silently — and neither shows up unless you probe both.
|
|
397
437
|
3. **Non-retryable vendor 4xx → `PermanentError`** (400/401/402/403/404/422), and 429/5xx *not*
|
|
398
438
|
permanent. A bare exception is `verdict: retry` — five doomed attempts, ~25 min of hammering the
|
|
399
|
-
customer's vendor account, and a locked filebox.
|
|
439
|
+
customer's vendor account, and a locked filebox. `kit.http.client` provides this (401/403 as
|
|
440
|
+
`AuthError`, a `PermanentError` subclass); the test still proves the connector uses it.
|
|
400
441
|
4. **Failure and cancellation hygiene.** A failing producer or consumer must surface promptly with
|
|
401
442
|
no orphaned tasks and no hang; the suite runs with `filterwarnings = ["error"]` and fails any
|
|
402
443
|
test that leaves a task pending.
|
|
@@ -813,8 +854,8 @@ unmerged connector has no version to ingest, bind, or run.
|
|
|
813
854
|
| New artifact code doesn't run even after publish | The runtime resolves the **active-version pointer** (`connector`+`org`), not your latest publish. | `dr connectors publish <dq.py>` then `dr connectors promote <name> --artifact-version vN` (both need `NODE_EXTRA_CA_CERTS`). Confirm: in-cluster `GET /internal/connectors/<name>/active?org_id=<org>`. |
|
|
814
855
|
| `dr filebox get` never shows the filebox `uuid` | Not exposed by that command. | Read `filebox_uuid` from the `sync.requested` event (`datarails-connect-inngest`), or capture it from `dr filebox create --json`. |
|
|
815
856
|
| Creating a binding via `curl localhost:8000/connectors` (or `/connector-queries`) → `401` / SPA HTML | Those POSTs are **authenticated** now; the port-forward serves the SPA. There is no unauthenticated create path. | Use the dr-cli (carries the JWT): `dr connectors connector create --secret-ref <ds>` then `dr connectors query create --connector-id <id> --filebox-uuid <uuid> --artifact-name … --artifact-version … --query-config '{…}'`. See step 4. |
|
|
816
|
-
| Connector's own `log.info(...)` lines never appear in the runner logs / Groundcover (but `dr.runner` INFO does) | The hook runs in a **sandbox whose root logger has no handler**, so bare `logging` is dropped (only WARNING+ via lastResort, and even that may not surface). |
|
|
817
|
-
| A credential reaches Groundcover through a log line that logs no secret | The connector logged the raw `base_url`. It is **operator-supplied binding config**, so it can carry `user:pass@` userinfo or a credential in the query string — and the runner log is a shared multi-tenant stream. 24 connectors in dr-connectors currently do this (harmless for the keyless public sources, not for the credentialed ones). | Log only the hostname: `urlsplit(base_url).hostname` drops userinfo, port, path and query, which is all a log line needs to identify the environment. `samples/sample_streaming_fanout/` and the databricks connections model this. |
|
|
857
|
+
| Connector's own `log.info(...)` lines never appear in the runner logs / Groundcover (but `dr.runner` INFO does) | The hook runs in a **sandbox whose root logger has no handler**, so bare `logging` is dropped (only WARNING+ via lastResort, and even that may not surface). | Use `kit.logging.get_logger("<vendor>__<dq>")`: a stdout handler with a `[<vendor>__<dq>]` prefix, so lines are greppable in the shared `connectors-runner` pod. Never hand-roll a `StreamHandler` helper. Log auth (never the secret), the window, per-page progress, row count. |
|
|
858
|
+
| A credential reaches Groundcover through a log line that logs no secret | The connector logged the raw `base_url`. It is **operator-supplied binding config**, so it can carry `user:pass@` userinfo or a credential in the query string — and the runner log is a shared multi-tenant stream. 24 connectors in dr-connectors currently do this (harmless for the keyless public sources, not for the credentialed ones). | Log only the hostname: `urlsplit(base_url).hostname` drops userinfo, port, path and query, which is all a log line needs to identify the environment. `samples/sample_streaming_fanout/` and the databricks connections model this. `kit.http.client`'s retry and error lines already name only `METHOD scheme://host[:port]/path`, and `kit.redact.safe_snippet` redacts credential-shaped vendor text. |
|
|
818
859
|
| `get_query_class` receives `None` / multi-dataset connector raises "unknown objectType" / wrong dataset runs | `objectType` was placed at the data-query **config top level** instead of **inside `config.query`**. The runtime reads `dq["query"].get("objectType")` with **no top-level fallback**, so it comes through as `None`. | Put `objectType` inside the Sync-query, which is JSON **text**: `config.query = '{"objectType":"<dataset>"}'` (drop any top-level `objectType`). `dr connectors dev-fetch` warns when it's missing. Single-dataset connectors survive via their default; multi-dataset ones fail. |
|
|
819
860
|
| Sync-query written as a nested **dict** (the pre-DR-50411 shape) | `config.query` was the only vendor field held as an object. It still syncs — `_sync_query_object` accepts a dict — but the monolith's legacy filebox-settings textarea binds the value directly, so an object rendered there as the literal **`[object Object]`** and a Save wrote that string back over the query. Monolith PR https://github.com/Datarails/dr-datarails-server/pull/7390 took `DatarailsConnector` out of `list_of_integrations_with_dynamics_fields` to put it on the free-text path. | Write `config.query` as JSON text (step 3c). There is **no backfill command** — the dropped `connector_sync_query_to_text` is referenced in an older control-plane commit message but never shipped — so an existing filebox converts only when re-saved through the connectors Sync-query editor. |
|
|
820
861
|
| Sync `Completed` but the landed file has only the **last page** of rows (and no CSV header) | The connector returned `has_more=True`/`next_cursor` (multi-batch pagination). `post_fetch` is fed only `batch_keys[-1]` — the last batch (Phase-2 assembly unimplemented, `activities.py:204-236`); and `write_output`'s `commit_block_list` replaces `req.output_sas` each call. So every batch overwrote the prior and only the last survived. | Paginate **inside one `fetch()`** with a single `write_output` (header once + one block per page), `has_more=False`. See §1. Verified: this is the only way to land a complete multi-page CSV on the current runtime. |
|
|
@@ -822,7 +863,7 @@ unmerged connector has no version to ingest, bind, or run.
|
|
|
822
863
|
| Filebox versions all carry a date tag (e.g. `Month: August 2026`) but every version holds the same full snapshot | A **date tag on a filebox whose Sync-query has no date window** — a reference/dictionary dataset (payers, HCPCS, modifiers, tags, users). The tag is applied at upload from the selected period regardless of what was fetched, so it records a period the data was never filtered by. | Match the tag to the query: `--no-date` at create for an undated query; a dated tag only when the query renders a `{{ date_tag| … }}` window. On an existing filebox, drop the tag with `dr filebox set-tag-config <id> --remove <TagName>` (or recreate it with `--no-date` — note that mints a NEW `uuid`, so the `ConnectorQuery` binding must be recreated too). |
|
|
823
864
|
| Confused about publish vs ingest | CI publishes to JFrog on merge (cuts the git tag `vN`); the CLI ingests a published version into a DC | `dr connectors publish <path/to/dataqueries/<dq>.py> --server DEV --account <customer-user-email> --json` reads the newest git tag (`git fetch --tags` + max `vN`, or `--tag vN`) — **no local compose** — then the control-plane **pulls that tag from JFrog** (no upload) and registers it (needs `NODE_EXTRA_CA_CERTS`); returns `{name, version: "vN", digest}`. |
|
|
824
865
|
| Run `Failed`, `connect` completion `verdict: ok` but `fetch` completion `verdict: retry`, error `'async for' requires an object with __aiter__ method, got function` | Passed the async-generator **function** to `write_output` — `write_output(req, gen)` instead of **calling** it `write_output(req, gen())`. `write_output` does `async for chunk in <arg>`, which needs an async **iterator**, not a function. (The connect gate passing but fetch dying instantly is the tell.) | Call the generator: `write_output(req, gen())`. The golden template already shows `write_output(req, rows())` / `write_output(req, blocks())` — match it exactly; the missing `()` is easy to drop. |
|
|
825
|
-
| Vendor **4xx** (e.g. `402 Payment Required`, `401`, `403`, bad-symbol `404`) makes the `fetch` completion `verdict: retry` and the run **retries forever**; the HTTP status shows ONLY in the completion store, never in the Inngest run `output` (which is `null`). | A bare `r.raise_for_status()` raises `httpx.HTTPStatusError` for **every** status ≥ 400, and the runtime classifies that as **transient → retry**. But a plan/auth/bad-request 4xx will never succeed on retry. |
|
|
866
|
+
| Vendor **4xx** (e.g. `402 Payment Required`, `401`, `403`, bad-symbol `404`) makes the `fetch` completion `verdict: retry` and the run **retries forever**; the HTTP status shows ONLY in the completion store, never in the Inngest run `output` (which is `null`). | A bare `r.raise_for_status()` raises `httpx.HTTPStatusError` for **every** status ≥ 400, and the runtime classifies that as **transient → retry**. But a plan/auth/bad-request 4xx will never succeed on retry. | Use `kit.http.client` (skill §1): 401/403 raise `AuthError` and any other 4xx `PermanentError`, each naming the status and a redacted body; 429 and 5xx are retried, then left to the runtime's retry (genuinely transient). Concrete case: FMP's free tier answers **402** when `limit` exceeds its historical allowance (dev key caps statements at the last **5** annual records) — a small `limit` or a paid key fixes that one; the PermanentError stops the infinite loop for the general case. The golden `sample_http_vendor` template is built on this client. |
|
|
826
867
|
| Connector works perfectly in local/dev testing, then in production `fetch` dies with `MemoryError`, `MemoryError: Unable to allocate output buffer`, an SSL/crypto allocation failure, or `RuntimeError: can't start new thread` — often within a second of "fetch start", and deterministically on every one of the 5 attempts | The sandbox caps **virtual address space at `RLIMIT_AS` = 512 MB** (`dr-connectors-runner/src/runner/sandbox_entry.py`), and the runner pod's limit is also 512Mi. The guard is `if sys.platform != "win32"`, so **the ceiling does not exist on a Windows dev box** — a buffering connector cannot fail locally. Two causes, usually together: (a) the fetch accumulates the result set (a `rows` list, `asyncio.gather` over every parent, a full CSV string + its `.encode()` copy) — journal_lines measured **378 MB live to emit 67 MB**, 243 MB of it row dicts at 846 B/row; (b) asyncio's **default** DNS ThreadPoolExecutor grew to `min(32, node_cores + 4)` threads × 8 MB stack ≈ **256 MB of address space that is never reclaimed**, which is why a brief concurrency spike poisons every later phase of the run. | Stream: do the fetching **inside** the generator passed to `write_output`, replace gather-over-everything with a bounded worker pool feeding a bounded `asyncio.Queue`, format rows to CSV bytes immediately (never hold dicts), and re-batch to ~4 MB blocks. Cap the executor once: `asyncio.get_running_loop().set_default_executor(ThreadPoolExecutor(max_workers=4))`. Full rules in §1 *Memory*; working template `samples/sample_streaming_fanout/`. Reproduce locally in `python:3.12-slim` under the same rlimits — never trust a Windows run for this. |
|
|
827
868
|
| Hook dies with no traceback and no `MemoryError`; runner reports `sandbox exited -24` | **`RLIMIT_CPU = 60s`** (`sandbox_entry.py`, right beside the memory limit) — 60 seconds of **CPU** time, then SIGXCPU/SIGKILL. Async I/O waiting is free, but TLS handshakes, gzip and JSON parsing across thousands of requests are not. | Cut CPU per row (fewer/larger vendor pages, `onlyData` where the vendor supports it, no per-row dict churn) and log `time.process_time()` next to wall clock so proximity to the cap is visible before it kills a run. |
|
|
828
869
|
| A sync `Completed` and the file **looks** complete, but the customer's totals are short | A page-limit "safety cap" (`_LINE_PAGE_LIMIT`, `_HEADER_PAGE_LIMIT`, …) tripped and the code **logged a warning and returned what it had**, so a partial result landed as if whole. Same failure class as a missing offset follow-up: journal_lines silently dropped **113,171 rows** of one customer's month this way. | Caps must `raise PermanentError`, never warn-and-return — a failed sync is recoverable, a silently partial landing gets reconciled against and believed. Audit every `log.warning` that is followed by `return`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datarails-ai/dr-cli",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.87",
|
|
4
4
|
"description": "Datarails CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@datarails-ai/dr-appspace-cli": "0.10.
|
|
20
|
+
"@datarails-ai/dr-appspace-cli": "0.10.13",
|
|
21
21
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
22
22
|
"@napi-rs/keyring": "^1.3.0",
|
|
23
23
|
"commander": "^12.1.0",
|