@querypanel/node-sdk 1.0.25 → 1.0.26
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/README.md +23 -0
- package/dist/cjs/__tests__/ingest.test.d.ts +5 -0
- package/dist/cjs/__tests__/ingest.test.d.ts.map +1 -0
- package/dist/cjs/__tests__/ingest.test.js +95 -0
- package/dist/cjs/__tests__/ingest.test.js.map +1 -0
- package/dist/cjs/adapters/clickhouse.d.ts +48 -0
- package/dist/cjs/adapters/clickhouse.d.ts.map +1 -0
- package/dist/cjs/adapters/clickhouse.js +284 -0
- package/dist/cjs/adapters/clickhouse.js.map +1 -0
- package/dist/cjs/adapters/introspection.spec.d.ts +2 -0
- package/dist/cjs/adapters/introspection.spec.d.ts.map +1 -0
- package/dist/cjs/adapters/introspection.spec.js +192 -0
- package/dist/cjs/adapters/introspection.spec.js.map +1 -0
- package/dist/cjs/adapters/postgres.d.ts +46 -0
- package/dist/cjs/adapters/postgres.d.ts.map +1 -0
- package/dist/cjs/adapters/postgres.js +457 -0
- package/dist/cjs/adapters/postgres.js.map +1 -0
- package/dist/cjs/adapters/postgres.spec.d.ts +2 -0
- package/dist/cjs/adapters/postgres.spec.d.ts.map +1 -0
- package/dist/cjs/adapters/postgres.spec.js +37 -0
- package/dist/cjs/adapters/postgres.spec.js.map +1 -0
- package/dist/cjs/adapters/types.d.ts +38 -0
- package/dist/cjs/adapters/types.d.ts.map +1 -0
- package/dist/cjs/adapters/types.js +3 -0
- package/dist/cjs/adapters/types.js.map +1 -0
- package/dist/cjs/anonymize.spec.d.ts +2 -0
- package/dist/cjs/anonymize.spec.d.ts.map +1 -0
- package/dist/cjs/anonymize.spec.js +78 -0
- package/dist/cjs/anonymize.spec.js.map +1 -0
- package/dist/cjs/clickhouseClient.spec.d.ts +2 -0
- package/dist/cjs/clickhouseClient.spec.d.ts.map +1 -0
- package/dist/cjs/clickhouseClient.spec.js +286 -0
- package/dist/cjs/clickhouseClient.spec.js.map +1 -0
- package/dist/cjs/connectors/__tests__/clickhouse.introspect.test.d.ts +2 -0
- package/dist/cjs/connectors/__tests__/clickhouse.introspect.test.d.ts.map +1 -0
- package/dist/cjs/connectors/__tests__/clickhouse.introspect.test.js +119 -0
- package/dist/cjs/connectors/__tests__/clickhouse.introspect.test.js.map +1 -0
- package/dist/cjs/connectors/base.d.ts +13 -0
- package/dist/cjs/connectors/base.d.ts.map +1 -0
- package/dist/cjs/connectors/base.js +3 -0
- package/dist/cjs/connectors/base.js.map +1 -0
- package/dist/cjs/connectors/clickhouse.d.ts +53 -0
- package/dist/cjs/connectors/clickhouse.d.ts.map +1 -0
- package/dist/cjs/connectors/clickhouse.js +270 -0
- package/dist/cjs/connectors/clickhouse.js.map +1 -0
- package/dist/cjs/index.d.ts +490 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +843 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/index.test.d.ts +2 -0
- package/dist/cjs/index.test.d.ts.map +1 -0
- package/dist/cjs/index.test.js +185 -0
- package/dist/cjs/index.test.js.map +1 -0
- package/dist/cjs/introspectV3.d.ts +45 -0
- package/dist/cjs/introspectV3.d.ts.map +1 -0
- package/dist/cjs/introspectV3.js +99 -0
- package/dist/cjs/introspectV3.js.map +1 -0
- package/dist/cjs/multidb.spec.d.ts +2 -0
- package/dist/cjs/multidb.spec.d.ts.map +1 -0
- package/dist/cjs/multidb.spec.js +76 -0
- package/dist/cjs/multidb.spec.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/schema/types.d.ts +73 -0
- package/dist/cjs/schema/types.d.ts.map +1 -0
- package/dist/cjs/schema/types.js +3 -0
- package/dist/cjs/schema/types.js.map +1 -0
- package/dist/cjs/tenant-isolation.spec.d.ts +2 -0
- package/dist/cjs/tenant-isolation.spec.d.ts.map +1 -0
- package/dist/cjs/tenant-isolation.spec.js +420 -0
- package/dist/cjs/tenant-isolation.spec.js.map +1 -0
- package/dist/cjs/utils/clickhouse.d.ts +9 -0
- package/dist/cjs/utils/clickhouse.d.ts.map +1 -0
- package/dist/cjs/utils/clickhouse.js +99 -0
- package/dist/cjs/utils/clickhouse.js.map +1 -0
- package/dist/esm/adapters/clickhouse.d.ts +48 -0
- package/dist/esm/adapters/clickhouse.d.ts.map +1 -0
- package/dist/esm/adapters/clickhouse.js +280 -0
- package/dist/esm/adapters/clickhouse.js.map +1 -0
- package/dist/esm/adapters/introspection.spec.d.ts +2 -0
- package/dist/esm/adapters/introspection.spec.d.ts.map +1 -0
- package/dist/esm/adapters/introspection.spec.js +190 -0
- package/dist/esm/adapters/introspection.spec.js.map +1 -0
- package/dist/esm/adapters/postgres.d.ts +46 -0
- package/dist/esm/adapters/postgres.d.ts.map +1 -0
- package/dist/esm/adapters/postgres.js +453 -0
- package/dist/esm/adapters/postgres.js.map +1 -0
- package/dist/esm/adapters/postgres.spec.d.ts +2 -0
- package/dist/esm/adapters/postgres.spec.d.ts.map +1 -0
- package/dist/esm/adapters/postgres.spec.js +35 -0
- package/dist/esm/adapters/postgres.spec.js.map +1 -0
- package/dist/esm/adapters/types.d.ts +38 -0
- package/dist/esm/adapters/types.d.ts.map +1 -0
- package/dist/esm/adapters/types.js +2 -0
- package/dist/esm/adapters/types.js.map +1 -0
- package/dist/esm/anonymize.spec.d.ts +2 -0
- package/dist/esm/anonymize.spec.d.ts.map +1 -0
- package/dist/esm/anonymize.spec.js +76 -0
- package/dist/esm/anonymize.spec.js.map +1 -0
- package/dist/esm/clickhouseClient.spec.d.ts +2 -0
- package/dist/esm/clickhouseClient.spec.d.ts.map +1 -0
- package/dist/esm/clickhouseClient.spec.js +281 -0
- package/dist/esm/clickhouseClient.spec.js.map +1 -0
- package/dist/esm/connectors/base.d.ts +14 -0
- package/dist/esm/connectors/base.d.ts.map +1 -0
- package/dist/esm/connectors/base.js +2 -0
- package/dist/esm/connectors/base.js.map +1 -0
- package/dist/esm/connectors/clickhouse.d.ts +35 -0
- package/dist/esm/connectors/clickhouse.d.ts.map +1 -0
- package/dist/esm/connectors/clickhouse.js +281 -0
- package/dist/esm/connectors/clickhouse.js.map +1 -0
- package/dist/esm/index.d.ts +490 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +838 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/index.test.d.ts +2 -0
- package/dist/esm/index.test.d.ts.map +1 -0
- package/dist/esm/index.test.js +183 -0
- package/dist/esm/index.test.js.map +1 -0
- package/dist/esm/introspectV3.d.ts +45 -0
- package/dist/esm/introspectV3.d.ts.map +1 -0
- package/dist/esm/introspectV3.js +96 -0
- package/dist/esm/introspectV3.js.map +1 -0
- package/dist/esm/multidb.spec.d.ts +2 -0
- package/dist/esm/multidb.spec.d.ts.map +1 -0
- package/dist/esm/multidb.spec.js +74 -0
- package/dist/esm/multidb.spec.js.map +1 -0
- package/dist/esm/schema/types.d.ts +73 -0
- package/dist/esm/schema/types.d.ts.map +1 -0
- package/dist/esm/schema/types.js +2 -0
- package/dist/esm/schema/types.js.map +1 -0
- package/dist/esm/tenant-isolation.spec.d.ts +2 -0
- package/dist/esm/tenant-isolation.spec.d.ts.map +1 -0
- package/dist/esm/tenant-isolation.spec.js +418 -0
- package/dist/esm/tenant-isolation.spec.js.map +1 -0
- package/dist/esm/utils/clickhouse.d.ts +9 -0
- package/dist/esm/utils/clickhouse.d.ts.map +1 -0
- package/dist/esm/utils/clickhouse.js +92 -0
- package/dist/esm/utils/clickhouse.js.map +1 -0
- package/package.json +73 -53
- package/dist/index.cjs +0 -1471
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -468
- package/dist/index.d.ts +0 -468
- package/dist/index.js +0 -1443
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { importPKCS8, SignJWT } from "jose";
|
|
3
|
+
import { ClickHouseAdapter, } from "./adapters/clickhouse.js";
|
|
4
|
+
import { PostgresAdapter, } from "./adapters/postgres.js";
|
|
5
|
+
import { transformToV3Export } from "./introspectV3.js";
|
|
6
|
+
// Re-export adapters for external use
|
|
7
|
+
export { ClickHouseAdapter, PostgresAdapter };
|
|
8
|
+
/**
|
|
9
|
+
* Anonymizes query results by replacing actual values with their type names.
|
|
10
|
+
* Useful for sharing result structure without exposing sensitive data.
|
|
11
|
+
*
|
|
12
|
+
* @param rows - Array of result rows to anonymize
|
|
13
|
+
* @returns Array of rows with values replaced by type strings
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* anonymizeResults([{ year: 2025, transactionSum: 1000 }])
|
|
17
|
+
* // Returns: [{ year: 'number', transactionSum: 'number' }]
|
|
18
|
+
*/
|
|
19
|
+
export function anonymizeResults(rows) {
|
|
20
|
+
if (!rows || rows.length === 0) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
return rows.map((row) => {
|
|
24
|
+
const anonymized = {};
|
|
25
|
+
for (const [key, value] of Object.entries(row)) {
|
|
26
|
+
if (value === null) {
|
|
27
|
+
anonymized[key] = "null";
|
|
28
|
+
}
|
|
29
|
+
else if (Array.isArray(value)) {
|
|
30
|
+
anonymized[key] = "array";
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
anonymized[key] = typeof value;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return anonymized;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export class QueryPanelSdkAPI {
|
|
40
|
+
constructor(baseUrl, jwtTokenOrPrivateKey, organizationId) {
|
|
41
|
+
// Database registry
|
|
42
|
+
this.databases = new Map();
|
|
43
|
+
this.databaseMetadata = new Map();
|
|
44
|
+
// Schema sync tracking
|
|
45
|
+
this.lastSyncHashes = new Map();
|
|
46
|
+
this.syncedDatabases = new Set();
|
|
47
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
48
|
+
if (organizationId) {
|
|
49
|
+
// New mode: generate JWTs
|
|
50
|
+
this.privateKey = jwtTokenOrPrivateKey;
|
|
51
|
+
this.organizationId = organizationId;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
// Legacy mode: use provided JWT
|
|
55
|
+
this.token = jwtTokenOrPrivateKey;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Attach a database adapter with a specific name
|
|
60
|
+
*/
|
|
61
|
+
attachDatabase(name, adapter) {
|
|
62
|
+
this.databases.set(name, adapter);
|
|
63
|
+
if (!this.defaultDatabase) {
|
|
64
|
+
this.defaultDatabase = name;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Attach a ClickHouse database
|
|
69
|
+
*/
|
|
70
|
+
attachClickhouse(name, clientFn, options) {
|
|
71
|
+
const adapterOptions = {
|
|
72
|
+
database: options?.database ?? name,
|
|
73
|
+
...(options?.defaultFormat
|
|
74
|
+
? { defaultFormat: options.defaultFormat }
|
|
75
|
+
: {}),
|
|
76
|
+
...(options?.kind ? { kind: options.kind } : {}),
|
|
77
|
+
...(options?.allowedTables
|
|
78
|
+
? { allowedTables: options.allowedTables }
|
|
79
|
+
: {}),
|
|
80
|
+
};
|
|
81
|
+
this.attachDatabase(name, new ClickHouseAdapter(clientFn, adapterOptions));
|
|
82
|
+
// Store metadata
|
|
83
|
+
const metadata = {
|
|
84
|
+
name,
|
|
85
|
+
dialect: "clickhouse",
|
|
86
|
+
};
|
|
87
|
+
if (options?.description)
|
|
88
|
+
metadata.description = options.description;
|
|
89
|
+
if (options?.tags)
|
|
90
|
+
metadata.tags = options.tags;
|
|
91
|
+
if (options?.tenantFieldName) {
|
|
92
|
+
metadata.tenantFieldName = options.tenantFieldName;
|
|
93
|
+
metadata.tenantFieldType = options.tenantFieldType ?? "String";
|
|
94
|
+
// Default to enforcing tenant isolation if tenantFieldName is provided
|
|
95
|
+
metadata.enforceTenantIsolation = options.enforceTenantIsolation ?? true;
|
|
96
|
+
}
|
|
97
|
+
this.databaseMetadata.set(name, metadata);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Attach a Postgres database
|
|
101
|
+
*/
|
|
102
|
+
attachPostgres(name, clientFn, options) {
|
|
103
|
+
const adapterOptions = {
|
|
104
|
+
database: options?.database ?? name,
|
|
105
|
+
...(options?.defaultSchema
|
|
106
|
+
? { defaultSchema: options.defaultSchema }
|
|
107
|
+
: {}),
|
|
108
|
+
...(options?.kind ? { kind: options.kind } : {}),
|
|
109
|
+
};
|
|
110
|
+
this.attachDatabase(name, new PostgresAdapter(clientFn, adapterOptions));
|
|
111
|
+
// Store metadata
|
|
112
|
+
const metadata = {
|
|
113
|
+
name,
|
|
114
|
+
dialect: "postgres",
|
|
115
|
+
};
|
|
116
|
+
if (options?.description)
|
|
117
|
+
metadata.description = options.description;
|
|
118
|
+
if (options?.tags)
|
|
119
|
+
metadata.tags = options.tags;
|
|
120
|
+
if (options?.tenantFieldName) {
|
|
121
|
+
metadata.tenantFieldName = options.tenantFieldName;
|
|
122
|
+
// Default to enforcing tenant isolation if tenantFieldName is provided
|
|
123
|
+
metadata.enforceTenantIsolation = options.enforceTenantIsolation ?? true;
|
|
124
|
+
}
|
|
125
|
+
this.databaseMetadata.set(name, metadata);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Legacy method for backward compatibility
|
|
129
|
+
* Attaches a ClickHouse client as the default database
|
|
130
|
+
*/
|
|
131
|
+
attachClickhouseClient(fn) {
|
|
132
|
+
this.attachClickhouse("default", fn);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get a database adapter by name, or return the default
|
|
136
|
+
*/
|
|
137
|
+
getDatabase(name) {
|
|
138
|
+
const dbName = name || this.defaultDatabase;
|
|
139
|
+
if (!dbName) {
|
|
140
|
+
throw new Error("No database attached. Use attachClickhouse() or attachPostgres() first.");
|
|
141
|
+
}
|
|
142
|
+
const adapter = this.databases.get(dbName);
|
|
143
|
+
if (!adapter) {
|
|
144
|
+
throw new Error(`Database '${dbName}' not found. Available: ${Array.from(this.databases.keys()).join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
return adapter;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Synchronize database schema to the API for vector search.
|
|
150
|
+
* Call this after schema migrations or when you want to update the schema context.
|
|
151
|
+
* Uses hash-based change detection to avoid unnecessary syncs.
|
|
152
|
+
*/
|
|
153
|
+
async syncSchema(databaseName, options, signal) {
|
|
154
|
+
const adapter = this.getDatabase(databaseName);
|
|
155
|
+
// Introspect schema
|
|
156
|
+
const introspection = await adapter.introspect(options.tables ? { tables: options.tables } : undefined);
|
|
157
|
+
// Generate hash of introspection for change detection
|
|
158
|
+
const hash = this.hashIntrospection(introspection);
|
|
159
|
+
const lastHash = this.lastSyncHashes.get(databaseName);
|
|
160
|
+
// Skip if schema unchanged (unless force is true)
|
|
161
|
+
if (!options.force && lastHash === hash) {
|
|
162
|
+
console.log(`Schema unchanged for ${databaseName}, skipping sync`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// Send introspection to API
|
|
166
|
+
const response = await this.post("/v2/vectorize-schema", {
|
|
167
|
+
database: databaseName,
|
|
168
|
+
dialect: adapter.getDialect(),
|
|
169
|
+
introspection,
|
|
170
|
+
schemaHash: hash,
|
|
171
|
+
force: options.force ?? false,
|
|
172
|
+
}, options.tenantId, options.userId, options.scopes, signal);
|
|
173
|
+
const effectiveHash = response?.schemaHash ?? hash;
|
|
174
|
+
if (response?.status === "skipped") {
|
|
175
|
+
console.log(`Schema hash unchanged on server for ${databaseName}, skipping upload`);
|
|
176
|
+
}
|
|
177
|
+
this.lastSyncHashes.set(databaseName, effectiveHash);
|
|
178
|
+
this.syncedDatabases.add(databaseName);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Introspect database schema and return V3 export format
|
|
182
|
+
* This format is compatible with Bedrock knowledge base ingestion
|
|
183
|
+
*
|
|
184
|
+
* @param databaseName - Name of the attached database to introspect
|
|
185
|
+
* @param options - Optional tenant ID and table filter
|
|
186
|
+
* @returns Schema export in V3 format (schema_export.json)
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```typescript
|
|
190
|
+
* const schema = await sdk.introspectV3("my_database", {
|
|
191
|
+
* tenantId: "tenant-123",
|
|
192
|
+
* tables: ["users", "orders"]
|
|
193
|
+
* });
|
|
194
|
+
*
|
|
195
|
+
* // Upload to Bedrock
|
|
196
|
+
* await fetch("/v3/ingest", {
|
|
197
|
+
* method: "POST",
|
|
198
|
+
* body: JSON.stringify(schema)
|
|
199
|
+
* });
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
async introspectV3(databaseName, options) {
|
|
203
|
+
const adapter = this.getDatabase(databaseName);
|
|
204
|
+
// Perform introspection
|
|
205
|
+
const introspection = await adapter.introspect(options?.tables ? { tables: options.tables } : undefined);
|
|
206
|
+
// Transform to V3 format
|
|
207
|
+
return transformToV3Export(introspection, options?.tenantId);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Introspect and ingest schema to Bedrock knowledge base (v3)
|
|
211
|
+
* Combines introspectV3 + API call to /v3/ingest
|
|
212
|
+
*
|
|
213
|
+
* @param databaseName - Name of the attached database to introspect
|
|
214
|
+
* @param options - Required tenant ID and optional table filter
|
|
215
|
+
* @returns Ingestion summary from Bedrock
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```typescript
|
|
219
|
+
* const result = await sdk.ingestSchemaV3("my_database", {
|
|
220
|
+
* tenantId: "tenant-123",
|
|
221
|
+
* tables: ["users", "orders"]
|
|
222
|
+
* });
|
|
223
|
+
*
|
|
224
|
+
* console.log(`Ingested ${result.total_documents} documents`);
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
async ingestSchemaV3(databaseName, options, signal) {
|
|
228
|
+
// Introspect schema in V3 format
|
|
229
|
+
const schema = await this.introspectV3(databaseName, {
|
|
230
|
+
tenantId: options.tenantId,
|
|
231
|
+
...(options.tables && { tables: options.tables }),
|
|
232
|
+
});
|
|
233
|
+
// Post to v3/ingest endpoint
|
|
234
|
+
const result = await this.post("/v3/ingest", schema, options.tenantId, options.userId, options.scopes, signal);
|
|
235
|
+
// Mark database as synced
|
|
236
|
+
this.syncedDatabases.add(databaseName);
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Introspect and ingest schema to LangChain vector store (v4)
|
|
241
|
+
* Combines introspectV3 + API call to /v4/ingest
|
|
242
|
+
*
|
|
243
|
+
* @param databaseName - Name of the attached database to introspect
|
|
244
|
+
* @param options - Required tenant ID and optional table filter
|
|
245
|
+
* @returns Ingestion summary from the LangChain module
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```typescript
|
|
249
|
+
* const result = await sdk.ingestSchemaV4("my_database", {
|
|
250
|
+
* tenantId: "tenant-123",
|
|
251
|
+
* tables: ["users", "orders"]
|
|
252
|
+
* });
|
|
253
|
+
*
|
|
254
|
+
* console.log(`Ingested ${result.chunksCreated} chunks`);
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
async ingestSchemaV4(databaseName, options, signal) {
|
|
258
|
+
// Introspect schema in V3 format (v4 uses the same schema format)
|
|
259
|
+
const schemaExport = await this.introspectV3(databaseName, {
|
|
260
|
+
tenantId: options.tenantId,
|
|
261
|
+
...(options.tables && { tables: options.tables }),
|
|
262
|
+
});
|
|
263
|
+
// Post to v4/ingest endpoint
|
|
264
|
+
const result = await this.post("/v4/ingest", { schema: schemaExport }, options.tenantId, options.userId, options.scopes, signal);
|
|
265
|
+
// Mark database as synced
|
|
266
|
+
this.syncedDatabases.add(databaseName);
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Ensure all attached databases have been synced at least once.
|
|
271
|
+
* Called automatically on first ask() if needed (unless disabled).
|
|
272
|
+
*/
|
|
273
|
+
async ensureDatabasesSynced(tenantId, userId, scopes, useV3, useV4, disableAutoSync) {
|
|
274
|
+
if (disableAutoSync) {
|
|
275
|
+
return; // Auto-sync disabled, user must call syncSchema/ingestSchemaV3/ingestSchemaV4 manually
|
|
276
|
+
}
|
|
277
|
+
const dbNames = Array.from(this.databases.keys());
|
|
278
|
+
if (dbNames.length === 0)
|
|
279
|
+
return;
|
|
280
|
+
let unsyncedDbs = dbNames.filter((name) => !this.syncedDatabases.has(name));
|
|
281
|
+
if (unsyncedDbs.length === 0)
|
|
282
|
+
return;
|
|
283
|
+
if (this.organizationId) {
|
|
284
|
+
await Promise.all(unsyncedDbs.map((dbName) => this.reconcileSchemaStatus(dbName, tenantId, userId, scopes)));
|
|
285
|
+
unsyncedDbs = dbNames.filter((name) => !this.syncedDatabases.has(name));
|
|
286
|
+
if (unsyncedDbs.length === 0)
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const version = useV4 ? "v4" : useV3 ? "v3" : "v2";
|
|
290
|
+
console.log(`Auto-syncing databases (${version}): ${unsyncedDbs.join(", ")}`);
|
|
291
|
+
// Sync all unsynced databases in parallel
|
|
292
|
+
await Promise.all(unsyncedDbs.map((dbName) => useV4
|
|
293
|
+
? this.ingestSchemaV4(dbName, {
|
|
294
|
+
tenantId,
|
|
295
|
+
...(userId ? { userId } : {}),
|
|
296
|
+
...(scopes ? { scopes } : {}),
|
|
297
|
+
}).catch((err) => console.warn(`Failed to sync ${dbName}:`, err))
|
|
298
|
+
: useV3
|
|
299
|
+
? this.ingestSchemaV3(dbName, {
|
|
300
|
+
tenantId,
|
|
301
|
+
...(userId ? { userId } : {}),
|
|
302
|
+
...(scopes ? { scopes } : {}),
|
|
303
|
+
}).catch((err) => console.warn(`Failed to sync ${dbName}:`, err))
|
|
304
|
+
: this.syncSchema(dbName, {
|
|
305
|
+
tenantId,
|
|
306
|
+
...(userId ? { userId } : {}),
|
|
307
|
+
...(scopes ? { scopes } : {}),
|
|
308
|
+
}).catch((err) => console.warn(`Failed to sync ${dbName}:`, err))));
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Generate a hash of the introspection for change detection
|
|
312
|
+
*/
|
|
313
|
+
hashIntrospection(introspection) {
|
|
314
|
+
const tables = Array.isArray(introspection?.tables)
|
|
315
|
+
? introspection.tables
|
|
316
|
+
: [];
|
|
317
|
+
const normalized = tables.map((t) => {
|
|
318
|
+
return {
|
|
319
|
+
name: t?.name ?? "",
|
|
320
|
+
schema: t?.schema ?? "",
|
|
321
|
+
columns: Array.isArray(t?.columns)
|
|
322
|
+
? t.columns
|
|
323
|
+
.map((c) => `${c?.name ?? ""}:${c?.type ?? ""}`)
|
|
324
|
+
.sort()
|
|
325
|
+
: [],
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
normalized.sort((a, b) => {
|
|
329
|
+
const schemaCompare = a.schema.localeCompare(b.schema);
|
|
330
|
+
if (schemaCompare !== 0)
|
|
331
|
+
return schemaCompare;
|
|
332
|
+
return a.name.localeCompare(b.name);
|
|
333
|
+
});
|
|
334
|
+
const content = JSON.stringify(normalized);
|
|
335
|
+
return createHash("sha256").update(content).digest("hex");
|
|
336
|
+
}
|
|
337
|
+
async reconcileSchemaStatus(databaseName, tenantId, userId, scopes) {
|
|
338
|
+
const cachedHash = this.lastSyncHashes.get(databaseName);
|
|
339
|
+
const params = new URLSearchParams({ database: databaseName });
|
|
340
|
+
if (cachedHash)
|
|
341
|
+
params.set("hash", cachedHash);
|
|
342
|
+
try {
|
|
343
|
+
const response = await this.get(`/v2/schema-sync-status?${params.toString()}`, tenantId, userId, scopes);
|
|
344
|
+
if (response.status === "up_to_date" && response.schemaHash) {
|
|
345
|
+
this.lastSyncHashes.set(databaseName, response.schemaHash);
|
|
346
|
+
this.syncedDatabases.add(databaseName);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (response.status === "stale" &&
|
|
350
|
+
response.schemaHash &&
|
|
351
|
+
response.introspectedAt) {
|
|
352
|
+
this.lastSyncHashes.set(databaseName, response.schemaHash);
|
|
353
|
+
}
|
|
354
|
+
if (response.status === "not_found") {
|
|
355
|
+
this.lastSyncHashes.delete(databaseName);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error?.status === 404 ||
|
|
360
|
+
error?.status === 400 ||
|
|
361
|
+
error?.status === 501) {
|
|
362
|
+
// Older server or missing org context – fall back to client-side sync
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
console.warn(`Failed to check schema sync status for '${databaseName}': ${error}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Ensures tenant isolation by adding tenant filter to SQL if not present.
|
|
370
|
+
* Modifies SQL and params in-place to include tenant field.
|
|
371
|
+
*/
|
|
372
|
+
ensureTenantIsolation(sql, params, metadata, tenantId) {
|
|
373
|
+
if (!metadata.tenantFieldName || !metadata.enforceTenantIsolation) {
|
|
374
|
+
return sql; // Enforcement disabled or no tenant field configured
|
|
375
|
+
}
|
|
376
|
+
const tenantFieldName = metadata.tenantFieldName;
|
|
377
|
+
const tenantFieldType = metadata.tenantFieldType || "String";
|
|
378
|
+
// Check if tenant field is already being filtered in SQL
|
|
379
|
+
let parameterFound = false;
|
|
380
|
+
let placeholderName;
|
|
381
|
+
if (metadata.dialect === "clickhouse") {
|
|
382
|
+
// ClickHouse: Check if the field name appears in a WHERE/AND clause with comparison
|
|
383
|
+
// This catches patterns like: "customer_id = {tenantId:Int32}" or "customer_id = {customer_id:String}"
|
|
384
|
+
const fieldFilterPattern = new RegExp(`\\b${tenantFieldName}\\s*=\\s*\\{([^}:]+)(?::[^}]+)?\\}`, "i");
|
|
385
|
+
const match = sql.match(fieldFilterPattern);
|
|
386
|
+
if (match) {
|
|
387
|
+
parameterFound = true;
|
|
388
|
+
placeholderName = match[1]; // Extract placeholder name (e.g., "tenantId" from "{tenantId:Int32}")
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (metadata.dialect === "postgres") {
|
|
392
|
+
// Check if field name appears in SQL (basic check for tenant field)
|
|
393
|
+
parameterFound = sql
|
|
394
|
+
.toLowerCase()
|
|
395
|
+
.includes(tenantFieldName.toLowerCase());
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
// Generic check: ensure the field name appears in SQL
|
|
399
|
+
parameterFound = sql
|
|
400
|
+
.toLowerCase()
|
|
401
|
+
.includes(tenantFieldName.toLowerCase());
|
|
402
|
+
}
|
|
403
|
+
// Always enforce tenant parameter with correct tenantId
|
|
404
|
+
// This ensures the parameter is set even if the API returns 0 or any other placeholder
|
|
405
|
+
if (placeholderName) {
|
|
406
|
+
// Use the actual placeholder name found in SQL (e.g., "tenantId")
|
|
407
|
+
params[placeholderName] = tenantId;
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
// Fallback to field name
|
|
411
|
+
params[tenantFieldName] = tenantId;
|
|
412
|
+
}
|
|
413
|
+
// If parameter not found in SQL, add tenant filter
|
|
414
|
+
if (!parameterFound) {
|
|
415
|
+
console.warn(`[Tenant Isolation] Adding missing tenant filter to SQL for field '${tenantFieldName}' on database '${metadata.name}'`);
|
|
416
|
+
// Determine how to add the filter based on dialect
|
|
417
|
+
let tenantFilter = "";
|
|
418
|
+
if (metadata.dialect === "clickhouse") {
|
|
419
|
+
// ClickHouse parameterized syntax
|
|
420
|
+
tenantFilter = `${tenantFieldName} = {${tenantFieldName}:${tenantFieldType}}`;
|
|
421
|
+
}
|
|
422
|
+
else if (metadata.dialect === "postgres") {
|
|
423
|
+
// Postgres would need $N syntax, but since we don't know the param index,
|
|
424
|
+
// we'll use a simple comparison (the adapter should handle parameterization)
|
|
425
|
+
tenantFilter = `${tenantFieldName} = '${tenantId}'`;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
tenantFilter = `${tenantFieldName} = '${tenantId}'`;
|
|
429
|
+
}
|
|
430
|
+
// Determine if SQL already has a WHERE clause
|
|
431
|
+
const hasWhere = /\bWHERE\b/i.test(sql);
|
|
432
|
+
if (hasWhere) {
|
|
433
|
+
// Find the WHERE clause and add AND condition
|
|
434
|
+
// Look for WHERE and insert after it, or at the end before GROUP BY/ORDER BY/LIMIT
|
|
435
|
+
const whereMatch = sql.match(/\bWHERE\b/i);
|
|
436
|
+
if (whereMatch) {
|
|
437
|
+
const whereIndex = whereMatch.index + whereMatch[0].length;
|
|
438
|
+
// Find the end of WHERE clause (before GROUP BY, ORDER BY, LIMIT, or end)
|
|
439
|
+
const afterWhere = sql.substring(whereIndex);
|
|
440
|
+
const endMatch = afterWhere.match(/\b(GROUP\s+BY|ORDER\s+BY|LIMIT|HAVING)\b/i);
|
|
441
|
+
if (endMatch && endMatch.index === 0) {
|
|
442
|
+
// WHERE is immediately followed by GROUP BY/ORDER BY/LIMIT - insert before it
|
|
443
|
+
sql =
|
|
444
|
+
sql.substring(0, whereIndex) + ` ${tenantFilter} ` + afterWhere;
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
// Add as AND condition
|
|
448
|
+
sql =
|
|
449
|
+
sql.substring(0, whereIndex) +
|
|
450
|
+
` (${tenantFilter}) AND (` +
|
|
451
|
+
afterWhere.trimStart() +
|
|
452
|
+
")";
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
// Add new WHERE clause before GROUP BY/ORDER BY/LIMIT or at the end
|
|
458
|
+
const clauseMatch = sql.match(/\b(GROUP\s+BY|ORDER\s+BY|LIMIT|HAVING)\b/i);
|
|
459
|
+
if (clauseMatch) {
|
|
460
|
+
const insertIndex = clauseMatch.index;
|
|
461
|
+
sql =
|
|
462
|
+
sql.substring(0, insertIndex) +
|
|
463
|
+
`WHERE ${tenantFilter} ` +
|
|
464
|
+
sql.substring(insertIndex);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
// Add at the end
|
|
468
|
+
sql = sql.trim();
|
|
469
|
+
if (!sql.endsWith(";")) {
|
|
470
|
+
sql += ` WHERE ${tenantFilter}`;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
sql = sql.substring(0, sql.length - 1) + ` WHERE ${tenantFilter};`;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
console.log(`[Tenant Isolation] Modified SQL to include tenant filter: ${tenantFilter}`);
|
|
478
|
+
}
|
|
479
|
+
return sql;
|
|
480
|
+
}
|
|
481
|
+
async ask(question, options, signal) {
|
|
482
|
+
const { tenantId, database, userId, scopes, sqlMaxAttempts = 3, chartMaxRetries = 3, useV3 = false, useV4 = false, disableAutoSync = false, } = options;
|
|
483
|
+
// Auto-sync databases on first use (unless disabled)
|
|
484
|
+
await this.ensureDatabasesSynced(tenantId, userId, scopes, useV3, useV4, disableAutoSync);
|
|
485
|
+
let attempt = 0;
|
|
486
|
+
const sessionId = randomUUID();
|
|
487
|
+
let lastError;
|
|
488
|
+
let failedSql;
|
|
489
|
+
while (attempt < sqlMaxAttempts) {
|
|
490
|
+
// Build available databases list with metadata (includes tenantFieldName per database)
|
|
491
|
+
const availableDatabases = Array.from(this.databaseMetadata.values());
|
|
492
|
+
// Determine endpoint based on version flag
|
|
493
|
+
const endpoint = useV4
|
|
494
|
+
? "/v4/generate-sql"
|
|
495
|
+
: useV3
|
|
496
|
+
? "/v3/generate-sql"
|
|
497
|
+
: "/v2/generate-sql";
|
|
498
|
+
const sqlResponse = await this.post(endpoint, {
|
|
499
|
+
question,
|
|
500
|
+
max_retries: Math.max(1, sqlMaxAttempts - attempt),
|
|
501
|
+
attempt_count: attempt,
|
|
502
|
+
...(lastError ? { last_error: lastError } : {}),
|
|
503
|
+
...(failedSql ? { failed_sql: failedSql } : {}),
|
|
504
|
+
...(database ? { database } : {}), // Optional hint
|
|
505
|
+
available_databases: availableDatabases,
|
|
506
|
+
...(options.additionalPrompts && options.additionalPrompts.length > 0
|
|
507
|
+
? { additional_prompts: options.additionalPrompts }
|
|
508
|
+
: {}),
|
|
509
|
+
}, tenantId, userId, scopes, signal, sessionId);
|
|
510
|
+
try {
|
|
511
|
+
// Get the database adapter (use API's selected database or hint)
|
|
512
|
+
const dbName = database || sqlResponse.database || this.defaultDatabase;
|
|
513
|
+
if (!dbName) {
|
|
514
|
+
throw new Error("No database selected");
|
|
515
|
+
}
|
|
516
|
+
const adapter = this.getDatabase(dbName);
|
|
517
|
+
// Validate dialect matches (if API provided it)
|
|
518
|
+
if (sqlResponse.dialect &&
|
|
519
|
+
adapter.getDialect() !== sqlResponse.dialect) {
|
|
520
|
+
throw new Error(`Dialect mismatch: API selected ${sqlResponse.dialect} but client has ${adapter.getDialect()} for database '${dbName}'`);
|
|
521
|
+
}
|
|
522
|
+
// Ensure tenant isolation: add tenant filter to SQL and params if not present
|
|
523
|
+
const dbMetadata = this.databaseMetadata.get(dbName);
|
|
524
|
+
if (dbMetadata) {
|
|
525
|
+
sqlResponse.sql = this.ensureTenantIsolation(sqlResponse.sql, sqlResponse.params, dbMetadata, tenantId);
|
|
526
|
+
}
|
|
527
|
+
// Validate SQL with EXPLAIN before executing (with merged params)
|
|
528
|
+
await adapter.validate(sqlResponse.sql, sqlResponse.params);
|
|
529
|
+
// Execute the query with parameters
|
|
530
|
+
const execution = await adapter.execute(sqlResponse.sql, sqlResponse.params);
|
|
531
|
+
const rows = Array.isArray(execution.rows) ? execution.rows : [];
|
|
532
|
+
let chart;
|
|
533
|
+
if (rows.length === 0) {
|
|
534
|
+
chart = {
|
|
535
|
+
vegaLiteSpec: null,
|
|
536
|
+
notes: "Query returned no rows; chart generation skipped.",
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
const chartResponse = await this.post("/v2/generate-chart", {
|
|
541
|
+
question,
|
|
542
|
+
sql: sqlResponse.sql,
|
|
543
|
+
rationale: sqlResponse.rationale,
|
|
544
|
+
fields: execution.fields,
|
|
545
|
+
rows: anonymizeResults(rows),
|
|
546
|
+
max_retries: chartMaxRetries,
|
|
547
|
+
}, tenantId, userId, scopes, signal, sessionId);
|
|
548
|
+
chart = {
|
|
549
|
+
vegaLiteSpec: {
|
|
550
|
+
...chartResponse.vegaLiteSpec,
|
|
551
|
+
data: {
|
|
552
|
+
values: rows,
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
notes: chartResponse.notes,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
sql: sqlResponse.sql,
|
|
560
|
+
params: sqlResponse.params,
|
|
561
|
+
rationale: sqlResponse.rationale,
|
|
562
|
+
context: sqlResponse.context,
|
|
563
|
+
chart,
|
|
564
|
+
fields: execution.fields,
|
|
565
|
+
rows,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
570
|
+
failedSql = sqlResponse.sql;
|
|
571
|
+
attempt += 1;
|
|
572
|
+
if (attempt >= sqlMaxAttempts) {
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
throw new Error("Failed to generate chart after maximum attempts");
|
|
578
|
+
}
|
|
579
|
+
async train(body, options, signal) {
|
|
580
|
+
return await this.post("/v2/train", body, options?.tenantId, options?.userId, options?.scopes, signal);
|
|
581
|
+
}
|
|
582
|
+
async stats(options, signal) {
|
|
583
|
+
return await this.get("/stats", options.tenantId, options.userId, options.scopes, signal);
|
|
584
|
+
}
|
|
585
|
+
// Charts CRUD
|
|
586
|
+
async createChart(body, options, signal) {
|
|
587
|
+
return await this.post("/v2/charts", body, options.tenantId, options.userId, options.scopes, signal);
|
|
588
|
+
}
|
|
589
|
+
async listCharts(options, signal) {
|
|
590
|
+
const queryParams = new URLSearchParams();
|
|
591
|
+
if (options.pagination?.page) {
|
|
592
|
+
queryParams.set("page", options.pagination.page.toString());
|
|
593
|
+
}
|
|
594
|
+
if (options.pagination?.limit) {
|
|
595
|
+
queryParams.set("limit", options.pagination.limit.toString());
|
|
596
|
+
}
|
|
597
|
+
if (options.sortBy) {
|
|
598
|
+
queryParams.set("sort_by", options.sortBy);
|
|
599
|
+
}
|
|
600
|
+
if (options.sortDir) {
|
|
601
|
+
queryParams.set("sort_dir", options.sortDir);
|
|
602
|
+
}
|
|
603
|
+
if (options.name) {
|
|
604
|
+
queryParams.set("name", options.name);
|
|
605
|
+
}
|
|
606
|
+
if (options.filterUserId) {
|
|
607
|
+
queryParams.set("user_id", options.filterUserId);
|
|
608
|
+
}
|
|
609
|
+
if (options.createdFrom) {
|
|
610
|
+
queryParams.set("created_from", options.createdFrom);
|
|
611
|
+
}
|
|
612
|
+
if (options.createdTo) {
|
|
613
|
+
queryParams.set("created_to", options.createdTo);
|
|
614
|
+
}
|
|
615
|
+
if (options.updatedFrom) {
|
|
616
|
+
queryParams.set("updated_from", options.updatedFrom);
|
|
617
|
+
}
|
|
618
|
+
if (options.updatedTo) {
|
|
619
|
+
queryParams.set("updated_to", options.updatedTo);
|
|
620
|
+
}
|
|
621
|
+
const query = queryParams.toString() ? `?${queryParams.toString()}` : "";
|
|
622
|
+
const charts = await this.get(`/v2/charts${query}`, options.tenantId, options.userId, options.scopes, signal);
|
|
623
|
+
const chartsWithData = await Promise.all(charts.data.map(async (chart) => {
|
|
624
|
+
return {
|
|
625
|
+
...chart,
|
|
626
|
+
vega_lite_spec: {
|
|
627
|
+
...chart.vega_lite_spec,
|
|
628
|
+
data: {
|
|
629
|
+
values: (await this.runSafeQueryOnClient(chart.sql, chart.database, chart.sql_params || undefined)) ?? [],
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}));
|
|
634
|
+
return {
|
|
635
|
+
...charts,
|
|
636
|
+
data: chartsWithData,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
async getChart(id, options, signal) {
|
|
640
|
+
const chart = await this.get(`/v2/charts/${encodeURIComponent(id)}`, options.tenantId, options.userId, options.scopes, signal);
|
|
641
|
+
return {
|
|
642
|
+
...chart,
|
|
643
|
+
vega_lite_spec: {
|
|
644
|
+
...chart.vega_lite_spec,
|
|
645
|
+
data: {
|
|
646
|
+
values: await this.runSafeQueryOnClient(chart.sql, chart.database, chart.sql_params || undefined),
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
async updateChart(id, body, options, signal) {
|
|
652
|
+
return await this.put(`/v2/charts/${encodeURIComponent(id)}`, body, options.tenantId, options.userId, options.scopes, signal);
|
|
653
|
+
}
|
|
654
|
+
async deleteChart(id, options, signal) {
|
|
655
|
+
await this.delete(`/v2/charts/${encodeURIComponent(id)}`, options.tenantId, options.userId, options.scopes, signal);
|
|
656
|
+
}
|
|
657
|
+
// Active Charts CRUD
|
|
658
|
+
async createActiveChart(body, options, signal) {
|
|
659
|
+
return await this.post("/v2/active-charts", body, options.tenantId, options.userId, options.scopes, signal);
|
|
660
|
+
}
|
|
661
|
+
async listActiveCharts(options, signal) {
|
|
662
|
+
const queryParams = new URLSearchParams();
|
|
663
|
+
if (options.pagination?.page) {
|
|
664
|
+
queryParams.set("page", options.pagination.page.toString());
|
|
665
|
+
}
|
|
666
|
+
if (options.pagination?.limit) {
|
|
667
|
+
queryParams.set("limit", options.pagination.limit.toString());
|
|
668
|
+
}
|
|
669
|
+
if (options.sortBy)
|
|
670
|
+
queryParams.set("sort_by", options.sortBy);
|
|
671
|
+
if (options.sortDir)
|
|
672
|
+
queryParams.set("sort_dir", options.sortDir);
|
|
673
|
+
if (options.name)
|
|
674
|
+
queryParams.set("name", options.name);
|
|
675
|
+
if (options.filterUserId)
|
|
676
|
+
queryParams.set("user_id", options.filterUserId);
|
|
677
|
+
if (options.createdFrom)
|
|
678
|
+
queryParams.set("created_from", options.createdFrom);
|
|
679
|
+
if (options.createdTo)
|
|
680
|
+
queryParams.set("created_to", options.createdTo);
|
|
681
|
+
if (options.updatedFrom)
|
|
682
|
+
queryParams.set("updated_from", options.updatedFrom);
|
|
683
|
+
if (options.updatedTo)
|
|
684
|
+
queryParams.set("updated_to", options.updatedTo);
|
|
685
|
+
const query = queryParams.toString() ? `?${queryParams.toString()}` : "";
|
|
686
|
+
const charts = await this.get(`/v2/active-charts${query}`, options.tenantId, options.userId, options.scopes, signal);
|
|
687
|
+
if (options.withdata) {
|
|
688
|
+
charts.data = await Promise.all(charts.data.map(async (chart) => {
|
|
689
|
+
return {
|
|
690
|
+
...chart,
|
|
691
|
+
chart: await this.getChart(chart.chart_id, options),
|
|
692
|
+
};
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
return charts;
|
|
696
|
+
}
|
|
697
|
+
async getActiveChart(id, options, signal) {
|
|
698
|
+
const activeChart = await this.get(`/v2/active-charts/${encodeURIComponent(id)}`, options.tenantId, options.userId, options.scopes, signal);
|
|
699
|
+
return {
|
|
700
|
+
...activeChart,
|
|
701
|
+
...(!!activeChart.chart && {
|
|
702
|
+
chart: {
|
|
703
|
+
...activeChart.chart,
|
|
704
|
+
vega_lite_spec: {
|
|
705
|
+
...activeChart.chart.vega_lite_spec,
|
|
706
|
+
data: {
|
|
707
|
+
values: await this.runSafeQueryOnClient(activeChart.chart.sql, activeChart.chart.database, activeChart.chart.sql_params || undefined),
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
}),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
async updateActiveChart(id, body, options, signal) {
|
|
715
|
+
return await this.put(`/v2/active-charts/${encodeURIComponent(id)}`, body, options.tenantId, options.userId, options.scopes, signal);
|
|
716
|
+
}
|
|
717
|
+
async deleteActiveChart(id, options, signal) {
|
|
718
|
+
await this.delete(`/v2/active-charts/${encodeURIComponent(id)}`, options.tenantId, options.userId, options.scopes, signal);
|
|
719
|
+
}
|
|
720
|
+
async get(path, tenantId, userId, scopes, signal, sessionId) {
|
|
721
|
+
const res = await fetch(this.baseUrl + path, {
|
|
722
|
+
method: "GET",
|
|
723
|
+
headers: await this.headers(tenantId, userId, scopes, false, sessionId),
|
|
724
|
+
signal,
|
|
725
|
+
});
|
|
726
|
+
return await this.parseResponse(res);
|
|
727
|
+
}
|
|
728
|
+
async post(path, body, tenantId, userId, scopes, signal, sessionId) {
|
|
729
|
+
const res = await fetch(this.baseUrl + path, {
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: await this.headers(tenantId, userId, scopes, true, sessionId),
|
|
732
|
+
body: JSON.stringify(body ?? {}),
|
|
733
|
+
signal,
|
|
734
|
+
});
|
|
735
|
+
return await this.parseResponse(res);
|
|
736
|
+
}
|
|
737
|
+
async put(path, body, tenantId, userId, scopes, signal, sessionId) {
|
|
738
|
+
const res = await fetch(this.baseUrl + path, {
|
|
739
|
+
method: "PUT",
|
|
740
|
+
headers: await this.headers(tenantId, userId, scopes, true, sessionId),
|
|
741
|
+
body: JSON.stringify(body ?? {}),
|
|
742
|
+
signal,
|
|
743
|
+
});
|
|
744
|
+
return await this.parseResponse(res);
|
|
745
|
+
}
|
|
746
|
+
async delete(path, tenantId, userId, scopes, signal, sessionId) {
|
|
747
|
+
const res = await fetch(this.baseUrl + path, {
|
|
748
|
+
method: "DELETE",
|
|
749
|
+
headers: await this.headers(tenantId, userId, scopes, false, sessionId),
|
|
750
|
+
signal,
|
|
751
|
+
});
|
|
752
|
+
return await this.parseResponse(res);
|
|
753
|
+
}
|
|
754
|
+
async generateJWT(tenantId, userId, scopes) {
|
|
755
|
+
if (!this.privateKey || !this.organizationId) {
|
|
756
|
+
throw new Error("Private key and organization ID are required for JWT generation");
|
|
757
|
+
}
|
|
758
|
+
// Cache the imported private key to avoid re-importing on every request
|
|
759
|
+
if (!this.cachedPrivateKey) {
|
|
760
|
+
this.cachedPrivateKey = await importPKCS8(this.privateKey, "RS256");
|
|
761
|
+
}
|
|
762
|
+
const payload = {
|
|
763
|
+
organizationId: this.organizationId,
|
|
764
|
+
};
|
|
765
|
+
if (tenantId) {
|
|
766
|
+
payload.tenantId = tenantId;
|
|
767
|
+
}
|
|
768
|
+
if (userId) {
|
|
769
|
+
payload.userId = userId;
|
|
770
|
+
}
|
|
771
|
+
if (scopes && scopes.length > 0) {
|
|
772
|
+
payload.scopes = scopes;
|
|
773
|
+
}
|
|
774
|
+
return await new SignJWT(payload)
|
|
775
|
+
.setProtectedHeader({ alg: "RS256" })
|
|
776
|
+
.setIssuedAt()
|
|
777
|
+
.setExpirationTime("1h")
|
|
778
|
+
.sign(this.cachedPrivateKey);
|
|
779
|
+
}
|
|
780
|
+
async headers(tenantId, userId, scopes, includeJsonContentType = true, sessionId) {
|
|
781
|
+
let authToken;
|
|
782
|
+
if (this.token) {
|
|
783
|
+
// Legacy mode: use provided JWT
|
|
784
|
+
authToken = this.token;
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
// New mode: generate JWT
|
|
788
|
+
authToken = await this.generateJWT(tenantId, userId, scopes);
|
|
789
|
+
}
|
|
790
|
+
const headers = {
|
|
791
|
+
Authorization: `Bearer ${authToken}`,
|
|
792
|
+
Accept: "application/json",
|
|
793
|
+
};
|
|
794
|
+
if (includeJsonContentType) {
|
|
795
|
+
headers["Content-Type"] = "application/json";
|
|
796
|
+
}
|
|
797
|
+
if (sessionId) {
|
|
798
|
+
headers["x-session-id"] = sessionId;
|
|
799
|
+
}
|
|
800
|
+
return headers;
|
|
801
|
+
}
|
|
802
|
+
async parseResponse(res) {
|
|
803
|
+
const text = await res.text();
|
|
804
|
+
let json;
|
|
805
|
+
try {
|
|
806
|
+
json = text ? JSON.parse(text) : undefined;
|
|
807
|
+
}
|
|
808
|
+
catch { }
|
|
809
|
+
if (!res.ok) {
|
|
810
|
+
const message = json?.error || res.statusText || "Request failed";
|
|
811
|
+
const err = new Error(message);
|
|
812
|
+
err.status = res.status;
|
|
813
|
+
if (json?.details)
|
|
814
|
+
err.details = json.details;
|
|
815
|
+
// Special handling for 429 (MaxRetriesError from v4 API)
|
|
816
|
+
if (res.status === 429 && json?.attemptCount && json?.maxRetries) {
|
|
817
|
+
err.details = {
|
|
818
|
+
...json,
|
|
819
|
+
message: `Maximum retry attempts (${json.maxRetries}) exceeded after ${json.attemptCount} attempts`,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
throw err;
|
|
823
|
+
}
|
|
824
|
+
return json;
|
|
825
|
+
}
|
|
826
|
+
async runSafeQueryOnClient(sql, database, params) {
|
|
827
|
+
try {
|
|
828
|
+
const adapter = this.getDatabase(database);
|
|
829
|
+
const result = await adapter.execute(sql, params || {});
|
|
830
|
+
return result.rows;
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
console.error(`Error running query on database '${database}': ${error}`);
|
|
834
|
+
return [];
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
//# sourceMappingURL=index.js.map
|