@lightdash/cli 0.3399.1 → 0.3400.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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/handlers/download.d.ts +14 -1
- package/dist/handlers/download.d.ts.map +1 -1
- package/dist/handlers/download.js +155 -145
- package/dist/handlers/download.test.js +474 -1
- package/dist/handlers/spacesAsCode.d.ts +29 -0
- package/dist/handlers/spacesAsCode.d.ts.map +1 -0
- package/dist/handlers/spacesAsCode.js +603 -0
- package/dist/index.js +5 -1
- package/package.json +3 -3
|
@@ -13,7 +13,7 @@ vitest_1.vi.mock('./dbt/apiClient', async (importOriginal) => ({
|
|
|
13
13
|
...(await importOriginal()),
|
|
14
14
|
lightdashApi: vitest_1.vi.fn(),
|
|
15
15
|
}));
|
|
16
|
-
const { getDashboardChartSlugs, readAiAgentFiles, sanitizeChartForDownload, shouldDownloadAiAgents, upsertVirtualViews, } = download_1.testHelpers;
|
|
16
|
+
const { assertUniqueSpacePaths, downloadSpaces, getDashboardChartSlugs, getFlatSpaceFileNames, readAiAgentFiles, readSpaceFiles, readSpaceNames, sanitizeChartForDownload, shouldFallBackToEmbeddedSpaces, shouldDownloadAiAgents, upsertSpaces, upsertVirtualViews, validateSpaceIdentity, writeSpaceFiles, } = download_1.testHelpers;
|
|
17
17
|
const makeLooseDashboard = (slug, chartSlugs) => ({
|
|
18
18
|
slug,
|
|
19
19
|
name: slug,
|
|
@@ -284,3 +284,476 @@ describe('shouldDownloadAiAgents', () => {
|
|
|
284
284
|
})).toBe(false);
|
|
285
285
|
});
|
|
286
286
|
});
|
|
287
|
+
describe('space files', () => {
|
|
288
|
+
const accessSpace = (slug) => ({
|
|
289
|
+
contentType: 'space',
|
|
290
|
+
version: 1,
|
|
291
|
+
spaceName: slug,
|
|
292
|
+
slug,
|
|
293
|
+
access: {
|
|
294
|
+
inheritParentPermissions: false,
|
|
295
|
+
projectMemberAccessRole: null,
|
|
296
|
+
users: [],
|
|
297
|
+
groups: [],
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
beforeEach(() => {
|
|
301
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
|
|
302
|
+
});
|
|
303
|
+
const embeddedSpaceResponse = (spaceName) => ({
|
|
304
|
+
charts: [makeChart([])],
|
|
305
|
+
languageMap: undefined,
|
|
306
|
+
missingIds: [],
|
|
307
|
+
offset: 1,
|
|
308
|
+
spaces: [
|
|
309
|
+
{
|
|
310
|
+
contentType: common_1.ContentAsCodeType.SPACE,
|
|
311
|
+
spaceName,
|
|
312
|
+
slug: 'test-space',
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
total: 1,
|
|
316
|
+
});
|
|
317
|
+
it('writes new flat space files under spaces by default', async () => {
|
|
318
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-flat-folder-'));
|
|
319
|
+
try {
|
|
320
|
+
await writeSpaceFiles([accessSpace('finance')], 'project', tmpDir, 'flat');
|
|
321
|
+
await expect(fs_1.promises.readFile(path.join(tmpDir, 'spaces', 'finance.space.yml'), 'utf-8')).resolves.toContain('slug: finance');
|
|
322
|
+
await expect(fs_1.promises.access(path.join(tmpDir, 'finance.space.yml'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
it('supports the legacy root-spaces flat layout', async () => {
|
|
329
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-root-layout-'));
|
|
330
|
+
try {
|
|
331
|
+
await writeSpaceFiles([accessSpace('finance')], 'project', tmpDir, 'flat', 'root');
|
|
332
|
+
await expect(fs_1.promises.readFile(path.join(tmpDir, 'finance.space.yml'), 'utf-8')).resolves.toContain('slug: finance');
|
|
333
|
+
await expect(fs_1.promises.access(path.join(tmpDir, 'spaces'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
it('preserves existing access when filtered metadata updates a space file', async () => {
|
|
340
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-filtered-policy-'));
|
|
341
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValue(embeddedSpaceResponse('Updated finance'));
|
|
342
|
+
const existingFile = path.join(tmpDir, 'finance.space.yml');
|
|
343
|
+
await fs_1.promises.writeFile(existingFile, `access:
|
|
344
|
+
groups: []
|
|
345
|
+
inheritParentPermissions: false
|
|
346
|
+
projectMemberAccessRole: null
|
|
347
|
+
users:
|
|
348
|
+
- email: owner@example.com
|
|
349
|
+
role: admin
|
|
350
|
+
contentType: space
|
|
351
|
+
slug: test-space
|
|
352
|
+
spaceName: Finance
|
|
353
|
+
version: 1
|
|
354
|
+
`);
|
|
355
|
+
try {
|
|
356
|
+
await (0, download_1.downloadContent)(['pivoted-chart'], 'charts', 'project-uuid', 'project', tmpDir, false, false, false, false, false);
|
|
357
|
+
await expect(readSpaceFiles(tmpDir)).resolves.toEqual([
|
|
358
|
+
expect.objectContaining({
|
|
359
|
+
filePath: existingFile,
|
|
360
|
+
space: {
|
|
361
|
+
contentType: 'space',
|
|
362
|
+
version: 1,
|
|
363
|
+
spaceName: 'Updated finance',
|
|
364
|
+
slug: 'test-space',
|
|
365
|
+
access: {
|
|
366
|
+
inheritParentPermissions: false,
|
|
367
|
+
projectMemberAccessRole: null,
|
|
368
|
+
users: [
|
|
369
|
+
{
|
|
370
|
+
email: 'owner@example.com',
|
|
371
|
+
role: common_1.SpaceMemberRole.ADMIN,
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
groups: [],
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
]);
|
|
379
|
+
await expect(fs_1.promises.access(path.join(tmpDir, 'spaces', 'updated-finance.space.yml'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
it('writes nested definitions inside each full space path', async () => {
|
|
386
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-nested-layout-'));
|
|
387
|
+
try {
|
|
388
|
+
await writeSpaceFiles([
|
|
389
|
+
{ ...accessSpace('parent'), spaceName: 'Parent' },
|
|
390
|
+
{
|
|
391
|
+
...accessSpace('parent/child'),
|
|
392
|
+
spaceName: 'Child',
|
|
393
|
+
},
|
|
394
|
+
], 'project', tmpDir, 'nested');
|
|
395
|
+
await expect(fs_1.promises.readFile(path.join(tmpDir, 'project', 'parent', 'parent.space.yml'), 'utf-8')).resolves.toContain('slug: parent');
|
|
396
|
+
await expect(fs_1.promises.readFile(path.join(tmpDir, 'project', 'parent', 'child', 'child.space.yml'), 'utf-8')).resolves.toContain('slug: parent/child');
|
|
397
|
+
await expect(fs_1.promises.access(path.join(tmpDir, 'spaces'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
it('downloads metadata-only spaces when access cannot be exported', async () => {
|
|
404
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-metadata-fallback-'));
|
|
405
|
+
const logSpy = vitest_1.vi
|
|
406
|
+
.spyOn(globalState_1.default, 'log')
|
|
407
|
+
.mockImplementation(() => undefined);
|
|
408
|
+
await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), `access:
|
|
409
|
+
groups: []
|
|
410
|
+
inheritParentPermissions: false
|
|
411
|
+
projectMemberAccessRole: null
|
|
412
|
+
users: []
|
|
413
|
+
contentType: space
|
|
414
|
+
slug: finance
|
|
415
|
+
spaceName: Finance
|
|
416
|
+
version: 1
|
|
417
|
+
`);
|
|
418
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValue({
|
|
419
|
+
spaces: [
|
|
420
|
+
{
|
|
421
|
+
contentType: common_1.ContentAsCodeType.SPACE,
|
|
422
|
+
spaceName: 'Finance',
|
|
423
|
+
slug: 'finance',
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
skipped: [
|
|
427
|
+
{
|
|
428
|
+
slug: 'finance',
|
|
429
|
+
reason: 'Direct access contains a user without a portable organization identity',
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
});
|
|
433
|
+
try {
|
|
434
|
+
await expect(downloadSpaces('project-uuid', 'Project', tmpDir)).resolves.toBe(1);
|
|
435
|
+
await expect(readSpaceFiles(tmpDir)).resolves.toEqual([
|
|
436
|
+
expect.objectContaining({
|
|
437
|
+
space: {
|
|
438
|
+
contentType: 'space',
|
|
439
|
+
spaceName: 'Finance',
|
|
440
|
+
slug: 'finance',
|
|
441
|
+
},
|
|
442
|
+
}),
|
|
443
|
+
]);
|
|
444
|
+
const downloadedFile = await fs_1.promises.readFile(path.join(tmpDir, 'finance.space.yml'), 'utf-8');
|
|
445
|
+
expect(downloadedFile).not.toContain('access:');
|
|
446
|
+
expect(downloadedFile).not.toContain('version:');
|
|
447
|
+
await expect(fs_1.promises.access(path.join(tmpDir, 'spaces', 'finance.space.yml'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
448
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Downloaded space "finance" without access'));
|
|
449
|
+
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Skipped space "finance"'));
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
logSpy.mockRestore();
|
|
453
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
it('accepts legacy metadata-only files without a version', () => {
|
|
457
|
+
expect(validateSpaceIdentity({
|
|
458
|
+
contentType: 'space',
|
|
459
|
+
spaceName: 'Finance',
|
|
460
|
+
slug: 'company/finance',
|
|
461
|
+
}, 'finance.space.yml')).toEqual({
|
|
462
|
+
contentType: 'space',
|
|
463
|
+
spaceName: 'Finance',
|
|
464
|
+
slug: 'company/finance',
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
it('reads legacy space names without validating access policy', async () => {
|
|
468
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-name-test-'));
|
|
469
|
+
try {
|
|
470
|
+
await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), `access:
|
|
471
|
+
unsupported: true
|
|
472
|
+
contentType: space
|
|
473
|
+
spaceName: Finance operations
|
|
474
|
+
slug: finance
|
|
475
|
+
version: 99
|
|
476
|
+
`);
|
|
477
|
+
await fs_1.promises.writeFile(path.join(tmpDir, 'invalid.space.yml'), 'contentType: [invalid\n');
|
|
478
|
+
await expect(readSpaceFiles(tmpDir)).rejects.toThrow('Invalid version');
|
|
479
|
+
await expect(readSpaceNames(tmpDir)).resolves.toEqual({
|
|
480
|
+
finance: 'Finance operations',
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
it('requires version 1 when access is present', () => {
|
|
488
|
+
expect(() => validateSpaceIdentity({
|
|
489
|
+
contentType: 'space',
|
|
490
|
+
spaceName: 'Finance',
|
|
491
|
+
slug: 'finance',
|
|
492
|
+
access: {
|
|
493
|
+
inheritParentPermissions: false,
|
|
494
|
+
projectMemberAccessRole: null,
|
|
495
|
+
users: [],
|
|
496
|
+
groups: [],
|
|
497
|
+
},
|
|
498
|
+
}, 'finance.space.yml')).toThrow('version 1 is required when access is present');
|
|
499
|
+
});
|
|
500
|
+
it('normalizes emails and rejects duplicate principals', () => {
|
|
501
|
+
const parsed = validateSpaceIdentity({
|
|
502
|
+
contentType: 'space',
|
|
503
|
+
version: 1,
|
|
504
|
+
spaceName: 'Finance',
|
|
505
|
+
slug: 'finance',
|
|
506
|
+
access: {
|
|
507
|
+
inheritParentPermissions: false,
|
|
508
|
+
projectMemberAccessRole: common_1.SpaceMemberRole.VIEWER,
|
|
509
|
+
users: [
|
|
510
|
+
{
|
|
511
|
+
email: ' Alice@Example.com ',
|
|
512
|
+
role: common_1.SpaceMemberRole.ADMIN,
|
|
513
|
+
},
|
|
514
|
+
],
|
|
515
|
+
groups: [
|
|
516
|
+
{
|
|
517
|
+
name: 'Finance team',
|
|
518
|
+
role: common_1.SpaceMemberRole.EDITOR,
|
|
519
|
+
},
|
|
520
|
+
],
|
|
521
|
+
},
|
|
522
|
+
}, 'finance.space.yml');
|
|
523
|
+
expect(parsed.access?.users).toEqual([
|
|
524
|
+
{ email: 'alice@example.com', role: common_1.SpaceMemberRole.ADMIN },
|
|
525
|
+
]);
|
|
526
|
+
expect(() => validateSpaceIdentity({
|
|
527
|
+
...parsed,
|
|
528
|
+
access: {
|
|
529
|
+
...parsed.access,
|
|
530
|
+
users: [
|
|
531
|
+
{
|
|
532
|
+
email: 'alice@example.com',
|
|
533
|
+
role: common_1.SpaceMemberRole.ADMIN,
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
email: 'ALICE@example.com',
|
|
537
|
+
role: common_1.SpaceMemberRole.VIEWER,
|
|
538
|
+
},
|
|
539
|
+
],
|
|
540
|
+
},
|
|
541
|
+
}, 'finance.space.yml')).toThrow('Duplicate user email "alice@example.com"');
|
|
542
|
+
expect(() => validateSpaceIdentity({
|
|
543
|
+
...parsed,
|
|
544
|
+
access: {
|
|
545
|
+
...parsed.access,
|
|
546
|
+
groups: [
|
|
547
|
+
{
|
|
548
|
+
name: 'Finance team',
|
|
549
|
+
role: common_1.SpaceMemberRole.EDITOR,
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: 'Finance team',
|
|
553
|
+
role: common_1.SpaceMemberRole.VIEWER,
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
},
|
|
557
|
+
}, 'finance.space.yml')).toThrow('Duplicate group name "Finance team"');
|
|
558
|
+
});
|
|
559
|
+
it('reads nested definitions parent-first', async () => {
|
|
560
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-test-'));
|
|
561
|
+
try {
|
|
562
|
+
await fs_1.promises.mkdir(path.join(tmpDir, 'nested'));
|
|
563
|
+
await fs_1.promises.writeFile(path.join(tmpDir, 'nested', 'child.space.yml'), `contentType: space\nspaceName: Child\nslug: parent/child\n`);
|
|
564
|
+
await fs_1.promises.writeFile(path.join(tmpDir, 'parent.space.yml'), `contentType: space\nspaceName: Parent\nslug: parent\n`);
|
|
565
|
+
await expect(readSpaceFiles(tmpDir)).resolves.toEqual([
|
|
566
|
+
expect.objectContaining({
|
|
567
|
+
space: expect.objectContaining({ slug: 'parent' }),
|
|
568
|
+
}),
|
|
569
|
+
expect.objectContaining({
|
|
570
|
+
space: expect.objectContaining({ slug: 'parent/child' }),
|
|
571
|
+
}),
|
|
572
|
+
]);
|
|
573
|
+
}
|
|
574
|
+
finally {
|
|
575
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
it('rejects non-canonical and colliding normalized paths', () => {
|
|
579
|
+
expect(() => validateSpaceIdentity({
|
|
580
|
+
contentType: 'space',
|
|
581
|
+
spaceName: 'Finance',
|
|
582
|
+
slug: 'Parent_With.Space',
|
|
583
|
+
}, 'finance.space.yml')).toThrow('canonical lowercase slash-separated path');
|
|
584
|
+
expect(() => assertUniqueSpacePaths([
|
|
585
|
+
{
|
|
586
|
+
filePath: 'one.space.yml',
|
|
587
|
+
space: accessSpace('parent-child'),
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
filePath: 'two.space.yml',
|
|
591
|
+
space: {
|
|
592
|
+
...accessSpace('parent-child'),
|
|
593
|
+
slug: 'parent_child',
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
])).toThrow('resolve to the same normalized path');
|
|
597
|
+
});
|
|
598
|
+
it('uses deterministic collision-safe flat filenames', () => {
|
|
599
|
+
const spaces = [
|
|
600
|
+
{ ...accessSpace('finance-a'), spaceName: 'Finance!' },
|
|
601
|
+
{ ...accessSpace('finance-b'), spaceName: 'Finance?' },
|
|
602
|
+
];
|
|
603
|
+
const first = getFlatSpaceFileNames(spaces);
|
|
604
|
+
expect(first).toEqual(getFlatSpaceFileNames([...spaces].reverse()).reverse());
|
|
605
|
+
expect(new Set(first).size).toBe(2);
|
|
606
|
+
expect(first.every((fileName) => /^finance-[a-f0-9]{8}\.space\.yml$/.test(fileName))).toBe(true);
|
|
607
|
+
});
|
|
608
|
+
it('never overwrites an existing filename owned by another slug', async () => {
|
|
609
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-protected-file-'));
|
|
610
|
+
const protectedContent = 'contentType: space\nspaceName: Existing\nslug: existing\n';
|
|
611
|
+
try {
|
|
612
|
+
await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), protectedContent);
|
|
613
|
+
await writeSpaceFiles([
|
|
614
|
+
{
|
|
615
|
+
...accessSpace('new-finance'),
|
|
616
|
+
spaceName: 'Finance',
|
|
617
|
+
},
|
|
618
|
+
], 'project', tmpDir, 'flat');
|
|
619
|
+
await expect(fs_1.promises.readFile(path.join(tmpDir, 'finance.space.yml'), 'utf-8')).resolves.toBe(protectedContent);
|
|
620
|
+
const newSpaceFile = (await fs_1.promises.readdir(tmpDir)).find((fileName) => fileName !== 'finance.space.yml' &&
|
|
621
|
+
fileName.endsWith('.space.yml'));
|
|
622
|
+
expect(newSpaceFile).toBeDefined();
|
|
623
|
+
expect(await fs_1.promises.readFile(path.join(tmpDir, newSpaceFile), 'utf-8')).toContain('slug: new-finance');
|
|
624
|
+
await expect(fs_1.promises.access(path.join(tmpDir, 'spaces'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
625
|
+
}
|
|
626
|
+
finally {
|
|
627
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
it('uses the legacy root layout for new slugs when a root space file exists', async () => {
|
|
631
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-mixed-layout-'));
|
|
632
|
+
try {
|
|
633
|
+
await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), 'contentType: space\nspaceName: Old finance\nslug: finance\n');
|
|
634
|
+
await writeSpaceFiles([
|
|
635
|
+
{ ...accessSpace('finance'), spaceName: 'New finance' },
|
|
636
|
+
{ ...accessSpace('operations'), spaceName: 'Operations' },
|
|
637
|
+
], 'project', tmpDir, 'flat');
|
|
638
|
+
await expect(fs_1.promises.readFile(path.join(tmpDir, 'finance.space.yml'), 'utf-8')).resolves.toContain('spaceName: New finance');
|
|
639
|
+
await expect(fs_1.promises.readFile(path.join(tmpDir, 'operations.space.yml'), 'utf-8')).resolves.toContain('slug: operations');
|
|
640
|
+
await expect(fs_1.promises.access(path.join(tmpDir, 'spaces'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
641
|
+
expect((await readSpaceFiles(tmpDir)).map(({ space }) => space.slug)).toEqual(['finance', 'operations']);
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
it('rejects duplicate existing slugs across layouts before writing', async () => {
|
|
648
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-duplicate-layout-'));
|
|
649
|
+
try {
|
|
650
|
+
await fs_1.promises.mkdir(path.join(tmpDir, 'spaces'));
|
|
651
|
+
const duplicate = 'contentType: space\nspaceName: Finance\nslug: finance\n';
|
|
652
|
+
await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), duplicate);
|
|
653
|
+
await fs_1.promises.writeFile(path.join(tmpDir, 'spaces', 'finance.space.yml'), duplicate);
|
|
654
|
+
await expect(writeSpaceFiles([accessSpace('operations')], 'project', tmpDir, 'flat')).rejects.toThrow('Multiple existing space files use slug "finance"');
|
|
655
|
+
await expect(fs_1.promises.access(path.join(tmpDir, 'spaces', 'operations.space.yml'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
it('fails a space download when the space endpoint is unavailable', async () => {
|
|
662
|
+
const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-api-failure-'));
|
|
663
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValue(new Error('space endpoint unavailable'));
|
|
664
|
+
try {
|
|
665
|
+
const error = await downloadSpaces('project-uuid', 'Project', tmpDir).catch((caughtError) => caughtError);
|
|
666
|
+
expect(error).toMatchObject({
|
|
667
|
+
name: 'SpaceAsCodeFetchError',
|
|
668
|
+
message: 'space endpoint unavailable',
|
|
669
|
+
});
|
|
670
|
+
expect(shouldFallBackToEmbeddedSpaces(error, false)).toBe(true);
|
|
671
|
+
expect(shouldFallBackToEmbeddedSpaces(error, true)).toBe(false);
|
|
672
|
+
}
|
|
673
|
+
finally {
|
|
674
|
+
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
it('uploads parent-first and aggregates API actions', async () => {
|
|
678
|
+
const logSpy = vitest_1.vi
|
|
679
|
+
.spyOn(globalState_1.default, 'log')
|
|
680
|
+
.mockImplementation(() => undefined);
|
|
681
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi)
|
|
682
|
+
.mockResolvedValueOnce({
|
|
683
|
+
action: common_1.SpaceAsCodeAction.CREATE,
|
|
684
|
+
warnings: ['A direct service-account grant will be removed'],
|
|
685
|
+
})
|
|
686
|
+
.mockResolvedValueOnce({
|
|
687
|
+
action: common_1.SpaceAsCodeAction.UPDATE,
|
|
688
|
+
});
|
|
689
|
+
const changes = await upsertSpaces('project-uuid', [
|
|
690
|
+
{ filePath: 'child.space.yml', space: accessSpace('a/b') },
|
|
691
|
+
{ filePath: 'parent.space.yml', space: accessSpace('a') },
|
|
692
|
+
], {}, false, false);
|
|
693
|
+
expect(changes).toEqual({
|
|
694
|
+
'spaces created': 1,
|
|
695
|
+
'spaces updated': 1,
|
|
696
|
+
});
|
|
697
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('A direct service-account grant will be removed'));
|
|
698
|
+
expect(vitest_1.vi
|
|
699
|
+
.mocked(apiClient_1.lightdashApi)
|
|
700
|
+
.mock.calls.map(([request]) => JSON.parse(typeof request.body === 'string'
|
|
701
|
+
? request.body
|
|
702
|
+
: '{}').slug)).toEqual(['a', 'a/b']);
|
|
703
|
+
logSpy.mockRestore();
|
|
704
|
+
});
|
|
705
|
+
it('treats missing spaces and descendants as nonfatal with skip-space-create', async () => {
|
|
706
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi)
|
|
707
|
+
.mockRejectedValueOnce(new common_1.LightdashError({
|
|
708
|
+
message: 'Space a does not exist, skipping creation',
|
|
709
|
+
name: 'NotFoundError',
|
|
710
|
+
statusCode: 404,
|
|
711
|
+
data: {},
|
|
712
|
+
}))
|
|
713
|
+
.mockResolvedValueOnce({
|
|
714
|
+
action: common_1.SpaceAsCodeAction.NO_CHANGES,
|
|
715
|
+
});
|
|
716
|
+
await expect(upsertSpaces('project-uuid', [
|
|
717
|
+
{ filePath: 'child.space.yml', space: accessSpace('a/b') },
|
|
718
|
+
{ filePath: 'other.space.yml', space: accessSpace('z') },
|
|
719
|
+
{ filePath: 'parent.space.yml', space: accessSpace('a') },
|
|
720
|
+
], {}, true, false)).resolves.toEqual({
|
|
721
|
+
'spaces skipped': 2,
|
|
722
|
+
'spaces unchanged': 1,
|
|
723
|
+
});
|
|
724
|
+
expect(apiClient_1.lightdashApi).toHaveBeenCalledTimes(2);
|
|
725
|
+
expect(vitest_1.vi
|
|
726
|
+
.mocked(apiClient_1.lightdashApi)
|
|
727
|
+
.mock.calls.map(([request]) => JSON.parse(typeof request.body === 'string'
|
|
728
|
+
? request.body
|
|
729
|
+
: '{}').slug)).toEqual(['a', 'z']);
|
|
730
|
+
});
|
|
731
|
+
it('skips descendants after a parent failure and blocks content', async () => {
|
|
732
|
+
const summarySpy = vitest_1.vi
|
|
733
|
+
.spyOn(console, 'info')
|
|
734
|
+
.mockImplementation(() => undefined);
|
|
735
|
+
vitest_1.vi.mocked(apiClient_1.lightdashApi)
|
|
736
|
+
.mockRejectedValueOnce(new Error('parent rejected'))
|
|
737
|
+
.mockResolvedValueOnce({
|
|
738
|
+
action: common_1.SpaceAsCodeAction.CREATE,
|
|
739
|
+
});
|
|
740
|
+
const changes = {};
|
|
741
|
+
await expect(upsertSpaces('project-uuid', [
|
|
742
|
+
{ filePath: 'child.space.yml', space: accessSpace('a/b') },
|
|
743
|
+
{ filePath: 'other.space.yml', space: accessSpace('z') },
|
|
744
|
+
{ filePath: 'parent.space.yml', space: accessSpace('a') },
|
|
745
|
+
], changes, false, false)).rejects.toThrow('content upload was not started');
|
|
746
|
+
expect(apiClient_1.lightdashApi).toHaveBeenCalledTimes(2);
|
|
747
|
+
expect(changes).toEqual({
|
|
748
|
+
'spaces with errors': 1,
|
|
749
|
+
'spaces created': 1,
|
|
750
|
+
'spaces dependency skipped': 1,
|
|
751
|
+
});
|
|
752
|
+
expect(summarySpy.mock.calls.map(([message]) => message)).toEqual([
|
|
753
|
+
'Total spaces with errors: 1 ',
|
|
754
|
+
'Total spaces created: 1 ',
|
|
755
|
+
'Total spaces dependency skipped: 1 ',
|
|
756
|
+
]);
|
|
757
|
+
summarySpy.mockRestore();
|
|
758
|
+
});
|
|
759
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ParameterError, type SpaceAsCode } from '@lightdash/common';
|
|
2
|
+
type FolderScheme = 'flat' | 'nested';
|
|
3
|
+
type FlatSpaceLayout = 'folder' | 'root';
|
|
4
|
+
export type SpaceCodeFile = {
|
|
5
|
+
filePath: string;
|
|
6
|
+
space: SpaceAsCode;
|
|
7
|
+
};
|
|
8
|
+
export declare const getFlatSpaceFileNames: (spaces: SpaceAsCode[]) => string[];
|
|
9
|
+
export declare const validateSpaceIdentity: (parsed: unknown, filePath: string) => SpaceAsCode;
|
|
10
|
+
export declare const assertUniqueSpacePaths: (files: SpaceCodeFile[]) => void;
|
|
11
|
+
export declare const sortSpaceFilesParentFirst: (files: SpaceCodeFile[]) => SpaceCodeFile[];
|
|
12
|
+
export declare const getUniqueExistingSpaceFilePathsBySlug: (baseDir: string) => Promise<Map<string, string[]>>;
|
|
13
|
+
/** Writes first-class space YAML files returned by GET /spaces/code. */
|
|
14
|
+
export declare const writeSpaceFiles: (spaces: SpaceAsCode[], projectName: string, customPath?: string, folderScheme?: FolderScheme, flatSpaceLayout?: FlatSpaceLayout, preserveExistingPolicy?: boolean) => Promise<void>;
|
|
15
|
+
export declare const readSpaceFiles: (customPath?: string) => Promise<SpaceCodeFile[]>;
|
|
16
|
+
/** Reads legacy identity fields without validating unrelated access policy. */
|
|
17
|
+
export declare const readSpaceNames: (customPath?: string) => Promise<Record<string, string>>;
|
|
18
|
+
export declare const getSpaceNames: (files: SpaceCodeFile[]) => Record<string, string>;
|
|
19
|
+
export declare const createSpaceAsCodeDownloadError: (message: string) => ParameterError;
|
|
20
|
+
export declare const isSpaceAsCodeDownloadError: (error: unknown) => error is Error;
|
|
21
|
+
export declare const isSpaceAsCodeFetchError: (error: unknown) => error is Error;
|
|
22
|
+
export declare const shouldFallBackToEmbeddedSpaces: (error: unknown, spacesOnly: boolean | undefined) => boolean;
|
|
23
|
+
export declare const downloadSpaces: (projectId: string, projectName: string, customPath?: string, nested?: boolean, rootSpaces?: boolean) => Promise<number>;
|
|
24
|
+
export declare const createSpaceAsCodeUploadError: (message: string) => ParameterError;
|
|
25
|
+
export declare const isSpaceAsCodeUploadError: (error: unknown) => error is Error;
|
|
26
|
+
export declare const logUploadChanges: (changes: Record<string, number>) => void;
|
|
27
|
+
export declare const upsertSpaces: (projectId: string, files: SpaceCodeFile[], changes: Record<string, number>, skipSpaceCreate: boolean, publicSpaceCreate: boolean) => Promise<Record<string, number>>;
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=spacesAsCode.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spacesAsCode.d.ts","sourceRoot":"","sources":["../../src/handlers/spacesAsCode.ts"],"names":[],"mappings":"AAEA,OAAO,EAUH,cAAc,EAId,KAAK,WAAW,EACnB,MAAM,mBAAmB,CAAC;AAW3B,KAAK,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAC;AACtC,KAAK,eAAe,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEzC,MAAM,MAAM,aAAa,GAAG;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;CACtB,CAAC;AAyDF,eAAO,MAAM,qBAAqB,GAAI,QAAQ,WAAW,EAAE,KAAG,MAAM,EAiBnE,CAAC;AAuBF,eAAO,MAAM,qBAAqB,GAC9B,QAAQ,OAAO,EACf,UAAU,MAAM,KACjB,WAgMF,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAAI,OAAO,aAAa,EAAE,KAAG,IAyB/D,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAClC,OAAO,aAAa,EAAE,KACvB,aAAa,EAQV,CAAC;AA4EP,eAAO,MAAM,qCAAqC,GAC9C,SAAS,MAAM,KAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAY/B,CAAC;AAyDF,wEAAwE;AACxE,eAAO,MAAM,eAAe,GACxB,QAAQ,WAAW,EAAE,EACrB,aAAa,MAAM,EACnB,aAAa,MAAM,EACnB,eAAc,YAAqB,EACnC,kBAAiB,eAA0B,EAC3C,yBAAwB,OAAe,KACxC,OAAO,CAAC,IAAI,CAoHd,CAAC;AAEF,eAAO,MAAM,cAAc,GACvB,aAAa,MAAM,KACpB,OAAO,CAAC,aAAa,EAAE,CA0BzB,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,cAAc,GACvB,aAAa,MAAM,KACpB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA8BhC,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,OAAO,aAAa,EAAE,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CACG,CAAC;AAqBhF,eAAO,MAAM,8BAA8B,GACvC,SAAS,MAAM,KAChB,cAIF,CAAC;AAEF,eAAO,MAAM,0BAA0B,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,KACS,CAAC;AAU/E,eAAO,MAAM,uBAAuB,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,KACS,CAAC;AAE5E,eAAO,MAAM,8BAA8B,GACvC,OAAO,OAAO,EACd,YAAY,OAAO,GAAG,SAAS,KAChC,OAAgE,CAAC;AAEpE,eAAO,MAAM,cAAc,GACvB,WAAW,MAAM,EACjB,aAAa,MAAM,EACnB,aAAa,MAAM,EACnB,SAAQ,OAAe,EACvB,aAAY,OAAe,KAC5B,OAAO,CAAC,MAAM,CAmDhB,CAAC;AAIF,eAAO,MAAM,4BAA4B,GACrC,SAAS,MAAM,KAChB,cAIF,CAAC;AAEF,eAAO,MAAM,wBAAwB,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,KACS,CAAC;AAE7E,eAAO,MAAM,gBAAgB,GAAI,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,SAY/D,CAAC;AAEF,eAAO,MAAM,YAAY,GACrB,WAAW,MAAM,EACjB,OAAO,aAAa,EAAE,EACtB,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,iBAAiB,OAAO,EACxB,mBAAmB,OAAO,KAC3B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAgGhC,CAAC"}
|