ktx/packages/cli/test/context/ingest/adapters/sigma/fetch.test.ts
Matt Senick (Sigma) acd20ac248
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>
2026-07-01 01:14:57 +02:00

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() }));
});
});