datagrok-tools 6.5.6 → 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.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);
@@ -400,6 +638,106 @@ function tryParseJson(s) {
400
638
  return null;
401
639
  }
402
640
  }
641
+ class NodePackagesDataSource extends NodeHttpDataSource {
642
+ constructor(client) {
643
+ super(client, 'packages');
644
+ }
645
+
646
+ /** GET /packages (internal API) — includes publishedVersions and repository. */
647
+ async listFull(text) {
648
+ return this.client.get(`/packages${buildQuery({
649
+ text
650
+ })}`);
651
+ }
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
+
658
+ /** Resolve by UUID, name, or friendlyName (case-insensitive). Returns null when
659
+ not found so callers can pass the raw string through and let the server's own
660
+ 'Package not found' surface. */
661
+ async resolve(idOrName) {
662
+ const all = await this.listFull();
663
+ if (UUID_RE.test(idOrName)) return all.find(p => p?.id === idOrName) ?? null;
664
+ const want = idOrName.toLowerCase();
665
+ const matches = all.filter(p => (p?.name ?? '').toLowerCase() === want || (p?.friendlyName ?? '').toLowerCase() === want);
666
+ if (matches.length > 1) {
667
+ const list = matches.map(p => ` ${p.id} ${p.name}`).join('\n');
668
+ throw new Error(`Multiple packages match '${idOrName}':\n${list}\nUse the ID to disambiguate.`);
669
+ }
670
+ return matches[0] ?? null;
671
+ }
672
+
673
+ /** Install/activate via the DeployPackageVersion server func: pulls the version
674
+ from the package repository (npm) when needed, then makes it current.
675
+ 'latest' marks the package for server-side auto-update. Synchronous — returns
676
+ the new published-package id, or null when nothing changed. */
677
+ async install(name, desiredVersion = 'latest') {
678
+ const result = await new NodeFuncsDataSource(this.client).run('DeployPackageVersion', {
679
+ name,
680
+ desiredVersion
681
+ });
682
+ const value = result && typeof result === 'object' ? result.result ?? result.id ?? null : result;
683
+ const text = typeof value === 'string' ? value.replace(/^"|"$/g, '') : '';
684
+ return text && text !== 'null' ? text : null;
685
+ }
686
+ async uninstall(idOrName) {
687
+ const pkg = await this.resolve(idOrName);
688
+ if (!pkg) throw new Error(`Package '${idOrName}' not found`);
689
+ await this.client.del(`/packages/${encodeURIComponent(pkg.id)}`);
690
+ return {
691
+ id: pkg.id,
692
+ name: pkg.name,
693
+ repoBacked: !!pkg.repository
694
+ };
695
+ }
696
+ async versions(idOrName) {
697
+ const pkg = await this.resolve(idOrName);
698
+ if (!pkg) throw new Error(`Package '${idOrName}' not found`);
699
+ return Array.isArray(pkg.publishedVersions) ? pkg.publishedVersions : [];
700
+ }
701
+ async outdated() {
702
+ const all = await this.listFull();
703
+ const rows = [];
704
+ for (const p of all) {
705
+ const versions = Array.isArray(p?.publishedVersions) ? p.publishedVersions : [];
706
+ // isLocal is not reliably serialized, so 'installed' = has a current non-debug version
707
+ const current = versions.find(v => v?.isCurrent && !v?.debug);
708
+ const latest = versions.find(v => v?.isLatest);
709
+ // Numeric compare so locally published builds (1.2.3.X-suffix) don't count
710
+ // as outdated against an equal or older registry version.
711
+ if (current && latest && NodePackagesDataSource.compareVersions(latest.version, current.version) > 0) rows.push({
712
+ name: p.name,
713
+ installed: current.version,
714
+ latest: latest.version,
715
+ desiredVersion: p.desiredVersion ?? ''
716
+ });
717
+ }
718
+ return rows;
719
+ }
720
+
721
+ /** Compare the leading numeric dot-groups of two version strings (suffixes like
722
+ '.X-4e32bb91' are ignored): positive when a > b. */
723
+ static compareVersions(a, b) {
724
+ const nums = s => (String(s).match(/^\d+(\.\d+)*/)?.[0] ?? '').split('.').map(Number);
725
+ const av = nums(a),
726
+ bv = nums(b);
727
+ for (let i = 0; i < Math.max(av.length, bv.length); i++) {
728
+ const d = (av[i] ?? 0) - (bv[i] ?? 0);
729
+ if (d !== 0) return d;
730
+ }
731
+ return 0;
732
+ }
733
+
734
+ /** The public API has no DELETE for packages — route to the internal endpoint. */
735
+ async delete(idOrEntity) {
736
+ const id = typeof idOrEntity === 'string' ? idOrEntity : idOrEntity?.id ?? '';
737
+ await this.client.del(`/packages/${encodeURIComponent(id)}`);
738
+ }
739
+ }
740
+ exports.NodePackagesDataSource = NodePackagesDataSource;
403
741
  class NodeFilesDataSource {
404
742
  constructor(client) {
405
743
  this.client = client;
@@ -428,16 +766,24 @@ class NodeFilesDataSource {
428
766
  path
429
767
  };
430
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
+ */
431
775
  async list(filePath, recursive = false) {
432
776
  const {
433
777
  connector,
434
778
  path
435
779
  } = this.splitPath(filePath);
780
+ const conn = await new NodeConnectionsDataSource(this.client).find(connector);
436
781
  const q = buildQuery({
437
782
  recursive: recursive ? 'true' : undefined
438
783
  });
439
- const seg = path ? `${connector}/${path}` : connector;
440
- 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 : [];
441
787
  }
442
788
  async get(filePath) {
443
789
  const {
@@ -447,6 +793,26 @@ class NodeFilesDataSource {
447
793
  const seg = path ? `${connector}/${path}` : connector;
448
794
  return this.client.get(`/public/v1/files/${seg}`);
449
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
+ }
450
816
  async delete(filePath) {
451
817
  const {
452
818
  connector,
@@ -463,43 +829,47 @@ class NodeFilesDataSource {
463
829
  */
464
830
  async put(localPath, remotePath) {
465
831
  const fs = require('fs');
466
- const {
467
- connector,
468
- path
469
- } = this.splitPath(remotePath);
470
- if (!path) throw new Error(`Remote path must include a file name after the connector: got '${remotePath}'`);
471
832
  const bytes = fs.readFileSync(localPath);
472
- const res = await this.client.putBytes(`/public/v1/files/${connector}/${path}`, bytes);
473
833
  return {
474
834
  path: remotePath,
475
835
  size: bytes.length,
476
- response: res
836
+ response: await this.writeBytes(remotePath, bytes)
477
837
  };
478
838
  }
479
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
+ */
480
845
  exports.NodeFilesDataSource = NodeFilesDataSource;
481
- class NodeTablesDataSource {
846
+ class NodeTablesDataSource extends NodeHttpDataSource {
482
847
  constructor(client) {
483
- this.client = client;
848
+ super(client, 'tables', '/tables');
484
849
  }
485
-
486
- /** GET /public/v1/tables/{name} — returns CSV text. Name accepts UUID or `namespace:name`. */
487
- async download(name) {
488
- const seg = encodeURIComponent(name.replace(/:/g, '.'));
489
- const result = await this.client.get(`/public/v1/tables/${seg}`);
490
- // Datagrok returns HTTP 200 + ApiError JSON when the table isn't found.
491
- const parsed = typeof result === 'string' ? tryParseJson(result) : result;
492
- if (parsed && typeof parsed === 'object' && parsed['#type'] === 'ApiError') {
493
- const err = {
494
- error: parsed.message ?? 'Table download failed',
495
- errorCode: parsed.errorCode,
496
- stackTrace: parsed.stackTrace
497
- };
498
- throw Object.assign(new Error(err.error), {
499
- apiError: err
500
- });
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.`);
501
861
  }
502
- 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)}`);
503
873
  }
504
874
 
505
875
  /**
@@ -515,6 +885,193 @@ class NodeTablesDataSource {
515
885
  }
516
886
  }
517
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;
518
1075
  class NodeDapi {
519
1076
  constructor(client) {
520
1077
  this.client = client;
@@ -526,22 +1083,22 @@ class NodeDapi {
526
1083
  return new NodeGroupsDataSource(this.client);
527
1084
  }
528
1085
  get functions() {
529
- return new NodeFuncsDataSource(this.client, 'functions');
1086
+ return new NodeFuncsDataSource(this.client);
530
1087
  }
531
1088
  get connections() {
532
1089
  return new NodeConnectionsDataSource(this.client);
533
1090
  }
534
1091
  get queries() {
535
- return new NodeHttpDataSource(this.client, 'queries');
1092
+ return this.internal('/connectors/queries');
536
1093
  }
537
1094
  get scripts() {
538
- return new NodeHttpDataSource(this.client, 'scripts');
1095
+ return this.internal('/scripts');
539
1096
  }
540
1097
  get packages() {
541
- return new NodeHttpDataSource(this.client, 'packages');
1098
+ return new NodePackagesDataSource(this.client);
542
1099
  }
543
1100
  get reports() {
544
- return new NodeHttpDataSource(this.client, 'reports');
1101
+ return this.internal('/reports');
545
1102
  }
546
1103
  get files() {
547
1104
  return new NodeFilesDataSource(this.client);
@@ -552,35 +1109,124 @@ class NodeDapi {
552
1109
  get tables() {
553
1110
  return new NodeTablesDataSource(this.client);
554
1111
  }
555
- async raw(method, path, body) {
556
- // Raw paths are relative to server root (e.g. /api/users/current).
557
- // Strip the trailing /api from baseUrl to avoid double prefix.
558
- const serverRoot = this.client.baseUrl.replace(/\/api\/?$/, '');
559
- const url = `${serverRoot}${path}`;
560
- const opts = {
561
- method: method.toUpperCase(),
562
- headers: {
563
- 'Authorization': this.client.token,
564
- 'Content-Type': 'application/json'
565
- }
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
566
1124
  };
567
- if (body !== undefined) opts.body = JSON.stringify(body);
568
- const res = await fetch(url, opts);
569
- const ct = res.headers.get('content-type') ?? '';
570
- if (ct.includes('application/json')) return res.json();
571
- 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);
572
1130
  }
573
1131
  async batch(request) {
574
1132
  return this.client.post('/public/v1/batch', request);
575
1133
  }
576
- async describe(entityType) {
577
- try {
578
- return await this.client.get(`/public/v1/entity-types/${encodeURIComponent(entityType)}`);
579
- } catch {
580
- return await this.client.get(`/entities/types${buildQuery({
581
- name: entityType
582
- })}`);
583
- }
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
+ };
1162
+ }
1163
+ }
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'
584
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;
585
1228
  }
586
- exports.NodeDapi = NodeDapi;
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
+ }