@lightdash/cli 0.2645.0 → 0.2646.0

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.
@@ -14,8 +14,13 @@ export type DownloadHandlerOptions = {
14
14
  concurrency: number;
15
15
  gzip?: boolean;
16
16
  };
17
+ type MetadataEntry = {
18
+ slug: string;
19
+ type: 'charts' | 'dashboards';
20
+ downloadedAt: string;
21
+ };
17
22
  type DownloadContentType = 'charts' | 'dashboards' | 'sqlCharts';
18
- export declare const downloadContent: (ids: string[], type: DownloadContentType, projectId: string, projectName: string, customPath?: string, languageMap?: boolean, nested?: boolean) => Promise<[number, string[]]>;
23
+ export declare const downloadContent: (ids: string[], type: DownloadContentType, projectId: string, projectName: string, customPath?: string, languageMap?: boolean, nested?: boolean) => Promise<[number, string[], MetadataEntry[]]>;
19
24
  export declare const downloadHandler: (options: DownloadHandlerOptions) => Promise<void>;
20
25
  export declare const uploadHandler: (options: DownloadHandlerOptions) => Promise<void>;
21
26
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"download.d.ts","sourceRoot":"","sources":["../../src/handlers/download.ts"],"names":[],"mappings":"AAqCA,MAAM,MAAM,sBAAsB,GAAG;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,OAAO,CAAC;IACrB,eAAe,EAAE,OAAO,CAAC;IACzB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAmQF,KAAK,mBAAmB,GAAG,QAAQ,GAAG,YAAY,GAAG,WAAW,CAAC;AAkDjE,eAAO,MAAM,eAAe,GACxB,KAAK,MAAM,EAAE,EACb,MAAM,mBAAmB,EACzB,WAAW,MAAM,EACjB,aAAa,MAAM,EACnB,aAAa,MAAM,EACnB,cAAa,OAAe,EAC5B,SAAQ,OAAe,KACxB,OAAO,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CA2G5B,CAAC;AAEF,eAAO,MAAM,eAAe,GACxB,SAAS,sBAAsB,KAChC,OAAO,CAAC,IAAI,CA0Kd,CAAC;AA4ZF,eAAO,MAAM,aAAa,GACtB,SAAS,sBAAsB,KAChC,OAAO,CAAC,IAAI,CAkJd,CAAC"}
1
+ {"version":3,"file":"download.d.ts","sourceRoot":"","sources":["../../src/handlers/download.ts"],"names":[],"mappings":"AA2CA,MAAM,MAAM,sBAAsB,GAAG;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,OAAO,CAAC;IACrB,eAAe,EAAE,OAAO,CAAC;IACzB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AA2FF,KAAK,aAAa,GAAG;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,QAAQ,GAAG,YAAY,CAAC;IAC9B,YAAY,EAAE,MAAM,CAAC;CACxB,CAAC;AAyOF,KAAK,mBAAmB,GAAG,QAAQ,GAAG,YAAY,GAAG,WAAW,CAAC;AAkDjE,eAAO,MAAM,eAAe,GACxB,KAAK,MAAM,EAAE,EACb,MAAM,mBAAmB,EACzB,WAAW,MAAM,EACjB,aAAa,MAAM,EACnB,aAAa,MAAM,EACnB,cAAa,OAAe,EAC5B,SAAQ,OAAe,KACxB,OAAO,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,aAAa,EAAE,CAAC,CA+G7C,CAAC;AAEF,eAAO,MAAM,eAAe,GACxB,SAAS,sBAAsB,KAChC,OAAO,CAAC,IAAI,CAmMd,CAAC;AA4ZF,eAAO,MAAM,aAAa,GACtB,SAAS,sBAAsB,KAChC,OAAO,CAAC,IAAI,CAkJd,CAAC"}
@@ -15,6 +15,7 @@ const config_1 = require("../config");
15
15
  const globalState_1 = tslib_1.__importDefault(require("../globalState"));
16
16
  const styles = tslib_1.__importStar(require("../styles"));
17
17
  const apiClient_1 = require("./dbt/apiClient");
18
+ const metadataFile_1 = require("./metadataFile");
18
19
  const selectProject_1 = require("./selectProject");
