feat(sigma): add Sigma Computing context-source adapter (#316)

* feat(sigma): add Sigma Computing context-source adapter

Closes #168

Adds a full ingest adapter for Sigma Computing so `ktx ingest` can pull
data model specs and workbook summaries into the ktx context layer. The
implementation follows the same fetch → chunk → project → LLM pattern
used by the Looker, Metabase, and MetricFlow adapters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sigma): address PR review comments

- Remove manifest from rawFiles; moves to peerFileIndex so fetchedAt
  changes don't mark all work units dirty every run
- Fix workbookFilter.updatedSince eviction bug: fetch full universe first,
  apply filter client-side, evict only on archived/deleted
- Remove measure projection entirely; project() writes measures: [] and
  the sigma_ingest skill surfaces Lookup/aggregation formulas as wiki prose
- Remove joins projection (v1 limitation); project() writes joins: [] and
  Lookup relationships are described in wiki prose instead
- Remove write-back dead code: createDataModel, updateDataModel,
  SigmaDataModelPushResult, mutate/post/put
- Fix emitBatches notes pluralization bug ('2 data modelss' → '2 data models')
- Add tokenInflight dedup on ensureToken to coalesce concurrent auth requests
- Retry spec fetch when existing staged spec is null (transient failure cache)
- Drop unused WorkbookFilter import from client-port.ts
- Note in docs that joins are not projected from Sigma data models in this release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* updates

* fix(sigma): restore sigma in local adapter test + small cleanups

The gdrive↔sigma merge dropped 'sigma' from the expected adapter source
list in local-adapters.test.ts while keeping gdrive, so the slow TS suite
failed even though the source registers both. Add 'sigma' back at its
registration position (after metabase, before gdrive).

Also:
- Move the orphaned SigmaPullConfig docstring onto the schema it documents
  and drop the stale BullMQ reference (standalone ktx has no BullMQ; the
  config lives in the ingest job's bundleRef.config).
- Drop an O(n^2) find() round-trip in fetch() when building the active
  data-model list; filter once and reuse for the eviction id set.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
Co-authored-by: Luca Martial <48870843+luca-martial@users.noreply.github.com>
This commit is contained in:
Matt Senick (Sigma) 2026-06-30 16:14:57 -07:00 committed by GitHub
parent 139ac08320
commit acd20ac248
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3610 additions and 6 deletions

View file

@ -0,0 +1,325 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { chunkSigmaStagedDir } from '../../../../../src/context/ingest/adapters/sigma/chunk.js';
// Keep in sync with constants in chunk.ts
const DATA_MODELS_PER_UNIT = 50;
const WORKBOOKS_PER_UNIT = 2000;
const FIXTURES = resolve(import.meta.dirname, '../../../../fixtures/sigma');
const SINGLE = join(FIXTURES, 'single-folder');
const MULTI = join(FIXTURES, 'multi-folder');
const EMPTY = join(FIXTURES, 'empty-manifest');
describe('chunkSigmaStagedDir — first run', () => {
it('single-folder fixture emits two WUs (data-models and workbooks)', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
expect(result.workUnits).toHaveLength(2);
});
it('data-models WU has correct unitKey and displayLabel', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu).toBeDefined();
expect(wu.displayLabel).toBe('Sigma: data models');
});
it('workbooks WU has correct unitKey and displayLabel', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu).toBeDefined();
expect(wu.displayLabel).toBe('Sigma: workbooks');
});
it('data-models WU rawFiles contains data model files but not the manifest', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu.rawFiles).toContain('data-models/dm-aaa111.json');
expect(wu.rawFiles).toContain('data-models/dm-bbb222.json');
expect(wu.rawFiles).not.toContain('sigma-manifest.json');
expect(wu.rawFiles).not.toContain('workbooks/wb-xxx111.json');
});
it('manifest is in peerFileIndex so the LLM can read it without affecting the hash', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const dmWu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
const wbWu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(dmWu.peerFileIndex).toContain('sigma-manifest.json');
expect(wbWu.peerFileIndex).toContain('sigma-manifest.json');
});
it('workbooks WU rawFiles contains workbook files but not the manifest', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu.rawFiles).toContain('workbooks/wb-xxx111.json');
expect(wu.rawFiles).not.toContain('sigma-manifest.json');
expect(wu.rawFiles).not.toContain('data-models/dm-aaa111.json');
});
it('data-models WU peerFileIndex contains workbook files', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu.peerFileIndex).toContain('workbooks/wb-xxx111.json');
});
it('workbooks WU peerFileIndex contains data model files', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu.peerFileIndex).toContain('data-models/dm-aaa111.json');
expect(wu.peerFileIndex).toContain('data-models/dm-bbb222.json');
});
it('data-models WU notes describes model count', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu.notes).toBe('2 data models');
});
it('workbooks WU notes describes workbook count', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu.notes).toBe('1 workbook');
});
it('dependencyPaths is empty on first run for both WUs', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
for (const wu of result.workUnits) {
expect(wu.dependencyPaths).toEqual([]);
}
});
it('multi-folder fixture still emits two WUs (data-models and workbooks)', async () => {
const result = await chunkSigmaStagedDir(MULTI);
expect(result.workUnits).toHaveLength(2);
expect(result.workUnits.map((w) => w.unitKey).sort()).toEqual(['sigma-data-models', 'sigma-workbooks']);
});
it('multi-folder: data-models WU contains all data models regardless of folder', async () => {
const result = await chunkSigmaStagedDir(MULTI);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu.rawFiles).toContain('data-models/dm-aaa111.json');
expect(wu.rawFiles).toContain('data-models/dm-bbb222.json');
expect(wu.rawFiles).toContain('data-models/dm-ccc333.json');
});
it('multi-folder: workbooks WU contains all workbooks regardless of folder', async () => {
const result = await chunkSigmaStagedDir(MULTI);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu.rawFiles).toContain('workbooks/wb-yyy222.json');
expect(wu.rawFiles).toContain('workbooks/wb-zzz333.json');
});
it('unitKey is slug-safe (no slashes or spaces)', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
for (const wu of result.workUnits) {
expect(wu.unitKey).toMatch(/^[a-zA-Z0-9_-]+$/);
}
});
it('empty-manifest fixture emits zero WUs', async () => {
const result = await chunkSigmaStagedDir(EMPTY);
expect(result.workUnits).toHaveLength(0);
});
it('missing manifest directory emits zero WUs without crashing', async () => {
const result = await chunkSigmaStagedDir('/tmp/sigma-nonexistent-dir-ktx-test');
expect(result.workUnits).toHaveLength(0);
});
it('is deterministic: two identical calls produce structurally equal output', async () => {
const r1 = await chunkSigmaStagedDir(SINGLE);
const r2 = await chunkSigmaStagedDir(SINGLE);
expect(JSON.stringify(r1)).toBe(JSON.stringify(r2));
});
});
describe('chunkSigmaStagedDir — data model batching', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-dm-batch-'));
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const manifest = JSON.stringify({
fetchedAt: new Date().toISOString(),
dataModelCount: DATA_MODELS_PER_UNIT + 1,
workbookCount: 0,
sigmaConnectionId: 'conn-1',
});
await writeFile(join(stagedDir, 'sigma-manifest.json'), manifest);
for (let i = 0; i < DATA_MODELS_PER_UNIT + 1; i++) {
const dm = JSON.stringify({
sigmaId: `dm-${i}`,
name: `Data Model ${i}`,
path: 'Engineering',
latestVersion: 1,
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
dataModelUrlId: `url-${i}`,
spec: null,
});
await writeFile(join(stagedDir, 'data-models', `dm-${String(i).padStart(6, '0')}.json`), dm);
}
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('splits into two data model WUs when count exceeds DATA_MODELS_PER_UNIT', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const dmUnits = result.workUnits.filter((w) => w.unitKey.startsWith('sigma-data-models'));
expect(dmUnits).toHaveLength(2);
});
it('batched data model WUs get indexed unitKeys (sigma-data-models-0, sigma-data-models-1)', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const keys = result.workUnits.map((w) => w.unitKey).filter((k) => k.startsWith('sigma-data-models')).sort();
expect(keys).toEqual(['sigma-data-models-0', 'sigma-data-models-1']);
});
it('first batch has exactly DATA_MODELS_PER_UNIT files (manifest excluded from rawFiles)', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models-0')!;
expect(wu.rawFiles).toHaveLength(DATA_MODELS_PER_UNIT);
});
it('displayLabel includes batch position when split', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models-0')!;
expect(wu.displayLabel).toMatch(/\(1\/2\)/);
});
});
describe('chunkSigmaStagedDir — workbook batching', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-batch-'));
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
const manifest = JSON.stringify({
fetchedAt: new Date().toISOString(),
dataModelCount: 0,
workbookCount: WORKBOOKS_PER_UNIT + 1,
sigmaConnectionId: 'conn-1',
});
await writeFile(join(stagedDir, 'sigma-manifest.json'), manifest);
for (let i = 0; i < WORKBOOKS_PER_UNIT + 1; i++) {
const wb = JSON.stringify({
sigmaId: `wb-${i}`,
name: `Workbook ${i}`,
path: 'Finance',
latestVersion: 1,
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
workbookUrlId: `url-${i}`,
});
await writeFile(join(stagedDir, 'workbooks', `wb-${String(i).padStart(6, '0')}.json`), wb);
}
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('splits into two workbook WUs when count exceeds WORKBOOKS_PER_UNIT', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wbUnits = result.workUnits.filter((w) => w.unitKey.startsWith('sigma-workbooks'));
expect(wbUnits).toHaveLength(2);
});
it('batched WUs get indexed unitKeys (sigma-workbooks-0, sigma-workbooks-1)', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const keys = result.workUnits.map((w) => w.unitKey).filter((k) => k.startsWith('sigma-workbooks')).sort();
expect(keys).toEqual(['sigma-workbooks-0', 'sigma-workbooks-1']);
});
it('first batch has exactly WORKBOOKS_PER_UNIT files (manifest excluded from rawFiles)', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks-0')!;
expect(wu.rawFiles).toHaveLength(WORKBOOKS_PER_UNIT);
});
it('second batch has the remainder only', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks-1')!;
expect(wu.rawFiles).toHaveLength(1); // 1 overflow workbook
});
it('displayLabel includes batch position when split', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks-0')!;
expect(wu.displayLabel).toMatch(/\(1\/2\)/);
});
});
describe('chunkSigmaStagedDir — diffSet re-sync', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-chunk-diff-'));
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const fs = await import('node:fs/promises');
const manifestBody = await fs.readFile(join(SINGLE, 'sigma-manifest.json'), 'utf-8');
await writeFile(join(stagedDir, 'sigma-manifest.json'), manifestBody);
for (const file of ['dm-aaa111.json', 'dm-bbb222.json']) {
const body = await fs.readFile(join(SINGLE, 'data-models', file), 'utf-8');
await writeFile(join(stagedDir, 'data-models', file), body);
}
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('only the WU containing the modified file is kept', async () => {
const result = await chunkSigmaStagedDir(stagedDir, {
diffSet: {
added: [],
modified: ['data-models/dm-aaa111.json'],
deleted: [],
unchanged: ['data-models/dm-bbb222.json', 'sigma-manifest.json'],
},
});
expect(result.workUnits).toHaveLength(1);
expect(result.workUnits[0]!.rawFiles).toEqual(['data-models/dm-aaa111.json']);
});
it('unchanged sibling data-model moves to dependencyPaths', async () => {
const result = await chunkSigmaStagedDir(stagedDir, {
diffSet: {
added: [],
modified: ['data-models/dm-aaa111.json'],
deleted: [],
unchanged: ['data-models/dm-bbb222.json', 'sigma-manifest.json'],
},
});
expect(result.workUnits[0]!.dependencyPaths).toContain('data-models/dm-bbb222.json');
});
it('all-unchanged diffSet produces zero WUs and no eviction', async () => {
const result = await chunkSigmaStagedDir(stagedDir, {
diffSet: {
added: [],
modified: [],
deleted: [],
unchanged: ['data-models/dm-aaa111.json', 'data-models/dm-bbb222.json', 'sigma-manifest.json'],
},
});
expect(result.workUnits).toHaveLength(0);
expect(result.eviction).toBeUndefined();
});
it('deleted paths produce an eviction unit listing those paths', async () => {
const result = await chunkSigmaStagedDir(stagedDir, {
diffSet: {
added: [],
modified: [],
deleted: ['data-models/dm-aaa111.json'],
unchanged: ['data-models/dm-bbb222.json', 'sigma-manifest.json'],
},
});
expect(result.eviction?.deletedRawPaths).toContain('data-models/dm-aaa111.json');
});
});

