@datarails-ai/dr-cli 1.6.86 → 1.6.88

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.86",
11
+ version: "1.6.88",
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.12",
41
+ "@datarails-ai/dr-appspace-cli": "0.10.14",
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:01:52.638Z";
70
+ var BUILD_TIME = "2026-10-01T09:50:32.245Z";
71
71
  function formatBuildTime(iso) {
72
72
  if (iso === "") return "";
73
73
  const d = new Date(iso);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "cliVersion": "1.6.86",
3
+ "cliVersion": "1.6.88",
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 **pass their field names** plus a few
12
- optional parameters. The worked example is `lib/ramp/dataqueries/select_query.py`; find its thin
13
- connectors with `grep -rl "lib.ramp.dataqueries.select_query" --include=*.py us us2 uk ca`. Read both first.
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: credentials, token, client, pagination, query vars, logger
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`; omit for undated datasets |
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 `_core` helpers and an existing connection that fits; never a second
62
- copy. Name modules after what they do, never after who uses them. Hand the user a list of lib
63
- improvements you noticed (duplicated helpers, code that belongs in `_core`), done in their own PR.
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
- | `csv.writer` / `str(True)` for the file | Write cells the way the landed file shows (`""`, LF, `true`) |
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`; test landing exactly on it and exceeding it |
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. While you're in the lib, list what could be shared or
96
- cleaned up (a helper two variants duplicate, a pattern that belongs in `_core`) and give that list to
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). No manifest — name = `<vendor>__<dq>`,
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 greppable logger, the non-retryable-4xx→PermanentError guard, and both pagination styles |
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
- async def blocks():
210
- yield csv_header_block(FIELDS)
211
- async for page in paginate(session, req): # loop the vendor cursor INTERNALLY
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 of `gather` over a handful of **pages** are fine (results are bounded); `gather` over
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.writer` over
258
- tuples, not `DictWriter` over dicts. The only legitimate accumulation is the **work list** (which
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 **log vendor 4xx** so a rejected page
301
- size is visible (a bare `raise_for_status` leaves no trace); (2) **bounded concurrency** inside the one
302
- `fetch` — for a FLAT source, waves of N≈3–5 page GETs (`asyncio.gather`), streamed in page order,
303
- stopping at the first short/empty page; for a **fan-out** source, a bounded worker pool feeding a
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** (retry a transient
307
- drop/429/5xx on that one page, not the whole month) since vendors throttle under concurrency; for
308
- waves use `return_exceptions` so a page fetched *beyond* the last one can't fail the run. Wave
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
- # mint a token / open a client, then return an in-memory session object
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
- cfg = req.config # public_config ∪ query_config ∪ filebox Sync-query (cfg["query"], parsed from its JSON text) ∪ cfg["period"]
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
- # httpx GET using `session` -> rows; write via an async generator yielding bytes
341
- return HookResult(output_blob_key=await self.write_output(req, gen), has_more=False)
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). | In the connector, attach an explicit `logging.StreamHandler(sys.stdout)` to your logger (idempotently), set level INFO, and format with a `[<vendor>__<dq>]` prefix so lines are greppable in the shared `connectors-runner` pod. Log auth (never the secret), the window, per-page progress, row count. |
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. | Log the status+body (skill §1), then for non-retryable statuses (`400/401/402/403/404/422`) `raise PermanentError(...)`; leave `429` + `5xx` to `raise_for_status` (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 now shows this guard. |
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.86",
3
+ "version": "1.6.88",
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.12",
20
+ "@datarails-ai/dr-appspace-cli": "0.10.14",
21
21
  "@modelcontextprotocol/sdk": "^1.29.0",
22
22
  "@napi-rs/keyring": "^1.3.0",
23
23
  "commander": "^12.1.0",