mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-04 10:52:13 +02:00
* 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>
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|