19
20
  const getDownloadFolder = (customPath) => {
20
21
  if (customPath) {
@@ -71,29 +72,69 @@ const getFileExtension = (contentType) => {
71
72
  const writeContent = async (contentAsCode, outputDir, languageMap) => {
72
73
  const extension = getFileExtension(contentAsCode.type);
73
74
  const itemPath = path.join(outputDir, `${contentAsCode.content.slug}${extension}`);
74
- const chartYml = yaml.dump(contentAsCode.content, {
75
+ // Strip timestamps — they go to .lightdash-metadata.json instead
76
+ const { updatedAt, downloadedAt, ...cleanContent } = contentAsCode.content;
77
+ const chartYml = yaml.dump(cleanContent, {
75
78
  quotingType: '"',
79
+ sortKeys: true,
76
80
  });
77
81
  await fs_1.promises.writeFile(itemPath, chartYml);
78
82
  if (contentAsCode.translationMap && languageMap) {
79
83
  const translationPath = path.join(outputDir, `${contentAsCode.content.slug}.language.map.yml`);
80
- await fs_1.promises.writeFile(translationPath, yaml.dump(contentAsCode.translationMap));
84
+ await fs_1.promises.writeFile(translationPath, yaml.dump(contentAsCode.translationMap, { sortKeys: true }));
81
85
  }
86
+ const metadataType = contentAsCode.type === 'dashboard' ? 'dashboards' : 'charts';
87
+ let downloadedAtString;
88
+ if (downloadedAt instanceof Date) {
89
+ downloadedAtString = downloadedAt.toISOString();
90
+ }
91
+ else if (typeof downloadedAt === 'string') {
92
+ downloadedAtString = downloadedAt;
93
+ }
94
+ else {
95
+ downloadedAtString = new Date().toISOString();
96
+ }
97
+ return {
98
+ slug: contentAsCode.content.slug,
99
+ type: metadataType,
100
+ downloadedAt: downloadedAtString,
101
+ };
102
+ };
103
+ const hasUnsortedKeys = (obj) => {
104
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
105
+ if (Array.isArray(obj)) {
106
+ return obj.some(hasUnsortedKeys);
107
+ }
108
+ return false;
109
+ }
110
+ const keys = Object.keys(obj);
111
+ const sorted = [...keys].sort();
112
+ if (keys.some((key, i) => key !== sorted[i])) {
113
+ return true;
114
+ }
115
+ return Object.values(obj).some(hasUnsortedKeys);
82
116
  };
83
117
  const isLightdashContentFile = (folder, entry) => entry.isFile() &&
84
118
  entry.parentPath &&
85
119
  entry.parentPath.endsWith(path.sep + folder) &&
86
120
  entry.name.endsWith('.yml') &&
87
121
  !entry.name.endsWith('.language.map.yml');
88
- const loadYamlFile = async (file) => {
122
+ const loadYamlFile = async (file, folder, metadata) => {
89
123
  const filePath = path.join(file.parentPath, file.name);
90
124
  const [fileContent, stats] = await Promise.all([
91
125
  fs_1.promises.readFile(filePath, 'utf-8'),
92
126
  fs_1.promises.stat(filePath),
93
127
  ]);
94
128
  const item = yaml.load(fileContent);
95
- const downloadedAt = item.downloadedAt
96
- ? new Date(item.downloadedAt)
129
+ if (hasUnsortedKeys(item)) {
130
+ globalState_1.default.log(styles.warning(`Warning: ${file.name} has unsorted YAML keys. Re-download to fix, or sort keys alphabetically.`));
131
+ }
132
+ const metadataSection = folder === 'dashboards' ? metadata.dashboards : metadata.charts;
133
+ const downloadedAtRaw = metadataSection[item.slug] ?? item.downloadedAt;
134
+ const downloadedAt = downloadedAtRaw
135
+ ? new Date(downloadedAtRaw instanceof Date
136
+ ? downloadedAtRaw.getTime()
137
+ : downloadedAtRaw)
97
138
  : undefined;
98
139
  const needsUpdating = downloadedAt &&
99
140
  Math.abs(stats.mtime.getTime() - downloadedAt.getTime()) > 30000;
@@ -111,13 +152,14 @@ const readCodeFiles = async (folder, customPath) => {
111
152
  throw new Error(`Node.js v20.12.0 or later is required for this command (current: ${process.version}).`);
112
153
  }
113
154
  try {
155
+ const metadata = await (0, metadataFile_1.readMetadataFile)(baseDir);
114
156
  const allEntries = await fs_1.promises.readdir(baseDir, {
115
157
  recursive: true,
116
158
  withFileTypes: true,
117
159
  });
118
160
  const items = await Promise.all(allEntries
119
161
  .filter((entry) => isLightdashContentFile(folder, entry))
120
- .map((file) => loadYamlFile(file)));
162
+ .map((file) => loadYamlFile(file, folder, metadata)));
121
163
  if (items.length === 0) {
122
164
  console.error(styles.warning(`Unable to upload ${folder}, no files found in "${baseDir}". Run download command first.`));
123
165
  }
@@ -140,16 +182,19 @@ const groupBySpace = (items) => {
140
182
  };
141
183
  const writeSpaceContent = async ({ projectName, spaceSlug, folder, contentType, contentInSpace, contentAsCode, customPath, languageMap, folderScheme, }) => {
142
184
  const outputDir = await createDirForContent(projectName, spaceSlug, folder, customPath, folderScheme);
185
+ const entries = [];
143
186
  for (const { item, index } of contentInSpace) {
144
187
  const translationMap = 'languageMap' in contentAsCode
145
188
  ? contentAsCode.languageMap?.[index]
146
189
  : undefined;
147
- await writeContent({
190
+ const entry = await writeContent({
148
191
  type: contentType,
149
192
  content: item,
150
193
  translationMap,
151
194
  }, outputDir, languageMap);
195
+ entries.push(entry);
152
196
  }
197
+ return entries;
153
198
  };
154
199
  const getContentTypeConfig = (type, projectId) => {
155
200
  switch (type) {
@@ -191,6 +236,7 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
191
236
  let offset = 0;
192
237
  let total = 0;
193
238
  let chartSlugs = [];
239
+ let allMetadataEntries = [];
194
240
  do {
195
241
  globalState_1.default.debug(`Downloading ${config.displayName} with offset "${offset}" and filters "${contentFilters}"`);
196
242
  const commonParams = config.supportsLanguageMap
@@ -214,7 +260,7 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
214
260
  if ('sqlCharts' in results) {
215
261
  const sqlChartsBySpace = groupBySpace(results.sqlCharts);
216
262
  for (const [spaceSlug, sqlChartsInSpace] of Object.entries(sqlChartsBySpace)) {
217
- await writeSpaceContent({
263
+ const entries = await writeSpaceContent({
218
264
  projectName,
219
265
  spaceSlug,
220
266
  folder: 'charts',
@@ -225,12 +271,13 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
225
271
  languageMap,
226
272
  folderScheme,
227
273
  });
274
+ allMetadataEntries = [...allMetadataEntries, ...entries];
228
275
  }
229
276
  }
230
277
  else if ('dashboards' in results) {
231
278
  const dashboardsBySpace = groupBySpace(results.dashboards);
232
279
  for (const [spaceSlug, dashboardsInSpace] of Object.entries(dashboardsBySpace)) {
233
- await writeSpaceContent({
280
+ const entries = await writeSpaceContent({
234
281
  projectName,
235
282
  spaceSlug,
236
283
  folder: 'dashboards',
@@ -241,6 +288,7 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
241
288
  languageMap,
242
289
  folderScheme,
243
290
  });
291
+ allMetadataEntries = [...allMetadataEntries, ...entries];
244
292
  }
245
293
  chartSlugs = [
246
294
  ...chartSlugs,
@@ -250,7 +298,7 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
250
298
  else {
251
299
  const chartsBySpace = groupBySpace(results.charts);
252
300
  for (const [spaceSlug, chartsInSpace] of Object.entries(chartsBySpace)) {
253
- await writeSpaceContent({
301
+ const entries = await writeSpaceContent({
254
302
  projectName,
255
303
  spaceSlug,
256
304
  folder: 'charts',
@@ -261,12 +309,13 @@ const downloadContent = async (ids, type, projectId, projectName, customPath, la
261
309
  languageMap,
262
310
  folderScheme,
263
311
  });
312
+ allMetadataEntries = [...allMetadataEntries, ...entries];
264
313
  }
265
314
  }
266
315
  offset = results.offset;
267
316
  total = results.total;
268
317
  } while (offset < total);
269
- return [total, [...new Set(chartSlugs)]];
318
+ return [total, [...new Set(chartSlugs)], allMetadataEntries];
270
319
  };
271
320
  exports.downloadContent = downloadContent;
272
321
  const downloadHandler = async (options) => {
@@ -310,20 +359,21 @@ const downloadHandler = async (options) => {
310
359
  },
311
360
  });
312
361
  try {
313
- // If any filter is provided, we skip those items without filters
314
- // eg: if a --charts filter is provided, we skip dashboards if no --dashboards filter is provided
315
362
  const hasFilters = options.charts.length > 0 || options.dashboards.length > 0;
363
+ let allMetadataEntries = [];
316
364
  // Download regular charts
317
365
  if (hasFilters && options.charts.length === 0) {
318
366
  console.info(styles.warning(`No charts filters provided, skipping`));
319
367
  }
320
368
  else {
321
- const [regularChartTotal] = await (0, exports.downloadContent)(options.charts, 'charts', projectId, projectName, options.path, options.languageMap, options.nested);
369
+ const [regularChartTotal, , regularChartMeta] = await (0, exports.downloadContent)(options.charts, 'charts', projectId, projectName, options.path, options.languageMap, options.nested);
322
370
  spinner.succeed(`Downloaded ${regularChartTotal} charts`);
371
+ allMetadataEntries = [...allMetadataEntries, ...regularChartMeta];
323
372
  // Download SQL charts
324
373
  spinner.start(`Downloading SQL charts`);
325
- const [sqlChartTotal] = await (0, exports.downloadContent)(options.charts, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested);
374
+ const [sqlChartTotal, , sqlChartMeta] = await (0, exports.downloadContent)(options.charts, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested);
326
375
  spinner.succeed(`Downloaded ${sqlChartTotal} SQL charts`);
376
+ allMetadataEntries = [...allMetadataEntries, ...sqlChartMeta];
327
377
  chartTotal = regularChartTotal + sqlChartTotal;
328
378
  }
329
379
  // Download dashboards
@@ -332,18 +382,34 @@ const downloadHandler = async (options) => {
332
382
  }
333
383
  else {
334
384
  let chartSlugs = [];
335
- [dashboardTotal, chartSlugs] = await (0, exports.downloadContent)(options.dashboards, 'dashboards', projectId, projectName, options.path, options.languageMap, options.nested);
385
+ let dashMeta;
386
+ [dashboardTotal, chartSlugs, dashMeta] = await (0, exports.downloadContent)(options.dashboards, 'dashboards', projectId, projectName, options.path, options.languageMap, options.nested);
387
+ allMetadataEntries = [...allMetadataEntries, ...dashMeta];
336
388
  spinner.succeed(`Downloaded ${dashboardTotal} dashboards`);
337
- // If any filter is provided, we download all charts linked to these dashboards
338
- // We don't need to do this if we download everything (no filters)
339
389
  if (hasFilters && chartSlugs.length > 0) {
340
390
  spinner.start(`Downloading ${chartSlugs.length} charts linked to dashboards`);
341
- // Download both regular charts and SQL charts linked to dashboards
342
- const [regularCharts] = await (0, exports.downloadContent)(chartSlugs, 'charts', projectId, projectName, options.path, options.languageMap, options.nested);
343
- const [sqlCharts] = await (0, exports.downloadContent)(chartSlugs, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested);
391
+ const [regularCharts, , linkedChartMeta] = await (0, exports.downloadContent)(chartSlugs, 'charts', projectId, projectName, options.path, options.languageMap, options.nested);
392
+ allMetadataEntries = [
393
+ ...allMetadataEntries,
394
+ ...linkedChartMeta,
395
+ ];
396
+ const [sqlCharts, , linkedSqlMeta] = await (0, exports.downloadContent)(chartSlugs, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested);
397
+ allMetadataEntries = [...allMetadataEntries, ...linkedSqlMeta];
344
398
  spinner.succeed(`Downloaded ${regularCharts + sqlCharts} charts linked to dashboards`);
345
399
  }
346
400
  }
401
+ // Write metadata file with all downloadedAt timestamps
402
+ const metadataToWrite = {
403
+ version: 1,
404
+ charts: {},
405
+ dashboards: {},
406
+ };
407
+ for (const entry of allMetadataEntries) {
408
+ metadataToWrite[entry.type][entry.slug] = entry.downloadedAt;
409
+ }
410
+ const baseDir = getDownloadFolder(options.path);
411
+ await (0, metadataFile_1.writeMetadataFile)(baseDir, metadataToWrite);
412
+ globalState_1.default.log(styles.warning(`\nNote: ${metadataFile_1.METADATA_FILENAME} was written to ${baseDir}. Consider adding it to your .gitignore.`));
347
413
  const end = Date.now();
348
414
  await analytics_1.LightdashAnalytics.track({
349
415
  event: 'download.completed',
@@ -353,7 +419,7 @@ const downloadHandler = async (options) => {
353
419
  projectId,
354
420
  chartsNum: chartTotal,
355
421
  dashboardsNum: dashboardTotal,
356
- timeToCompleted: (end - start) / 1000, // in seconds
422
+ timeToCompleted: (end - start) / 1000,
357
423
  },
358
424
  });
359
425
  }
@@ -0,0 +1,9 @@
1
+ export declare const METADATA_FILENAME = ".lightdash-metadata.json";
2
+ export type LightdashMetadata = {
3
+ version: 1;
4
+ charts: Record<string, string>;
5
+ dashboards: Record<string, string>;
6
+ };
7
+ export declare const readMetadataFile: (baseDir: string) => Promise<LightdashMetadata>;
8
+ export declare const writeMetadataFile: (baseDir: string, metadata: LightdashMetadata) => Promise<void>;
9
+ //# sourceMappingURL=metadataFile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metadataFile.d.ts","sourceRoot":"","sources":["../../src/handlers/metadataFile.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,iBAAiB,6BAA6B,CAAC;AAE5D,MAAM,MAAM,iBAAiB,GAAG;IAC5B,OAAO,EAAE,CAAC,CAAC;IACX,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC,CAAC;AAQF,eAAO,MAAM,gBAAgB,GACzB,SAAS,MAAM,KAChB,OAAO,CAAC,iBAAiB,CAQ3B,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAC1B,SAAS,MAAM,EACf,UAAU,iBAAiB,KAC5B,OAAO,CAAC,IAAI,CASd,CAAC"}
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.writeMetadataFile = exports.readMetadataFile = exports.METADATA_FILENAME = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs_1 = require("fs");
6
+ const path = tslib_1.__importStar(require("path"));
7
+ exports.METADATA_FILENAME = '.lightdash-metadata.json';
8
+ const emptyMetadata = () => ({
9
+ version: 1,
10
+ charts: {},
11
+ dashboards: {},
12
+ });
13
+ const readMetadataFile = async (baseDir) => {
14
+ const filePath = path.join(baseDir, exports.METADATA_FILENAME);
15
+ try {
16
+ const content = await fs_1.promises.readFile(filePath, 'utf-8');
17
+ return JSON.parse(content);
18
+ }
19
+ catch {
20
+ return emptyMetadata();
21
+ }
22
+ };
23
+ exports.readMetadataFile = readMetadataFile;
24
+ const writeMetadataFile = async (baseDir, metadata) => {
25
+ const existing = await (0, exports.readMetadataFile)(baseDir);
26
+ const merged = {
27
+ version: 1,
28
+ charts: { ...existing.charts, ...metadata.charts },
29
+ dashboards: { ...existing.dashboards, ...metadata.dashboards },
30
+ };
31
+ const filePath = path.join(baseDir, exports.METADATA_FILENAME);
32
+ await fs_1.promises.writeFile(filePath, JSON.stringify(merged, null, 2));
33
+ };
34
+ exports.writeMetadataFile = writeMetadataFile;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightdash/cli",
3
- "version": "0.2645.0",
3
+ "version": "0.2646.0",
4
4
  "description": "Lightdash CLI tool",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,8 +40,8 @@
40
40
  "unique-names-generator": "^4.7.1",
41
41
  "uuid": "^11.0.3",
42
42
  "yaml": "^2.7.0",
43
- "@lightdash/common": "0.2645.0",
44
- "@lightdash/warehouses": "0.2645.0"
43
+ "@lightdash/common": "0.2646.0",
44
+ "@lightdash/warehouses": "0.2646.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/inquirer": "^8.2.1",