datagrok-tools 6.5.7 → 6.5.8

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.
@@ -3,8 +3,13 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.NodeUsersDataSource = exports.NodeTablesDataSource = exports.NodeSharesDataSource = exports.NodePackagesDataSource = exports.NodeHttpDataSource = exports.NodeGroupsDataSource = exports.NodeFuncsDataSource = exports.NodeFilesDataSource = exports.NodeDapi = exports.NodeConnectionsDataSource = exports.NodeApiClient = void 0;
6
+ exports.NodeUsersDataSource = exports.NodeTablesDataSource = exports.NodeSharesDataSource = exports.NodePackagesDataSource = exports.NodeHttpDataSource = exports.NodeGroupsDataSource = exports.NodeFuncsDataSource = exports.NodeFilesDataSource = exports.NodeDomainsDataSource = exports.NodeDapi = exports.NodeConnectionsDataSource = exports.NodeApiClient = exports.InternalDataSource = void 0;
7
+ exports.apiPath = apiPath;
8
+ exports.buildQuery = buildQuery;
7
9
  exports.ensureBodyId = ensureBodyId;
10
+ exports.mapPositionalParams = mapPositionalParams;
11
+ exports.parseDomainAddress = parseDomainAddress;
12
+ exports.throwIfApiError = throwIfApiError;
8
13
  var _crypto = require("crypto");
9
14
  /// Docs: [Grok Dapi](/docs/plans/grok-dapi/)
10
15
 
@@ -12,20 +17,80 @@ function ensureBodyId(body) {
12
17
  if (body && typeof body === 'object' && !body.id) body.id = (0, _crypto.randomUUID)();
13
18
  return body;
14
19
  }
