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.
@@ -0,0 +1,392 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.handleMigrate = handleMigrate;
7
+ exports.loadCreds = loadCreds;
8
+ var fs = _interopRequireWildcard(require("fs"));
9
+ var os = _interopRequireWildcard(require("os"));
10
+ var path = _interopRequireWildcard(require("path"));
11
+ var yaml = _interopRequireWildcard(require("js-yaml"));
12
+ var _nodeDapi = require("../utils/node-dapi");
13
+ var _serverClient = require("../utils/server-client");
14
+ var _serverOutput = require("../utils/server-output");
15
+ var _registry = require("../utils/migrate/registry");
16
+ var bundle = _interopRequireWildcard(require("../utils/migrate/bundle"));
17
+ var _walker = require("../utils/migrate/walker");
18
+ var _pusher = require("../utils/migrate/pusher");
19
+ var _parts = require("../utils/migrate/parts");
20
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
21
+ /// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
22
+
23
+ const SELECTION_USAGE = ' [<nqName|id>...] [--type t,t] [--namespace ns] [--space s] [--name n] [--author login]\n' + ' [--tag t] [--since 2w] [--filter expr] [--no-deps] [--no-include-data] [--include-files]';
24
+ const PULL_USAGE = `Usage: grok s pull --out <dir> [--replace] [--admin] [--host <alias>]\n${SELECTION_USAGE}`;
25
+ const MIGRATE_USAGE = 'Usage: grok s migrate --from <alias> --to <alias> [--dry-run] [--keep] [--admin]\n' + ' [--on-conflict fail|skip|duplicate|adopt] [--creds <file.yaml>]\n' + ' --by-namespace [--only a,b] [--skip c] [--state <file>] [--force] [--no-sweep]\n' + ' moves the instance one space at a time, checking users and packages first\n' + SELECTION_USAGE;
26
+ async function handleMigrate(dapi, verb, rest, argv, output) {
27
+ if (verb === 'pull') return await handlePull(dapi, rest, argv, output);
28
+ if (verb === 'push') return await handlePush(dapi, rest, argv, output);
29
+ if (verb === 'migrate') return await handleTransfer(rest, argv, output);
30
+ if (verb === 'diff') return await handleDiff(dapi, rest, argv, output);
31
+ if (verb === 'bundle') return handleBundle(rest, output);
32
+ (0, _serverOutput.printError)(new Error(`Unknown migrate verb '${verb}'. Valid: pull, push, migrate, diff, bundle ls`));
33
+ return false;
34
+ }
35
+ const hasSelection = (rest, argv) => !!rest.length || !!argv.type || ['name', 'namespace', 'space', 'author', 'tag', 'since', 'filter', 'f'].some(f => argv[f]);
36
+ async function handlePull(dapi, rest, argv, output, print = true, report) {
37
+ const out = argv.out ?? '';
38
+ if (!out || !hasSelection(rest, argv)) {
39
+ (0, _serverOutput.printError)(new Error(out ? `Refusing to pull the whole server — pass entity names, --type, or a filter flag.\n${PULL_USAGE}` : PULL_USAGE));
40
+ return false;
41
+ }
42
+ const sel = {
43
+ ...(0, _registry.resolveTypes)(argv.type ? String(argv.type).split(',') : _registry.DEFAULT_TYPES),
44
+ names: rest,
45
+ name: argv.name,
46
+ namespace: argv.namespace,
47
+ space: argv.space,
48
+ author: argv.author,
49
+ tag: argv.tag,
50
+ since: (0, _walker.normalizeSince)(argv.since),
51
+ filter: argv.filter ?? argv.f
52
+ };
53
+ const notes = [];
54
+ const note = row => notes.push(row);
55
+ const leftBehind = [];
56
+ const progress = (0, _serverOutput.progressReporter)(output === 'quiet');
57
+ const selected = await (0, _walker.select)(dapi, sel, note, progress);
58
+ const entities = argv.deps !== false ? await (0, _walker.expand)(dapi, selected, note, leftBehind, progress) : selected;
59
+ // Everything the bundle points at but leaves behind, so the push can re-find it by name.
60
+ const outside = await (0, _walker.collectExternals)(dapi, entities, note, progress);
61
+ const externals = [...leftBehind, ...outside.externals];
62
+ const kinds = [];
63
+ if (argv['include-data'] !== false) kinds.push('tables');
64
+ if (argv['include-files']) kinds.push('files');
65
+ const bytes = await (0, _walker.pullBytes)(dapi, entities, note, kinds, progress);
66
+ // A datasync table rebuilds itself from a share on open, so the file has to travel with it.
67
+ const shares = argv['include-files'] ? await (0, _walker.pullShares)(dapi, entities, note, progress) : new Map();
68
+ const info = await dapi.serverInfo();
69
+ const user = await dapi.client.get('/users/current');
70
+ bundle.write(out, entities, {
71
+ source: {
72
+ url: dapi.client.baseUrl,
73
+ version: info.version,
74
+ commit: info.commit,
75
+ userNamespace: user?.project?.name ? `${user.project.name}:` : ''
76
+ },
77
+ args: process.argv.slice(3),
78
+ packages: notes.filter(n => n.reason === 'package_entity').map(n => n.detail).filter(Boolean),
79
+ externals,
80
+ dangling: outside.dangling
81
+ }, {
82
+ replace: !!argv.replace
83
+ }, bytes);
84
+ bundle.writeShares(out, shares);
85
+ progress(`wrote ${entities.size} entities to ${out}`);
86
+ const rows = [...notes];
87
+ if (outside.dangling.length) rows.push({
88
+ name: dapi.client.baseUrl,
89
+ entityType: 'Bundle',
90
+ action: 'warn',
91
+ reason: 'source_dangling_refs',
92
+ detail: `${outside.dangling.length} reference(s) point at entities the source itself no longer has`
93
+ });
94
+ for (const [, {
95
+ type,
96
+ json
97
+ }] of entities) rows.push({
98
+ name: (0, _registry.nqNameOf)(json),
99
+ entityType: type,
100
+ action: 'info',
101
+ reason: 'pulled'
102
+ });
103
+ // An entity the server would not hand over is missing from the bundle, and pushing it would
104
+ // quietly promote less than was asked for. Absent bytes are not that: a datasync table that was
105
+ // never materialised has no data file to give, travels fine and refreshes on the target — so
106
+ // `no_data` is reported per table and left to the operator rather than blocking the push.
107
+ const dropped = notes.filter(n => n.reason === 'fetch_failed').length;
108
+ if (dropped) {
109
+ rows.push({
110
+ name: out,
111
+ entityType: 'Bundle',
112
+ action: 'failed',
113
+ reason: 'incomplete',
114
+ detail: `${dropped} entities could not be read in full`
115
+ });
116
+ process.exitCode = 1;
117
+ if (report) report.dropped = dropped;
118
+ }
119
+ // `migrate` silences the pull's own report, but a refusal has to say why.
120
+ if (print || dropped) (0, _serverOutput.printOutput)(rows, output);
121
+ return true;
122
+ }
123
+ const ENV_RE = /\$\{(\w*)\}/g;
124
+
125
+ /**
126
+ * Target-side secrets, keyed by connection nqName. `${VAR}` is resolved from the
127
+ * environment, as `grok publish` does for `connections/*.json` — after the YAML is parsed,
128
+ * so a broken file is reported without a secret in the message.
129
+ */
130
+ function loadCreds(file) {
131
+ if (!file) return undefined;
132
+ const loaded = yaml.load(fs.readFileSync(file, 'utf8')) ?? {};
133
+ const missing = [];
134
+ const creds = {};
135
+ for (const [key, params] of Object.entries(loaded)) {
136
+ if (!params || typeof params !== 'object' || Array.isArray(params)) throw new Error(`${file}: "${key}" must be a map of connection parameters, e.g. '${key}: {password: \${VAR}}'`);
137
+ creds[key] = {};
138
+ for (const [name, value] of Object.entries(params)) creds[key][name] = typeof value !== 'string' ? value : value.replace(ENV_RE, (whole, env) => {
139
+ const resolved = process.env[env];
140
+ if (resolved !== undefined) return resolved;
141
+ missing.push(env);
142
+ return whole;
143
+ });
144
+ }
145
+ if (missing.length) throw new Error(`${file}: cannot find environment variable "${[...new Set(missing)].join('", "')}"`);
146
+ return creds;
147
+ }
148
+ const POLICIES = ['fail', 'skip', 'duplicate', 'adopt'];
149
+ const SWEEP_TYPES = 'layout,view';
150
+ function conflictPolicy(argv) {
151
+ const policy = argv['on-conflict'] ?? 'fail';
152
+ if (!POLICIES.includes(policy)) throw new Error(`Unsupported conflict policy '${policy}'. Valid: ${POLICIES.join(', ')}`);
153
+ return policy;
154
+ }
155
+ async function handlePush(dapi, rest, argv, output) {
156
+ const dir = rest[0];
157
+ if (!dir) {
158
+ (0, _serverOutput.printError)(new Error('Usage: grok s push <bundle-dir> [--dry-run] [--on-conflict fail|skip|duplicate|adopt] [--creds <file.yaml>] [--admin] [--host <alias>]'));
159
+ return false;
160
+ }
161
+ const onConflict = conflictPolicy(argv);
162
+ const creds = loadCreds(argv.creds);
163
+ const dryRun = !!argv['dry-run'];
164
+ const result = await (0, _pusher.push)(dapi, bundle.read(dir), {
165
+ dryRun,
166
+ onConflict,
167
+ creds,
168
+ progress: (0, _serverOutput.progressReporter)(output === 'quiet')
169
+ }, rows => {
170
+ if (output === 'table' && !dryRun) {
171
+ console.log('Plan:');
172
+ (0, _serverOutput.printOutput)(rows, output);
173
+ console.log('');
174
+ }
175
+ });
176
+ if (result.items.some(r => r.action === 'failed')) process.exitCode = 1;
177
+ if (output === 'json') {
178
+ (0, _serverOutput.printOutput)({
179
+ ...result,
180
+ items: result.items.map(r => ({
181
+ ...r,
182
+ detail: r.detail ?? ''
183
+ }))
184
+ }, 'json');
185
+ return true;
186
+ }
187
+ if (!dryRun) console.log('Result:');
188
+ (0, _serverOutput.printOutput)(result.items, output);
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * A bundle pulled from one instance and pushed into another, with a temporary bundle
194
+ * directory in between — the same two verbs, so nothing behaves differently.
195
+ */
196
+ async function handleTransfer(rest, argv, output) {
197
+ if (!argv.from || !argv.to || !argv['by-namespace'] && !hasSelection(rest, argv)) {
198
+ (0, _serverOutput.printError)(new Error(MIGRATE_USAGE));
199
+ return false;
200
+ }
201
+ const from = new _nodeDapi.NodeDapi(await (0, _serverClient.createClient)(String(argv.from), !!argv.admin));
202
+ const to = new _nodeDapi.NodeDapi(await (0, _serverClient.createClient)(String(argv.to), !!argv.admin));
203
+ if (from.client.baseUrl === to.client.baseUrl) throw new Error(`--from and --to are the same server (${to.client.baseUrl}) — nothing to migrate`);
204
+ if (argv['by-namespace']) {
205
+ if (rest.length) throw new Error('--by-namespace migrates whole spaces; it takes no entity selection');
206
+ return await transferByNamespace(from, to, argv, output);
207
+ }
208
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grok-migrate-'));
209
+ try {
210
+ const report = {
211
+ dropped: 0
212
+ };
213
+ if (!(await handlePull(from, rest, {
214
+ ...argv,
215
+ out: dir
216
+ }, output, output !== 'json', report))) return false;
217
+ if (report.dropped) {
218
+ (0, _serverOutput.printError)(new Error(`Refusing to push a partial bundle: ${report.dropped} entities could not be read. ` + 'Re-run, or pull with --keep and push the bundle yourself.'));
219
+ return true;
220
+ }
221
+ return await handlePush(to, [dir], argv, output);
222
+ } finally {
223
+ // stderr: `--output json` must stay one parseable document.
224
+ if (argv.keep) console.error(`Bundle kept at ${dir}`);else fs.rmSync(dir, {
225
+ recursive: true,
226
+ force: true
227
+ });
228
+ }
229
+ }
230
+
231
+ /**
232
+ * A whole instance, one space at a time. Placement is exclusive on the server, so a single bundle
233
+ * holding every space has its projects taking entities from one another; scoped to one space that
234
+ * contention stays inside it. A part that fails does not stop the rest, and what finished is
235
+ * recorded so a re-run picks up where it stopped.
236
+ */
237
+ async function transferByNamespace(from, to, argv, output) {
238
+ const only = argv.only ? String(argv.only).split(',').map(s => s.trim()) : [];
239
+ const skip = new Set(argv.skip ? String(argv.skip).split(',').map(s => s.trim()) : []);
240
+ // `--from` and `--to` can be full URLs, which are not file names.
241
+ const tag = v => String(v).replace(/[^\w.-]+/g, '_');
242
+ const stateFile = argv.state ?? path.join(os.tmpdir(), `grok-migrate-${tag(argv.from)}-${tag(argv.to)}.json`);
243
+ const state = (0, _parts.readState)(stateFile);
244
+
245
+ // Without an admin session the source lists only what this account can see, so the run would
246
+ // enumerate a subset of the instance and report a whole-instance migration.
247
+ if (!argv.admin && !argv.force) throw new Error('--by-namespace needs --admin, or it sees only the spaces this account can ' + 'reach and migrates part of the instance; pass --force to accept that.');
248
+
249
+ // Both are prerequisites of the instance, not of any bundle, and both are cheaper to fix now
250
+ // than to discover space by space: content of a user the target lacks lands under the pusher.
251
+ const [users, packages] = await Promise.all([(0, _parts.missingUsers)(from, to), (0, _parts.missingPackages)(from, to)]);
252
+ // A source can have hundreds of each; the count is what decides, and a few names say which kind.
253
+ const few = all => all.slice(0, 5).join(', ') + (all.length > 5 ? `, +${all.length - 5} more` : '');
254
+ const notes = [];
255
+ if (users.length) notes.push({
256
+ name: `${users.length} user(s)`,
257
+ entityType: 'User',
258
+ action: 'warn',
259
+ reason: 'user_missing',
260
+ detail: `${few(users)} — create them on the target first, or their content lands under the pushing account`
261
+ });
262
+ if (packages.length) notes.push({
263
+ name: `${packages.length} package(s)`,
264
+ entityType: 'Package',
265
+ action: 'warn',
266
+ reason: 'package_not_installed',
267
+ detail: `${few(packages)} — publish them on the target, or what they own cannot resolve`
268
+ });
269
+ (0, _serverOutput.printOutput)(notes, output);
270
+ // A whole-instance run stops: content of a user the target lacks lands under the pushing account,
271
+ // and that is not worth discovering space by space. A run the operator has already scoped with
272
+ // `--only` is their call, so it is reported and allowed.
273
+ if (notes.length && !argv.force && !only.length) {
274
+ (0, _serverOutput.printError)(new Error('Refusing to start: fix the above, or pass --force to migrate anyway.'));
275
+ process.exitCode = 1;
276
+ return true;
277
+ }
278
+
279
+ // Resolved once: a bad policy or a broken creds file is the run's problem, not each part's.
280
+ const onConflict = conflictPolicy(argv);
281
+ const creds = loadCreds(argv.creds);
282
+ const dryRun = !!argv['dry-run'];
283
+
284
+ // Not everything belongs to a space: a layout can sit under no namespace at all, and would
285
+ // otherwise never travel, so a full run ends with a sweep for what no space owns.
286
+ const spaces = (0, _parts.plannedParts)(await (0, _parts.namespacesOf)(from), {
287
+ only,
288
+ skip: [...skip],
289
+ state,
290
+ sweep: !only.length && argv.sweep !== false
291
+ });
292
+ const parts = [];
293
+ for (const [i, name] of spaces.entries()) {
294
+ console.error(`[${i + 1}/${spaces.length}] ${name === _parts.SWEEP ? 'everything a space does not own' : name}`);
295
+ const started = Date.now();
296
+ const part = {
297
+ name
298
+ };
299
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grok-migrate-'));
300
+ try {
301
+ const report = {
302
+ dropped: 0
303
+ };
304
+ // What a space can fail to own is a leaf — a layout or a view under no namespace. Sweeping
305
+ // every type instead would list the whole instance, including the loose tables a stand
306
+ // accumulates in the millions, which is the shape this command exists to avoid.
307
+ const scope = name === _parts.SWEEP ? {
308
+ type: argv.type ?? SWEEP_TYPES,
309
+ namespace: undefined
310
+ } : {
311
+ namespace: name
312
+ };
313
+ const pulled = await handlePull(from, [], {
314
+ ...argv,
315
+ ...scope,
316
+ 'include-files': true,
317
+ out: dir
318
+ }, 'quiet', false, report);
319
+ const read = pulled ? bundle.read(dir) : null;
320
+ part.entities = read ? read.entities.size : 0;
321
+ // Pushing a bundle the pull could not fill promotes less than the space holds, and recording
322
+ // it as done would hide that for good: the part stays failed so a re-run takes it again.
323
+ if (report.dropped) part.error = `${report.dropped} entities could not be read — not pushed`;else if (read && part.entities) {
324
+ const result = await (0, _pusher.push)(to, read, {
325
+ onConflict,
326
+ creds,
327
+ dryRun,
328
+ progress: (0, _serverOutput.progressReporter)(output === 'quiet' || output === 'json')
329
+ }, () => {});
330
+ part.failed = result.items.filter(r => r.action === 'failed').length;
331
+ }
332
+ } catch (err) {
333
+ part.error = err?.message ?? String(err);
334
+ } finally {
335
+ fs.rmSync(dir, {
336
+ recursive: true,
337
+ force: true
338
+ });
339
+ }
340
+ part.seconds = Math.round((Date.now() - started) / 1000);
341
+ if (!dryRun) {
342
+ state[name] = part;
343
+ (0, _parts.writeState)(stateFile, state);
344
+ }
345
+ parts.push(part);
346
+ }
347
+
348
+ // What a resume skipped belongs in the table too, or the run reports less than it has done.
349
+ const all = [...Object.values(state).filter(p => !parts.some(q => q.name === p.name)), ...parts];
350
+ (0, _serverOutput.printOutput)(all.map(p => ({
351
+ space: p.name,
352
+ entities: p.entities ?? 0,
353
+ failed: p.failed ?? 0,
354
+ seconds: p.seconds ?? 0,
355
+ error: p.error ?? ''
356
+ })), output);
357
+ console.error(`state: ${stateFile}`);
358
+ if (all.some(p => p.error || (p.failed ?? 0) > 0)) process.exitCode = 1;
359
+ return true;
360
+ }
361
+
362
+ /** What a push would do, read-only: the plan plus the top-level keys that differ. */
363
+ async function handleDiff(dapi, rest, argv, output) {
364
+ const dir = rest[0];
365
+ if (!dir) {
366
+ (0, _serverOutput.printError)(new Error('Usage: grok s diff <bundle-dir> [--host <alias>]'));
367
+ return false;
368
+ }
369
+ const read = bundle.read(dir);
370
+ const {
371
+ rows
372
+ } = await (0, _pusher.plan)(dapi, read, {
373
+ onConflict: argv['on-conflict'] ? conflictPolicy(argv) : 'skip',
374
+ idmap: {
375
+ ...read.idmap
376
+ }
377
+ });
378
+ const items = rows.map(r => ({
379
+ ...r,
380
+ detail: r.detail ?? ''
381
+ }));
382
+ (0, _serverOutput.printOutput)(output === 'json' ? (0, _pusher.summarize)(items, dapi, 'dry-run') : items, output);
383
+ return true;
384
+ }
385
+ function handleBundle(rest, output) {
386
+ if (rest[0] !== 'ls' || !rest[1]) {
387
+ (0, _serverOutput.printError)(new Error('Usage: grok s bundle ls <bundle-dir>'));
388
+ return false;
389
+ }
390
+ (0, _serverOutput.printOutput)(bundle.list(rest[1]), output);
391
+ return true;
392
+ }