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
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.UUID_RE = exports.UUID = exports.TYPE_OPTIONS = exports.TYPE_ALIASES = exports.TYPES = exports.SAME_PASSWORD = exports.PASSWORD_PLACEHOLDER = exports.DEFAULT_TYPES = void 0;
|
|
7
|
+
exports.datasyncConnectionRefs = datasyncConnectionRefs;
|
|
8
|
+
exports.datasyncFilePaths = datasyncFilePaths;
|
|
9
|
+
exports.fileNameFor = fileNameFor;
|
|
10
|
+
exports.idOnly = idOnly;
|
|
11
|
+
exports.nqNameOf = exports.isUuid = exports.isBuiltinGroup = exports.inNamespace = void 0;
|
|
12
|
+
exports.rankOf = rankOf;
|
|
13
|
+
exports.ref = ref;
|
|
14
|
+
exports.resolveType = resolveType;
|
|
15
|
+
exports.resolveTypes = resolveTypes;
|
|
16
|
+
exports.stripCredentials = stripCredentials;
|
|
17
|
+
exports.typeOptionsOf = typeOptionsOf;
|
|
18
|
+
exports.untransferableReason = untransferableReason;
|
|
19
|
+
/// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
|
|
20
|
+
|
|
21
|
+
/** Extra listing rules a `--type` alias adds on top of its entity type. */
|
|
22
|
+
|
|
23
|
+
const UUID = exports.UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
24
|
+
const UUID_RE = exports.UUID_RE = new RegExp(`^${UUID}$`, 'i');
|
|
25
|
+
const isUuid = token => UUID_RE.test(token);
|
|
26
|
+
exports.isUuid = isUuid;
|
|
27
|
+
const nqNameOf = json => `${json?.namespace ?? ''}${json?.name ?? json?.id ?? ''}`;
|
|
28
|
+
|
|
29
|
+
/** `/entities` records of some types (DataJob) carry no namespace; the server-side filter applied it. */
|
|
30
|
+
exports.nqNameOf = nqNameOf;
|
|
31
|
+
const inNamespace = (json, namespace) => json?.namespace === undefined || json.namespace === null || json.namespace === namespace;
|
|
32
|
+
exports.inNamespace = inNamespace;
|
|
33
|
+
function ref(type, id) {
|
|
34
|
+
return {
|
|
35
|
+
type,
|
|
36
|
+
id
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function idOnly(v) {
|
|
40
|
+
return v?.id ? {
|
|
41
|
+
id: v.id
|
|
42
|
+
} : v;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Credentials.PASSWORD_PLACEHOLDER / SAME_PASSWORD (grok_shared/lib/src/credentials.dart)
|
|
46
|
+
const PASSWORD_PLACEHOLDER = exports.PASSWORD_PLACEHOLDER = '_____________';
|
|
47
|
+
const SAME_PASSWORD = exports.SAME_PASSWORD = 'not—changed';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Seeded by `core/server/db/create_admin.sql` under these ids on every instance, so they are the
|
|
51
|
+
* target's own rows, not content. Matched by id, not name: `Developers` is seeded nowhere and is
|
|
52
|
+
* an ordinary group a customer may own, which has to travel like any other.
|
|
53
|
+
*/
|
|
54
|
+
const BUILTIN_GROUP_IDS = new Set(['a4b45840-9a50-11e6-9cc9-8546b8bf62e6', '1ab8b38d-9c4e-4b1e-81c3-ae2bde3e12c5']);
|
|
55
|
+
const isBuiltinGroup = g => BUILTIN_GROUP_IDS.has(String(g?.id ?? ''));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Never travels, whichever side asks: the platform's own connections, a space's `Files`
|
|
59
|
+
* connection, and the personal `Home` share belong to the instance, not to the content.
|
|
60
|
+
*/
|
|
61
|
+
exports.isBuiltinGroup = isBuiltinGroup;
|
|
62
|
+
function untransferableReason(type, json) {
|
|
63
|
+
// A project the platform keeps for something else — the namespace an installed package occupies,
|
|
64
|
+
// or the wrapper it maintains around a saved entity. The target builds its own, and a pushed one
|
|
65
|
+
// is accepted and then simply not there (113 of them on a real 1.27 → 1.27 push). Its own space
|
|
66
|
+
// listing draws the same line: `isDashboard = false and isEntity = false`.
|
|
67
|
+
if (type === 'Project' && (json?.isEntity === true || json?.isPackage === true)) return {
|
|
68
|
+
action: 'info',
|
|
69
|
+
reason: 'platform_project'
|
|
70
|
+
};
|
|
71
|
+
if (type === 'UserGroup') return isBuiltinGroup(json) ? {
|
|
72
|
+
action: 'info',
|
|
73
|
+
reason: 'platform_group'
|
|
74
|
+
} : null;
|
|
75
|
+
if (type !== 'DataConnection') return null;
|
|
76
|
+
if (json?.namespace === 'System:') return {
|
|
77
|
+
action: 'info',
|
|
78
|
+
reason: 'platform_connection'
|
|
79
|
+
};
|
|
80
|
+
if (json?.parameters?.isProject === true) return {
|
|
81
|
+
action: 'info',
|
|
82
|
+
reason: 'space_files_connection'
|
|
83
|
+
};
|
|
84
|
+
if (json?.name === 'Home') return {
|
|
85
|
+
action: 'warn',
|
|
86
|
+
reason: 'personal_storage'
|
|
87
|
+
};
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
function stripCredentials(c) {
|
|
91
|
+
delete c.credentials;
|
|
92
|
+
const params = c.parameters;
|
|
93
|
+
if (!params) return;
|
|
94
|
+
const masked = Object.keys(params).filter(k => params[k] === PASSWORD_PLACEHOLDER || params[k] === SAME_PASSWORD);
|
|
95
|
+
for (const k of masked) delete params[k];
|
|
96
|
+
if (masked.length) c._credentials = masked;
|
|
97
|
+
}
|
|
98
|
+
const FILE_CALL_RE = /Open(?:ServerFile|File|Folder)[A-Za-z]*\s*\(\s*["']([^"']+)["']/g;
|
|
99
|
+
const NS_CALL_RE = /\b([A-Za-z]\w*(?::[A-Za-z]\w*)+)\s*\(/g;
|
|
100
|
+
|
|
101
|
+
/** What a datasync script needs to re-run: the shares it opens, and the entities it calls. */
|
|
102
|
+
/** The share paths a datasync table reads, e.g. `User:Home/Test projects/compounds.csv`. */
|
|
103
|
+
function datasyncFilePaths(t) {
|
|
104
|
+
if (t?.metaParams?.['.data-sync'] !== 'sync') return [];
|
|
105
|
+
const script = t.metaParams?.['.script'] ?? '';
|
|
106
|
+
return [...script.matchAll(FILE_CALL_RE)].map(m => m[1]).filter(p => p.includes('/'));
|
|
107
|
+
}
|
|
108
|
+
function datasyncConnectionRefs(t) {
|
|
109
|
+
if (t.metaParams?.['.data-sync'] !== 'sync') return [];
|
|
110
|
+
const script = t.metaParams?.['.script'] ?? '';
|
|
111
|
+
const refs = [];
|
|
112
|
+
for (const m of script.matchAll(FILE_CALL_RE)) {
|
|
113
|
+
const nqName = m[1].split('/')[0];
|
|
114
|
+
if (nqName) refs.push({
|
|
115
|
+
type: 'DataConnection',
|
|
116
|
+
nqName
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// A query- or script-backed sync names its source as `Namespace:Name(...)`. Which type
|
|
120
|
+
// answers to that nqName is not knowable from the script, so it is resolved, not assumed.
|
|
121
|
+
for (const m of script.matchAll(NS_CALL_RE)) refs.push({
|
|
122
|
+
nqName: m[1]
|
|
123
|
+
});
|
|
124
|
+
return refs;
|
|
125
|
+
}
|
|
126
|
+
function compactRelations(p) {
|
|
127
|
+
if (!Array.isArray(p.relations)) return;
|
|
128
|
+
p.relations = p.relations
|
|
129
|
+
// The space's own Files connection is stamped in by whichever server holds the space,
|
|
130
|
+
// so its relation belongs to that server, not to the content.
|
|
131
|
+
.filter(r => r?.entity?.id && r.entity.parameters?.isProject !== true).map(r => ({
|
|
132
|
+
id: r.id,
|
|
133
|
+
entity: {
|
|
134
|
+
'#type': 'EntityRecord',
|
|
135
|
+
id: r.entity.id
|
|
136
|
+
},
|
|
137
|
+
isLink: r.isLink ?? false
|
|
138
|
+
})).sort((a, b) => a.entity.id.localeCompare(b.entity.id));
|
|
139
|
+
}
|
|
140
|
+
const TYPES = exports.TYPES = {
|
|
141
|
+
// POST is routed as `/groups/` (`routers/groups.dart:30`); `/groups` is a 404.
|
|
142
|
+
UserGroup: {
|
|
143
|
+
route: '/groups',
|
|
144
|
+
saveRoute: '/groups/',
|
|
145
|
+
rank: 0,
|
|
146
|
+
strip: g => {
|
|
147
|
+
g.parents = [];
|
|
148
|
+
g.children = [];
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
DataConnection: {
|
|
152
|
+
route: '/connectors/connections',
|
|
153
|
+
rank: 1,
|
|
154
|
+
tags: true,
|
|
155
|
+
strip: stripCredentials
|
|
156
|
+
},
|
|
157
|
+
Project: {
|
|
158
|
+
route: '/projects',
|
|
159
|
+
rank: 2,
|
|
160
|
+
tags: true,
|
|
161
|
+
strip: p => {
|
|
162
|
+
delete p.storage;
|
|
163
|
+
compactRelations(p);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
DataQuery: {
|
|
167
|
+
route: '/connectors/queries',
|
|
168
|
+
rank: 3,
|
|
169
|
+
tags: true,
|
|
170
|
+
strip: q => {
|
|
171
|
+
q.connection = idOnly(q.connection);
|
|
172
|
+
},
|
|
173
|
+
deps: q => [ref('DataConnection', q.connection?.id)]
|
|
174
|
+
},
|
|
175
|
+
Script: {
|
|
176
|
+
route: '/scripts',
|
|
177
|
+
rank: 3,
|
|
178
|
+
tags: true
|
|
179
|
+
},
|
|
180
|
+
// A visual query has no listing of its own and is only visible to an admin session, which is
|
|
181
|
+
// why it never showed up before `--admin`.
|
|
182
|
+
TableQuery: {
|
|
183
|
+
route: '/connectors/table_queries',
|
|
184
|
+
rank: 3,
|
|
185
|
+
listVia: 'entities',
|
|
186
|
+
typeId: '34d867a0-e870-11e6-af38-653465436553',
|
|
187
|
+
strip: q => {
|
|
188
|
+
q.connection = idOnly(q.connection);
|
|
189
|
+
},
|
|
190
|
+
deps: q => [ref('DataConnection', q.connection?.id)]
|
|
191
|
+
},
|
|
192
|
+
TableInfo: {
|
|
193
|
+
route: '/tables',
|
|
194
|
+
rank: 4,
|
|
195
|
+
tags: true,
|
|
196
|
+
bytes: {
|
|
197
|
+
kind: 'tables',
|
|
198
|
+
get: id => `/tables/${id}/data`,
|
|
199
|
+
put: id => `/tables/data?id=${id}`
|
|
200
|
+
},
|
|
201
|
+
strip: t => {
|
|
202
|
+
delete t.fileInfo;
|
|
203
|
+
delete t.columns;
|
|
204
|
+
},
|
|
205
|
+
deps: datasyncConnectionRefs
|
|
206
|
+
},
|
|
207
|
+
ViewInfo: {
|
|
208
|
+
route: '/views',
|
|
209
|
+
rank: 5,
|
|
210
|
+
strip: v => {
|
|
211
|
+
v.table = idOnly(v.table);
|
|
212
|
+
},
|
|
213
|
+
deps: v => [ref('TableInfo', v.table?.id)]
|
|
214
|
+
},
|
|
215
|
+
ViewLayout: {
|
|
216
|
+
route: '/layouts',
|
|
217
|
+
rank: 5
|
|
218
|
+
},
|
|
219
|
+
FileInfo: {
|
|
220
|
+
route: '/files',
|
|
221
|
+
rank: 6,
|
|
222
|
+
listVia: 'entities',
|
|
223
|
+
typeId: '34d75630-e870-11e6-bfe1-590ff6f10d14',
|
|
224
|
+
// `POST /files` dedups by (connection, dir, name) and answers with the existing row's
|
|
225
|
+
// id (`files_service.dart:22-32`), so the bytes can only be written once it is known.
|
|
226
|
+
bytes: {
|
|
227
|
+
kind: 'files',
|
|
228
|
+
afterSave: true,
|
|
229
|
+
get: id => `/files/data/${id}`,
|
|
230
|
+
put: id => `/files/data/${id}`
|
|
231
|
+
},
|
|
232
|
+
strip: f => {
|
|
233
|
+
f.connection = idOnly(f.connection);
|
|
234
|
+
delete f.tables;
|
|
235
|
+
},
|
|
236
|
+
deps: f => [ref('DataConnection', f.connection?.id)]
|
|
237
|
+
},
|
|
238
|
+
DataJob: {
|
|
239
|
+
route: '/connectors/jobs',
|
|
240
|
+
rank: 7,
|
|
241
|
+
tags: true
|
|
242
|
+
},
|
|
243
|
+
Notebook: {
|
|
244
|
+
route: '/notebooks',
|
|
245
|
+
rank: 7,
|
|
246
|
+
tags: true,
|
|
247
|
+
// `environment` is a view of the ipynb kernelspec, and pushing it back overwrites the
|
|
248
|
+
// kernel's display name with its id (`notebook.dart` `set environment`).
|
|
249
|
+
strip: n => {
|
|
250
|
+
n.tables = (n.tables ?? []).map(idOnly);
|
|
251
|
+
delete n.environment;
|
|
252
|
+
},
|
|
253
|
+
deps: n => (n.tables ?? []).map(t => ref('TableInfo', t?.id))
|
|
254
|
+
},
|
|
255
|
+
PredictiveModelInfo: {
|
|
256
|
+
route: '/ml',
|
|
257
|
+
rank: 7,
|
|
258
|
+
tags: true,
|
|
259
|
+
strip: m => {
|
|
260
|
+
m.trainedOn = idOnly(m.trainedOn);
|
|
261
|
+
},
|
|
262
|
+
deps: m => [ref('TableInfo', m.trainedOn?.id)]
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** A root space is skipped by the default `/projects` listing — it has no namespace of its own. */
|
|
267
|
+
const TYPE_OPTIONS = exports.TYPE_OPTIONS = {
|
|
268
|
+
project: {
|
|
269
|
+
params: {
|
|
270
|
+
includeRoot: 'true'
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
dashboard: {
|
|
274
|
+
clause: 'isDashboard = true'
|
|
275
|
+
},
|
|
276
|
+
space: {
|
|
277
|
+
clause: 'isDashboard = false and isEntity = false',
|
|
278
|
+
params: {
|
|
279
|
+
includeRoot: 'true'
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const TYPE_ALIASES = exports.TYPE_ALIASES = {
|
|
284
|
+
conn: 'DataConnection',
|
|
285
|
+
connection: 'DataConnection',
|
|
286
|
+
query: 'DataQuery',
|
|
287
|
+
script: 'Script',
|
|
288
|
+
project: 'Project',
|
|
289
|
+
dashboard: 'Project',
|
|
290
|
+
space: 'Project',
|
|
291
|
+
view: 'ViewInfo',
|
|
292
|
+
layout: 'ViewLayout',
|
|
293
|
+
table: 'TableInfo',
|
|
294
|
+
file: 'FileInfo',
|
|
295
|
+
group: 'UserGroup',
|
|
296
|
+
job: 'DataJob',
|
|
297
|
+
notebook: 'Notebook',
|
|
298
|
+
model: 'PredictiveModelInfo'
|
|
299
|
+
};
|
|
300
|
+
const DEFAULT_TYPES = exports.DEFAULT_TYPES = Object.keys(TYPES);
|
|
301
|
+
function typeOptionsOf(alias) {
|
|
302
|
+
return TYPE_OPTIONS[alias.trim().toLowerCase()];
|
|
303
|
+
}
|
|
304
|
+
function resolveType(alias) {
|
|
305
|
+
const name = TYPE_ALIASES[alias.trim().toLowerCase()] ?? alias.trim();
|
|
306
|
+
if (!TYPES[name]) throw new Error(`Entity type '${alias}' is not supported. Supported: ${Object.keys(TYPES).join(', ')}`);
|
|
307
|
+
return name;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** One entity type can be asked for under several aliases, but only under one listing rule. */
|
|
311
|
+
function resolveTypes(aliases) {
|
|
312
|
+
const types = [];
|
|
313
|
+
const typeOptions = {};
|
|
314
|
+
const aliasOf = {};
|
|
315
|
+
for (const alias of aliases) {
|
|
316
|
+
const type = resolveType(alias);
|
|
317
|
+
const options = typeOptionsOf(alias);
|
|
318
|
+
if (types.includes(type)) {
|
|
319
|
+
if (JSON.stringify(typeOptions[type] ?? null) !== JSON.stringify(options ?? null)) throw new Error(`--type ${aliasOf[type]} and ${alias.trim()} both select ${type} but list it differently — pass only one`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
types.push(type);
|
|
323
|
+
aliasOf[type] = alias.trim();
|
|
324
|
+
if (options) typeOptions[type] = options;
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
types,
|
|
328
|
+
typeOptions
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function rankOf(type) {
|
|
332
|
+
return TYPES[type]?.rank ?? 99;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* `Chem:Chembl` → `Chem.Chembl`; FileInfo `Admin:Data/a/b.csv` → `Admin.Data.a.b.csv`.
|
|
337
|
+
* A namespace-less entity is named by its full path, so two files with the same basename
|
|
338
|
+
* in different directories do not claim the same bundle file.
|
|
339
|
+
*/
|
|
340
|
+
function fileNameFor(json) {
|
|
341
|
+
const name = json?.name ?? '';
|
|
342
|
+
if (!name) return json?.id ?? 'unnamed';
|
|
343
|
+
const nqName = json.namespace ? `${json.namespace}${name}` : json.path ?? name;
|
|
344
|
+
const cleaned = String(nqName).replace(/[:/\\?*"<>|]+/g, '.').replace(/^\.+|\.+$/g, '');
|
|
345
|
+
// Windows resolves `NUL.json` (and `COM1.csv.json`) to a device, whatever the extension.
|
|
346
|
+
const head = cleaned.split('.')[0];
|
|
347
|
+
return RESERVED_RE.test(head) ? `${head}-${json.id ?? 'entity'}${cleaned.slice(head.length)}` : cleaned;
|
|
348
|
+
}
|
|
349
|
+
const RESERVED_RE = /^(NUL|CON|PRN|AUX|COM[1-9]|LPT[1-9])$/i;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
|
|
2
|
+
|
|
3
|
+
export type BytesKind = 'tables' | 'files';
|
|
4
|
+
|
|
5
|
+
export interface Ref {type?: string; id?: string; nqName?: string}
|
|
6
|
+
|
|
7
|
+
export interface TypeSpec {
|
|
8
|
+
route: string;
|
|
9
|
+
saveRoute?: string;
|
|
10
|
+
rank: number;
|
|
11
|
+
tags?: boolean;
|
|
12
|
+
listVia?: 'entities';
|
|
13
|
+
typeId?: string;
|
|
14
|
+
/** `afterSave` when the save may return another id and the bytes belong to that one. */
|
|
15
|
+
bytes?: {kind: BytesKind; afterSave?: boolean; get(id: string): string; put(id: string): string};
|
|
16
|
+
strip?(json: any): void;
|
|
17
|
+
deps?(json: any): Ref[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Extra listing rules a `--type` alias adds on top of its entity type. */
|
|
21
|
+
export interface TypeOptions {clause?: string; params?: Record<string, string>}
|
|
22
|
+
|
|
23
|
+
export const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
24
|
+
export const UUID_RE = new RegExp(`^${UUID}$`, 'i');
|
|
25
|
+
|
|
26
|
+
export const isUuid = (token: string): boolean => UUID_RE.test(token);
|
|
27
|
+
|
|
28
|
+
export const nqNameOf = (json: any): string => `${json?.namespace ?? ''}${json?.name ?? json?.id ?? ''}`;
|
|
29
|
+
|
|
30
|
+
/** `/entities` records of some types (DataJob) carry no namespace; the server-side filter applied it. */
|
|
31
|
+
export const inNamespace = (json: any, namespace: string): boolean =>
|
|
32
|
+
json?.namespace === undefined || json.namespace === null || json.namespace === namespace;
|
|
33
|
+
|
|
34
|
+
export function ref(type: string, id?: string): Ref {
|
|
35
|
+
return {type, id};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function idOnly(v: any): any {
|
|
39
|
+
return v?.id ? {id: v.id} : v;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Credentials.PASSWORD_PLACEHOLDER / SAME_PASSWORD (grok_shared/lib/src/credentials.dart)
|
|
43
|
+
export const PASSWORD_PLACEHOLDER = '_____________';
|
|
44
|
+
export const SAME_PASSWORD = 'not—changed';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Seeded by `core/server/db/create_admin.sql` under these ids on every instance, so they are the
|
|
48
|
+
* target's own rows, not content. Matched by id, not name: `Developers` is seeded nowhere and is
|
|
49
|
+
* an ordinary group a customer may own, which has to travel like any other.
|
|
50
|
+
*/
|
|
51
|
+
const BUILTIN_GROUP_IDS = new Set([
|
|
52
|
+
'a4b45840-9a50-11e6-9cc9-8546b8bf62e6',
|
|
53
|
+
'1ab8b38d-9c4e-4b1e-81c3-ae2bde3e12c5',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
export const isBuiltinGroup = (g: any): boolean => BUILTIN_GROUP_IDS.has(String(g?.id ?? ''));
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Never travels, whichever side asks: the platform's own connections, a space's `Files`
|
|
60
|
+
* connection, and the personal `Home` share belong to the instance, not to the content.
|
|
61
|
+
*/
|
|
62
|
+
export function untransferableReason(type: string, json: any): {action: 'warn' | 'info'; reason: string} | null {
|
|
63
|
+
// A project the platform keeps for something else — the namespace an installed package occupies,
|
|
64
|
+
// or the wrapper it maintains around a saved entity. The target builds its own, and a pushed one
|
|
65
|
+
// is accepted and then simply not there (113 of them on a real 1.27 → 1.27 push). Its own space
|
|
66
|
+
// listing draws the same line: `isDashboard = false and isEntity = false`.
|
|
67
|
+
if (type === 'Project' && (json?.isEntity === true || json?.isPackage === true))
|
|
68
|
+
return {action: 'info', reason: 'platform_project'};
|
|
69
|
+
if (type === 'UserGroup')
|
|
70
|
+
return isBuiltinGroup(json) ? {action: 'info', reason: 'platform_group'} : null;
|
|
71
|
+
if (type !== 'DataConnection') return null;
|
|
72
|
+
if (json?.namespace === 'System:') return {action: 'info', reason: 'platform_connection'};
|
|
73
|
+
if (json?.parameters?.isProject === true) return {action: 'info', reason: 'space_files_connection'};
|
|
74
|
+
if (json?.name === 'Home') return {action: 'warn', reason: 'personal_storage'};
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function stripCredentials(c: any): void {
|
|
79
|
+
delete c.credentials;
|
|
80
|
+
const params = c.parameters;
|
|
81
|
+
if (!params) return;
|
|
82
|
+
const masked = Object.keys(params).filter((k) => params[k] === PASSWORD_PLACEHOLDER || params[k] === SAME_PASSWORD);
|
|
83
|
+
for (const k of masked)
|
|
84
|
+
delete params[k];
|
|
85
|
+
if (masked.length)
|
|
86
|
+
c._credentials = masked;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const FILE_CALL_RE = /Open(?:ServerFile|File|Folder)[A-Za-z]*\s*\(\s*["']([^"']+)["']/g;
|
|
90
|
+
const NS_CALL_RE = /\b([A-Za-z]\w*(?::[A-Za-z]\w*)+)\s*\(/g;
|
|
91
|
+
|
|
92
|
+
/** What a datasync script needs to re-run: the shares it opens, and the entities it calls. */
|
|
93
|
+
/** The share paths a datasync table reads, e.g. `User:Home/Test projects/compounds.csv`. */
|
|
94
|
+
export function datasyncFilePaths(t: any): string[] {
|
|
95
|
+
if (t?.metaParams?.['.data-sync'] !== 'sync') return [];
|
|
96
|
+
const script: string = t.metaParams?.['.script'] ?? '';
|
|
97
|
+
return [...script.matchAll(FILE_CALL_RE)].map((m) => m[1]).filter((p) => p.includes('/'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function datasyncConnectionRefs(t: any): Ref[] {
|
|
101
|
+
if (t.metaParams?.['.data-sync'] !== 'sync') return [];
|
|
102
|
+
const script: string = t.metaParams?.['.script'] ?? '';
|
|
103
|
+
const refs: Ref[] = [];
|
|
104
|
+
for (const m of script.matchAll(FILE_CALL_RE)) {
|
|
105
|
+
const nqName = m[1].split('/')[0];
|
|
106
|
+
if (nqName)
|
|
107
|
+
refs.push({type: 'DataConnection', nqName});
|
|
108
|
+
}
|
|
109
|
+
// A query- or script-backed sync names its source as `Namespace:Name(...)`. Which type
|
|
110
|
+
// answers to that nqName is not knowable from the script, so it is resolved, not assumed.
|
|
111
|
+
for (const m of script.matchAll(NS_CALL_RE))
|
|
112
|
+
refs.push({nqName: m[1]});
|
|
113
|
+
return refs;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function compactRelations(p: any): void {
|
|
117
|
+
if (!Array.isArray(p.relations)) return;
|
|
118
|
+
p.relations = p.relations
|
|
119
|
+
// The space's own Files connection is stamped in by whichever server holds the space,
|
|
120
|
+
// so its relation belongs to that server, not to the content.
|
|
121
|
+
.filter((r: any) => r?.entity?.id && r.entity.parameters?.isProject !== true)
|
|
122
|
+
.map((r: any) => ({id: r.id, entity: {'#type': 'EntityRecord', id: r.entity.id}, isLink: r.isLink ?? false}))
|
|
123
|
+
.sort((a: any, b: any) => a.entity.id.localeCompare(b.entity.id));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const TYPES: Record<string, TypeSpec> = {
|
|
127
|
+
// POST is routed as `/groups/` (`routers/groups.dart:30`); `/groups` is a 404.
|
|
128
|
+
UserGroup: {route: '/groups', saveRoute: '/groups/', rank: 0, strip: (g) => { g.parents = []; g.children = []; }},
|
|
129
|
+
DataConnection: {route: '/connectors/connections', rank: 1, tags: true, strip: stripCredentials},
|
|
130
|
+
Project: {route: '/projects', rank: 2, tags: true, strip: (p) => { delete p.storage; compactRelations(p); }},
|
|
131
|
+
DataQuery: {
|
|
132
|
+
route: '/connectors/queries', rank: 3, tags: true,
|
|
133
|
+
strip: (q) => { q.connection = idOnly(q.connection); },
|
|
134
|
+
deps: (q) => [ref('DataConnection', q.connection?.id)],
|
|
135
|
+
},
|
|
136
|
+
Script: {route: '/scripts', rank: 3, tags: true},
|
|
137
|
+
// A visual query has no listing of its own and is only visible to an admin session, which is
|
|
138
|
+
// why it never showed up before `--admin`.
|
|
139
|
+
TableQuery: {
|
|
140
|
+
route: '/connectors/table_queries', rank: 3,
|
|
141
|
+
listVia: 'entities', typeId: '34d867a0-e870-11e6-af38-653465436553',
|
|
142
|
+
strip: (q) => { q.connection = idOnly(q.connection); },
|
|
143
|
+
deps: (q) => [ref('DataConnection', q.connection?.id)],
|
|
144
|
+
},
|
|
145
|
+
TableInfo: {
|
|
146
|
+
route: '/tables', rank: 4, tags: true,
|
|
147
|
+
bytes: {kind: 'tables', get: (id) => `/tables/${id}/data`, put: (id) => `/tables/data?id=${id}`},
|
|
148
|
+
strip: (t) => { delete t.fileInfo; delete t.columns; },
|
|
149
|
+
deps: datasyncConnectionRefs,
|
|
150
|
+
},
|
|
151
|
+
ViewInfo: {
|
|
152
|
+
route: '/views', rank: 5,
|
|
153
|
+
strip: (v) => { v.table = idOnly(v.table); },
|
|
154
|
+
deps: (v) => [ref('TableInfo', v.table?.id)],
|
|
155
|
+
},
|
|
156
|
+
ViewLayout: {route: '/layouts', rank: 5},
|
|
157
|
+
FileInfo: {
|
|
158
|
+
route: '/files', rank: 6, listVia: 'entities', typeId: '34d75630-e870-11e6-bfe1-590ff6f10d14',
|
|
159
|
+
// `POST /files` dedups by (connection, dir, name) and answers with the existing row's
|
|
160
|
+
// id (`files_service.dart:22-32`), so the bytes can only be written once it is known.
|
|
161
|
+
bytes: {kind: 'files', afterSave: true, get: (id) => `/files/data/${id}`, put: (id) => `/files/data/${id}`},
|
|
162
|
+
strip: (f) => { f.connection = idOnly(f.connection); delete f.tables; },
|
|
163
|
+
deps: (f) => [ref('DataConnection', f.connection?.id)],
|
|
164
|
+
},
|
|
165
|
+
DataJob: {route: '/connectors/jobs', rank: 7, tags: true},
|
|
166
|
+
Notebook: {
|
|
167
|
+
route: '/notebooks', rank: 7, tags: true,
|
|
168
|
+
// `environment` is a view of the ipynb kernelspec, and pushing it back overwrites the
|
|
169
|
+
// kernel's display name with its id (`notebook.dart` `set environment`).
|
|
170
|
+
strip: (n) => { n.tables = (n.tables ?? []).map(idOnly); delete n.environment; },
|
|
171
|
+
deps: (n) => (n.tables ?? []).map((t: any) => ref('TableInfo', t?.id)),
|
|
172
|
+
},
|
|
173
|
+
PredictiveModelInfo: {
|
|
174
|
+
route: '/ml', rank: 7, tags: true,
|
|
175
|
+
strip: (m) => { m.trainedOn = idOnly(m.trainedOn); },
|
|
176
|
+
deps: (m) => [ref('TableInfo', m.trainedOn?.id)],
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** A root space is skipped by the default `/projects` listing — it has no namespace of its own. */
|
|
181
|
+
export const TYPE_OPTIONS: Record<string, TypeOptions> = {
|
|
182
|
+
project: {params: {includeRoot: 'true'}},
|
|
183
|
+
dashboard: {clause: 'isDashboard = true'},
|
|
184
|
+
space: {clause: 'isDashboard = false and isEntity = false', params: {includeRoot: 'true'}},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const TYPE_ALIASES: Record<string, string> = {
|
|
188
|
+
conn: 'DataConnection', connection: 'DataConnection',
|
|
189
|
+
query: 'DataQuery',
|
|
190
|
+
script: 'Script',
|
|
191
|
+
project: 'Project', dashboard: 'Project', space: 'Project',
|
|
192
|
+
view: 'ViewInfo',
|
|
193
|
+
layout: 'ViewLayout',
|
|
194
|
+
table: 'TableInfo',
|
|
195
|
+
file: 'FileInfo',
|
|
196
|
+
group: 'UserGroup',
|
|
197
|
+
job: 'DataJob',
|
|
198
|
+
notebook: 'Notebook',
|
|
199
|
+
model: 'PredictiveModelInfo',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const DEFAULT_TYPES: string[] = Object.keys(TYPES);
|
|
203
|
+
|
|
204
|
+
export function typeOptionsOf(alias: string): TypeOptions | undefined {
|
|
205
|
+
return TYPE_OPTIONS[alias.trim().toLowerCase()];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function resolveType(alias: string): string {
|
|
209
|
+
const name = TYPE_ALIASES[alias.trim().toLowerCase()] ?? alias.trim();
|
|
210
|
+
if (!TYPES[name])
|
|
211
|
+
throw new Error(`Entity type '${alias}' is not supported. Supported: ${Object.keys(TYPES).join(', ')}`);
|
|
212
|
+
return name;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One entity type can be asked for under several aliases, but only under one listing rule. */
|
|
216
|
+
export function resolveTypes(aliases: string[]): {types: string[]; typeOptions: Record<string, TypeOptions>} {
|
|
217
|
+
const types: string[] = [];
|
|
218
|
+
const typeOptions: Record<string, TypeOptions> = {};
|
|
219
|
+
const aliasOf: Record<string, string> = {};
|
|
220
|
+
for (const alias of aliases) {
|
|
221
|
+
const type = resolveType(alias);
|
|
222
|
+
const options = typeOptionsOf(alias);
|
|
223
|
+
if (types.includes(type)) {
|
|
224
|
+
if (JSON.stringify(typeOptions[type] ?? null) !== JSON.stringify(options ?? null))
|
|
225
|
+
throw new Error(`--type ${aliasOf[type]} and ${alias.trim()} both select ${type} but list it differently — pass only one`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
types.push(type);
|
|
229
|
+
aliasOf[type] = alias.trim();
|
|
230
|
+
if (options)
|
|
231
|
+
typeOptions[type] = options;
|
|
232
|
+
}
|
|
233
|
+
return {types, typeOptions};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function rankOf(type: string): number {
|
|
237
|
+
return TYPES[type]?.rank ?? 99;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `Chem:Chembl` → `Chem.Chembl`; FileInfo `Admin:Data/a/b.csv` → `Admin.Data.a.b.csv`.
|
|
242
|
+
* A namespace-less entity is named by its full path, so two files with the same basename
|
|
243
|
+
* in different directories do not claim the same bundle file.
|
|
244
|
+
*/
|
|
245
|
+
export function fileNameFor(json: any): string {
|
|
246
|
+
const name: string = json?.name ?? '';
|
|
247
|
+
if (!name) return json?.id ?? 'unnamed';
|
|
248
|
+
const nqName = json.namespace ? `${json.namespace}${name}` : (json.path ?? name);
|
|
249
|
+
const cleaned = String(nqName).replace(/[:/\\?*"<>|]+/g, '.').replace(/^\.+|\.+$/g, '');
|
|
250
|
+
// Windows resolves `NUL.json` (and `COM1.csv.json`) to a device, whatever the extension.
|
|
251
|
+
const head = cleaned.split('.')[0];
|
|
252
|
+
return RESERVED_RE.test(head) ? `${head}-${json.id ?? 'entity'}${cleaned.slice(head.length)}` : cleaned;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const RESERVED_RE = /^(NUL|CON|PRN|AUX|COM[1-9]|LPT[1-9])$/i;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.nestedIds = nestedIds;
|
|
7
|
+
exports.rewrite = rewrite;
|
|
8
|
+
var _registry = require("./registry");
|
|
9
|
+
/// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
|
|
10
|
+
|
|
11
|
+
const TYPED_RE = new RegExp(`^(.+):(${_registry.UUID})$`, 'i');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Depth-first copy with every mapped UUID replaced, in values and in keys, bare or as
|
|
15
|
+
* `<Type>:<uuid>`. UUIDs with no mapping are left in place and collected into `orphans`.
|
|
16
|
+
*/
|
|
17
|
+
function rewrite(json, idmap, orphans) {
|
|
18
|
+
return walk(json, idmap, orphans);
|
|
19
|
+
}
|
|
20
|
+
function walk(v, idmap, orphans) {
|
|
21
|
+
if (typeof v === 'string') return mapString(v, idmap, orphans);
|
|
22
|
+
if (Array.isArray(v)) return v.map(x => walk(x, idmap, orphans));
|
|
23
|
+
if (v === null || typeof v !== 'object') return v;
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const k of Object.keys(v)) out[mapString(k, idmap, orphans)] = walk(v[k], idmap, orphans);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function mapString(s, idmap, orphans) {
|
|
29
|
+
if (_registry.UUID_RE.test(s)) return mapId(s, idmap, orphans);
|
|
30
|
+
const typed = TYPED_RE.exec(s);
|
|
31
|
+
return typed ? `${typed[1]}:${mapId(typed[2], idmap, orphans)}` : s;
|
|
32
|
+
}
|
|
33
|
+
function mapId(id, idmap, orphans) {
|
|
34
|
+
if (idmap[id]) return idmap[id];
|
|
35
|
+
orphans?.add(id);
|
|
36
|
+
return id;
|
|
37
|
+
}
|
|
38
|
+
const isRef = o => Object.keys(o).every(k => k === 'id' || k === '#type');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ids of nested rows that own a primary key (`relations[]`, `entityTags[]`, query `params[]`,
|
|
42
|
+
* view/layout child rows) — reusing them on the same server re-points the source's rows at
|
|
43
|
+
* the copy, so a re-id or an adoption has to mint fresh ones. `{id}` / `{'#type', id}`
|
|
44
|
+
* references are not rows and keep pointing at whatever the idmap says.
|
|
45
|
+
*/
|
|
46
|
+
function nestedIds(json) {
|
|
47
|
+
const ids = [];
|
|
48
|
+
const collect = (v, top) => {
|
|
49
|
+
if (Array.isArray(v)) {
|
|
50
|
+
for (const x of v) collect(x, false);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (v === null || typeof v !== 'object') return;
|
|
54
|
+
if (!top && typeof v.id === 'string' && _registry.UUID_RE.test(v.id) && !isRef(v)) ids.push(v.id);
|
|
55
|
+
for (const k of Object.keys(v)) collect(v[k], false);
|
|
56
|
+
};
|
|
57
|
+
collect(json, true);
|
|
58
|
+
return ids;
|
|
59
|
+
}
|