20
+ const setting = (name, fallback) => {
21
+ const value = Number(process.env[`GROK_HTTP_${name}`]);
22
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
23
+ };
24
+ const BYTES_TIMEOUT_MS = setting('BYTES_TIMEOUT', 600000);
25
+
26
+ /** Load shedding, not a verdict on the request: the same call succeeds once the queue drains. */
27
+ const RETRIABLE_STATUS = new Set([429, 502, 503, 504]);
28
+
29
+ /**
30
+ * undici keeps the socket checked out until the body is read, so a response that is answered but
31
+ * never consumed leaks a connection. Enough of them exhaust the pool, and the requests that
32
+ * follow queue forever — before the request starts, so no deadline ever fires.
33
+ */
34
+ const discard = res => res.body?.cancel() ?? Promise.resolve();
35
+
36
+ /**
37
+ * Without a deadline one unresponsive entity stalls a whole pull — `GET /projects/{id}` on a
38
+ * space holding tens of thousands of children never answers. A request that hung or dropped is
39
+ * retried, since a deadline is as often a server busy with this very pull as a dead one; a reply
40
+ * the server actually sent is not. The deadline covers the body too, so a transfer that is slow
41
+ * by nature rather than stuck (`.d42` table data) asks for a longer one.
42
+ */
43
+ async function fetchOrRetry(url, opts, retriable, timeoutMs = setting('TIMEOUT', 60000)) {
44
+ const retries = setting('RETRIES', 5);
45
+ for (let attempt = 0;; attempt++) {
46
+ const last = !retriable || attempt >= retries;
47
+ try {
48
+ const res = await fetch(url, {
49
+ ...opts,
50
+ signal: AbortSignal.timeout(timeoutMs)
51
+ });
52
+ if (last || !RETRIABLE_STATUS.has(res.status)) return res;
53
+ await res.body?.cancel();
54
+ } catch (err) {
55
+ if (last) throw new Error(`${opts.method ?? 'GET'} ${url}: ` + (err?.name === 'TimeoutError' ? `no answer in ${timeoutMs}ms` : err?.message ?? err));
56
+ }
57
+ // Capped: uncapped doubling turns a long retry budget into minutes asleep on one request.
58
+ const backoff = Math.min(setting('BACKOFF', 1000) * Math.pow(2, attempt), setting('BACKOFF_MAX', 15000));
59
+ await new Promise(resolve => setTimeout(resolve, backoff));
60
+ }
61
+ }
15
62
  class NodeApiClient {
16
- constructor(baseUrl, token) {
63
+ /** Set by `createClient` when the run asked for an admin session, so a re-login restores it. */
64
+ adminMode = false;
65
+ constructor(baseUrl, token, devKey) {
17
66
  this.baseUrl = baseUrl;
18
67
  this.token = token;
68
+ this.devKey = devKey;
19
69
  }
20
70
  static async login(baseUrl, devKey) {
21
71
  const res = await fetch(`${baseUrl}/users/login/dev/${devKey}`, {
22
72
  method: 'POST'
23
73
  });
24
- const json = await res.json();
25
- if (!json.token) throw new Error('Login failed. Check your developer key.');
26
- return new NodeApiClient(baseUrl, json.token);
74
+ const json = (res.headers.get('content-type') ?? '').includes('application/json') ? await res.json() : null;
75
+ if (!json) throw new Error(`Login failed at ${baseUrl} (HTTP ${res.status}): not a Datagrok API URL — it should end with /api`);
76
+ if (!json.token) throw new Error(`Login failed at ${baseUrl}: ${json.message ?? 'check your developer key'}`);
77
+ return new NodeApiClient(baseUrl, json.token, devKey);
27
78
  }
28
- async request(method, path, body, headers) {
79
+
80
+ /**
81
+ * A stand serving several isolates can reject a session one of them does not know, and an
82
+ * hour-long walk has no way to ask the operator to log in again. The developer key is good
83
+ * for a new session, so one is taken rather than losing the run.
84
+ */
85
+ async reauthenticate() {
86
+ if (!this.devKey) return false;
87
+ const fresh = await NodeApiClient.login(this.baseUrl, this.devKey).catch(() => null);
88
+ if (!fresh) return false;
89
+ this.token = fresh.token;
90
+ if (this.adminMode) this.token = (await fresh.post('/users/sessions/current/admin'))?.token ?? this.token;
91
+ return true;
92
+ }
93
+ async request(method, path, body, headers, reauthed = false, timeoutMs) {
29
94
  const url = `${this.baseUrl}${path}`;
30
95
  const opts = {
31
96
  method,
@@ -36,38 +101,32 @@ class NodeApiClient {
36
101
  }
37
102
  };
38
103
  if (body !== undefined) opts.body = JSON.stringify(body);
39
- const res = await fetch(url, opts);
40
- if (!res.ok) {
41
- // Read as text first to avoid "Body has already been read" when JSON.parse fails
42
- const rawText = await res.text();
43
- let errBody;
44
- try {
45
- errBody = JSON.parse(rawText);
46
- } catch {
47
- errBody = {
48
- error: rawText || `HTTP ${res.status}`
49
- };
50
- }
51
- const err = {
52
- error: errBody?.message ?? errBody?.error ?? `HTTP ${res.status}`,
53
- source: errBody?.source ?? 'Server',
54
- errorCode: errBody?.errorCode ?? res.status,
55
- stackTrace: errBody?.stackTrace
56
- };
57
- throw Object.assign(new Error(err.error), {
58
- apiError: err
104
+ const res = await fetchOrRetry(url, opts, method === 'GET', timeoutMs);
105
+ if (res.status === 401 && !reauthed) {
106
+ const refusal = await res.text();
107
+ if (await this.reauthenticate()) return this.request(method, path, body, headers, true, timeoutMs);
108
+ const error = refusal || `HTTP ${res.status}`;
109
+ throw Object.assign(new Error(error), {
110
+ apiError: {
111
+ error,
112
+ source: 'Server',
113
+ errorCode: 401
114
+ }
59
115
  });
60
116
  }
61
- if (res.status === 204 || res.headers.get('content-length') === '0') return null;
117
+ if (!res.ok) await throwHttpError(res);
118
+ if (res.status === 204 || res.headers.get('content-length') === '0') {
119
+ await discard(res);
120
+ return null;
121
+ }
62
122
  const ct = res.headers.get('content-type') ?? '';
63
- if (ct.includes('application/json')) return res.json();
64
- return res.text();
123
+ return throwIfApiError(ct.includes('application/json') ? await res.json() : await res.text());
65
124
  }
66
125
  get(path) {
67
126
  return this.request('GET', path);
68
127
  }
69
- post(path, body) {
70
- return this.request('POST', path, body);
128
+ post(path, body, timeoutMs) {
129
+ return this.request('POST', path, body, undefined, false, timeoutMs);
71
130
  }
72
131
  del(path) {
73
132
  return this.request('DELETE', path);
@@ -79,53 +138,129 @@ class NodeApiClient {
79
138
  * similar) when the server demands a specific content type.
80
139
  */
81
140
  async putBytes(path, bytes, contentType = 'application/octet-stream') {
82
- const res = await fetch(`${this.baseUrl}${path}`, {
141
+ const res = await fetchOrRetry(`${this.baseUrl}${path}`, {
83
142
  method: 'POST',
84
143
  headers: {
85
144
  'Authorization': this.token,
86
145
  'Content-Type': contentType
87
146
  },
88
147
  body: bytes
148
+ }, false, BYTES_TIMEOUT_MS);
149
+ if (!res.ok) await throwHttpError(res);
150
+ const ct = res.headers.get('content-type') ?? '';
151
+ return throwIfApiError(ct.includes('application/json') ? await res.json() : await res.text());
152
+ }
153
+
154
+ /** POST a JSON body and read the response as raw bytes (d42 query results). */
155
+ async postForBytes(path, body) {
156
+ const res = await fetch(`${this.baseUrl}${path}`, {
157
+ method: 'POST',
158
+ headers: {
159
+ 'Authorization': this.token,
160
+ 'Content-Type': 'application/json'
161
+ },
162
+ body: JSON.stringify(body),
163
+ signal: AbortSignal.timeout(BYTES_TIMEOUT_MS)
89
164
  });
90
- if (!res.ok) {
91
- const rawText = await res.text();
92
- let errBody;
93
- try {
94
- errBody = JSON.parse(rawText);
95
- } catch {
96
- errBody = {
97
- error: rawText || `HTTP ${res.status}`
98
- };
165
+ if (!res.ok) await throwHttpError(res);
166
+ return Buffer.from(await res.arrayBuffer());
167
+ }
168
+
169
+ /** GET raw bytes — d42 table data, file content, model blobs. */
170
+ async getBytes(path) {
171
+ const res = await fetchOrRetry(`${this.baseUrl}${path}`, {
172
+ headers: {
173
+ 'Authorization': this.token
99
174
  }
100
- const err = {
101
- error: errBody?.message ?? errBody?.error ?? `HTTP ${res.status}`,
102
- source: errBody?.source ?? 'Server',
103
- errorCode: errBody?.errorCode ?? res.status,
104
- stackTrace: errBody?.stackTrace
105
- };
106
- throw Object.assign(new Error(err.error), {
107
- apiError: err
108
- });
109
- }
110
- const ct = res.headers.get('content-type') ?? '';
111
- if (ct.includes('application/json')) return res.json();
112
- return res.text();
175
+ }, true, BYTES_TIMEOUT_MS);
176
+ if (!res.ok) await throwHttpError(res);
177
+ const bytes = Buffer.from(await res.arrayBuffer());
178
+ // A table whose data file is missing answers 200 — as text/plain — with an ApiError body.
179
+ // Writing that into the bundle ships an error message as a table and only fails on the far
180
+ // stand at push time; d42 never starts with `{`, so an envelope here is an error, not data.
181
+ if (bytes[0] === 0x7B) throwIfApiError(bytes.toString('utf8'));
182
+ return bytes;
113
183
  }
114
184
  }
185
+
186
+ // Read as text first to avoid "Body has already been read" when JSON.parse fails
115
187
  exports.NodeApiClient = NodeApiClient;
188
+ async function throwHttpError(res) {
189
+ const rawText = await res.text();
190
+ let errBody;
191
+ // A gateway answers overload with an HTML page, where the status is the only real information.
192
+ const markup = rawText.trimStart().startsWith('<') || rawText.length > 200;
193
+ try {
194
+ errBody = JSON.parse(rawText);
195
+ } catch {
196
+ errBody = {
197
+ error: markup || !rawText ? `HTTP ${res.status} ${res.statusText}`.trim() : rawText
198
+ };
199
+ }
200
+ const err = {
201
+ error: errBody?.message ?? errBody?.error ?? `HTTP ${res.status}`,
202
+ source: errBody?.source ?? 'Server',
203
+ errorCode: errBody?.errorCode ?? res.status,
204
+ stackTrace: errBody?.stackTrace,
205
+ body: errBody
206
+ };
207
+ throw Object.assign(new Error(err.error), {
208
+ apiError: err
209
+ });
210
+ }
211
+
212
+ /**
213
+ * The server answers many failures with HTTP 200 and an `ApiError` body — as an object, or as
214
+ * a JSON string when the handler returned text. Every response passes through here so a
215
+ * missing entity, a rejected save or an unknown function throws instead of printing as data.
216
+ */
217
+ function throwIfApiError(payload) {
218
+ const parsed = typeof payload === 'string' ? tryParseJson(payload) : payload;
219
+ if (parsed?.['#type'] === 'ApiError') {
220
+ const err = {
221
+ error: parsed.message ?? 'Request failed',
222
+ source: 'Server',
223
+ errorCode: parsed.errorCode,
224
+ stackTrace: parsed.stackTrace,
225
+ body: parsed
226
+ };
227
+ throw Object.assign(new Error(err.error), {
228
+ apiError: err
229
+ });
230
+ }
231
+ return payload;
232
+ }
233
+
234
+ /**
235
+ * `grok s raw` paths are API-relative; a leading `/api` is accepted and dropped, so the same
236
+ * path works against an nginx-fronted `.../api` base and a bare Datlas root.
237
+ */
238
+ function apiPath(path) {
239
+ const p = path.startsWith('/') ? path : `/${path}`;
240
+ return p === '/api' ? '/' : p.startsWith('/api/') ? p.slice(4) : p;
241
+ }
242
+
243
+ /** Empty values are sent verbatim — an empty `namespace` selects the root namespace. */
116
244
  function buildQuery(params) {
117
- const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '');
245
+ const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null);
118
246
  if (!entries.length) return '';
119
247
  return '?' + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join('&');
120
248
  }
249
+
250
+ /**
251
+ * A public-API entity. `find`/`save`/`delete` go to `/public/v1/<path>`; `list` and `count` go to
252
+ * [listRoute] when given — the internal router that pages (`limit`, 1-based `page`, `order`) and
253
+ * has `/count`, which the public list routes of users, groups, connections and functions do not.
254
+ */
121
255
  class NodeHttpDataSource {
122
256
  _filter = '';
123
257
  _limit = 50;
124
258
  _page = 0;
125
259
  _order = '';
126
- constructor(client, path) {
260
+ constructor(client, path, listRoute) {
127
261
  this.client = client;
128
262
  this.path = path;
263
+ this.listRoute = listRoute;
129
264
  }
130
265
  filter(w) {
131
266
  this._filter = w;
@@ -135,22 +270,24 @@ class NodeHttpDataSource {
135
270
  this._limit = n;
136
271
  return this;
137
272
  }
273
+ /** Zero-based page of `by(n)` rows. */
138
274
  page(n) {
139
275
  this._page = n;
140
276
  return this;
141
277
  }
278
+ /** Smart-order syntax: `!field` is descending. */
142
279
  order(field, desc = false) {
143
- this._order = desc ? `-${field}` : field;
280
+ this._order = desc ? `!${field}` : field;
144
281
  return this;
145
282
  }
146
283
  async list() {
147
284
  const q = buildQuery({
148
285
  text: this._filter || undefined,
149
286
  limit: this._limit,
150
- page: this._page || undefined,
287
+ page: this._page + 1,
151
288
  order: this._order || undefined
152
289
  });
153
- return this.client.get(`/public/v1/${this.path}${q}`);
290
+ return this.client.get(`${this.listRoute ?? `/public/v1/${this.path}`}${q}`);
154
291
  }
155
292
  async find(id) {
156
293
  return this.client.get(`/public/v1/${this.path}/${encodeURIComponent(id.replace(':', '.'))}`);
@@ -159,19 +296,80 @@ class NodeHttpDataSource {
159
296
  const q = buildQuery({
160
297
  text: this._filter || undefined
161
298
  });
162
- const res = await this.client.get(`/public/v1/${this.path}/count${q}`);
163
- return typeof res === 'number' ? res : res?.count ?? 0;
299
+ const res = await this.client.get(`${this.listRoute ?? `/public/v1/${this.path}`}/count${q}`);
300
+ return typeof res === 'number' ? res : Number(res?.count ?? res ?? 0);
164
301
  }
165
302
  async delete(idOrEntity) {
166
303
  const id = typeof idOrEntity === 'string' ? idOrEntity : idOrEntity?.id ?? '';
167
304
  await this.client.del(`/public/v1/${this.path}/${encodeURIComponent(id)}`);
168
305
  }
169
306
  }
307
+
308
+ /**
309
+ * Generic client for the internal entity routers (`/projects`, `/scripts`,
310
+ * `/connectors/queries`, ...) the browser itself uses. Unlike `NodeDapi.raw` it goes
311
+ * through `client.request`, so a non-2xx response throws instead of returning the
312
+ * error body as data.
313
+ */
170
314
  exports.NodeHttpDataSource = NodeHttpDataSource;
315
+ class InternalDataSource {
316
+ constructor(client, route) {
317
+ this.client = client;
318
+ this.route = route;
319
+ }
320
+
321
+ /**
322
+ * The internal routers answer a missing or rejected entity with HTTP 200 and an
323
+ * `ApiError` body, so success has to be decided from the payload, not the status.
324
+ * Only a router that says so is a 404 — everything else is a server-side failure and
325
+ * must not be mistaken for an absent entity.
326
+ */
327
+ async call(method, path, body) {
328
+ const res = await this.client.request(method, path, body);
329
+ return (typeof res === 'string' ? tryParseJson(res) : res) ?? res;
330
+ }
331
+ list(params = {}) {
332
+ return this.call('GET', `${this.route}${buildQuery(params)}`);
333
+ }
334
+ async count(params = {}) {
335
+ return Number(await this.call('GET', `${this.route}/count${buildQuery(params)}`));
336
+ }
337
+
338
+ /** Server paging is 1-based (`repository_query.dart` `paging`), so page 0 would repeat page 1. */
339
+ async listAll(params = {}, pageSize = 500) {
340
+ const all = [];
341
+ for (let page = 1;; page++) {
342
+ const batch = (await this.list({
343
+ ...params,
344
+ limit: pageSize,
345
+ page
346
+ })) ?? [];
347
+ all.push(...batch);
348
+ if (batch.length < pageSize) return all;
349
+ }
350
+ }
351
+ async find(id, include) {
352
+ try {
353
+ return await this.call('GET', `${this.route}/${encodeURIComponent(id)}${buildQuery({
354
+ include
355
+ })}`);
356
+ } catch (err) {
357
+ if (err?.apiError?.errorCode === 404 || err?.message === 'Not Found') return null;
358
+ throw err;
359
+ }
360
+ }
361
+ save(json, query) {
362
+ return this.call('POST', `${this.route}${query ? '?' + query : ''}`, ensureBodyId(json));
363
+ }
364
+ delete(id) {
365
+ return this.call('DELETE', `${this.route}/${encodeURIComponent(id)}`);
366
+ }
367
+ }
368
+ exports.InternalDataSource = InternalDataSource;
171
369
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
172
370
  class NodeGroupsDataSource extends NodeHttpDataSource {
173
371
  constructor(client) {
174
- super(client, 'groups');
372
+ super(client, 'groups', '/groups');
175
373
  }
176
374
  async save(group, saveRelations = false) {
177
375
  const q = buildQuery({
@@ -190,6 +388,10 @@ class NodeGroupsDataSource extends NodeHttpDataSource {
190
388
  const matches = await this.lookup(idOrName);
191
389
  let candidates = matches;
192
390
  if (opts.personalOnly) candidates = matches.filter(g => g?.personal === true);
391
+ // lookup is a substring search ('admin' also finds 'Administrators'): an exact name wins
392
+ const want = idOrName.toLowerCase();
393
+ const exact = candidates.filter(g => [g?.name, g?.friendlyName].some(n => (n ?? '').toLowerCase() === want));
394
+ if (exact.length) candidates = exact;
193
395
  if (!candidates.length) {
194
396
  const suffix = opts.personalOnly ? ' (personal)' : '';
195
397
  throw new Error(`No group matching '${idOrName}'${suffix}`);
@@ -305,15 +507,19 @@ class NodeGroupsDataSource extends NodeHttpDataSource {
305
507
  }
306
508
  return results;
307
509
  }
308
- async getMembers(group, admin) {
309
- const parent = await this.resolve(group);
510
+ async getMembers(group, admin, personalOnly = false) {
511
+ const parent = await this.resolve(group, {
512
+ personalOnly
513
+ });
310
514
  const q = buildQuery({
311
515
  admin: admin === undefined ? undefined : String(admin)
312
516
  });
313
517
  return this.client.get(`/public/v1/groups/${encodeURIComponent(parent.id)}/members${q}`);
314
518
  }
315
- async getMemberships(group, admin) {
316
- const parent = await this.resolve(group);
519
+ async getMemberships(group, admin, personalOnly = false) {
520
+ const parent = await this.resolve(group, {
521
+ personalOnly
522
+ });
317
523
  const q = buildQuery({
318
524
  admin: admin === undefined ? undefined : String(admin)
319
525
  });
@@ -333,9 +539,12 @@ class NodeSharesDataSource {
333
539
  });
334
540
  return this.client.post(`/public/v1/entities/${name}/shares${q}`);
335
541
  }
542
+
543
+ /** `all` walks the entity's project links the way the browser's sharing dialog does; a bare `entityId` answers nothing for most entities. */
336
544
  async list(entityId) {
337
545
  const q = buildQuery({
338
- entityId
546
+ entityId,
547
+ all: 'true'
339
548
  });
340
549
  return this.client.get(`/privileges/permissions${q}`);
341
550
  }
@@ -343,11 +552,21 @@ class NodeSharesDataSource {
343
552
  exports.NodeSharesDataSource = NodeSharesDataSource;
344
553
  class NodeUsersDataSource extends NodeHttpDataSource {
345
554
  constructor(client) {
346
- super(client, 'users');
555
+ super(client, 'users', '/users');
347
556
  }
348
557
  async save(user) {
349
558
  return this.client.post('/public/v1/users', ensureBodyId(user));
350
559
  }
560
+
561
+ /**
562
+ * Datagrok has no user deletion: the server offers no route, the UI only blocks, and removing
563
+ * the entity record (`DELETE /entities/{id}`, what the batch API does) leaves the `users` row,
564
+ * the personal group and the root project behind, so the login can never be re-created.
565
+ */
566
+ async delete(idOrLogin) {
567
+ const login = typeof idOrLogin === 'string' ? idOrLogin : idOrLogin?.id ?? '';
568
+ throw new Error(`Users cannot be deleted through the API; block the account instead: grok s users block ${login}`);
569
+ }
351
570
  async block(user) {
352
571
  await this.client.post('/public/v1/users/block', user);
353
572
  }
@@ -358,7 +577,7 @@ class NodeUsersDataSource extends NodeHttpDataSource {
358
577
  exports.NodeUsersDataSource = NodeUsersDataSource;
359
578
  class NodeConnectionsDataSource extends NodeHttpDataSource {
360
579
  constructor(client) {
361
- super(client, 'connections');
580
+ super(client, 'connections', '/connectors/connections');
362
581
  }
363
582
  async save(conn, saveCredentials = false) {
364
583
  const q = buildQuery({
@@ -366,6 +585,12 @@ class NodeConnectionsDataSource extends NodeHttpDataSource {
366
585
  });
367
586
  return this.client.post(`/public/v1/connections${q}`, conn);
368
587
  }
588
+
589
+ /** The route answers 200 for an unknown id, so look the connection up first. */
590
+ async delete(idOrName) {
591
+ const conn = typeof idOrName === 'string' ? await this.find(idOrName) : idOrName;
592
+ await super.delete(conn?.id ?? '');
593
+ }
369
594
  async test(conn) {
370
595
  const result = await this.client.post(`/public/v1/connections/test`, conn);
371
596
  const text = typeof result === 'string' ? result.replace(/^"|"$/g, '') : String(result ?? '');
@@ -374,25 +599,38 @@ class NodeConnectionsDataSource extends NodeHttpDataSource {
374
599
  }
375
600
  exports.NodeConnectionsDataSource = NodeConnectionsDataSource;
376
601
  class NodeFuncsDataSource extends NodeHttpDataSource {
602
+ constructor(client) {
603
+ super(client, 'functions', '/log/funcs');
604
+ }
377
605
  async run(name, params) {
378
606
  const normalizedName = name.replace(':', '.');
379
- const result = await this.client.post(`/public/v1/functions/${encodeURIComponent(normalizedName)}/call`, params ?? {});
380
- // Datagrok returns HTTP 200 with an ApiError body when the function doesn't exist
381
- const parsed = typeof result === 'string' ? tryParseJson(result) : result;
382
- if (parsed?.['#type'] === 'ApiError') {
383
- const err = {
384
- error: parsed.message ?? 'Function call failed',
385
- errorCode: parsed.errorCode,
386
- stackTrace: parsed.stackTrace
387
- };
388
- throw Object.assign(new Error(err.error), {
389
- apiError: err
390
- });
391
- }
392
- return result;
607
+ return this.client.post(`/public/v1/functions/${encodeURIComponent(normalizedName)}/call`, params ?? {});
608
+ }
609
+
610
+ /** The public API has no DELETE for functions; scripts and queries go to their own routers. */
611
+ async delete(idOrName) {
612
+ const func = typeof idOrName === 'string' ? await this.find(idOrName) : idOrName;
613
+ const route = func?.['#type'] === 'Script' ? '/scripts' : func?.['#type'] === 'DataQuery' ? '/connectors/queries' : null;
614
+ if (!route) throw new Error(`Only scripts and queries can be deleted; '${func?.name ?? idOrName}' is a ${func?.['#type'] ?? 'function'} (package functions go away with their package)`);
615
+ await this.client.del(`${route}/${encodeURIComponent(func.id)}`);
393
616
  }
394
617
  }
618
+
619
+ /**
620
+ * The server binds a call's arguments by parameter name, so positional values have to be
621
+ * mapped onto the function's inputs (`parameterInfos`, declared order; outputs carry
622
+ * `isInput: false`) before the call.
623
+ */
395
624
  exports.NodeFuncsDataSource = NodeFuncsDataSource;
625
+ function mapPositionalParams(params, parameterInfos, funcName) {
626
+ const positional = Object.keys(params).filter(k => /^\d+$/.test(k)).sort((a, b) => Number(a) - Number(b));
627
+ if (!positional.length) return params;
628
+ const inputs = Object.values(parameterInfos ?? {}).filter(p => p?.isInput !== false).map(p => p.name);
629
+ if (positional.length > inputs.length) throw new Error(`${funcName} takes ${inputs.length} input${inputs.length === 1 ? '' : 's'} (${inputs.join(', ')}), got ${positional.length}`);
630
+ const mapped = {};
631
+ for (const [k, v] of Object.entries(params)) mapped[/^\d+$/.test(k) ? inputs[Number(k)] : k] = v;
632
+ return mapped;
633
+ }
396
634
  function tryParseJson(s) {
397
635
  try {
398
636
  return JSON.parse(s);
@@ -412,6 +650,11 @@ class NodePackagesDataSource extends NodeHttpDataSource {
412
650
  })}`);
413
651
  }
414
652
 
653
+ /** No `/count` route for packages; the catalog is small enough to count client-side. */
654
+ async count() {
655
+ return (await this.listFull(this._filter || undefined)).length;
656
+ }
657
+
415
658
  /** Resolve by UUID, name, or friendlyName (case-insensitive). Returns null when
416
659
  not found so callers can pass the raw string through and let the server's own
417
660
  'Package not found' surface. */
@@ -432,7 +675,7 @@ class NodePackagesDataSource extends NodeHttpDataSource {
432
675
  'latest' marks the package for server-side auto-update. Synchronous — returns
433
676
  the new published-package id, or null when nothing changed. */
434
677
  async install(name, desiredVersion = 'latest') {
435
- const result = await new NodeFuncsDataSource(this.client, 'functions').run('DeployPackageVersion', {
678
+ const result = await new NodeFuncsDataSource(this.client).run('DeployPackageVersion', {
436
679
  name,
437
680
  desiredVersion
438
681
  });
@@ -523,16 +766,24 @@ class NodeFilesDataSource {
523
766
  path
524
767
  };
525
768
  }
769
+
770
+ /**
771
+ * The public files route only downloads, so listing goes through the internal
772
+ * `/connectors/connections/{id}/files/<path>` the file browser uses (a trailing `/`
773
+ * is what makes it a listing). Resolves to `FileInfo` records (`path`, `isFile`, `size`).
774
+ */
526
775
  async list(filePath, recursive = false) {
527
776
  const {
528
777
  connector,
529
778
  path
530
779
  } = this.splitPath(filePath);
780
+ const conn = await new NodeConnectionsDataSource(this.client).find(connector);
531
781
  const q = buildQuery({
532
782
  recursive: recursive ? 'true' : undefined
533
783
  });
534
- const seg = path ? `${connector}/${path}` : connector;
535
- return this.client.get(`/public/v1/files/${seg}${q}`);
784
+ const seg = path ? `${path.replace(/\/+$/, '')}/` : '';
785
+ const res = await this.client.get(`/connectors/connections/${encodeURIComponent(conn.id)}/files/${seg}${q}`);
786
+ return Array.isArray(res) ? res : [];
536
787
  }
537
788
  async get(filePath) {
538
789
  const {
@@ -542,6 +793,26 @@ class NodeFilesDataSource {
542
793
  const seg = path ? `${connector}/${path}` : connector;
543
794
  return this.client.get(`/public/v1/files/${seg}`);
544
795
  }
796
+
797
+ /** The file's raw bytes, for copying a share file between instances. */
798
+ async readBytes(filePath) {
799
+ const {
800
+ connector,
801
+ path
802
+ } = this.splitPath(filePath);
803
+ if (!path) throw new Error(`Path must name a file inside the share: got '${filePath}'`);
804
+ return this.client.getBytes(`/public/v1/files/${connector}/${path}`);
805
+ }
806
+
807
+ /** Upload bytes already in memory — the bundle holds them, there is no local file. */
808
+ async writeBytes(filePath, bytes) {
809
+ const {
810
+ connector,
811
+ path
812
+ } = this.splitPath(filePath);
813
+ if (!path) throw new Error(`Path must name a file inside the share: got '${filePath}'`);
814
+ return await this.client.putBytes(`/public/v1/files/${connector}/${path}`, bytes);
815
+ }
545
816
  async delete(filePath) {
546
817
  const {
547
818
  connector,
@@ -558,43 +829,47 @@ class NodeFilesDataSource {
558
829
  */
559
830
  async put(localPath, remotePath) {
560
831
  const fs = require('fs');
561
- const {
562
- connector,
563
- path
564
- } = this.splitPath(remotePath);
565
- if (!path) throw new Error(`Remote path must include a file name after the connector: got '${remotePath}'`);
566
832
  const bytes = fs.readFileSync(localPath);
567
- const res = await this.client.putBytes(`/public/v1/files/${connector}/${path}`, bytes);
568
833
  return {
569
834
  path: remotePath,
570
835
  size: bytes.length,
571
- response: res
836
+ response: await this.writeBytes(remotePath, bytes)
572
837
  };
573
838
  }
574
839
  }
840
+
841
+ /**
842
+ * Tables live under a project namespace (`Admin:MyTable:MyTable`), so a table is addressed by
843
+ * UUID, by that full name, or by its bare name when only one table carries it.
844
+ */
575
845
  exports.NodeFilesDataSource = NodeFilesDataSource;
576
- class NodeTablesDataSource {
846
+ class NodeTablesDataSource extends NodeHttpDataSource {
577
847
  constructor(client) {
578
- this.client = client;
848
+ super(client, 'tables', '/tables');
579
849
  }
580
-
581
- /** GET /public/v1/tables/{name} — returns CSV text. Name accepts UUID or `namespace:name`. */
582
- async download(name) {
583
- const seg = encodeURIComponent(name.replace(/:/g, '.'));
584
- const result = await this.client.get(`/public/v1/tables/${seg}`);
585
- // Datagrok returns HTTP 200 + ApiError JSON when the table isn't found.
586
- const parsed = typeof result === 'string' ? tryParseJson(result) : result;
587
- if (parsed && typeof parsed === 'object' && parsed['#type'] === 'ApiError') {
588
- const err = {
589
- error: parsed.message ?? 'Table download failed',
590
- errorCode: parsed.errorCode,
591
- stackTrace: parsed.stackTrace
592
- };
593
- throw Object.assign(new Error(err.error), {
594
- apiError: err
595
- });
850
+ async find(idOrName) {
851
+ if (UUID_RE.test(idOrName)) return this.client.get(`/tables/${encodeURIComponent(idOrName)}`);
852
+ const all = await this.client.get(`/tables${buildQuery({
853
+ text: idOrName,
854
+ limit: 100
855
+ })}`);
856
+ const matches = all.filter(t => t?.name === idOrName || `${t?.namespace ?? ''}${t?.name ?? ''}` === idOrName);
857
+ if (!matches.length) throw new Error(`No table named '${idOrName}'`);
858
+ if (matches.length > 1) {
859
+ const list = matches.map(t => ` ${t.id} ${t.namespace ?? ''}${t.name}`).join('\n');
860
+ throw new Error(`Multiple tables match '${idOrName}':\n${list}\nUse the full name or the ID.`);
596
861
  }
597
- return result;
862
+ return matches[0];
863
+ }
864
+ async delete(idOrName) {
865
+ const table = typeof idOrName === 'string' ? await this.find(idOrName) : idOrName;
866
+ await this.client.del(`/tables/${encodeURIComponent(table?.id ?? '')}`);
867
+ }
868
+
869
+ /** GET /public/v1/tables/{id} — returns CSV text. */
870
+ async download(idOrName) {
871
+ const table = await this.find(idOrName);
872
+ return this.client.get(`/public/v1/tables/${encodeURIComponent(table.id)}`);
598
873
  }
599
874
 
600
875
  /**
@@ -610,6 +885,193 @@ class NodeTablesDataSource {
610
885
  }
611
886
  }
612
887
  exports.NodeTablesDataSource = NodeTablesDataSource;
888
+ /** `'<schema>'` or `'<schema>.<table>'` — the address every `grok s domains` verb takes. */
889
+ function parseDomainAddress(s, opts = {}) {
890
+ const str = String(s ?? '');
891
+ const dot = str.indexOf('.');
892
+ const address = dot === -1 ? {
893
+ schema: str
894
+ } : {
895
+ schema: str.slice(0, dot),
896
+ table: str.slice(dot + 1)
897
+ };
898
+ if (!address.schema || dot !== -1 && !address.table) throw new Error(`Invalid domain address '${str}': expected <schema> or <schema>.<table>`);
899
+ if (opts.table === true && !address.table) throw new Error(`'${str}' names a schema; this command needs a table: <schema>.<table>`);
900
+ if (opts.table === false && address.table) throw new Error(`'${str}' names a table; this command needs a schema`);
901
+ return address;
902
+ }
903
+ /**
904
+ * Client for entity-mapped domain tables (`/domains/...`, DomainsRouter). Rows are plain
905
+ * JSON objects; the server validates, permission-checks and audits every write. Errors
906
+ * arrive as HTTP 4xx with a JSON envelope, so `client.request` throws with `apiError.body`
907
+ * carrying the structured fields (per-row `rows`, a dry-run `plan`, version numbers).
908
+ */
909
+ class NodeDomainsDataSource {
910
+ constructor(client) {
911
+ this.client = client;
912
+ }
913
+ rows(schema, table) {
914
+ return `/domains/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`;
915
+ }
916
+
917
+ /** Registered schemas with their tables (`GET /domains/schemas`); [text] is a smart filter. */
918
+ schemas(text) {
919
+ return this.client.get(`/domains/schemas${buildQuery({
920
+ text: text || undefined
921
+ })}`);
922
+ }
923
+ async schema(name) {
924
+ const all = await this.schemas();
925
+ const s = all.find(x => x?.name === name);
926
+ if (!s) throw new Error(`Domain schema '${name}' not found`);
927
+ return s;
928
+ }
929
+
930
+ /** Registry entity id of a schema or a table — the target of the grants endpoints. */
931
+ async entityId(address) {
932
+ const s = await this.schema(address.schema);
933
+ if (!address.table) return s.id;
934
+ const t = (s.tables ?? []).find(x => x?.name === address.table);
935
+ if (!t) throw new Error(`Domain table '${address.schema}.${address.table}' not found`);
936
+ return t.id;
937
+ }
938
+ manifest(schema) {
939
+ return this.client.get(`/domains/schemas/${encodeURIComponent(schema)}/manifest`);
940
+ }
941
+ createSchema(name, friendlyName, description) {
942
+ return this.client.post('/domains/schemas', {
943
+ name,
944
+ friendlyName,
945
+ description
946
+ });
947
+ }
948
+ applySchema(schema, body, dryRun = false) {
949
+ const q = buildQuery({
950
+ dryRun: dryRun ? 'true' : undefined
951
+ });
952
+ return this.client.post(`/domains/schemas/${encodeURIComponent(schema)}/apply${q}`, body);
953
+ }
954
+ deleteSchema(schema) {
955
+ return this.client.del(`/domains/schemas/${encodeURIComponent(schema)}`);
956
+ }
957
+ schemaAudit(schema, limit) {
958
+ return this.client.get(`/domains/schemas/${encodeURIComponent(schema)}/audit${buildQuery({
959
+ limit
960
+ })}`);
961
+ }
962
+ tableAudit(schema, table, limit) {
963
+ return this.client.get(`${this.rows(schema, table)}/audit${buildQuery({
964
+ limit
965
+ })}`);
966
+ }
967
+ rowAudit(schema, table, id) {
968
+ return this.client.get(`${this.rows(schema, table)}/${encodeURIComponent(id)}/audit`);
969
+ }
970
+ grants(entityId) {
971
+ return this.client.get(`/domains/grants/${encodeURIComponent(entityId)}`);
972
+ }
973
+ grant(entityId, group, permission) {
974
+ return this.client.post(`/domains/grants/${encodeURIComponent(entityId)}`, {
975
+ group,
976
+ permission
977
+ });
978
+ }
979
+ revoke(entityId, group, permission) {
980
+ return this.client.del(`/domains/grants/${encodeURIComponent(entityId)}${buildQuery({
981
+ group,
982
+ permission
983
+ })}`);
984
+ }
985
+ capabilities(schema, table) {
986
+ return this.client.get(`${this.rows(schema, table)}/capabilities`);
987
+ }
988
+
989
+ /** JSON rows; spec = {filter, sort, columns, expand, limit, offset} (10k row cap). */
990
+ query(schema, table, spec = {}) {
991
+ return this.client.post(`${this.rows(schema, table)}/query`, spec);
992
+ }
993
+
994
+ /** The same query as a d42 DataFrame blob (10M row cap). */
995
+ queryD42(schema, table, spec = {}) {
996
+ return this.client.postForBytes(`${this.rows(schema, table)}/query`, {
997
+ ...spec,
998
+ format: 'd42'
999
+ });
1000
+ }
1001
+ aggregate(schema, table, spec) {
1002
+ return this.client.post(`${this.rows(schema, table)}/aggregate`, spec);
1003
+ }
1004
+ async count(schema, table, filter) {
1005
+ const spec = {
1006
+ measures: [{
1007
+ fn: 'count'
1008
+ }]
1009
+ };
1010
+ if (filter) spec.filter = filter;
1011
+ const rows = await this.aggregate(schema, table, spec);
1012
+ return Number(rows?.[0]?.count ?? 0);
1013
+ }
1014
+
1015
+ /** One row, or null when it does not exist or is not visible (the server answers 404). */
1016
+ async getRow(schema, table, id) {
1017
+ try {
1018
+ return await this.client.get(`${this.rows(schema, table)}/${encodeURIComponent(id)}`);
1019
+ } catch (err) {
1020
+ if (err?.apiError?.errorCode === 404) return null;
1021
+ throw err;
1022
+ }
1023
+ }
1024
+
1025
+ /** [rows] is one row object or an array; resolves to per-row `{id, created}` reports. */
1026
+ async insert(schema, table, rows, errorOnDuplicate = false) {
1027
+ const q = buildQuery({
1028
+ errorOnDuplicate: errorOnDuplicate ? 'true' : undefined
1029
+ });
1030
+ const res = await this.client.post(`${this.rows(schema, table)}${q}`, rows);
1031
+ return Array.isArray(res) ? res : [res];
1032
+ }
1033
+ update(schema, table, id, values, version) {
1034
+ const body = {
1035
+ values
1036
+ };
1037
+ if (version !== undefined) body.version = version;
1038
+ return this.client.request('PATCH', `${this.rows(schema, table)}/${encodeURIComponent(id)}`, body);
1039
+ }
1040
+ deleteRow(schema, table, id) {
1041
+ return this.client.del(`${this.rows(schema, table)}/${encodeURIComponent(id)}`);
1042
+ }
1043
+
1044
+ /** Soft-deletes up to [limit] (≤1000) matching rows in one transaction; `{deleted, hasMore}`. */
1045
+ deleteWhere(schema, table, filter, limit) {
1046
+ const body = {
1047
+ filter
1048
+ };
1049
+ if (limit !== undefined) body.limit = limit;
1050
+ return this.client.post(`${this.rows(schema, table)}/delete`, body);
1051
+ }
1052
+
1053
+ /**
1054
+ * Bulk upload: [bytes] is the file content and [contentType] selects how the server
1055
+ * reads it — `text/csv`, `application/octet-stream` (d42), or `application/json` (a row
1056
+ * array, bare or under `rows`). Resolves to the batch report `{inserted, updated,
1057
+ * skipped, errorCount, rows}`; a report-carrying failure rejects with the report in
1058
+ * `apiError.body`.
1059
+ */
1060
+ batch(schema, table, bytes, contentType, options = {}) {
1061
+ const q = buildQuery({
1062
+ mode: options.mode ?? 'insert',
1063
+ allOrNothing: options.allOrNothing === false ? 'false' : 'true',
1064
+ errorOnDuplicate: options.errorOnDuplicate ? 'true' : undefined
1065
+ });
1066
+ return this.client.putBytes(`${this.rows(schema, table)}/batch${q}`, bytes, contentType);
1067
+ }
1068
+
1069
+ /** Ordered ops (`{op, table, ref?, values?, id?, expectedVersion?}`) applied atomically. */
1070
+ transaction(schema, ops) {
1071
+ return this.client.post(`/domains/${encodeURIComponent(schema)}/transaction`, ops);
1072
+ }
1073
+ }
1074
+ exports.NodeDomainsDataSource = NodeDomainsDataSource;
613
1075
  class NodeDapi {
614
1076
  constructor(client) {
615
1077
  this.client = client;
@@ -621,22 +1083,22 @@ class NodeDapi {
621
1083
  return new NodeGroupsDataSource(this.client);
622
1084
  }
623
1085
  get functions() {
624
- return new NodeFuncsDataSource(this.client, 'functions');
1086
+ return new NodeFuncsDataSource(this.client);
625
1087
  }
626
1088
  get connections() {
627
1089
  return new NodeConnectionsDataSource(this.client);
628
1090
  }
629
1091
  get queries() {
630
- return new NodeHttpDataSource(this.client, 'queries');
1092
+ return this.internal('/connectors/queries');
631
1093
  }
632
1094
  get scripts() {
633
- return new NodeHttpDataSource(this.client, 'scripts');
1095
+ return this.internal('/scripts');
634
1096
  }
635
1097
  get packages() {
636
1098
  return new NodePackagesDataSource(this.client);
637
1099
  }
638
1100
  get reports() {
639
- return new NodeHttpDataSource(this.client, 'reports');
1101
+ return this.internal('/reports');
640
1102
  }
641
1103
  get files() {
642
1104
  return new NodeFilesDataSource(this.client);
@@ -647,35 +1109,124 @@ class NodeDapi {
647
1109
  get tables() {
648
1110
  return new NodeTablesDataSource(this.client);
649
1111
  }
650
- async raw(method, path, body) {
651
- // Raw paths are relative to server root (e.g. /api/users/current).
652
- // Strip the trailing /api from baseUrl to avoid double prefix.
653
- const serverRoot = this.client.baseUrl.replace(/\/api\/?$/, '');
654
- const url = `${serverRoot}${path}`;
655
- const opts = {
656
- method: method.toUpperCase(),
657
- headers: {
658
- 'Authorization': this.client.token,
659
- 'Content-Type': 'application/json'
660
- }
1112
+ get domains() {
1113
+ return new NodeDomainsDataSource(this.client);
1114
+ }
1115
+ internal(route) {
1116
+ return new InternalDataSource(this.client, route);
1117
+ }
1118
+ async serverInfo() {
1119
+ const raw = await this.client.get('/info/server');
1120
+ const info = typeof raw === 'string' ? JSON.parse(raw) : raw;
1121
+ return {
1122
+ version: info?.Version ?? '',
1123
+ commit: info?.Commit
661
1124
  };
662
- if (body !== undefined) opts.body = JSON.stringify(body);
663
- const res = await fetch(url, opts);
664
- const ct = res.headers.get('content-type') ?? '';
665
- if (ct.includes('application/json')) return res.json();
666
- return res.text();
1125
+ }
1126
+
1127
+ /** Any API endpoint; [path] is API-relative (`/users/current`), a leading `/api` is accepted. */
1128
+ async raw(method, path, body) {
1129
+ return this.client.request(method.toUpperCase(), apiPath(path), body);
667
1130
  }
668
1131
  async batch(request) {
669
1132
  return this.client.post('/public/v1/batch', request);
670
1133
  }
671
- async describe(entityType) {
672
- try {
673
- return await this.client.get(`/public/v1/entity-types/${encodeURIComponent(entityType)}`);
674
- } catch {
675
- return await this.client.get(`/entities/types${buildQuery({
676
- name: entityType
677
- })}`);
678
- }
1134
+
1135
+ /**
1136
+ * The shape of an entity type: its registry record (`/entities/types`) plus the top-level
1137
+ * fields of one existing entity of that type, since the server publishes no JSON schema.
1138
+ * [nameOrAlias] is a `grok s` entity (`connections`) or a type name (`DataConnection`).
1139
+ */
1140
+ async describe(nameOrAlias) {
1141
+ const alias = DESCRIBE_ALIASES[nameOrAlias.toLowerCase()];
1142
+ const typeName = alias?.type ?? nameOrAlias;
1143
+ const types = await this.client.get(`/entities/types${buildQuery({
1144
+ showSystem: 'true',
1145
+ text: `name="${typeName}"`,
1146
+ limit: 5
1147
+ })}`);
1148
+ const type = types.find(t => (t?.name ?? '').toLowerCase() === typeName.toLowerCase()) ?? null;
1149
+ const sampleFrom = alias?.sample ?? DESCRIBE_SAMPLES[typeName];
1150
+ if (!type && !sampleFrom) throw new Error(`Unknown entity type '${nameOrAlias}'. Try one of: ${Object.keys(DESCRIBE_ALIASES).join(', ')}, or a type name such as Project`);
1151
+ const sample = sampleFrom ? (await sampleFrom(this))[0] ?? null : null;
1152
+ const fields = sample ? Object.entries(sample).map(([field, v]) => ({
1153
+ field,
1154
+ type: describeType(v),
1155
+ example: describeExample(v)
1156
+ })) : [];
1157
+ return {
1158
+ type,
1159
+ fields,
1160
+ sample
1161
+ };
679
1162
  }
680
1163
  }
681
- exports.NodeDapi = NodeDapi;
1164
+ exports.NodeDapi = NodeDapi;
1165
+ const DESCRIBE_SAMPLES = {
1166
+ User: d => d.users.by(1).list(),
1167
+ UserGroup: d => d.groups.by(1).list(),
1168
+ DataConnection: d => d.connections.by(1).list(),
1169
+ DataQuery: d => d.queries.list({
1170
+ limit: 1
1171
+ }),
1172
+ Script: d => d.scripts.list({
1173
+ limit: 1
1174
+ }),
1175
+ Func: d => d.functions.by(1).list(),
1176
+ Package: d => d.packages.by(1).list(),
1177
+ UserReport: d => d.reports.list({
1178
+ limit: 1
1179
+ }),
1180
+ TableInfo: d => d.tables.by(1).list(),
1181
+ Project: d => d.internal('/projects').list({
1182
+ limit: 1
1183
+ }),
1184
+ FileInfo: d => d.internal('/files').list({
1185
+ limit: 1
1186
+ })
1187
+ };
1188
+ const DESCRIBE_ALIASES = {
1189
+ users: {
1190
+ type: 'User'
1191
+ },
1192
+ groups: {
1193
+ type: 'UserGroup'
1194
+ },
1195
+ connections: {
1196
+ type: 'DataConnection'
1197
+ },
1198
+ queries: {
1199
+ type: 'DataQuery'
1200
+ },
1201
+ scripts: {
1202
+ type: 'Script'
1203
+ },
1204
+ functions: {
1205
+ type: 'Func'
1206
+ },
1207
+ packages: {
1208
+ type: 'Package'
1209
+ },
1210
+ reports: {
1211
+ type: 'UserReport'
1212
+ },
1213
+ tables: {
1214
+ type: 'TableInfo'
1215
+ },
1216
+ projects: {
1217
+ type: 'Project'
1218
+ },
1219
+ files: {
1220
+ type: 'FileInfo'
1221
+ }
1222
+ };
1223
+ function describeType(v) {
1224
+ if (v === null || v === undefined) return 'null';
1225
+ if (Array.isArray(v)) return `array(${v.length})`;
1226
+ if (typeof v === 'object') return v['#type'] ?? (v.id ? 'ref' : 'object');
1227
+ return typeof v;
1228
+ }
1229
+ function describeExample(v) {
1230
+ const s = v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
1231
+ return s.length > 60 ? `${s.slice(0, 57)}...` : s;
1232
+ }