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.
- package/.devcontainer/docker-compose.yaml +68 -24
- package/CHANGELOG.md +41 -6
- package/CLAUDE.md +35 -10
- package/Core.json +1027 -0
- package/GROK_S.md +563 -27
- package/bin/commands/api.js +121 -70
- package/bin/commands/help.js +3 -65
- package/bin/commands/server-domains.js +468 -0
- package/bin/commands/server-migrate.js +392 -0
- package/bin/commands/server.js +455 -92
- package/bin/grok.js +16 -5
- package/bin/utils/migrate/bundle.js +223 -0
- package/bin/utils/migrate/bundle.ts +222 -0
- package/bin/utils/migrate/parts.js +83 -0
- package/bin/utils/migrate/parts.ts +72 -0
- package/bin/utils/migrate/pool.js +17 -0
- package/bin/utils/migrate/pool.ts +13 -0
- package/bin/utils/migrate/pusher.js +980 -0
- package/bin/utils/migrate/pusher.ts +829 -0
- package/bin/utils/migrate/registry.js +349 -0
- package/bin/utils/migrate/registry.ts +255 -0
- package/bin/utils/migrate/rewriter.js +59 -0
- package/bin/utils/migrate/rewriter.ts +59 -0
- package/bin/utils/migrate/walker.js +571 -0
- package/bin/utils/migrate/walker.ts +509 -0
- package/bin/utils/node-dapi.js +787 -141
- package/bin/utils/playwright-runner.js +55 -39
- package/bin/utils/server-client.js +15 -2
- package/bin/utils/server-output.js +65 -4
- package/bin/utils/test-utils.js +1 -1
- package/domain-schema.schema.json +57 -6
- package/package.json +6 -1
- /package/{vitest.config.ts → vitest.config.mts} +0 -0
package/bin/utils/node-dapi.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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 ? `${
|
|
440
|
-
|
|
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:
|
|
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
|
-
|
|
848
|
+
super(client, 'tables', '/tables');
|
|
484
849
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
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
|
|
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
|
|
1092
|
+
return this.internal('/connectors/queries');
|
|
536
1093
|
}
|
|
537
1094
|
get scripts() {
|
|
538
|
-
return
|
|
1095
|
+
return this.internal('/scripts');
|
|
539
1096
|
}
|
|
540
1097
|
get packages() {
|
|
541
|
-
return new
|
|
1098
|
+
return new NodePackagesDataSource(this.client);
|
|
542
1099
|
}
|
|
543
1100
|
get reports() {
|
|
544
|
-
return
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
return
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
+
}
|