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>
493 lines
22 KiB
TypeScript
493 lines
22 KiB
TypeScript
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() }));
|
|
});
|
|
});
|