View file

@ -0,0 +1,309 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DefaultSigmaClient } from '../../../../../src/context/ingest/adapters/sigma/client.js';
const BASE = 'https://api.sigmacomputing.com';
const TOKEN_RESPONSE = {
access_token: 'test-token',
token_type: 'Bearer',
expires_in: 3600,
};
function makeResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function makeClient(): DefaultSigmaClient {
return new DefaultSigmaClient(
{ apiUrl: BASE, clientId: 'cid', clientSecret: 'csec' }, // pragma: allowlist secret
{ maxRetries: 1, baseDelayMs: 0, maxDelayMs: 0, timeoutMs: 5000 },
);
}
beforeEach(() => {
globalThis.fetch = vi.fn<typeof fetch>();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('DefaultSigmaClient.testConnection', () => {
it('returns success:true when auth succeeds', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE));
const client = makeClient();
const result = await client.testConnection();
expect(result.success).toBe(true);
});
it('returns success:false with error message when auth fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, 401));
const client = makeClient();
const result = await client.testConnection();
expect(result.success).toBe(false);
expect(result.error).toMatch(/401/);
});
});
describe('DefaultSigmaClient.listDataModels', () => {
it('returns entries from a single page', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // auth
.mockResolvedValueOnce(
makeResponse({
entries: [
{
dataModelId: 'dm-1',
dataModelUrlId: 'url-1',
name: 'Revenue Model',
path: 'Finance/Revenue',
latestVersion: 1,
ownerId: 'user-1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
},
],
nextPage: null,
}),
);
const client = makeClient();
const models = await client.listDataModels();
expect(models).toHaveLength(1);
expect(models[0]!.name).toBe('Revenue Model');
});
it('paginates across multiple pages', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(
makeResponse({
entries: [
{
dataModelId: 'dm-1',
dataModelUrlId: 'url-1',
name: 'Model A',
path: 'Finance/A',
latestVersion: 1,
ownerId: 'u1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
],
nextPage: 'cursor-abc',
}),
)
.mockResolvedValueOnce(
makeResponse({
entries: [
{
dataModelId: 'dm-2',
dataModelUrlId: 'url-2',
name: 'Model B',
path: 'Finance/B',
latestVersion: 1,
ownerId: 'u1',
createdAt: '2026-01-02T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
},
],
nextPage: null,
}),
);
const client = makeClient();
const models = await client.listDataModels();
expect(models).toHaveLength(2);
expect(models.map((m) => m.name)).toEqual(['Model A', 'Model B']);
});
it('second page request includes cursor in query string', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ entries: [{ dataModelId: 'dm-1', dataModelUrlId: 'url-1', name: 'A', path: 'F/A', latestVersion: 1, ownerId: 'u', createdAt: '', updatedAt: '' }], nextPage: 'cursor-xyz' }))
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null }));
const client = makeClient();
await client.listDataModels();
const calls = vi.mocked(fetch).mock.calls;
const pageCall = calls[calls.length - 1]!;
expect(String(pageCall[0])).toContain('cursor-xyz');
});
});
function makeWorkbook(overrides: Record<string, unknown> = {}) {
return {
workbookId: 'wb-1',
workbookUrlId: 'Sales-Dashboard-wb1',
name: 'Sales Dashboard',
url: 'https://app.sigmacomputing.com/workbooks/wb-1',
path: 'Finance',
latestVersion: 3,
ownerId: 'user-1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-15T00:00:00Z',
createdBy: 'user-1',
updatedBy: 'user-1',
isArchived: false,
...overrides,
};
}
describe('DefaultSigmaClient.listWorkbooks', () => {
it('returns entries from a single page', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ entries: [makeWorkbook()], nextPage: null }));
const client = makeClient();
const workbooks = await client.listWorkbooks();
expect(workbooks).toHaveLength(1);
expect(workbooks[0]!.name).toBe('Sales Dashboard');
});
it('passes excludeExplorations=true by default', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null }));
const client = makeClient();
await client.listWorkbooks();
const url = String(vi.mocked(fetch).mock.calls[1]![0]);
expect(url).toContain('excludeExplorations=true');
});
it('omits excludeExplorations when includeExplorations=true', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null }));
const client = makeClient();
await client.listWorkbooks({ includeExplorations: true });
const url = String(vi.mocked(fetch).mock.calls[1]![0]);
expect(url).not.toContain('excludeExplorations');
});
it('filters out archived workbooks by default', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(
makeResponse({
entries: [makeWorkbook({ isArchived: false }), makeWorkbook({ workbookId: 'wb-2', name: 'Old', isArchived: true })],
nextPage: null,
}),
);
const client = makeClient();
const workbooks = await client.listWorkbooks();
expect(workbooks).toHaveLength(1);
expect(workbooks[0]!.name).toBe('Sales Dashboard');
});
it('includes archived workbooks when includeArchived=true', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(
makeResponse({
entries: [makeWorkbook({ isArchived: false }), makeWorkbook({ workbookId: 'wb-2', name: 'Old', isArchived: true })],
nextPage: null,
}),
);
const client = makeClient();
const workbooks = await client.listWorkbooks({ includeArchived: true });
expect(workbooks).toHaveLength(2);
});
it('filters workbooks by updatedSince', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(
makeResponse({
entries: [
makeWorkbook({ workbookId: 'wb-1', updatedAt: '2026-01-10T00:00:00Z' }),
makeWorkbook({ workbookId: 'wb-2', updatedAt: '2026-01-20T00:00:00Z' }),
],
nextPage: null,
}),
);
const client = makeClient();
const workbooks = await client.listWorkbooks({ updatedSince: '2026-01-15T00:00:00Z' });
expect(workbooks).toHaveLength(1);
expect(workbooks[0]!.workbookId).toBe('wb-2');
});
});
describe('DefaultSigmaClient.getDataModelSpec', () => {
it('calls the correct URL with encoded id', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ schemaVersion: 1 }));
const client = makeClient();
const spec = await client.getDataModelSpec('dm/123');
expect(spec).toEqual({ schemaVersion: 1 });
const calls = vi.mocked(fetch).mock.calls;
expect(String(calls[1]![0])).toContain('/v2/dataModels/dm%2F123/spec');
});
});
describe('DefaultSigmaClient — error handling', () => {
it('retries on 500 and succeeds on retry', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // auth
.mockResolvedValueOnce(makeResponse({ error: 'server error' }, 500)) // first attempt
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null })); // retry
const client = makeClient();
const models = await client.listDataModels();
expect(models).toHaveLength(0);
});
it('throws after exhausting retries on 500', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValue(makeResponse({ error: 'server error' }, 500));
const client = makeClient();
await expect(client.listDataModels()).rejects.toThrow(/500/);
});
it('throws immediately on service_error 500 without retrying', async () => {
const serviceError = { requestId: 'abc', message: 'dataSource subtype not supported in data model read', code: 'service_error' };
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse(serviceError, 500));
const client = makeClient();
await expect(client.getDataModelSpec('dm-1')).rejects.toThrow(/service_error/);
// Only 2 calls: auth + one request. No retries.
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it('throws immediately on 404 (non-retryable)', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ error: 'not found' }, 404));
const client = makeClient();
await expect(client.getDataModelSpec('dm-999')).rejects.toThrow(/404/);
});
it('re-authenticates and retries on 401', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // initial auth
.mockResolvedValueOnce(makeResponse({ error: 'expired' }, 401)) // 401 on first request
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // re-auth
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null })); // retried request
const client = makeClient();
const models = await client.listDataModels();
expect(models).toHaveLength(0);
});
});
describe('DefaultSigmaClient.cleanup', () => {
it('clears cached token so next call re-authenticates', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // first auth
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null })) // first list
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // second auth after cleanup
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null })); // second list
const client = makeClient();
await client.listDataModels();
await client.cleanup();
await client.listDataModels();
// 4 calls total: 2 auths + 2 lists
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(4);
});
});

