datagrok-tools 6.5.7 → 6.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +25 -10
- package/Core.json +1027 -0
- package/GROK_S.md +511 -27
- package/bin/commands/api.js +121 -70
- package/bin/commands/help.js +3 -75
- package/bin/commands/server-domains.js +468 -0
- package/bin/commands/server-migrate.js +392 -0
- package/bin/commands/server.js +208 -72
- package/bin/grok.js +14 -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 +692 -141
- package/bin/utils/playwright-runner.js +3 -1
- 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.NodePackagesDataSource = exports.NodeHttpDataSource = exports.NodeGroupsDataSource = exports.NodeFuncsDataSource = exports.NodeFilesDataSource = exports.NodeDapi = exports.NodeConnectionsDataSource = exports.NodeApiClient = void 0;
|
|
6
|
+
exports.NodeUsersDataSource = exports.NodeTablesDataSource = exports.NodeSharesDataSource = exports.NodePackagesDataSource = exports.NodeHttpDataSource = exports.NodeGroupsDataSource = exports.NodeFuncsDataSource = exports.NodeFilesDataSource = exports.NodeDomainsDataSource = exports.NodeDapi = exports.NodeConnectionsDataSource = exports.NodeApiClient = exports.InternalDataSource = void 0;
|
|
7
|
+
exports.apiPath = apiPath;
|
|
8
|
+
exports.buildQuery = buildQuery;
|
|
7
9
|
exports.ensureBodyId = ensureBodyId;
|
|
10
|
+
exports.mapPositionalParams = mapPositionalParams;
|
|
11
|
+
exports.parseDomainAddress = parseDomainAddress;
|
|
12
|
+
exports.throwIfApiError = throwIfApiError;
|
|
8
13
|
var _crypto = require("crypto");
|
|
9
14
|
/// Docs: [Grok Dapi](/docs/plans/grok-dapi/)
|
|
10
15
|
|
|
@@ -12,20 +17,80 @@ function ensureBodyId(body) {
|
|
|
12
17
|
if (body && typeof body === 'object' && !body.id) body.id = (0, _crypto.randomUUID)();
|
|
13
18
|
return body;
|
|
14
19
|
}
|
|
20
|
+
const setting = (name, fallback) => {
|
|
21
|
+
const value = Number(process.env[`GROK_HTTP_${name}`]);
|
|
22
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
23
|
+
};
|
|
24
|
+
const BYTES_TIMEOUT_MS = setting('BYTES_TIMEOUT', 600000);
|
|
25
|
+
|
|
26
|
+
/** Load shedding, not a verdict on the request: the same call succeeds once the queue drains. */
|
|
27
|
+
const RETRIABLE_STATUS = new Set([429, 502, 503, 504]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* undici keeps the socket checked out until the body is read, so a response that is answered but
|
|
31
|
+
* never consumed leaks a connection. Enough of them exhaust the pool, and the requests that
|
|
32
|
+
* follow queue forever — before the request starts, so no deadline ever fires.
|
|
33
|
+
*/
|
|
34
|
+
const discard = res => res.body?.cancel() ?? Promise.resolve();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Without a deadline one unresponsive entity stalls a whole pull — `GET /projects/{id}` on a
|
|
38
|
+
* space holding tens of thousands of children never answers. A request that hung or dropped is
|
|
39
|
+
* retried, since a deadline is as often a server busy with this very pull as a dead one; a reply
|
|
40
|
+
* the server actually sent is not. The deadline covers the body too, so a transfer that is slow
|
|
41
|
+
* by nature rather than stuck (`.d42` table data) asks for a longer one.
|
|
42
|
+
*/
|
|
43
|
+
async function fetchOrRetry(url, opts, retriable, timeoutMs = setting('TIMEOUT', 60000)) {
|
|
44
|
+
const retries = setting('RETRIES', 5);
|
|
45
|
+
for (let attempt = 0;; attempt++) {
|
|
46
|
+
const last = !retriable || attempt >= retries;
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
...opts,
|
|
50
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
51
|
+
});
|
|
52
|
+
if (last || !RETRIABLE_STATUS.has(res.status)) return res;
|
|
53
|
+
await res.body?.cancel();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (last) throw new Error(`${opts.method ?? 'GET'} ${url}: ` + (err?.name === 'TimeoutError' ? `no answer in ${timeoutMs}ms` : err?.message ?? err));
|
|
56
|
+
}
|
|
57
|
+
// Capped: uncapped doubling turns a long retry budget into minutes asleep on one request.
|
|
58
|
+
const backoff = Math.min(setting('BACKOFF', 1000) * Math.pow(2, attempt), setting('BACKOFF_MAX', 15000));
|
|
59
|
+
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
15
62
|
class NodeApiClient {
|
|
16
|
-
|
|
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);
|
|
@@ -412,6 +650,11 @@ class NodePackagesDataSource extends NodeHttpDataSource {
|
|
|
412
650
|
})}`);
|
|
413
651
|
}
|
|
414
652
|
|
|
653
|
+
/** No `/count` route for packages; the catalog is small enough to count client-side. */
|
|
654
|
+
async count() {
|
|
655
|
+
return (await this.listFull(this._filter || undefined)).length;
|
|
656
|
+
}
|
|
657
|
+
|
|
415
658
|
/** Resolve by UUID, name, or friendlyName (case-insensitive). Returns null when
|
|
416
659
|
not found so callers can pass the raw string through and let the server's own
|
|
417
660
|
'Package not found' surface. */
|
|
@@ -432,7 +675,7 @@ class NodePackagesDataSource extends NodeHttpDataSource {
|
|
|
432
675
|
'latest' marks the package for server-side auto-update. Synchronous — returns
|
|
433
676
|
the new published-package id, or null when nothing changed. */
|
|
434
677
|
async install(name, desiredVersion = 'latest') {
|
|
435
|
-
const result = await new NodeFuncsDataSource(this.client
|
|
678
|
+
const result = await new NodeFuncsDataSource(this.client).run('DeployPackageVersion', {
|
|
436
679
|
name,
|
|
437
680
|
desiredVersion
|
|
438
681
|
});
|
|
@@ -523,16 +766,24 @@ class NodeFilesDataSource {
|
|
|
523
766
|
path
|
|
524
767
|
};
|
|
525
768
|
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* The public files route only downloads, so listing goes through the internal
|
|
772
|
+
* `/connectors/connections/{id}/files/<path>` the file browser uses (a trailing `/`
|
|
773
|
+
* is what makes it a listing). Resolves to `FileInfo` records (`path`, `isFile`, `size`).
|
|
774
|
+
*/
|
|
526
775
|
async list(filePath, recursive = false) {
|
|
527
776
|
const {
|
|
528
777
|
connector,
|
|
529
778
|
path
|
|
530
779
|
} = this.splitPath(filePath);
|
|
780
|
+
const conn = await new NodeConnectionsDataSource(this.client).find(connector);
|
|
531
781
|
const q = buildQuery({
|
|
532
782
|
recursive: recursive ? 'true' : undefined
|
|
533
783
|
});
|
|
534
|
-
const seg = path ? `${
|
|
535
|
-
|
|
784
|
+
const seg = path ? `${path.replace(/\/+$/, '')}/` : '';
|
|
785
|
+
const res = await this.client.get(`/connectors/connections/${encodeURIComponent(conn.id)}/files/${seg}${q}`);
|
|
786
|
+
return Array.isArray(res) ? res : [];
|
|
536
787
|
}
|
|
537
788
|
async get(filePath) {
|
|
538
789
|
const {
|
|
@@ -542,6 +793,26 @@ class NodeFilesDataSource {
|
|
|
542
793
|
const seg = path ? `${connector}/${path}` : connector;
|
|
543
794
|
return this.client.get(`/public/v1/files/${seg}`);
|
|
544
795
|
}
|
|
796
|
+
|
|
797
|
+
/** The file's raw bytes, for copying a share file between instances. */
|
|
798
|
+
async readBytes(filePath) {
|
|
799
|
+
const {
|
|
800
|
+
connector,
|
|
801
|
+
path
|
|
802
|
+
} = this.splitPath(filePath);
|
|
803
|
+
if (!path) throw new Error(`Path must name a file inside the share: got '${filePath}'`);
|
|
804
|
+
return this.client.getBytes(`/public/v1/files/${connector}/${path}`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Upload bytes already in memory — the bundle holds them, there is no local file. */
|
|
808
|
+
async writeBytes(filePath, bytes) {
|
|
809
|
+
const {
|
|
810
|
+
connector,
|
|
811
|
+
path
|
|
812
|
+
} = this.splitPath(filePath);
|
|
813
|
+
if (!path) throw new Error(`Path must name a file inside the share: got '${filePath}'`);
|
|
814
|
+
return await this.client.putBytes(`/public/v1/files/${connector}/${path}`, bytes);
|
|
815
|
+
}
|
|
545
816
|
async delete(filePath) {
|
|
546
817
|
const {
|
|
547
818
|
connector,
|
|
@@ -558,43 +829,47 @@ class NodeFilesDataSource {
|
|
|
558
829
|
*/
|
|
559
830
|
async put(localPath, remotePath) {
|
|
560
831
|
const fs = require('fs');
|
|
561
|
-
const {
|
|
562
|
-
connector,
|
|
563
|
-
path
|
|
564
|
-
} = this.splitPath(remotePath);
|
|
565
|
-
if (!path) throw new Error(`Remote path must include a file name after the connector: got '${remotePath}'`);
|
|
566
832
|
const bytes = fs.readFileSync(localPath);
|
|
567
|
-
const res = await this.client.putBytes(`/public/v1/files/${connector}/${path}`, bytes);
|
|
568
833
|
return {
|
|
569
834
|
path: remotePath,
|
|
570
835
|
size: bytes.length,
|
|
571
|
-
response:
|
|
836
|
+
response: await this.writeBytes(remotePath, bytes)
|
|
572
837
|
};
|
|
573
838
|
}
|
|
574
839
|
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Tables live under a project namespace (`Admin:MyTable:MyTable`), so a table is addressed by
|
|
843
|
+
* UUID, by that full name, or by its bare name when only one table carries it.
|
|
844
|
+
*/
|
|
575
845
|
exports.NodeFilesDataSource = NodeFilesDataSource;
|
|
576
|
-
class NodeTablesDataSource {
|
|
846
|
+
class NodeTablesDataSource extends NodeHttpDataSource {
|
|
577
847
|
constructor(client) {
|
|
578
|
-
|
|
848
|
+
super(client, 'tables', '/tables');
|
|
579
849
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
if (
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
stackTrace: parsed.stackTrace
|
|
592
|
-
};
|
|
593
|
-
throw Object.assign(new Error(err.error), {
|
|
594
|
-
apiError: err
|
|
595
|
-
});
|
|
850
|
+
async find(idOrName) {
|
|
851
|
+
if (UUID_RE.test(idOrName)) return this.client.get(`/tables/${encodeURIComponent(idOrName)}`);
|
|
852
|
+
const all = await this.client.get(`/tables${buildQuery({
|
|
853
|
+
text: idOrName,
|
|
854
|
+
limit: 100
|
|
855
|
+
})}`);
|
|
856
|
+
const matches = all.filter(t => t?.name === idOrName || `${t?.namespace ?? ''}${t?.name ?? ''}` === idOrName);
|
|
857
|
+
if (!matches.length) throw new Error(`No table named '${idOrName}'`);
|
|
858
|
+
if (matches.length > 1) {
|
|
859
|
+
const list = matches.map(t => ` ${t.id} ${t.namespace ?? ''}${t.name}`).join('\n');
|
|
860
|
+
throw new Error(`Multiple tables match '${idOrName}':\n${list}\nUse the full name or the ID.`);
|
|
596
861
|
}
|
|
597
|
-
return
|
|
862
|
+
return matches[0];
|
|
863
|
+
}
|
|
864
|
+
async delete(idOrName) {
|
|
865
|
+
const table = typeof idOrName === 'string' ? await this.find(idOrName) : idOrName;
|
|
866
|
+
await this.client.del(`/tables/${encodeURIComponent(table?.id ?? '')}`);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** GET /public/v1/tables/{id} — returns CSV text. */
|
|
870
|
+
async download(idOrName) {
|
|
871
|
+
const table = await this.find(idOrName);
|
|
872
|
+
return this.client.get(`/public/v1/tables/${encodeURIComponent(table.id)}`);
|
|
598
873
|
}
|
|
599
874
|
|
|
600
875
|
/**
|
|
@@ -610,6 +885,193 @@ class NodeTablesDataSource {
|
|
|
610
885
|
}
|
|
611
886
|
}
|
|
612
887
|
exports.NodeTablesDataSource = NodeTablesDataSource;
|
|
888
|
+
/** `'<schema>'` or `'<schema>.<table>'` — the address every `grok s domains` verb takes. */
|
|
889
|
+
function parseDomainAddress(s, opts = {}) {
|
|
890
|
+
const str = String(s ?? '');
|
|
891
|
+
const dot = str.indexOf('.');
|
|
892
|
+
const address = dot === -1 ? {
|
|
893
|
+
schema: str
|
|
894
|
+
} : {
|
|
895
|
+
schema: str.slice(0, dot),
|
|
896
|
+
table: str.slice(dot + 1)
|
|
897
|
+
};
|
|
898
|
+
if (!address.schema || dot !== -1 && !address.table) throw new Error(`Invalid domain address '${str}': expected <schema> or <schema>.<table>`);
|
|
899
|
+
if (opts.table === true && !address.table) throw new Error(`'${str}' names a schema; this command needs a table: <schema>.<table>`);
|
|
900
|
+
if (opts.table === false && address.table) throw new Error(`'${str}' names a table; this command needs a schema`);
|
|
901
|
+
return address;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Client for entity-mapped domain tables (`/domains/...`, DomainsRouter). Rows are plain
|
|
905
|
+
* JSON objects; the server validates, permission-checks and audits every write. Errors
|
|
906
|
+
* arrive as HTTP 4xx with a JSON envelope, so `client.request` throws with `apiError.body`
|
|
907
|
+
* carrying the structured fields (per-row `rows`, a dry-run `plan`, version numbers).
|
|
908
|
+
*/
|
|
909
|
+
class NodeDomainsDataSource {
|
|
910
|
+
constructor(client) {
|
|
911
|
+
this.client = client;
|
|
912
|
+
}
|
|
913
|
+
rows(schema, table) {
|
|
914
|
+
return `/domains/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Registered schemas with their tables (`GET /domains/schemas`); [text] is a smart filter. */
|
|
918
|
+
schemas(text) {
|
|
919
|
+
return this.client.get(`/domains/schemas${buildQuery({
|
|
920
|
+
text: text || undefined
|
|
921
|
+
})}`);
|
|
922
|
+
}
|
|
923
|
+
async schema(name) {
|
|
924
|
+
const all = await this.schemas();
|
|
925
|
+
const s = all.find(x => x?.name === name);
|
|
926
|
+
if (!s) throw new Error(`Domain schema '${name}' not found`);
|
|
927
|
+
return s;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Registry entity id of a schema or a table — the target of the grants endpoints. */
|
|
931
|
+
async entityId(address) {
|
|
932
|
+
const s = await this.schema(address.schema);
|
|
933
|
+
if (!address.table) return s.id;
|
|
934
|
+
const t = (s.tables ?? []).find(x => x?.name === address.table);
|
|
935
|
+
if (!t) throw new Error(`Domain table '${address.schema}.${address.table}' not found`);
|
|
936
|
+
return t.id;
|
|
937
|
+
}
|
|
938
|
+
manifest(schema) {
|
|
939
|
+
return this.client.get(`/domains/schemas/${encodeURIComponent(schema)}/manifest`);
|
|
940
|
+
}
|
|
941
|
+
createSchema(name, friendlyName, description) {
|
|
942
|
+
return this.client.post('/domains/schemas', {
|
|
943
|
+
name,
|
|
944
|
+
friendlyName,
|
|
945
|
+
description
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
applySchema(schema, body, dryRun = false) {
|
|
949
|
+
const q = buildQuery({
|
|
950
|
+
dryRun: dryRun ? 'true' : undefined
|
|
951
|
+
});
|
|
952
|
+
return this.client.post(`/domains/schemas/${encodeURIComponent(schema)}/apply${q}`, body);
|
|
953
|
+
}
|
|
954
|
+
deleteSchema(schema) {
|
|
955
|
+
return this.client.del(`/domains/schemas/${encodeURIComponent(schema)}`);
|
|
956
|
+
}
|
|
957
|
+
schemaAudit(schema, limit) {
|
|
958
|
+
return this.client.get(`/domains/schemas/${encodeURIComponent(schema)}/audit${buildQuery({
|
|
959
|
+
limit
|
|
960
|
+
})}`);
|
|
961
|
+
}
|
|
962
|
+
tableAudit(schema, table, limit) {
|
|
963
|
+
return this.client.get(`${this.rows(schema, table)}/audit${buildQuery({
|
|
964
|
+
limit
|
|
965
|
+
})}`);
|
|
966
|
+
}
|
|
967
|
+
rowAudit(schema, table, id) {
|
|
968
|
+
return this.client.get(`${this.rows(schema, table)}/${encodeURIComponent(id)}/audit`);
|
|
969
|
+
}
|
|
970
|
+
grants(entityId) {
|
|
971
|
+
return this.client.get(`/domains/grants/${encodeURIComponent(entityId)}`);
|
|
972
|
+
}
|
|
973
|
+
grant(entityId, group, permission) {
|
|
974
|
+
return this.client.post(`/domains/grants/${encodeURIComponent(entityId)}`, {
|
|
975
|
+
group,
|
|
976
|
+
permission
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
revoke(entityId, group, permission) {
|
|
980
|
+
return this.client.del(`/domains/grants/${encodeURIComponent(entityId)}${buildQuery({
|
|
981
|
+
group,
|
|
982
|
+
permission
|
|
983
|
+
})}`);
|
|
984
|
+
}
|
|
985
|
+
capabilities(schema, table) {
|
|
986
|
+
return this.client.get(`${this.rows(schema, table)}/capabilities`);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** JSON rows; spec = {filter, sort, columns, expand, limit, offset} (10k row cap). */
|
|
990
|
+
query(schema, table, spec = {}) {
|
|
991
|
+
return this.client.post(`${this.rows(schema, table)}/query`, spec);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** The same query as a d42 DataFrame blob (10M row cap). */
|
|
995
|
+
queryD42(schema, table, spec = {}) {
|
|
996
|
+
return this.client.postForBytes(`${this.rows(schema, table)}/query`, {
|
|
997
|
+
...spec,
|
|
998
|
+
format: 'd42'
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
aggregate(schema, table, spec) {
|
|
1002
|
+
return this.client.post(`${this.rows(schema, table)}/aggregate`, spec);
|
|
1003
|
+
}
|
|
1004
|
+
async count(schema, table, filter) {
|
|
1005
|
+
const spec = {
|
|
1006
|
+
measures: [{
|
|
1007
|
+
fn: 'count'
|
|
1008
|
+
}]
|
|
1009
|
+
};
|
|
1010
|
+
if (filter) spec.filter = filter;
|
|
1011
|
+
const rows = await this.aggregate(schema, table, spec);
|
|
1012
|
+
return Number(rows?.[0]?.count ?? 0);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** One row, or null when it does not exist or is not visible (the server answers 404). */
|
|
1016
|
+
async getRow(schema, table, id) {
|
|
1017
|
+
try {
|
|
1018
|
+
return await this.client.get(`${this.rows(schema, table)}/${encodeURIComponent(id)}`);
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
if (err?.apiError?.errorCode === 404) return null;
|
|
1021
|
+
throw err;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** [rows] is one row object or an array; resolves to per-row `{id, created}` reports. */
|
|
1026
|
+
async insert(schema, table, rows, errorOnDuplicate = false) {
|
|
1027
|
+
const q = buildQuery({
|
|
1028
|
+
errorOnDuplicate: errorOnDuplicate ? 'true' : undefined
|
|
1029
|
+
});
|
|
1030
|
+
const res = await this.client.post(`${this.rows(schema, table)}${q}`, rows);
|
|
1031
|
+
return Array.isArray(res) ? res : [res];
|
|
1032
|
+
}
|
|
1033
|
+
update(schema, table, id, values, version) {
|
|
1034
|
+
const body = {
|
|
1035
|
+
values
|
|
1036
|
+
};
|
|
1037
|
+
if (version !== undefined) body.version = version;
|
|
1038
|
+
return this.client.request('PATCH', `${this.rows(schema, table)}/${encodeURIComponent(id)}`, body);
|
|
1039
|
+
}
|
|
1040
|
+
deleteRow(schema, table, id) {
|
|
1041
|
+
return this.client.del(`${this.rows(schema, table)}/${encodeURIComponent(id)}`);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** Soft-deletes up to [limit] (≤1000) matching rows in one transaction; `{deleted, hasMore}`. */
|
|
1045
|
+
deleteWhere(schema, table, filter, limit) {
|
|
1046
|
+
const body = {
|
|
1047
|
+
filter
|
|
1048
|
+
};
|
|
1049
|
+
if (limit !== undefined) body.limit = limit;
|
|
1050
|
+
return this.client.post(`${this.rows(schema, table)}/delete`, body);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Bulk upload: [bytes] is the file content and [contentType] selects how the server
|
|
1055
|
+
* reads it — `text/csv`, `application/octet-stream` (d42), or `application/json` (a row
|
|
1056
|
+
* array, bare or under `rows`). Resolves to the batch report `{inserted, updated,
|
|
1057
|
+
* skipped, errorCount, rows}`; a report-carrying failure rejects with the report in
|
|
1058
|
+
* `apiError.body`.
|
|
1059
|
+
*/
|
|
1060
|
+
batch(schema, table, bytes, contentType, options = {}) {
|
|
1061
|
+
const q = buildQuery({
|
|
1062
|
+
mode: options.mode ?? 'insert',
|
|
1063
|
+
allOrNothing: options.allOrNothing === false ? 'false' : 'true',
|
|
1064
|
+
errorOnDuplicate: options.errorOnDuplicate ? 'true' : undefined
|
|
1065
|
+
});
|
|
1066
|
+
return this.client.putBytes(`${this.rows(schema, table)}/batch${q}`, bytes, contentType);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** Ordered ops (`{op, table, ref?, values?, id?, expectedVersion?}`) applied atomically. */
|
|
1070
|
+
transaction(schema, ops) {
|
|
1071
|
+
return this.client.post(`/domains/${encodeURIComponent(schema)}/transaction`, ops);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
exports.NodeDomainsDataSource = NodeDomainsDataSource;
|
|
613
1075
|
class NodeDapi {
|
|
614
1076
|
constructor(client) {
|
|
615
1077
|
this.client = client;
|
|
@@ -621,22 +1083,22 @@ class NodeDapi {
|
|
|
621
1083
|
return new NodeGroupsDataSource(this.client);
|
|
622
1084
|
}
|
|
623
1085
|
get functions() {
|
|
624
|
-
return new NodeFuncsDataSource(this.client
|
|
1086
|
+
return new NodeFuncsDataSource(this.client);
|
|
625
1087
|
}
|
|
626
1088
|
get connections() {
|
|
627
1089
|
return new NodeConnectionsDataSource(this.client);
|
|
628
1090
|
}
|
|
629
1091
|
get queries() {
|
|
630
|
-
return
|
|
1092
|
+
return this.internal('/connectors/queries');
|
|
631
1093
|
}
|
|
632
1094
|
get scripts() {
|
|
633
|
-
return
|
|
1095
|
+
return this.internal('/scripts');
|
|
634
1096
|
}
|
|
635
1097
|
get packages() {
|
|
636
1098
|
return new NodePackagesDataSource(this.client);
|
|
637
1099
|
}
|
|
638
1100
|
get reports() {
|
|
639
|
-
return
|
|
1101
|
+
return this.internal('/reports');
|
|
640
1102
|
}
|
|
641
1103
|
get files() {
|
|
642
1104
|
return new NodeFilesDataSource(this.client);
|
|
@@ -647,35 +1109,124 @@ class NodeDapi {
|
|
|
647
1109
|
get tables() {
|
|
648
1110
|
return new NodeTablesDataSource(this.client);
|
|
649
1111
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1112
|
+
get domains() {
|
|
1113
|
+
return new NodeDomainsDataSource(this.client);
|
|
1114
|
+
}
|
|
1115
|
+
internal(route) {
|
|
1116
|
+
return new InternalDataSource(this.client, route);
|
|
1117
|
+
}
|
|
1118
|
+
async serverInfo() {
|
|
1119
|
+
const raw = await this.client.get('/info/server');
|
|
1120
|
+
const info = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
1121
|
+
return {
|
|
1122
|
+
version: info?.Version ?? '',
|
|
1123
|
+
commit: info?.Commit
|
|
661
1124
|
};
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
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);
|
|
667
1130
|
}
|
|
668
1131
|
async batch(request) {
|
|
669
1132
|
return this.client.post('/public/v1/batch', request);
|
|
670
1133
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* The shape of an entity type: its registry record (`/entities/types`) plus the top-level
|
|
1137
|
+
* fields of one existing entity of that type, since the server publishes no JSON schema.
|
|
1138
|
+
* [nameOrAlias] is a `grok s` entity (`connections`) or a type name (`DataConnection`).
|
|
1139
|
+
*/
|
|
1140
|
+
async describe(nameOrAlias) {
|
|
1141
|
+
const alias = DESCRIBE_ALIASES[nameOrAlias.toLowerCase()];
|
|
1142
|
+
const typeName = alias?.type ?? nameOrAlias;
|
|
1143
|
+
const types = await this.client.get(`/entities/types${buildQuery({
|
|
1144
|
+
showSystem: 'true',
|
|
1145
|
+
text: `name="${typeName}"`,
|
|
1146
|
+
limit: 5
|
|
1147
|
+
})}`);
|
|
1148
|
+
const type = types.find(t => (t?.name ?? '').toLowerCase() === typeName.toLowerCase()) ?? null;
|
|
1149
|
+
const sampleFrom = alias?.sample ?? DESCRIBE_SAMPLES[typeName];
|
|
1150
|
+
if (!type && !sampleFrom) throw new Error(`Unknown entity type '${nameOrAlias}'. Try one of: ${Object.keys(DESCRIBE_ALIASES).join(', ')}, or a type name such as Project`);
|
|
1151
|
+
const sample = sampleFrom ? (await sampleFrom(this))[0] ?? null : null;
|
|
1152
|
+
const fields = sample ? Object.entries(sample).map(([field, v]) => ({
|
|
1153
|
+
field,
|
|
1154
|
+
type: describeType(v),
|
|
1155
|
+
example: describeExample(v)
|
|
1156
|
+
})) : [];
|
|
1157
|
+
return {
|
|
1158
|
+
type,
|
|
1159
|
+
fields,
|
|
1160
|
+
sample
|
|
1161
|
+
};
|
|
679
1162
|
}
|
|
680
1163
|
}
|
|
681
|
-
exports.NodeDapi = NodeDapi;
|
|
1164
|
+
exports.NodeDapi = NodeDapi;
|
|
1165
|
+
const DESCRIBE_SAMPLES = {
|
|
1166
|
+
User: d => d.users.by(1).list(),
|
|
1167
|
+
UserGroup: d => d.groups.by(1).list(),
|
|
1168
|
+
DataConnection: d => d.connections.by(1).list(),
|
|
1169
|
+
DataQuery: d => d.queries.list({
|
|
1170
|
+
limit: 1
|
|
1171
|
+
}),
|
|
1172
|
+
Script: d => d.scripts.list({
|
|
1173
|
+
limit: 1
|
|
1174
|
+
}),
|
|
1175
|
+
Func: d => d.functions.by(1).list(),
|
|
1176
|
+
Package: d => d.packages.by(1).list(),
|
|
1177
|
+
UserReport: d => d.reports.list({
|
|
1178
|
+
limit: 1
|
|
1179
|
+
}),
|
|
1180
|
+
TableInfo: d => d.tables.by(1).list(),
|
|
1181
|
+
Project: d => d.internal('/projects').list({
|
|
1182
|
+
limit: 1
|
|
1183
|
+
}),
|
|
1184
|
+
FileInfo: d => d.internal('/files').list({
|
|
1185
|
+
limit: 1
|
|
1186
|
+
})
|
|
1187
|
+
};
|
|
1188
|
+
const DESCRIBE_ALIASES = {
|
|
1189
|
+
users: {
|
|
1190
|
+
type: 'User'
|
|
1191
|
+
},
|
|
1192
|
+
groups: {
|
|
1193
|
+
type: 'UserGroup'
|
|
1194
|
+
},
|
|
1195
|
+
connections: {
|
|
1196
|
+
type: 'DataConnection'
|
|
1197
|
+
},
|
|
1198
|
+
queries: {
|
|
1199
|
+
type: 'DataQuery'
|
|
1200
|
+
},
|
|
1201
|
+
scripts: {
|
|
1202
|
+
type: 'Script'
|
|
1203
|
+
},
|
|
1204
|
+
functions: {
|
|
1205
|
+
type: 'Func'
|
|
1206
|
+
},
|
|
1207
|
+
packages: {
|
|
1208
|
+
type: 'Package'
|
|
1209
|
+
},
|
|
1210
|
+
reports: {
|
|
1211
|
+
type: 'UserReport'
|
|
1212
|
+
},
|
|
1213
|
+
tables: {
|
|
1214
|
+
type: 'TableInfo'
|
|
1215
|
+
},
|
|
1216
|
+
projects: {
|
|
1217
|
+
type: 'Project'
|
|
1218
|
+
},
|
|
1219
|
+
files: {
|
|
1220
|
+
type: 'FileInfo'
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
function describeType(v) {
|
|
1224
|
+
if (v === null || v === undefined) return 'null';
|
|
1225
|
+
if (Array.isArray(v)) return `array(${v.length})`;
|
|
1226
|
+
if (typeof v === 'object') return v['#type'] ?? (v.id ? 'ref' : 'object');
|
|
1227
|
+
return typeof v;
|
|
1228
|
+
}
|
|
1229
|
+
function describeExample(v) {
|
|
1230
|
+
const s = v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
1231
|
+
return s.length > 60 ? `${s.slice(0, 57)}...` : s;
|
|
1232
|
+
}
|