@kaelio/ktx 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/python/{kaelio_ktx-0.14.0-py3-none-any.whl → kaelio_ktx-0.15.0-py3-none-any.whl} +0 -0
- package/assets/python/manifest.json +4 -4
- package/dist/.tsbuildinfo +1 -1
- package/dist/commands/setup-commands.js +1 -0
- package/dist/context/ingest/adapters/sigma/chunk.d.ts +6 -0
- package/dist/context/ingest/adapters/sigma/chunk.js +119 -0
- package/dist/context/ingest/adapters/sigma/client-port.d.ts +45 -0
- package/dist/context/ingest/adapters/sigma/client-port.js +1 -0
- package/dist/context/ingest/adapters/sigma/client.d.ts +33 -0
- package/dist/context/ingest/adapters/sigma/client.js +176 -0
- package/dist/context/ingest/adapters/sigma/detect.d.ts +1 -0
- package/dist/context/ingest/adapters/sigma/detect.js +23 -0
- package/dist/context/ingest/adapters/sigma/fetch.d.ts +14 -0
- package/dist/context/ingest/adapters/sigma/fetch.js +191 -0
- package/dist/context/ingest/adapters/sigma/local-sigma.adapter.d.ts +17 -0
- package/dist/context/ingest/adapters/sigma/local-sigma.adapter.js +41 -0
- package/dist/context/ingest/adapters/sigma/project.d.ts +8 -0
- package/dist/context/ingest/adapters/sigma/project.js +186 -0
- package/dist/context/ingest/adapters/sigma/sigma.adapter.d.ts +18 -0
- package/dist/context/ingest/adapters/sigma/sigma.adapter.js +43 -0
- package/dist/context/ingest/adapters/sigma/types.d.ts +80 -0
- package/dist/context/ingest/adapters/sigma/types.js +82 -0
- package/dist/context/ingest/local-adapters.d.ts +2 -1
- package/dist/context/ingest/local-adapters.js +22 -0
- package/dist/context/project/config.d.ts +30 -0
- package/dist/context/project/driver-schemas.d.ts +15 -0
- package/dist/context/project/driver-schemas.js +39 -0
- package/dist/public-ingest.js +1 -0
- package/dist/setup-sources.d.ts +2 -1
- package/dist/setup-sources.js +84 -0
- package/dist/skills/sigma_ingest/SKILL.md +189 -0
- package/package.json +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { sigmaManifestSchema, stagedDataModelFileSchema, stagedWorkbookFileSchema, STAGED_FILES, } from './types.js';
|
|
4
|
+
async function walkStagedDir(stagedDir) {
|
|
5
|
+
let entries;
|
|
6
|
+
try {
|
|
7
|
+
entries = await readdir(stagedDir, { withFileTypes: true, recursive: true });
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
if (err.code === 'ENOENT')
|
|
11
|
+
return [];
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
const paths = [];
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (!entry.isFile())
|
|
17
|
+
continue;
|
|
18
|
+
const abs = join(entry.parentPath, entry.name);
|
|
19
|
+
paths.push(relative(stagedDir, abs).replace(/\\/g, '/'));
|
|
20
|
+
}
|
|
21
|
+
paths.sort();
|
|
22
|
+
return paths;
|
|
23
|
+
}
|
|
24
|
+
async function loadBundle(stagedDir) {
|
|
25
|
+
const allPaths = await walkStagedDir(stagedDir);
|
|
26
|
+
let manifest = null;
|
|
27
|
+
try {
|
|
28
|
+
const body = await readFile(join(stagedDir, STAGED_FILES.manifest), 'utf-8');
|
|
29
|
+
manifest = sigmaManifestSchema.parse(JSON.parse(body));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
manifest = null;
|
|
33
|
+
}
|
|
34
|
+
const dataModelsByPath = new Map();
|
|
35
|
+
const dmPrefix = `${STAGED_FILES.dataModelsDir}/`;
|
|
36
|
+
for (const path of allPaths) {
|
|
37
|
+
if (!path.startsWith(dmPrefix) || !path.endsWith('.json'))
|
|
38
|
+
continue;
|
|
39
|
+
try {
|
|
40
|
+
const body = await readFile(join(stagedDir, path), 'utf-8');
|
|
41
|
+
const parsed = stagedDataModelFileSchema.parse(JSON.parse(body));
|
|
42
|
+
dataModelsByPath.set(path, parsed);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Malformed file — skip.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const workbooksByPath = new Map();
|
|
49
|
+
const wbPrefix = `${STAGED_FILES.workbooksDir}/`;
|
|
50
|
+
for (const path of allPaths) {
|
|
51
|
+
if (!path.startsWith(wbPrefix) || !path.endsWith('.json'))
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
const body = await readFile(join(stagedDir, path), 'utf-8');
|
|
55
|
+
const parsed = stagedWorkbookFileSchema.parse(JSON.parse(body));
|
|
56
|
+
workbooksByPath.set(path, parsed);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Malformed file — skip.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { manifest, dataModelsByPath, workbooksByPath, allPaths };
|
|
63
|
+
}
|
|
64
|
+
/** Max data models per LLM work unit. Controls parallel processing granularity. */
|
|
65
|
+
const DATA_MODELS_PER_UNIT = 50;
|
|
66
|
+
/** Max workbooks per LLM work unit. Controls incremental re-sync granularity. */
|
|
67
|
+
const WORKBOOKS_PER_UNIT = 2000;
|
|
68
|
+
function emitBatches(paths, perUnit, unitKeyBase, labelBase, noun, allPaths) {
|
|
69
|
+
const batches = Math.ceil(paths.length / perUnit) || 0;
|
|
70
|
+
const units = [];
|
|
71
|
+
for (let i = 0; i < batches; i++) {
|
|
72
|
+
const batch = paths.slice(i * perUnit, (i + 1) * perUnit);
|
|
73
|
+
const rawFiles = [...batch].sort();
|
|
74
|
+
const rawFilesSet = new Set(rawFiles);
|
|
75
|
+
const suffix = batches > 1 ? `-${i}` : '';
|
|
76
|
+
units.push({
|
|
77
|
+
unitKey: `${unitKeyBase}${suffix}`,
|
|
78
|
+
displayLabel: batches > 1 ? `${labelBase} (${i + 1}/${batches})` : labelBase,
|
|
79
|
+
rawFiles,
|
|
80
|
+
peerFileIndex: allPaths.filter((p) => !rawFilesSet.has(p)).sort(),
|
|
81
|
+
dependencyPaths: [],
|
|
82
|
+
notes: `${batch.length} ${noun}${batch.length === 1 ? '' : 's'}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return units;
|
|
86
|
+
}
|
|
87
|
+
function emitWorkUnits(bundle) {
|
|
88
|
+
if (!bundle.manifest)
|
|
89
|
+
return [];
|
|
90
|
+
const dmPaths = [...bundle.dataModelsByPath.keys()].sort();
|
|
91
|
+
const wbPaths = [...bundle.workbooksByPath.keys()].sort();
|
|
92
|
+
return [
|
|
93
|
+
...emitBatches(dmPaths, DATA_MODELS_PER_UNIT, 'sigma-data-models', 'Sigma: data models', 'data model', bundle.allPaths),
|
|
94
|
+
...emitBatches(wbPaths, WORKBOOKS_PER_UNIT, 'sigma-workbooks', 'Sigma: workbooks', 'workbook', bundle.allPaths),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
export async function chunkSigmaStagedDir(stagedDir, opts = {}) {
|
|
98
|
+
const bundle = await loadBundle(stagedDir);
|
|
99
|
+
if (!bundle.manifest) {
|
|
100
|
+
return { workUnits: [] };
|
|
101
|
+
}
|
|
102
|
+
const firstRunUnits = emitWorkUnits(bundle);
|
|
103
|
+
if (!opts.diffSet) {
|
|
104
|
+
return { workUnits: firstRunUnits };
|
|
105
|
+
}
|
|
106
|
+
const touched = new Set([...opts.diffSet.added, ...opts.diffSet.modified]);
|
|
107
|
+
const kept = [];
|
|
108
|
+
for (const wu of firstRunUnits) {
|
|
109
|
+
const anyTouched = wu.rawFiles.some((p) => touched.has(p));
|
|
110
|
+
if (!anyTouched)
|
|
111
|
+
continue;
|
|
112
|
+
const changedFiles = wu.rawFiles.filter((p) => touched.has(p));
|
|
113
|
+
const unchangedFiles = wu.rawFiles.filter((p) => !touched.has(p));
|
|
114
|
+
const deps = new Set([...wu.dependencyPaths, ...unchangedFiles]);
|
|
115
|
+
kept.push({ ...wu, rawFiles: changedFiles.sort(), dependencyPaths: [...deps].sort() });
|
|
116
|
+
}
|
|
117
|
+
const eviction = opts.diffSet.deleted.length > 0 ? { deletedRawPaths: [...opts.diffSet.deleted].sort() } : undefined;
|
|
118
|
+
return { workUnits: kept, eviction };
|
|
119
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FetchContext } from '../../types.js';
|
|
2
|
+
import type { SigmaPullConfig, WorkbookFilterInput } from './types.js';
|
|
3
|
+
export interface SigmaTestConnectionResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
message?: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Data model summary shape from GET /v2/dataModels list response. */
|
|
9
|
+
export interface SigmaDataModelSummary {
|
|
10
|
+
dataModelId: string;
|
|
11
|
+
dataModelUrlId: string;
|
|
12
|
+
name: string;
|
|
13
|
+
path: string;
|
|
14
|
+
latestVersion: number;
|
|
15
|
+
ownerId: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
isArchived?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Workbook summary shape from GET /v2/workbooks list response. */
|
|
21
|
+
export interface SigmaWorkbookSummary {
|
|
22
|
+
workbookId: string;
|
|
23
|
+
workbookUrlId: string;
|
|
24
|
+
name: string;
|
|
25
|
+
path: string;
|
|
26
|
+
latestVersion: number;
|
|
27
|
+
ownerId: string;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
isArchived?: boolean;
|
|
31
|
+
description?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Re-exported so callers can reference the type without importing from types.ts directly. */
|
|
34
|
+
export type { WorkbookFilterInput as ListWorkbooksOptions } from './types.js';
|
|
35
|
+
export interface SigmaRuntimeClient {
|
|
36
|
+
testConnection(): Promise<SigmaTestConnectionResult>;
|
|
37
|
+
listDataModels(): Promise<SigmaDataModelSummary[]>;
|
|
38
|
+
listWorkbooks(opts?: WorkbookFilterInput): Promise<SigmaWorkbookSummary[]>;
|
|
39
|
+
/** Returns the raw spec object from GET /v2/dataModels/{id}/spec. */
|
|
40
|
+
getDataModelSpec(dataModelId: string): Promise<unknown>;
|
|
41
|
+
cleanup(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export interface SigmaClientFactory {
|
|
44
|
+
createClient(config: SigmaPullConfig, ctx: FetchContext): Promise<SigmaRuntimeClient> | SigmaRuntimeClient;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ListWorkbooksOptions, SigmaDataModelSummary, SigmaRuntimeClient, SigmaTestConnectionResult, SigmaWorkbookSummary } from './client-port.js';
|
|
2
|
+
export interface SigmaClientRuntimeConfig {
|
|
3
|
+
apiUrl: string;
|
|
4
|
+
clientId: string;
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SigmaClientConfig {
|
|
8
|
+
maxRetries: number;
|
|
9
|
+
baseDelayMs: number;
|
|
10
|
+
maxDelayMs: number;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const DEFAULT_SIGMA_CLIENT_CONFIG: SigmaClientConfig;
|
|
14
|
+
export declare class DefaultSigmaClient implements SigmaRuntimeClient {
|
|
15
|
+
private readonly runtimeConfig;
|
|
16
|
+
private readonly clientConfig;
|
|
17
|
+
private accessToken;
|
|
18
|
+
private refreshToken;
|
|
19
|
+
private tokenExpiresAt;
|
|
20
|
+
private tokenInflight;
|
|
21
|
+
constructor(runtimeConfig: SigmaClientRuntimeConfig, clientConfig?: SigmaClientConfig);
|
|
22
|
+
private get apiUrl();
|
|
23
|
+
private basicAuthHeader;
|
|
24
|
+
private fetchToken;
|
|
25
|
+
private ensureToken;
|
|
26
|
+
private request;
|
|
27
|
+
private paginateAll;
|
|
28
|
+
testConnection(): Promise<SigmaTestConnectionResult>;
|
|
29
|
+
listDataModels(): Promise<SigmaDataModelSummary[]>;
|
|
30
|
+
listWorkbooks(opts?: ListWorkbooksOptions): Promise<SigmaWorkbookSummary[]>;
|
|
31
|
+
getDataModelSpec(dataModelId: string): Promise<unknown>;
|
|
32
|
+
cleanup(): Promise<void>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export const DEFAULT_SIGMA_CLIENT_CONFIG = {
|
|
2
|
+
maxRetries: 3,
|
|
3
|
+
baseDelayMs: 500,
|
|
4
|
+
maxDelayMs: 10_000,
|
|
5
|
+
timeoutMs: 30_000,
|
|
6
|
+
};
|
|
7
|
+
function isNonRetryable500(text) {
|
|
8
|
+
try {
|
|
9
|
+
const body = JSON.parse(text);
|
|
10
|
+
// service_error indicates a deterministic Sigma rejection (e.g. unsupported data
|
|
11
|
+
// source subtype). Retrying will not help, so throw immediately.
|
|
12
|
+
return body['code'] === 'service_error';
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class DefaultSigmaClient {
|
|
19
|
+
runtimeConfig;
|
|
20
|
+
clientConfig;
|
|
21
|
+
accessToken = null;
|
|
22
|
+
refreshToken = null;
|
|
23
|
+
tokenExpiresAt = 0;
|
|
24
|
+
tokenInflight = null;
|
|
25
|
+
constructor(runtimeConfig, clientConfig = DEFAULT_SIGMA_CLIENT_CONFIG) {
|
|
26
|
+
this.runtimeConfig = runtimeConfig;
|
|
27
|
+
this.clientConfig = clientConfig;
|
|
28
|
+
}
|
|
29
|
+
get apiUrl() {
|
|
30
|
+
return this.runtimeConfig.apiUrl.replace(/\/$/, '');
|
|
31
|
+
}
|
|
32
|
+
basicAuthHeader() {
|
|
33
|
+
const credentials = Buffer.from(`${this.runtimeConfig.clientId}:${this.runtimeConfig.clientSecret}`).toString('base64');
|
|
34
|
+
return `Basic ${credentials}`;
|
|
35
|
+
}
|
|
36
|
+
async fetchToken(body) {
|
|
37
|
+
const res = await fetch(`${this.apiUrl}/v2/auth/token`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
41
|
+
Authorization: this.basicAuthHeader(),
|
|
42
|
+
},
|
|
43
|
+
body: body.toString(),
|
|
44
|
+
signal: AbortSignal.timeout(this.clientConfig.timeoutMs),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const text = await res.text().catch(() => '');
|
|
48
|
+
throw new Error(`Sigma auth failed (${res.status}): ${text}`);
|
|
49
|
+
}
|
|
50
|
+
return res.json();
|
|
51
|
+
}
|
|
52
|
+
async ensureToken() {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
// Refresh 60 s before expiry so in-flight requests don't get 401.
|
|
55
|
+
if (this.accessToken && now < this.tokenExpiresAt - 60_000) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (this.tokenInflight)
|
|
59
|
+
return this.tokenInflight;
|
|
60
|
+
const body = new URLSearchParams();
|
|
61
|
+
if (this.refreshToken) {
|
|
62
|
+
body.set('grant_type', 'refresh_token');
|
|
63
|
+
body.set('refresh_token', this.refreshToken);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
body.set('grant_type', 'client_credentials');
|
|
67
|
+
}
|
|
68
|
+
this.tokenInflight = this.fetchToken(body)
|
|
69
|
+
.then((data) => {
|
|
70
|
+
this.accessToken = data.access_token;
|
|
71
|
+
this.refreshToken = data.refresh_token ?? null;
|
|
72
|
+
this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
|
|
73
|
+
})
|
|
74
|
+
.finally(() => {
|
|
75
|
+
this.tokenInflight = null;
|
|
76
|
+
});
|
|
77
|
+
return this.tokenInflight;
|
|
78
|
+
}
|
|
79
|
+
async request(path, query) {
|
|
80
|
+
await this.ensureToken();
|
|
81
|
+
const url = new URL(`${this.apiUrl}${path}`);
|
|
82
|
+
if (query) {
|
|
83
|
+
for (const [k, v] of Object.entries(query)) {
|
|
84
|
+
url.searchParams.set(k, v);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let lastError = null;
|
|
88
|
+
for (let attempt = 0; attempt <= this.clientConfig.maxRetries; attempt++) {
|
|
89
|
+
if (attempt > 0) {
|
|
90
|
+
const delay = Math.min(this.clientConfig.baseDelayMs * 2 ** (attempt - 1), this.clientConfig.maxDelayMs);
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
92
|
+
}
|
|
93
|
+
const res = await fetch(url.toString(), {
|
|
94
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
95
|
+
signal: AbortSignal.timeout(this.clientConfig.timeoutMs),
|
|
96
|
+
});
|
|
97
|
+
if (res.status === 401) {
|
|
98
|
+
// Token rejected — force full re-auth and retry once.
|
|
99
|
+
this.accessToken = null;
|
|
100
|
+
this.refreshToken = null;
|
|
101
|
+
this.tokenExpiresAt = 0;
|
|
102
|
+
await this.ensureToken();
|
|
103
|
+
const retried = await fetch(url.toString(), {
|
|
104
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
105
|
+
signal: AbortSignal.timeout(this.clientConfig.timeoutMs),
|
|
106
|
+
});
|
|
107
|
+
if (!retried.ok) {
|
|
108
|
+
const text = await retried.text().catch(() => '');
|
|
109
|
+
throw new Error(`Sigma API error after token refresh (${retried.status}): ${text}`);
|
|
110
|
+
}
|
|
111
|
+
return retried.json();
|
|
112
|
+
}
|
|
113
|
+
if (res.status === 429 || res.status >= 500) {
|
|
114
|
+
const text = await res.text().catch(() => '');
|
|
115
|
+
lastError = new Error(`Sigma API error (${res.status}): ${text}`);
|
|
116
|
+
if (isNonRetryable500(text))
|
|
117
|
+
throw lastError;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
const text = await res.text().catch(() => '');
|
|
122
|
+
throw new Error(`Sigma API error (${res.status}): ${text}`);
|
|
123
|
+
}
|
|
124
|
+
return res.json();
|
|
125
|
+
}
|
|
126
|
+
throw lastError ?? new Error('Sigma API request failed after retries');
|
|
127
|
+
}
|
|
128
|
+
async paginateAll(path, query = {}) {
|
|
129
|
+
const all = [];
|
|
130
|
+
let page = null;
|
|
131
|
+
do {
|
|
132
|
+
const q = { ...query, limit: '1000' };
|
|
133
|
+
if (page) {
|
|
134
|
+
q['page'] = page;
|
|
135
|
+
}
|
|
136
|
+
const res = await this.request(path, q);
|
|
137
|
+
all.push(...res.entries);
|
|
138
|
+
page = res.nextPage ?? null;
|
|
139
|
+
} while (page !== null);
|
|
140
|
+
return all;
|
|
141
|
+
}
|
|
142
|
+
async testConnection() {
|
|
143
|
+
try {
|
|
144
|
+
await this.ensureToken();
|
|
145
|
+
return { success: true };
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async listDataModels() {
|
|
152
|
+
return this.paginateAll('/v2/dataModels');
|
|
153
|
+
}
|
|
154
|
+
async listWorkbooks(opts = {}) {
|
|
155
|
+
const query = {};
|
|
156
|
+
if (!opts.includeExplorations)
|
|
157
|
+
query['excludeExplorations'] = 'true';
|
|
158
|
+
let results = await this.paginateAll('/v2/workbooks', query);
|
|
159
|
+
if (!opts.includeArchived) {
|
|
160
|
+
results = results.filter((wb) => !wb.isArchived);
|
|
161
|
+
}
|
|
162
|
+
if (opts.updatedSince) {
|
|
163
|
+
const since = new Date(opts.updatedSince).getTime();
|
|
164
|
+
results = results.filter((wb) => new Date(wb.updatedAt).getTime() >= since);
|
|
165
|
+
}
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
async getDataModelSpec(dataModelId) {
|
|
169
|
+
return this.request(`/v2/dataModels/${encodeURIComponent(dataModelId)}/spec`);
|
|
170
|
+
}
|
|
171
|
+
async cleanup() {
|
|
172
|
+
this.accessToken = null;
|
|
173
|
+
this.refreshToken = null;
|
|
174
|
+
this.tokenExpiresAt = 0;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function detectSigmaStagedDir(stagedDir: string): Promise<boolean>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { STAGED_FILES } from './types.js';
|
|
4
|
+
export async function detectSigmaStagedDir(stagedDir) {
|
|
5
|
+
try {
|
|
6
|
+
await stat(join(stagedDir, STAGED_FILES.manifest));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
for (const subdir of [STAGED_FILES.dataModelsDir, STAGED_FILES.workbooksDir]) {
|
|
12
|
+
let entries;
|
|
13
|
+
try {
|
|
14
|
+
entries = await readdir(join(stagedDir, subdir));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (entries.some((name) => name.endsWith('.json')))
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FetchContext } from '../../types.js';
|
|
2
|
+
import type { SigmaClientFactory } from './client-port.js';
|
|
3
|
+
export interface SigmaFetchLogger {
|
|
4
|
+
log(message: string): void;
|
|
5
|
+
warn(message: string): void;
|
|
6
|
+
}
|
|
7
|
+
export interface FetchSigmaBundleParams {
|
|
8
|
+
pullConfig: unknown;
|
|
9
|
+
stagedDir: string;
|
|
10
|
+
ctx: FetchContext;
|
|
11
|
+
clientFactory: SigmaClientFactory;
|
|
12
|
+
logger?: SigmaFetchLogger;
|
|
13
|
+
}
|
|
14
|
+
export declare function fetchSigmaBundle({ pullConfig, stagedDir, ctx, clientFactory, logger, }: FetchSigmaBundleParams): Promise<void>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseSigmaPullConfig, stagedDataModelFileSchema, stagedWorkbookFileSchema, STAGED_FILES, } from './types.js';
|
|
4
|
+
const noopLogger = { log: () => undefined, warn: () => undefined };
|
|
5
|
+
async function loadExistingStagedFiles(stagedDir) {
|
|
6
|
+
const existing = new Map();
|
|
7
|
+
const dmDir = join(stagedDir, STAGED_FILES.dataModelsDir);
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = await readdir(dmDir);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return existing;
|
|
14
|
+
}
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (!entry.endsWith('.json'))
|
|
17
|
+
continue;
|
|
18
|
+
try {
|
|
19
|
+
const body = await readFile(join(dmDir, entry), 'utf-8');
|
|
20
|
+
const parsed = stagedDataModelFileSchema.parse(JSON.parse(body));
|
|
21
|
+
existing.set(parsed.sigmaId, parsed);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Skip malformed files.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return existing;
|
|
28
|
+
}
|
|
29
|
+
async function loadExistingWorkbookFiles(stagedDir) {
|
|
30
|
+
const existing = new Map();
|
|
31
|
+
const wbDir = join(stagedDir, STAGED_FILES.workbooksDir);
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = await readdir(wbDir);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return existing;
|
|
38
|
+
}
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (!entry.endsWith('.json'))
|
|
41
|
+
continue;
|
|
42
|
+
try {
|
|
43
|
+
const body = await readFile(join(wbDir, entry), 'utf-8');
|
|
44
|
+
const parsed = stagedWorkbookFileSchema.parse(JSON.parse(body));
|
|
45
|
+
existing.set(parsed.sigmaId, parsed);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Skip malformed files.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return existing;
|
|
52
|
+
}
|
|
53
|
+
export async function fetchSigmaBundle({ pullConfig, stagedDir, ctx, clientFactory, logger = noopLogger, }) {
|
|
54
|
+
const config = parseSigmaPullConfig(pullConfig);
|
|
55
|
+
const client = await clientFactory.createClient(config, ctx);
|
|
56
|
+
try {
|
|
57
|
+
await mkdir(join(stagedDir, STAGED_FILES.dataModelsDir), { recursive: true });
|
|
58
|
+
await mkdir(join(stagedDir, STAGED_FILES.workbooksDir), { recursive: true });
|
|
59
|
+
// Load existing staged files to enable incremental sync.
|
|
60
|
+
const existingByModelId = await loadExistingStagedFiles(stagedDir);
|
|
61
|
+
const existingByWorkbookId = await loadExistingWorkbookFiles(stagedDir);
|
|
62
|
+
logger.log('Listing Sigma data models...');
|
|
63
|
+
const summaries = await client.listDataModels();
|
|
64
|
+
const nonArchived = summaries.filter((dm) => !dm.isArchived);
|
|
65
|
+
const nonArchivedIds = new Set(nonArchived.map((dm) => dm.dataModelId));
|
|
66
|
+
let active = nonArchived;
|
|
67
|
+
if (config.dataModelFilter?.updatedSince) {
|
|
68
|
+
const since = new Date(config.dataModelFilter.updatedSince).getTime();
|
|
69
|
+
active = active.filter((dm) => new Date(dm.updatedAt).getTime() >= since);
|
|
70
|
+
}
|
|
71
|
+
logger.log(`Found ${active.length} active data model(s) (${summaries.length} total).`);
|
|
72
|
+
let fetched = 0;
|
|
73
|
+
let skipped = 0;
|
|
74
|
+
const SPEC_CONCURRENCY = 10;
|
|
75
|
+
const queue = [...active];
|
|
76
|
+
await Promise.all(Array.from({ length: Math.min(SPEC_CONCURRENCY, queue.length) }, async () => {
|
|
77
|
+
let summary;
|
|
78
|
+
while ((summary = queue.shift()) !== undefined) {
|
|
79
|
+
const existing = existingByModelId.get(summary.dataModelId);
|
|
80
|
+
// Only skip when the cached spec was successfully fetched. spec: null means
|
|
81
|
+
// the previous attempt failed transiently — retry regardless of updatedAt.
|
|
82
|
+
if (existing && existing.updatedAt === summary.updatedAt && existing.spec !== null) {
|
|
83
|
+
logger.log(`Unchanged: ${summary.name}`);
|
|
84
|
+
skipped++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
let spec = null;
|
|
88
|
+
try {
|
|
89
|
+
spec = await client.getDataModelSpec(summary.dataModelId);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
93
|
+
if (msg.includes('dataSource subtype not supported')) {
|
|
94
|
+
logger.warn(`Skipping spec for "${summary.name}" (${summary.dataModelId}): data source type not supported by Sigma spec export API.`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
logger.warn(`Failed to fetch spec for "${summary.name}" (${summary.dataModelId}): ${msg}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const staged = {
|
|
101
|
+
sigmaId: summary.dataModelId,
|
|
102
|
+
name: summary.name,
|
|
103
|
+
path: summary.path,
|
|
104
|
+
latestVersion: summary.latestVersion,
|
|
105
|
+
updatedAt: summary.updatedAt,
|
|
106
|
+
isArchived: summary.isArchived ?? false,
|
|
107
|
+
dataModelUrlId: summary.dataModelUrlId,
|
|
108
|
+
spec,
|
|
109
|
+
};
|
|
110
|
+
const filePath = join(stagedDir, STAGED_FILES.dataModelsDir, `${summary.dataModelId}.json`);
|
|
111
|
+
await writeFile(filePath, JSON.stringify(staged, null, 2), 'utf-8');
|
|
112
|
+
logger.log(`Staged data model: ${summary.name}`);
|
|
113
|
+
fetched++;
|
|
114
|
+
}
|
|
115
|
+
}));
|
|
116
|
+
// Remove staged files for models that are archived or deleted — but not those merely outside the filter window.
|
|
117
|
+
for (const [modelId] of existingByModelId) {
|
|
118
|
+
if (nonArchivedIds.has(modelId))
|
|
119
|
+
continue;
|
|
120
|
+
try {
|
|
121
|
+
await rm(join(stagedDir, STAGED_FILES.dataModelsDir, `${modelId}.json`));
|
|
122
|
+
logger.log(`Removed stale staged file for model ${modelId}.`);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Best-effort removal.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Fetch workbooks (summary metadata only — no separate spec endpoint).
|
|
129
|
+
// Fetch the full non-archived/non-exploration universe first so eviction is based on
|
|
130
|
+
// all known workbooks, not just the updatedSince slice. Mirrors the data-model path.
|
|
131
|
+
logger.log('Listing Sigma workbooks...');
|
|
132
|
+
const { updatedSince, ...filterWithoutSince } = config.workbookFilter ?? {};
|
|
133
|
+
const allWorkbooks = await client.listWorkbooks(filterWithoutSince);
|
|
134
|
+
const nonArchivedWorkbookIds = new Set(allWorkbooks.map((wb) => wb.workbookId));
|
|
135
|
+
const activeWorkbooks = updatedSince
|
|
136
|
+
? allWorkbooks.filter((wb) => new Date(wb.updatedAt).getTime() >= new Date(updatedSince).getTime())
|
|
137
|
+
: allWorkbooks;
|
|
138
|
+
logger.log(`Found ${activeWorkbooks.length} workbook(s) to process (${allWorkbooks.length} total).`);
|
|
139
|
+
let workbooksFetched = 0;
|
|
140
|
+
let workbooksSkipped = 0;
|
|
141
|
+
for (const wb of activeWorkbooks) {
|
|
142
|
+
const existing = existingByWorkbookId.get(wb.workbookId);
|
|
143
|
+
if (existing && existing.updatedAt === wb.updatedAt) {
|
|
144
|
+
workbooksSkipped++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const staged = {
|
|
148
|
+
sigmaId: wb.workbookId,
|
|
149
|
+
name: wb.name,
|
|
150
|
+
path: wb.path,
|
|
151
|
+
latestVersion: wb.latestVersion,
|
|
152
|
+
updatedAt: wb.updatedAt,
|
|
153
|
+
isArchived: wb.isArchived ?? false,
|
|
154
|
+
workbookUrlId: wb.workbookUrlId,
|
|
155
|
+
description: wb.description,
|
|
156
|
+
};
|
|
157
|
+
const filePath = join(stagedDir, STAGED_FILES.workbooksDir, `${wb.workbookId}.json`);
|
|
158
|
+
await writeFile(filePath, JSON.stringify(staged, null, 2), 'utf-8');
|
|
159
|
+
logger.log(`Staged workbook: ${wb.name}`);
|
|
160
|
+
workbooksFetched++;
|
|
161
|
+
}
|
|
162
|
+
// Evict only workbooks that are archived or deleted — not those outside the updatedSince window.
|
|
163
|
+
for (const [workbookId] of existingByWorkbookId) {
|
|
164
|
+
if (nonArchivedWorkbookIds.has(workbookId))
|
|
165
|
+
continue;
|
|
166
|
+
try {
|
|
167
|
+
await rm(join(stagedDir, STAGED_FILES.workbooksDir, `${workbookId}.json`));
|
|
168
|
+
logger.log(`Removed stale staged file for workbook ${workbookId}.`);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Best-effort removal.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const projectionConfig = {
|
|
175
|
+
connectionMappings: config.connectionMappings ?? {},
|
|
176
|
+
workbookFilter: config.workbookFilter ?? { includeArchived: false, includeExplorations: false },
|
|
177
|
+
};
|
|
178
|
+
await writeFile(join(stagedDir, STAGED_FILES.projectionConfig), JSON.stringify(projectionConfig, null, 2), 'utf-8');
|
|
179
|
+
const manifest = {
|
|
180
|
+
sigmaConnectionId: config.sigmaConnectionId,
|
|
181
|
+
fetchedAt: new Date().toISOString(),
|
|
182
|
+
dataModelCount: active.length,
|
|
183
|
+
workbookCount: activeWorkbooks.length,
|
|
184
|
+
};
|
|
185
|
+
await writeFile(join(stagedDir, STAGED_FILES.manifest), JSON.stringify(manifest, null, 2), 'utf-8');
|
|
186
|
+
logger.log(`Sigma fetch complete. Data models: ${fetched} fetched, ${skipped} unchanged. Workbooks: ${workbooksFetched} fetched, ${workbooksSkipped} unchanged.`);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
await client.cleanup();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { KtxProjectConnectionConfig } from '../../../../context/project/config.js';
|
|
2
|
+
import type { KtxLocalProject } from '../../../../context/project/project.js';
|
|
3
|
+
import { type SigmaClientConfig } from './client.js';
|
|
4
|
+
import type { SigmaFetchLogger } from './fetch.js';
|
|
5
|
+
import { SigmaSourceAdapter } from './sigma.adapter.js';
|
|
6
|
+
export declare function sigmaRuntimeConfigFromLocalConnection(connectionId: string, connection: KtxProjectConnectionConfig | undefined, env?: NodeJS.ProcessEnv): {
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
clientId: string;
|
|
9
|
+
clientSecret: string;
|
|
10
|
+
};
|
|
11
|
+
interface CreateLocalSigmaSourceAdapterOptions {
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
defaultClientConfig?: SigmaClientConfig;
|
|
14
|
+
logger?: SigmaFetchLogger;
|
|
15
|
+
}
|
|
16
|
+
export declare function createLocalSigmaSourceAdapter(project: KtxLocalProject, options?: CreateLocalSigmaSourceAdapterOptions): SigmaSourceAdapter;
|
|
17
|
+
export {};
|