View file

@ -0,0 +1,61 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { detectSigmaStagedDir } from '../../../../../src/context/ingest/adapters/sigma/detect.js';
async function touch(dir: string, relPath: string, body = '{}'): Promise<void> {
const abs = join(dir, relPath);
await mkdir(join(abs, '..'), { recursive: true });
await writeFile(abs, body, 'utf-8');
}
describe('detectSigmaStagedDir', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-detect-'));
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('returns true when manifest and at least one data-model file are present', async () => {
await touch(stagedDir, 'sigma-manifest.json');
await touch(stagedDir, 'data-models/dm-aaa111.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(true);
});
it('returns true when manifest and at least one workbook file are present', async () => {
await touch(stagedDir, 'sigma-manifest.json');
await touch(stagedDir, 'workbooks/wb-xxx111.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(true);
});
it('returns false when sigma-manifest.json is absent', async () => {
await touch(stagedDir, 'data-models/dm-aaa111.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
it('returns false for a completely empty directory', async () => {
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
it('returns false when manifest is present but both entity dirs are empty', async () => {
await touch(stagedDir, 'sigma-manifest.json');
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
it('returns false when manifest is present but entity dirs are absent', async () => {
await touch(stagedDir, 'sigma-manifest.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
it('returns false when only unrelated files are present', async () => {
await touch(stagedDir, 'data-models/dm-aaa111.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
});

View file

@ -0,0 +1,493 @@
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SigmaClientFactory, SigmaRuntimeClient } from '../../../../../src/context/ingest/adapters/sigma/client-port.js';
import { fetchSigmaBundle } from '../../../../../src/context/ingest/adapters/sigma/fetch.js';
import type { SigmaPullConfig } from '../../../../../src/context/ingest/adapters/sigma/types.js';
const TEST_PULL_CONFIG = { sigmaConnectionId: 'sigma-prod' };
function makeSummary(id: string, name: string, path: string, isArchived = false) {
return {
dataModelId: id,
dataModelUrlId: `${name.replace(/\s+/g, '-')}-${id}`,
name,
path,
latestVersion: 1,
ownerId: 'user-1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-15T00:00:00Z',
isArchived,
};
}
function makeFactory(client: Partial<SigmaRuntimeClient>): SigmaClientFactory {
const fullClient: SigmaRuntimeClient = {
testConnection: vi.fn().mockResolvedValue({ success: true }),
listDataModels: vi.fn().mockResolvedValue([]),
listWorkbooks: vi.fn().mockResolvedValue([]),
getDataModelSpec: vi.fn().mockResolvedValue(null),
cleanup: vi.fn().mockResolvedValue(undefined),
...client,
};
return {
createClient: vi.fn().mockResolvedValue(fullClient),
};
}
describe('fetchSigmaBundle', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-fetch-'));
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('creates sigma-manifest.json after a successful fetch', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'Revenue Model', 'Finance/Revenue'),
]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.sigmaConnectionId).toBe('sigma-prod');
expect(manifest.dataModelCount).toBe(1);
expect(manifest.fetchedAt).toBeDefined();
});
it('writes one data-model file per active model', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'Revenue Model', 'Finance/Revenue'),
makeSummary('dm-2', 'ARR Model', 'Finance/ARR'),
]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const dm1 = JSON.parse(await readFile(join(stagedDir, 'data-models', 'dm-1.json'), 'utf-8'));
const dm2 = JSON.parse(await readFile(join(stagedDir, 'data-models', 'dm-2.json'), 'utf-8'));
expect(dm1.name).toBe('Revenue Model');
expect(dm2.name).toBe('ARR Model');
});
it('skips archived models and does not write their files', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'Active Model', 'Finance/Active', false),
makeSummary('dm-archived', 'Archived Model', 'Finance/Old', true),
]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(1);
await expect(readFile(join(stagedDir, 'data-models', 'dm-archived.json'), 'utf-8')).rejects.toThrow();
});
it('logs a specific message for unsupported data source subtype (service_error)', async () => {
const warnMessages: string[] = [];
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'CSV Upload Model', 'Finance/CSV'),
]),
getDataModelSpec: vi.fn().mockRejectedValue(
new Error('Sigma API error (500): {"code":"service_error","message":"dataSource subtype not supported in data model read"}'),
),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
logger: { log: () => undefined, warn: (m) => warnMessages.push(m) },
});
expect(warnMessages[0]).toContain('data source type not supported');
expect(warnMessages[0]).not.toContain('Sigma API error (500)');
});
it('writes null spec when getDataModelSpec throws, and does not abort the whole fetch', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'Good Model', 'Finance/Good'),
makeSummary('dm-2', 'Broken Model', 'Finance/Broken'),
]),
getDataModelSpec: vi
.fn()
.mockResolvedValueOnce({ schemaVersion: 1 })
.mockRejectedValueOnce(new Error('Spec fetch failed')),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const dm2 = JSON.parse(await readFile(join(stagedDir, 'data-models', 'dm-2.json'), 'utf-8'));
expect(dm2.spec).toBeNull();
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(2);
});
it('calls cleanup on the client even when an error is thrown', async () => {
const cleanupMock = vi.fn().mockResolvedValue(undefined);
const factory = makeFactory({
listDataModels: vi.fn().mockRejectedValue(new Error('Network failure')),
cleanup: cleanupMock,
});
await expect(
fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
}),
).rejects.toThrow('Network failure');
expect(cleanupMock).toHaveBeenCalledOnce();
});
it('passes the resolved config to clientFactory.createClient', async () => {
const createClientMock = vi.fn().mockResolvedValue({
testConnection: vi.fn(),
listDataModels: vi.fn().mockResolvedValue([]),
listWorkbooks: vi.fn().mockResolvedValue([]),
getDataModelSpec: vi.fn(),
cleanup: vi.fn().mockResolvedValue(undefined),
} satisfies SigmaRuntimeClient);
const factory: SigmaClientFactory = { createClient: createClientMock };
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const calledConfig = createClientMock.mock.calls[0]![0] as SigmaPullConfig;
expect(calledConfig.sigmaConnectionId).toBe('sigma-prod');
});
it('writes sigma-projection-config.json with connectionMappings from pullConfig', async () => {
const factory = makeFactory({});
await fetchSigmaBundle({
pullConfig: { sigmaConnectionId: 'sigma-prod', connectionMappings: { 'uuid-1': 'snowflake-prod' } },
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const config = JSON.parse(await readFile(join(stagedDir, 'sigma-projection-config.json'), 'utf-8'));
expect(config.connectionMappings['uuid-1']).toBe('snowflake-prod');
});
it('writes sigma-projection-config.json with empty mappings when none are provided', async () => {
const factory = makeFactory({});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const config = JSON.parse(await readFile(join(stagedDir, 'sigma-projection-config.json'), 'utf-8'));
expect(config.connectionMappings).toEqual({});
});
it('writes workbookFilter defaults to projection config when not specified', async () => {
const factory = makeFactory({});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const config = JSON.parse(await readFile(join(stagedDir, 'sigma-projection-config.json'), 'utf-8'));
expect(config.workbookFilter.includeArchived).toBe(false);
expect(config.workbookFilter.includeExplorations).toBe(false);
expect(config.workbookFilter.updatedSince).toBeUndefined();
});
it('writes explicit workbookFilter settings to projection config', async () => {
const factory = makeFactory({});
await fetchSigmaBundle({
pullConfig: {
sigmaConnectionId: 'sigma-prod',
workbookFilter: { includeArchived: true, includeExplorations: false, updatedSince: '2026-01-01T00:00:00Z' },
},
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const config = JSON.parse(await readFile(join(stagedDir, 'sigma-projection-config.json'), 'utf-8'));
expect(config.workbookFilter.includeArchived).toBe(true);
expect(config.workbookFilter.updatedSince).toBe('2026-01-01T00:00:00Z');
});
it('throws on invalid pullConfig', async () => {
const factory = makeFactory({});
await expect(
fetchSigmaBundle({
pullConfig: { sigmaConnectionId: 'invalid id with spaces' },
stagedDir,
ctx: {} as never,
clientFactory: factory,
}),
).rejects.toThrow();
});
it('handles zero active models gracefully', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([]),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(0);
});
it('skips spec fetch for a model whose updatedAt matches the existing staged file', async () => {
const summary = makeSummary('dm-1', 'Revenue Model', 'Finance/Revenue');
// Pre-populate a staged file with the same updatedAt.
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const existingStaged = {
sigmaId: 'dm-1',
name: 'Revenue Model',
path: 'Finance/Revenue',
latestVersion: 1,
updatedAt: summary.updatedAt,
isArchived: false,
spec: { schemaVersion: 1, name: 'old' },
};
await writeFile(
join(stagedDir, 'data-models', 'dm-1.json'),
JSON.stringify(existingStaged),
'utf-8',
);
const getSpecMock = vi.fn().mockResolvedValue({ schemaVersion: 1 });
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([summary]),
getDataModelSpec: getSpecMock,
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
// Spec fetch must be skipped for the unchanged model.
expect(getSpecMock).not.toHaveBeenCalled();
});
it('retries spec fetch for a model whose updatedAt matches but staged spec is null (transient failure)', async () => {
const summary = makeSummary('dm-1', 'Revenue Model', 'Finance/Revenue');
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const existingStaged = {
sigmaId: 'dm-1',
name: 'Revenue Model',
path: 'Finance/Revenue',
latestVersion: 1,
updatedAt: summary.updatedAt,
isArchived: false,
spec: null,
};
await writeFile(
join(stagedDir, 'data-models', 'dm-1.json'),
JSON.stringify(existingStaged),
'utf-8',
);
const freshSpec = { schemaVersion: 1, name: 'Revenue Model' };
const getSpecMock = vi.fn().mockResolvedValue(freshSpec);
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([summary]),
getDataModelSpec: getSpecMock,
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
expect(getSpecMock).toHaveBeenCalledWith('dm-1');
const written = JSON.parse(await readFile(join(stagedDir, 'data-models', 'dm-1.json'), 'utf-8'));
expect(written.spec).toEqual(freshSpec);
});
it('writes workbook count to manifest', async () => {
const factory = makeFactory({
listWorkbooks: vi.fn().mockResolvedValue([
{ workbookId: 'wb-1', workbookUrlId: 'wb-url-1', name: 'Sales Dashboard', path: 'Finance/Dashboards', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-15T00:00:00Z', isArchived: false },
{ workbookId: 'wb-2', workbookUrlId: 'wb-url-2', name: 'ARR Tracker', path: 'Finance/Dashboards', latestVersion: 2, ownerId: 'u1', createdAt: '2026-01-02T00:00:00Z', updatedAt: '2026-01-16T00:00:00Z', isArchived: false },
]),
});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.workbookCount).toBe(2);
});
it('writes one staged file per active workbook', async () => {
const factory = makeFactory({
listWorkbooks: vi.fn().mockResolvedValue([
{ workbookId: 'wb-1', workbookUrlId: 'wb-url-1', name: 'Sales Dashboard', path: 'Finance/Dashboards', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-15T00:00:00Z', isArchived: false, description: 'Finance overview' },
]),
});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const wb = JSON.parse(await readFile(join(stagedDir, 'workbooks', 'wb-1.json'), 'utf-8'));
expect(wb.name).toBe('Sales Dashboard');
expect(wb.description).toBe('Finance overview');
});
it('skips workbook re-staging when updatedAt is unchanged', async () => {
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
const existing = { sigmaId: 'wb-1', name: 'Sales Dashboard', path: 'Finance', latestVersion: 1, updatedAt: '2026-01-15T00:00:00Z', isArchived: false };
await writeFile(join(stagedDir, 'workbooks', 'wb-1.json'), JSON.stringify(existing), 'utf-8');
const listWorkbooksMock = vi.fn().mockResolvedValue([
{ workbookId: 'wb-1', workbookUrlId: 'wb-url-1', name: 'Sales Dashboard', path: 'Finance', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-15T00:00:00Z', isArchived: false },
]);
const factory = makeFactory({ listWorkbooks: listWorkbooksMock });
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
// File should still contain the pre-existing content (not overwritten).
const wb = JSON.parse(await readFile(join(stagedDir, 'workbooks', 'wb-1.json'), 'utf-8'));
expect(wb.sigmaId).toBe('wb-1');
});
it('removes the staged file when a model is no longer in the active list', async () => {
// Pre-populate a staged file for dm-stale.
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const staleStaged = {
sigmaId: 'dm-stale',
name: 'Stale Model',
path: 'Old/Stale',
latestVersion: 1,
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
spec: null,
};
await writeFile(
join(stagedDir, 'data-models', 'dm-stale.json'),
JSON.stringify(staleStaged),
'utf-8',
);
// API now returns only dm-1 (dm-stale was archived or deleted).
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([makeSummary('dm-1', 'Active Model', 'Finance/Active')]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
await expect(
readFile(join(stagedDir, 'data-models', 'dm-stale.json'), 'utf-8'),
).rejects.toThrow();
// The active model's file must still exist.
await expect(
readFile(join(stagedDir, 'data-models', 'dm-1.json'), 'utf-8'),
).resolves.toBeDefined();
});
it('filters spec fetches by dataModelFilter.updatedSince but preserves existing staged files for filtered-out models', async () => {
// Pre-stage the old model from a previous full fetch.
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const oldStaged = {
sigmaId: 'dm-old', name: 'Old Model', path: 'Finance/Old',
latestVersion: 1, updatedAt: '2026-06-20T00:00:00Z', isArchived: false, spec: { schemaVersion: 0 },
};
await writeFile(join(stagedDir, 'data-models', 'dm-old.json'), JSON.stringify(oldStaged), 'utf-8');
const getSpecMock = vi.fn().mockResolvedValue({ schemaVersion: 1 });
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
{ ...makeSummary('dm-old', 'Old Model', 'Finance/Old'), updatedAt: '2026-06-20T00:00:00Z' },
{ ...makeSummary('dm-new', 'New Model', 'Finance/New'), updatedAt: '2026-06-26T00:00:00Z' },
]),
getDataModelSpec: getSpecMock,
});
await fetchSigmaBundle({
pullConfig: { sigmaConnectionId: 'sigma-prod', dataModelFilter: { updatedSince: '2026-06-25T00:00:00Z' } },
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
// Only the new model's spec is fetched (old one is outside the filter window).
expect(getSpecMock).toHaveBeenCalledTimes(1);
// Manifest reflects only the filtered count.
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(1);
// New model is staged.
await expect(readFile(join(stagedDir, 'data-models', 'dm-new.json'), 'utf-8')).resolves.toBeDefined();
// Old model's staged file is PRESERVED — it is still active, just outside the filter window.
await expect(readFile(join(stagedDir, 'data-models', 'dm-old.json'), 'utf-8')).resolves.toBeDefined();
});
it('includes all active models when dataModelFilter is not set', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
{ ...makeSummary('dm-old', 'Old Model', 'Finance/Old'), updatedAt: '2026-01-01T00:00:00Z' },
{ ...makeSummary('dm-new', 'New Model', 'Finance/New'), updatedAt: '2026-06-26T00:00:00Z' },
]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(2);
});
it('removes the staged file when a workbook is no longer returned by the API', async () => {
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
const stale = {
sigmaId: 'wb-stale',
name: 'Old Dashboard',
path: 'Finance/Old',
latestVersion: 1,
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
};
await writeFile(join(stagedDir, 'workbooks', 'wb-stale.json'), JSON.stringify(stale), 'utf-8');
const factory = makeFactory({
listWorkbooks: vi.fn().mockResolvedValue([
{ workbookId: 'wb-active', workbookUrlId: 'wb-url-active', name: 'Active Dashboard', path: 'Finance/Active', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-16T00:00:00Z', isArchived: false },
]),
});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
await expect(readFile(join(stagedDir, 'workbooks', 'wb-stale.json'), 'utf-8')).rejects.toThrow();
await expect(readFile(join(stagedDir, 'workbooks', 'wb-active.json'), 'utf-8')).resolves.toBeDefined();
});
it('workbookFilter.updatedSince filters fetch but preserves existing staged files for older workbooks', async () => {
// Pre-stage an old workbook from a previous full fetch.
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
const oldStaged = {
sigmaId: 'wb-old', name: 'Old Dashboard', path: 'Finance/Old',
latestVersion: 1, updatedAt: '2026-06-20T00:00:00Z', isArchived: false, workbookUrlId: 'wb-url-old',
};
await writeFile(join(stagedDir, 'workbooks', 'wb-old.json'), JSON.stringify(oldStaged), 'utf-8');
const listWorkbooksMock = vi.fn().mockResolvedValue([
{ workbookId: 'wb-old', workbookUrlId: 'wb-url-old', name: 'Old Dashboard', path: 'Finance/Old', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-06-20T00:00:00Z', isArchived: false },
{ workbookId: 'wb-new', workbookUrlId: 'wb-url-new', name: 'New Dashboard', path: 'Finance/New', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-06-26T00:00:00Z', isArchived: false },
]);
const factory = makeFactory({ listWorkbooks: listWorkbooksMock });
await fetchSigmaBundle({
pullConfig: { sigmaConnectionId: 'sigma-prod', workbookFilter: { updatedSince: '2026-06-25T00:00:00Z' } },
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
// Only the new workbook is staged on this run.
await expect(readFile(join(stagedDir, 'workbooks', 'wb-new.json'), 'utf-8')).resolves.toBeDefined();
// Old workbook's staged file is PRESERVED — it is still active, just outside the filter window.
await expect(readFile(join(stagedDir, 'workbooks', 'wb-old.json'), 'utf-8')).resolves.toBeDefined();
// listWorkbooks is called without updatedSince to get the full universe for eviction.
expect(listWorkbooksMock).toHaveBeenCalledWith(expect.not.objectContaining({ updatedSince: expect.anything() }));
});
});

View file

@ -0,0 +1,301 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { projectSigmaDataModels } from '../../../../../src/context/ingest/adapters/sigma/project.js';
import type { DeterministicProjectionContext } from '../../../../../src/context/ingest/types.js';
import type { SemanticLayerService } from '../../../../../src/context/sl/semantic-layer.service.js';
import type { SemanticLayerSource } from '../../../../../src/context/sl/types.js';
function makeCtx(
stagedDir: string,
writeSource: (connectionId: string, source: SemanticLayerSource, ...rest: string[]) => Promise<{ warnings: string[] }>,
): DeterministicProjectionContext {
const svc = {
writeSource,
forWorktree: () => ({ writeSource }),
} as unknown as SemanticLayerService;
return {
connectionId: 'sigma-prod',
sourceKey: 'sigma-prod',
syncId: 'sync-1',
jobId: 'job-1',
runId: 'run-1',
stagedDir,
workdir: '',
semanticLayerService: svc,
};
}
function makeSpec(elements: unknown[]) {
return {
schemaVersion: 1,
name: 'Test Model',
pages: [{ id: 'p1', name: 'Main', elements }],
};
}
function makeStagedModel(id: string, name: string, spec: unknown) {
return JSON.stringify({
sigmaId: id,
name,
path: 'Finance/Models',
latestVersion: 1,
updatedAt: '2026-01-15T00:00:00Z',
isArchived: false,
spec,
});
}
/** Write a projection config that maps the given sigma connection IDs to 'warehouse-main'. */
async function writeProjectionConfig(stagedDir: string, sigmaConnectionIds: string[]): Promise<void> {
const mappings = Object.fromEntries(sigmaConnectionIds.map((id) => [id, 'warehouse-main']));
await writeFile(
join(stagedDir, 'sigma-projection-config.json'),
JSON.stringify({ connectionMappings: mappings }),
'utf-8',
);
}
describe('projectSigmaDataModels', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-project-'));
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('returns empty result when data-models directory is missing', async () => {
const emptyDir = await mkdtemp(join(tmpdir(), 'sigma-project-empty-'));
try {
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(makeCtx(emptyDir, writeSource), makeCtx(emptyDir, writeSource).semanticLayerService as never);
expect(result.touchedSources).toHaveLength(0);
expect(writeSource).not.toHaveBeenCalled();
} finally {
await rm(emptyDir, { recursive: true, force: true });
}
});
it('converts a warehouse-table element to a semantic-layer source', async () => {
await writeProjectionConfig(stagedDir, ['sigma-conn-uuid']);
const spec = makeSpec([
{
id: 'elem1',
kind: 'table',
name: 'Opportunities',
source: { kind: 'warehouse-table', connectionId: 'sigma-conn-uuid', path: ['FIVETRAN', 'SALESFORCE', 'OPPORTUNITIES'] },
columns: [
{ id: 'c1', formula: '[OPPORTUNITIES/Amount]', name: 'Deal Amount' },
{ id: 'c2', formula: 'Sum([OPPORTUNITIES/Amount])', name: 'Total Amount' },
],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Revenue Model', spec));
const written: Array<{ connectionId: string; source: SemanticLayerSource }> = [];
const writeSource = vi.fn().mockImplementation((connectionId: string, source: SemanticLayerSource) => {
written.push({ connectionId, source });
return Promise.resolve({ warnings: [] });
});
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(writeSource).toHaveBeenCalledOnce();
expect(written[0]!.connectionId).toBe('warehouse-main');
const source = written[0]!.source;
expect(source.table).toBe('FIVETRAN.SALESFORCE.OPPORTUNITIES');
expect(source.columns.some((c) => c.name === 'deal_amount')).toBe(true);
expect(source.columns.some((c) => c.name === 'total_amount')).toBe(false);
expect(source.measures).toEqual([]);
expect(result.touchedSources).toHaveLength(1);
expect(result.errors).toHaveLength(0);
});
it('skips elements whose source kind is not warehouse-table', async () => {
const spec = makeSpec([
{
id: 'elem1',
kind: 'table',
name: 'Derived',
source: { kind: 'data-model', dataModelId: 'dm-other', elementId: 'e1' },
columns: [{ id: 'c1', formula: '[Derived/Revenue]' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Derived Model', spec));
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(writeSource).not.toHaveBeenCalled();
expect(result.touchedSources).toHaveLength(0);
});
it('skips hidden elements', async () => {
const spec = makeSpec([
{
id: 'elem1',
kind: 'table',
name: 'Hidden',
hidden: true,
source: { kind: 'warehouse-table', connectionId: 'c', path: ['DB', 'SCHEMA', 'TABLE'] },
columns: [{ id: 'c1', formula: '[TABLE/Col]' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Hidden Model', spec));
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(writeSource).not.toHaveBeenCalled();
expect(result.touchedSources).toHaveLength(0);
});
it('skips hidden columns', async () => {
await writeProjectionConfig(stagedDir, ['c']);
const spec = makeSpec([
{
id: 'elem1',
kind: 'table',
name: 'Revenue',
source: { kind: 'warehouse-table', connectionId: 'c', path: ['DB', 'S', 'T'] },
columns: [
{ id: 'c1', formula: '[T/Visible]', name: 'Visible' },
{ id: 'c2', formula: '[T/Hidden]', name: 'Hidden', hidden: true },
],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Revenue', spec));
const written: SemanticLayerSource[] = [];
const writeSource = vi.fn().mockImplementation((_: string, source: SemanticLayerSource) => {
written.push(source);
return Promise.resolve({ warnings: [] });
});
await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
const source = written[0]!;
expect(source.columns.some((c) => c.name === 'visible')).toBe(true);
expect(source.columns.some((c) => c.name === 'hidden')).toBe(false);
});
it('silently skips aggregation formula columns and never emits measures', async () => {
await writeProjectionConfig(stagedDir, ['c']);
const spec = makeSpec([
{
id: 'e1',
kind: 'table',
name: 'Sales',
source: { kind: 'warehouse-table', connectionId: 'c', path: ['DB', 'S', 'ORDERS'] },
columns: [
{ id: 'c1', formula: 'Sum([ORDERS/Revenue])', name: 'Total Revenue' },
{ id: 'c2', formula: 'CountDistinct([ORDERS/CustomerId])', name: 'Unique Customers' },
{ id: 'c3', formula: '[ORDERS/OrderDate]', name: 'Order Date' },
],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Sales', spec));
const written: SemanticLayerSource[] = [];
const writeSource = vi.fn().mockImplementation((_: string, source: SemanticLayerSource) => {
written.push(source);
return Promise.resolve({ warnings: [] });
});
await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
const source = written[0]!;
expect(source.measures).toEqual([]);
expect(source.columns.map((c) => c.name)).toContain('order_date');
expect(source.columns.map((c) => c.name)).not.toContain('total_revenue');
expect(source.columns.map((c) => c.name)).not.toContain('unique_customers');
});
it('skips models with null spec', async () => {
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'No Spec Model', null));
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(writeSource).not.toHaveBeenCalled();
expect(result.touchedSources).toHaveLength(0);
});
it('routes to the mapped warehouse connection when connectionMappings is set', async () => {
// Write a projection config that maps the Sigma internal connection UUID to a ktx warehouse.
await writeFile(
join(stagedDir, 'sigma-projection-config.json'),
JSON.stringify({ connectionMappings: { 'sigma-internal-uuid': 'snowflake-prod' } }),
'utf-8',
);
const spec = makeSpec([
{
id: 'e1',
kind: 'table',
name: 'Accounts',
source: { kind: 'warehouse-table', connectionId: 'sigma-internal-uuid', path: ['PROD', 'SF', 'ACCOUNTS'] },
columns: [{ id: 'c1', formula: '[ACCOUNTS/Name]', name: 'Account Name' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Accounts', spec));
const written: Array<{ connectionId: string }> = [];
const writeSource = vi.fn().mockImplementation((connectionId: string) => {
written.push({ connectionId });
return Promise.resolve({ warnings: [] });
});
await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(written[0]!.connectionId).toBe('snowflake-prod');
});
it('skips SL source and emits a warning when no connectionMappings entry exists for the element', async () => {
await writeFile(
join(stagedDir, 'sigma-projection-config.json'),
JSON.stringify({ connectionMappings: { 'other-uuid': 'snowflake-prod' } }),
'utf-8',
);
const spec = makeSpec([
{
id: 'e1',
kind: 'table',
name: 'Orders',
source: { kind: 'warehouse-table', connectionId: 'unmapped-uuid', path: ['DB', 'S', 'ORDERS'] },
columns: [{ id: 'c1', formula: '[ORDERS/Id]', name: 'Order Id' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Orders', spec));
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(
makeCtx(stagedDir, writeSource),
makeCtx(stagedDir, writeSource).semanticLayerService as never,
);
expect(writeSource).not.toHaveBeenCalled();
expect(result.touchedSources).toHaveLength(0);
expect(result.warnings.some((w) => w.includes('no connectionMappings entry'))).toBe(true);
});
it('surfaces writeSource warnings in result', async () => {
await writeProjectionConfig(stagedDir, ['c']);
const spec = makeSpec([
{
id: 'e1',
kind: 'table',
name: 'Revenue',
source: { kind: 'warehouse-table', connectionId: 'c', path: ['DB', 'S', 'T'] },
columns: [{ id: 'c1', formula: '[T/Amount]', name: 'Amount' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Revenue', spec));
const writeSource = vi.fn().mockResolvedValue({ warnings: ['schema: some warning'] });
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(result.warnings).toContain('schema: some warning');
});
});

View file

@ -0,0 +1,64 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SigmaSourceAdapter } from '../../../../../src/context/ingest/adapters/sigma/sigma.adapter.js';
import type { SigmaClientFactory } from '../../../../../src/context/ingest/adapters/sigma/client-port.js';
function makeFactory(): SigmaClientFactory {
return { createClient: vi.fn() };
}
describe('SigmaSourceAdapter.listTargetConnectionIds', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-adapter-'));
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
async function writeProjectionConfig(mappings: Record<string, string>) {
await writeFile(
join(stagedDir, 'sigma-projection-config.json'),
JSON.stringify({ connectionMappings: mappings }),
'utf-8',
);
}
it('returns mapped warehouse connection IDs when mappings are present', async () => {
await writeProjectionConfig({ 'uuid-a': 'snowflake-prod', 'uuid-b': 'snowflake-prod', 'uuid-c': 'bigquery-prod' });
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual(['bigquery-prod', 'snowflake-prod']);
});
it('returns empty array when connectionMappings is empty', async () => {
await writeProjectionConfig({});
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual([]);
});
it('returns empty array when the projection config file is missing', async () => {
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual([]);
});
it('returns empty array when the projection config is malformed', async () => {
await mkdir(stagedDir, { recursive: true });
await writeFile(join(stagedDir, 'sigma-projection-config.json'), 'not json', 'utf-8');
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual([]);
});
it('returns empty array when both projection config and manifest are missing', async () => {
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual([]);
});
});

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import {
parseSigmaPullConfig,
sigmaManifestSchema,
stagedDataModelFileSchema,
} from '../../../../../src/context/ingest/adapters/sigma/types.js';
describe('parseSigmaPullConfig', () => {
it('accepts a simple alphanumeric connection ID', () => {
const result = parseSigmaPullConfig({ sigmaConnectionId: 'sigma-prod' });
expect(result.sigmaConnectionId).toBe('sigma-prod');
});
it('accepts IDs with underscores', () => {
const result = parseSigmaPullConfig({ sigmaConnectionId: 'sigma_prod_2' });
expect(result.sigmaConnectionId).toBe('sigma_prod_2');
});
it('rejects IDs starting with a special char', () => {
expect(() => parseSigmaPullConfig({ sigmaConnectionId: '../prod' })).toThrow();
});
it('rejects IDs with spaces', () => {
expect(() => parseSigmaPullConfig({ sigmaConnectionId: 'sigma prod' })).toThrow();
});
it('rejects missing sigmaConnectionId', () => {
expect(() => parseSigmaPullConfig({})).toThrow();
});
it('rejects null', () => {
expect(() => parseSigmaPullConfig(null)).toThrow();
});
});
describe('stagedDataModelFileSchema', () => {
const minimal = {
sigmaId: 'dm-aaa111',
name: 'Revenue Model',
path: 'My Documents/Finance/Revenue Model',
latestVersion: 3,
updatedAt: '2026-01-15T10:00:00Z',
isArchived: false,
spec: { schemaVersion: 1, pages: [] },
};
it('parses a fully-populated file', () => {
const result = stagedDataModelFileSchema.parse(minimal);
expect(result.sigmaId).toBe('dm-aaa111');
expect(result.name).toBe('Revenue Model');
expect(result.isArchived).toBe(false);
});
it('coerces absent isArchived to false', () => {
const { isArchived: _, ...rest } = minimal;
void _;
const result = stagedDataModelFileSchema.parse(rest);
expect(result.isArchived).toBe(false);
});
it('accepts null spec', () => {
const result = stagedDataModelFileSchema.parse({ ...minimal, spec: null });
expect(result.spec).toBeNull();
});
it('rejects missing sigmaId', () => {
const { sigmaId: _, ...rest } = minimal;
void _;
expect(() => stagedDataModelFileSchema.parse(rest)).toThrow();
});
it('rejects missing name', () => {
const { name: _, ...rest } = minimal;
void _;
expect(() => stagedDataModelFileSchema.parse(rest)).toThrow();
});
it('rejects missing path', () => {
const { path: _, ...rest } = minimal;
void _;
expect(() => stagedDataModelFileSchema.parse(rest)).toThrow();
});
});
describe('sigmaManifestSchema', () => {
const valid = {
sigmaConnectionId: 'sigma-prod',
fetchedAt: '2026-01-15T10:00:00Z',
dataModelCount: 2,
};
it('parses a valid manifest', () => {
const result = sigmaManifestSchema.parse(valid);
expect(result.sigmaConnectionId).toBe('sigma-prod');
expect(result.dataModelCount).toBe(2);
});
it('rejects missing fetchedAt', () => {
const { fetchedAt: _, ...rest } = valid;
void _;
expect(() => sigmaManifestSchema.parse(rest)).toThrow();
});
it('rejects missing dataModelCount', () => {
const { dataModelCount: _, ...rest } = valid;
void _;
expect(() => sigmaManifestSchema.parse(rest)).toThrow();
});
it('rejects a non-integer dataModelCount', () => {
expect(() => sigmaManifestSchema.parse({ ...valid, dataModelCount: 2.5 })).toThrow();
});
});

View file

@ -73,6 +73,7 @@ describe('local ingest adapters', () => {
'lookml',
'dbt',
'metabase',
'sigma',
'gdrive',
'looker',
'metricflow',

View file

@ -24,6 +24,7 @@ const expectedAdapterSkillHeadings: Record<string, string> = {
lookml_ingest: '# LookML to ktx Semantic Layer',
metabase_ingest: '# Metabase to ktx Semantic Layer',
metricflow_ingest: '# MetricFlow to ktx Semantic Layer',
sigma_ingest: '# Sigma Ingest',
};
const verificationWriterSkills = [
'gdrive_synthesize',
@ -33,6 +34,7 @@ const verificationWriterSkills = [
'looker_ingest',
'metabase_ingest',
'metricflow_ingest',
'sigma_ingest',
'live_database_ingest',
'historic_sql_table_digest',
'historic_sql_patterns',

View file

@ -0,0 +1,5 @@
{
"sigmaConnectionId": "sigma-prod",
"fetchedAt": "2026-01-15T10:00:00Z",
"dataModelCount": 0
}

View file

@ -0,0 +1,9 @@
{
"sigmaId": "dm-aaa111",
"name": "Revenue Model",
"path": "Finance/Revenue Model",
"latestVersion": 3,
"updatedAt": "2026-01-15T10:00:00Z",
"isArchived": false,
"spec": { "schemaVersion": 1, "pages": [] }
}

View file

@ -0,0 +1,9 @@
{
"sigmaId": "dm-bbb222",
"name": "ARR Model",
"path": "Finance/ARR Model",
"latestVersion": 1,
"updatedAt": "2026-01-10T08:00:00Z",
"isArchived": false,
"spec": { "schemaVersion": 1, "pages": [] }
}

View file

@ -0,0 +1,9 @@
{
"sigmaId": "dm-ccc333",
"name": "Usage Model",
"path": "Product/Usage Model",
"latestVersion": 2,
"updatedAt": "2026-01-12T09:00:00Z",
"isArchived": false,
"spec": { "schemaVersion": 1, "pages": [] }
}

View file

@ -0,0 +1,6 @@
{
"sigmaConnectionId": "sigma-prod",
"fetchedAt": "2026-01-15T10:00:00Z",
"dataModelCount": 3,
"workbookCount": 2
}

View file

@ -0,0 +1,9 @@
{
"sigmaId": "wb-yyy222",
"name": "Revenue Tracker",
"path": "Finance/Revenue Tracker",
"latestVersion": 2,
"updatedAt": "2026-01-13T08:00:00Z",
"isArchived": false,
"workbookUrlId": "Revenue-Tracker-yyy222"
}

View file

@ -0,0 +1,10 @@
{
"sigmaId": "wb-zzz333",
"name": "Usage Dashboard",
"path": "Product/Usage Dashboard",
"latestVersion": 8,
"updatedAt": "2026-01-12T11:00:00Z",
"isArchived": false,
"workbookUrlId": "Usage-Dashboard-zzz333",
"description": "Product usage metrics"
}

View file

@ -0,0 +1,9 @@
{
"sigmaId": "dm-aaa111",
"name": "Revenue Model",
"path": "Finance/Revenue Model",
"latestVersion": 3,
"updatedAt": "2026-01-15T10:00:00Z",
"isArchived": false,
"spec": { "schemaVersion": 1, "pages": [] }
}

View file

@ -0,0 +1,9 @@
{
"sigmaId": "dm-bbb222",
"name": "ARR Model",
"path": "Finance/ARR Model",
"latestVersion": 1,
"updatedAt": "2026-01-10T08:00:00Z",
"isArchived": false,
"spec": { "schemaVersion": 1, "pages": [] }
}

View file

@ -0,0 +1,6 @@
{
"sigmaConnectionId": "sigma-prod",
"fetchedAt": "2026-01-15T10:00:00Z",
"dataModelCount": 2,
"workbookCount": 1
}

View file

@ -0,0 +1,10 @@
{
"sigmaId": "wb-xxx111",
"name": "Finance Overview",
"path": "Finance/Finance Overview",
"latestVersion": 5,
"updatedAt": "2026-01-14T09:00:00Z",
"isArchived": false,
"workbookUrlId": "Finance-Overview-xxx111",
"description": "Top-level finance dashboard"
}

View file

@ -1364,6 +1364,18 @@ describe('setup sources step', () => {
deps: { validateNotion: vi.fn(async () => ({ ok: true as const, detail: 'roots=0' })) },
expectedLabel: 'Notion',
},
{
source: 'sigma',
connectionId: 'sigma-main',
connection: {
driver: 'sigma',
api_url: 'https://api.sigmacomputing.com',
client_id: 'my-client-id',
client_secret_ref: 'env:SIGMA_CLIENT_SECRET', // pragma: allowlist secret
},
deps: { validateSigma: vi.fn(async () => ({ ok: true as const, detail: 'Sigma API connection verified' })) },
expectedLabel: 'Sigma Computing',
},
];
for (const testCase of cases) {
@ -2035,4 +2047,206 @@ describe('setup sources step', () => {
path: 'staging',
});
});
it('writes Sigma config in non-interactive mode', async () => {
await addPrimarySource();
const validateSigma = vi.fn(async () => ({ ok: true as const, detail: 'Sigma API connection verified' }));
await expect(
runKtxSetupSourcesStep(
{
projectDir,
inputMode: 'disabled',
source: 'sigma',
sourceConnectionId: 'sigma-prod',
sourceUrl: 'https://api.sigmacomputing.com',
sourceClientId: 'my-client-id',
sourceClientSecretRef: 'env:SIGMA_CLIENT_SECRET', // pragma: allowlist secret
runInitialSourceIngest: false,
skipSources: false,
},
makeIo().io,
{ validateSigma },
),
).resolves.toEqual({ status: 'ready', projectDir, connectionIds: ['sigma-prod'] });
expect((await readConfig()).connections['sigma-prod']).toMatchObject({
driver: 'sigma',
api_url: 'https://api.sigmacomputing.com',
client_id: 'my-client-id',
client_secret_ref: 'env:SIGMA_CLIENT_SECRET', // pragma: allowlist secret
});
expect(validateSigma).toHaveBeenCalledOnce();
});
it('defaults Sigma api_url when --source-url is omitted', async () => {
await addPrimarySource();
const validateSigma = vi.fn(async () => ({ ok: true as const, detail: 'Sigma API connection verified' }));
await expect(
runKtxSetupSourcesStep(
{
projectDir,
inputMode: 'disabled',
source: 'sigma',
sourceConnectionId: 'sigma-main',
sourceClientId: 'my-client-id',
sourceClientSecretRef: 'env:SIGMA_CLIENT_SECRET', // pragma: allowlist secret
runInitialSourceIngest: false,
skipSources: false,
},
makeIo().io,
{ validateSigma },
),
).resolves.toEqual({ status: 'ready', projectDir, connectionIds: ['sigma-main'] });
expect((await readConfig()).connections['sigma-main']).toMatchObject({
driver: 'sigma',
api_url: 'https://api.sigmacomputing.com',
});
});
it('rejects --source-auth-token-ref for Sigma and points at --source-client-secret-ref', async () => {
await addPrimarySource();
const io = makeIo();
await expect(
runKtxSetupSourcesStep(
{
projectDir,
inputMode: 'disabled',
source: 'sigma',
sourceConnectionId: 'sigma-main',
sourceClientId: 'my-client-id',
sourceAuthTokenRef: 'env:SIGMA_CLIENT_SECRET', // pragma: allowlist secret
runInitialSourceIngest: false,
skipSources: false,
},
io.io,
{},
),
).resolves.toEqual({ status: 'failed', projectDir });
expect(io.stderr()).toContain('--source-auth-token-ref does not apply to --source sigma; use --source-client-secret-ref.');
});
it('runs interactive Sigma setup with API URL, client ID, and env credential', async () => {
await addPrimarySource();
const validateSigma = vi.fn(async () => ({ ok: true as const, detail: 'Sigma API connection verified' }));
const testPrompts = prompts({
multiselect: [['sigma']],
select: ['env', 'done'],
// connection name, API URL (default accepted), client ID
text: ['sigma-main', 'https://api.sigmacomputing.com', 'my-client-id'],
});
await expect(
runKtxSetupSourcesStep(
{ projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false },
makeIo().io,
{ prompts: testPrompts, validateSigma },
),
).resolves.toEqual({ status: 'ready', projectDir, connectionIds: ['sigma-main'] });
expect(testPrompts.text).toHaveBeenCalledWith(
expect.objectContaining({
message: textInputPrompt(connectionNamePrompt('Sigma Computing')),
initialValue: 'sigma-main',
}),
);
expect(testPrompts.text).toHaveBeenCalledWith(
expect.objectContaining({ message: textInputPrompt('Sigma API URL') }),
);
expect(testPrompts.text).toHaveBeenCalledWith(
expect.objectContaining({ message: textInputPrompt('Sigma client ID') }),
);
expect(testPrompts.select).toHaveBeenCalledWith({
message: 'How should ktx find your Sigma client secret?',
options: [
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
{ value: 'env', label: 'Use SIGMA_CLIENT_SECRET from the environment' },
{ value: 'back', label: 'Back' },
],
});
expect((await readConfig()).connections['sigma-main']).toMatchObject({
driver: 'sigma',
api_url: 'https://api.sigmacomputing.com',
client_id: 'my-client-id',
client_secret_ref: 'env:SIGMA_CLIENT_SECRET', // pragma: allowlist secret
});
});
it('edits an existing Sigma source with current URL and client ID as defaults', async () => {
await addPrimarySource();
await addConnection('sigma-main', {
driver: 'sigma',
api_url: 'https://api.sigmacomputing.com',
client_id: 'old-client-id',
client_secret_ref: 'env:SIGMA_CLIENT_SECRET', // pragma: allowlist secret
});
const testPrompts = prompts({
multiselect: [['sigma']],
select: ['edit:sigma-main', 'keep', 'done'],
// API URL and new client ID
text: ['https://api.sigmacomputing.com', 'new-client-id'],
});
await expect(
runKtxSetupSourcesStep(
{ projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false },
makeIo().io,
{
prompts: testPrompts,
validateSigma: vi.fn(async () => ({ ok: true as const, detail: 'Sigma API connection verified' })),
},
),
).resolves.toEqual({ status: 'ready', projectDir, connectionIds: ['sigma-main'] });
expect(testPrompts.text).toHaveBeenCalledWith(
expect.objectContaining({
message: textInputPrompt('Sigma API URL'),
initialValue: 'https://api.sigmacomputing.com',
}),
);
expect(testPrompts.text).toHaveBeenCalledWith(
expect.objectContaining({
message: textInputPrompt('Sigma client ID'),
initialValue: 'old-client-id', // pre-filled from existing connection
}),
);
expect(testPrompts.select).toHaveBeenCalledWith(
expect.objectContaining({
message: 'How should ktx find your Sigma client secret?',
options: expect.arrayContaining([{ value: 'keep', label: 'Keep existing credential' }]),
}),
);
expect((await readConfig()).connections['sigma-main']).toMatchObject({
driver: 'sigma',
client_id: 'new-client-id',
client_secret_ref: 'env:SIGMA_CLIENT_SECRET', // pragma: allowlist secret
});
});
it('fails Sigma setup when validation rejects the credentials', async () => {
await addPrimarySource();
const io = makeIo();
const result = await runKtxSetupSourcesStep(
{
projectDir,
inputMode: 'disabled',
source: 'sigma',
sourceConnectionId: 'sigma-main',
sourceClientId: 'bad-client-id',
sourceClientSecretRef: 'env:SIGMA_CLIENT_SECRET', // pragma: allowlist secret
runInitialSourceIngest: false,
skipSources: false,
},
io.io,
{ validateSigma: vi.fn(async () => ({ ok: false as const, message: 'Sigma auth failed (401): Unauthorized' })) },
);
expect(result.status).toBe('failed');
expect(io.stderr()).toContain('Sigma auth failed (401): Unauthorized');
});
});