@askdb/studio 0.2.0-beta.4 → 0.2.0-beta.41
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 +33 -29
- package/bin/askdb-studio.js +3 -1
- package/dist/bin.d.ts +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +24 -3
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +72 -15
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/index-Bl9jqOjj.css +2 -0
- package/dist/client/assets/index-CGC5sKu1.js +100 -0
- package/dist/client/index.html +2 -2
- package/dist/execute-registry.d.ts +89 -0
- package/dist/execute-registry.d.ts.map +1 -0
- package/dist/execute-registry.js +445 -0
- package/dist/execute-registry.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/introspection.d.ts +35 -0
- package/dist/introspection.d.ts.map +1 -0
- package/dist/introspection.js +106 -0
- package/dist/introspection.js.map +1 -0
- package/dist/package-manager.d.ts +44 -0
- package/dist/package-manager.d.ts.map +1 -0
- package/dist/package-manager.js +86 -0
- package/dist/package-manager.js.map +1 -0
- package/dist/prisma-install.d.ts +7 -0
- package/dist/prisma-install.d.ts.map +1 -0
- package/dist/prisma-install.js +7 -0
- package/dist/prisma-install.js.map +1 -0
- package/dist/request-guard.d.ts +44 -0
- package/dist/request-guard.d.ts.map +1 -0
- package/dist/request-guard.js +170 -0
- package/dist/request-guard.js.map +1 -0
- package/dist/server.d.ts +22 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1123 -148
- package/dist/server.js.map +1 -1
- package/dist/setup.d.ts +77 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +511 -0
- package/dist/setup.js.map +1 -0
- package/dist/shared/api.d.ts +176 -3
- package/dist/shared/api.d.ts.map +1 -1
- package/package.json +81 -30
- package/dist/client/assets/index-Bsn2S5X2.css +0 -2
- package/dist/client/assets/index-C_RfRKkd.js +0 -8
package/dist/server.js
CHANGED
|
@@ -1,56 +1,138 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
|
-
import { extname, relative, join, resolve } from "node:path";
|
|
4
|
+
import { extname, relative, join, resolve, dirname, sep } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
7
|
import { generateText as defaultGenerateText } from "ai";
|
|
6
|
-
import { getAskDbRuntimeConfig } from "@askdb/config";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { createAiSdkEmbedder } from "@askdb/rag
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
8
|
+
import { ASKDB_AI_PROVIDERS, bootstrapAskDbEnv, getAskDbRuntimeConfig, } from "@askdb/config";
|
|
9
|
+
import { createAiRegistry, resolveReasoningEffort, } from "@askdb/ai";
|
|
10
|
+
import { ask, DEFAULT_TENANT_SQL_MODE, formatSchemaV2ForNlToSql, formatSensitiveReference, isBuiltInDialectId, loadSchema, saveSchemaFile, schemaHasSensitiveIdentifiers, SensitiveEnrichmentTargetError, SqlValidationError, validateSensitiveReferences, suggestEnrichment, tenantScopeSchema, SchemaParseError, tenantPolicyFrontmatterSchema, } from "@askdb/core";
|
|
11
|
+
import { buildSchemaIndex, chunkContentHash, chunkSchema, aiSdkEmbedderId, createAiSdkEmbedder, createFileStore, createMemoryStore, createPgvectorStore, createRetriever, detectEmbeddingDimensions, loadChunkerSourcesFromDir, PgvectorDimensionMismatchError, } from "@askdb/rag";
|
|
12
|
+
import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveConcepts, saveTable, saveTenantPolicy, } from "@askdb/enrich";
|
|
13
|
+
import { EXECUTE_DRIVER_REGISTRY, executeDialectFor, isDriverInstalled, isStudioExecuteProvider, validateExecuteSql, } from "./execute-registry.js";
|
|
14
|
+
import { lockfilePackageManager, packageManagerAddArgs, packageManagerSpawnSpec, } from "./package-manager.js";
|
|
15
|
+
import { resolveStudioIntrospectionPlan, runStudioIntrospection } from "./introspection.js";
|
|
16
|
+
import { probeSetupState, writeSetupConfig, SetupError } from "./setup.js";
|
|
17
|
+
import { checkApiRequest, checkHost, createStudioSessionToken, injectSessionToken, } from "./request-guard.js";
|
|
18
|
+
// Batteries-included surface: every built-in provider is registered, and each
|
|
19
|
+
// loads its @ai-sdk/* package only when first used, so env config alone
|
|
20
|
+
// selects the provider.
|
|
21
|
+
const ai = createAiRegistry();
|
|
22
|
+
const DEFAULT_CLIENT_DIR = fileURLToPath(new URL("./client/", import.meta.url));
|
|
23
|
+
let clientDirForTests;
|
|
24
|
+
/** @internal Tests only — serve client assets from another directory. */
|
|
25
|
+
export function setStudioClientDirForTests(dir) {
|
|
26
|
+
clientDirForTests = dir;
|
|
27
|
+
}
|
|
28
|
+
function clientDir() {
|
|
29
|
+
return clientDirForTests ?? DEFAULT_CLIENT_DIR;
|
|
30
|
+
}
|
|
15
31
|
const STUDIO_RAG_MOCK_DIMENSIONS = 64;
|
|
16
32
|
const STUDIO_RAG_MOCK_EMBEDDER_ID = `studio:mock-lexical-${STUDIO_RAG_MOCK_DIMENSIONS}`;
|
|
17
|
-
const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
|
|
18
33
|
let studioPgvectorStoreFactoryForTests;
|
|
19
34
|
export function setStudioPgvectorStoreFactoryForTests(factory) {
|
|
20
35
|
studioPgvectorStoreFactoryForTests = factory;
|
|
21
36
|
}
|
|
22
37
|
export function createStudioServer(options) {
|
|
23
38
|
const schemaDir = resolve(options.schema);
|
|
24
|
-
|
|
39
|
+
const setupReason = options.setupReason ?? null;
|
|
40
|
+
if (!setupReason && !existsSync(schemaDir)) {
|
|
25
41
|
throw new Error(`schema directory not found: ${schemaDir}`);
|
|
26
42
|
}
|
|
27
43
|
const state = {
|
|
28
44
|
schemaDir,
|
|
29
|
-
workspace: loadWorkspace(schemaDir),
|
|
45
|
+
workspace: setupReason ? null : loadWorkspace(schemaDir),
|
|
46
|
+
setupReason,
|
|
30
47
|
};
|
|
31
|
-
|
|
48
|
+
const sessionToken = createStudioSessionToken();
|
|
49
|
+
const server = createServer(async (req, res) => {
|
|
32
50
|
try {
|
|
51
|
+
// Host allowlist on every request (static assets included) — defeats DNS rebinding.
|
|
52
|
+
const hostFailure = checkHost(req, options.host);
|
|
53
|
+
if (hostFailure) {
|
|
54
|
+
return writeJson(res, hostFailure.status, { error: { message: hostFailure.message } });
|
|
55
|
+
}
|
|
33
56
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
57
|
+
// Origin, session token, and content-type checks on every API call.
|
|
58
|
+
if (url.pathname.startsWith("/api/")) {
|
|
59
|
+
const apiFailure = checkApiRequest(req, sessionToken);
|
|
60
|
+
if (apiFailure) {
|
|
61
|
+
return writeJson(res, apiFailure.status, { error: { message: apiFailure.message } });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
34
64
|
if (req.method === "GET" && url.pathname === "/") {
|
|
35
|
-
return
|
|
65
|
+
return serveIndexHtml(res, sessionToken);
|
|
36
66
|
}
|
|
37
67
|
if (req.method === "GET" && url.pathname.startsWith("/assets/")) {
|
|
38
68
|
return serveClientFile(res, decodeURIComponent(url.pathname.slice(1)));
|
|
39
69
|
}
|
|
70
|
+
if (req.method === "GET" && url.pathname === "/api/setup/status") {
|
|
71
|
+
return writeJson(res, 200, buildSetupStatus(state));
|
|
72
|
+
}
|
|
73
|
+
if (req.method === "POST" && url.pathname === "/api/setup/config") {
|
|
74
|
+
if (!isLoopbackRequest(req)) {
|
|
75
|
+
return writeJson(res, 403, { error: { message: "Setup is only available from loopback clients." } });
|
|
76
|
+
}
|
|
77
|
+
const body = await readJson(req);
|
|
78
|
+
const result = handleSetupConfig(state, body);
|
|
79
|
+
return writeJson(res, 200, result);
|
|
80
|
+
}
|
|
81
|
+
if (req.method === "POST" && url.pathname === "/api/setup/introspect") {
|
|
82
|
+
if (!isLoopbackRequest(req)) {
|
|
83
|
+
return writeJson(res, 403, { error: { message: "Setup is only available from loopback clients." } });
|
|
84
|
+
}
|
|
85
|
+
const result = await handleSetupIntrospect(state);
|
|
86
|
+
return writeJson(res, 200, result);
|
|
87
|
+
}
|
|
88
|
+
// Everything below needs a loaded workspace — route setup-mode clients to the wizard.
|
|
89
|
+
if (state.setupReason && url.pathname.startsWith("/api/")) {
|
|
90
|
+
return writeJson(res, 409, {
|
|
91
|
+
error: { message: "Studio needs setup. Open Studio in the browser to finish the guided setup." },
|
|
92
|
+
setup: buildSetupStatus(state),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
40
95
|
if (req.method === "GET" && url.pathname === "/api/workspace") {
|
|
41
|
-
return writeJson(res, 200, serializeWorkspace(state
|
|
96
|
+
return writeJson(res, 200, serializeWorkspace(requireWorkspace(state)));
|
|
97
|
+
}
|
|
98
|
+
if (req.method === "GET" && url.pathname === "/api/introspect/status") {
|
|
99
|
+
requireWorkspace(state);
|
|
100
|
+
return writeJson(res, 200, await buildIntrospectionPlanDto());
|
|
101
|
+
}
|
|
102
|
+
if (req.method === "POST" && url.pathname === "/api/introspect") {
|
|
103
|
+
if (!isLoopbackRequest(req)) {
|
|
104
|
+
return writeJson(res, 403, { error: { message: "Resync is only available from loopback clients." } });
|
|
105
|
+
}
|
|
106
|
+
const result = await handleResync(state);
|
|
107
|
+
return writeJson(res, 200, result);
|
|
42
108
|
}
|
|
43
109
|
if (req.method === "POST" && url.pathname.startsWith("/api/tables/")) {
|
|
44
110
|
const tableId = decodeURIComponent(url.pathname.slice("/api/tables/".length));
|
|
45
111
|
const body = await readJson(req);
|
|
46
112
|
const draft = parseTableDraftBody(body);
|
|
47
113
|
saveDraft(state, tableId, draft);
|
|
48
|
-
return writeJson(res, 200, serializeWorkspace(state
|
|
114
|
+
return writeJson(res, 200, serializeWorkspace(requireWorkspace(state)));
|
|
115
|
+
}
|
|
116
|
+
if (req.method === "POST" && url.pathname === "/api/concepts") {
|
|
117
|
+
const body = await readJson(req);
|
|
118
|
+
const concepts = parseConceptsBody(body);
|
|
119
|
+
saveConceptsDraft(state, concepts);
|
|
120
|
+
return writeJson(res, 200, serializeWorkspace(requireWorkspace(state)));
|
|
121
|
+
}
|
|
122
|
+
if (req.method === "POST" && url.pathname === "/api/tenant-policy") {
|
|
123
|
+
const body = await readJson(req);
|
|
124
|
+
const { frontmatter, bodyText } = parseTenantPolicyBody(body);
|
|
125
|
+
saveTenantPolicyDraft(state, frontmatter, bodyText);
|
|
126
|
+
return writeJson(res, 200, serializeWorkspace(requireWorkspace(state)));
|
|
127
|
+
}
|
|
128
|
+
if (req.method === "POST" && url.pathname === "/api/suggest-tenant-policy") {
|
|
129
|
+
const draft = await suggestTenantPolicyDraft(state);
|
|
130
|
+
return writeJson(res, 200, draft);
|
|
49
131
|
}
|
|
50
132
|
if (req.method === "POST" && url.pathname === "/api/suggest") {
|
|
51
133
|
const body = await readJson(req);
|
|
52
134
|
const source = parseSuggestSource(body);
|
|
53
|
-
const candidates = await suggestForSource(state
|
|
135
|
+
const candidates = await suggestForSource(state, source);
|
|
54
136
|
return writeJson(res, 200, { candidates });
|
|
55
137
|
}
|
|
56
138
|
if (req.method === "GET" && url.pathname === "/api/rag/status") {
|
|
@@ -72,26 +154,56 @@ export function createStudioServer(options) {
|
|
|
72
154
|
const result = await askSampleQuestion(state, options);
|
|
73
155
|
return writeJson(res, 200, result);
|
|
74
156
|
}
|
|
157
|
+
if (req.method === "GET" && url.pathname === "/api/history") {
|
|
158
|
+
return writeJson(res, 200, readPlaygroundHistory(state.schemaDir));
|
|
159
|
+
}
|
|
160
|
+
if (req.method === "POST" && url.pathname === "/api/history") {
|
|
161
|
+
const body = await readJson(req);
|
|
162
|
+
const entry = parsePlaygroundHistoryEntry(body);
|
|
163
|
+
appendPlaygroundHistory(state.schemaDir, entry);
|
|
164
|
+
return writeJson(res, 200, { ok: true });
|
|
165
|
+
}
|
|
166
|
+
if (req.method === "DELETE" && url.pathname.startsWith("/api/history/")) {
|
|
167
|
+
const id = decodeURIComponent(url.pathname.slice("/api/history/".length));
|
|
168
|
+
const result = deletePlaygroundHistoryEntry(state.schemaDir, id);
|
|
169
|
+
return writeJson(res, 200, result);
|
|
170
|
+
}
|
|
171
|
+
if (req.method === "GET" && url.pathname === "/api/execute/status") {
|
|
172
|
+
const result = await getExecuteStatus(state.schemaDir);
|
|
173
|
+
return writeJson(res, 200, result);
|
|
174
|
+
}
|
|
175
|
+
if (req.method === "POST" && url.pathname === "/api/execute/install-driver") {
|
|
176
|
+
if (!isLoopbackRequest(req)) {
|
|
177
|
+
return writeJson(res, 403, { error: { message: "Driver install is only available from loopback clients." } });
|
|
178
|
+
}
|
|
179
|
+
const body = await readJson(req);
|
|
180
|
+
const result = await installExecuteDriver(body, state.schemaDir);
|
|
181
|
+
return writeJson(res, 200, result);
|
|
182
|
+
}
|
|
183
|
+
if (req.method === "POST" && url.pathname === "/api/execute") {
|
|
184
|
+
const body = await readJson(req);
|
|
185
|
+
const result = await executeQuery(body, state.schemaDir);
|
|
186
|
+
return writeJson(res, 200, result);
|
|
187
|
+
}
|
|
75
188
|
if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
|
|
76
|
-
return
|
|
189
|
+
return serveIndexHtml(res, sessionToken);
|
|
77
190
|
}
|
|
78
191
|
writeJson(res, 404, { error: { message: "Not found" } });
|
|
79
192
|
}
|
|
80
193
|
catch (error) {
|
|
81
|
-
const status = error instanceof StudioHttpError ? error.status : 500;
|
|
194
|
+
const status = error instanceof StudioHttpError || error instanceof SetupError ? error.status : 500;
|
|
82
195
|
writeJson(res, status, {
|
|
83
196
|
error: { message: error instanceof Error ? error.message : String(error) },
|
|
84
197
|
});
|
|
85
198
|
}
|
|
86
199
|
});
|
|
200
|
+
return Object.assign(server, { sessionToken });
|
|
87
201
|
}
|
|
88
202
|
export function serializeWorkspace(workspace) {
|
|
89
203
|
const rt = getAskDbRuntimeConfig();
|
|
90
204
|
const aiConfig = (() => {
|
|
91
205
|
try {
|
|
92
|
-
return
|
|
93
|
-
modelEnvVar: rt.ai.studioModel ? "ASKDB_STUDIO_MODEL" : undefined,
|
|
94
|
-
});
|
|
206
|
+
return ai.resolveAiConfig(rt.ai.aiEnv);
|
|
95
207
|
}
|
|
96
208
|
catch {
|
|
97
209
|
// A misconfigured AI env (e.g. azure without resourceName) shouldn't crash the workspace
|
|
@@ -99,13 +211,19 @@ export function serializeWorkspace(workspace) {
|
|
|
99
211
|
return undefined;
|
|
100
212
|
}
|
|
101
213
|
})();
|
|
214
|
+
const schema = loadSchema(workspace.schemaDir);
|
|
215
|
+
const schemaProvider = "provider" in schema && typeof schema.provider === "string" ? schema.provider : undefined;
|
|
216
|
+
const dialect = rt.nlToSql.dialect ??
|
|
217
|
+
(schemaProvider && isBuiltInDialectId(schemaProvider) ? schemaProvider : "postgres");
|
|
102
218
|
return {
|
|
103
219
|
schemaDir: workspace.schemaDir,
|
|
220
|
+
schemaPathRelative: toRelativeSchemaPath(workspace.schemaDir),
|
|
104
221
|
schemaId: workspace.physical.schemaId,
|
|
222
|
+
dialect,
|
|
105
223
|
warnings: workspace.warnings,
|
|
106
224
|
aiConfigured: Boolean(aiConfig),
|
|
107
|
-
model: aiConfig?.model ?? "
|
|
108
|
-
aiProvider: aiConfig?.provider ??
|
|
225
|
+
model: aiConfig?.model ?? rt.ai.language.model ?? "provider default",
|
|
226
|
+
aiProvider: aiConfig?.provider ?? rt.ai.language.provider,
|
|
109
227
|
tables: workspace.tables.map((table) => {
|
|
110
228
|
const draft = buildTableDraft(table.physical, table.parsed);
|
|
111
229
|
return {
|
|
@@ -116,16 +234,225 @@ export function serializeWorkspace(workspace) {
|
|
|
116
234
|
missingColumnIds: workspace.warnings
|
|
117
235
|
.filter((warning) => warning.kind === "missing_column_md" && warning.tableId === table.physical.id)
|
|
118
236
|
.map((warning) => warning.columnId),
|
|
237
|
+
escalatedByOtherFiles: table.escalatedByOtherFiles ?? [],
|
|
119
238
|
};
|
|
120
239
|
}),
|
|
121
240
|
concepts: workspace.concepts?.frontmatter.concepts ?? [],
|
|
241
|
+
tenantPolicy: schema.tenantPolicy ?? null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function toRelativeSchemaPath(schemaDir) {
|
|
245
|
+
const rel = relative(process.cwd(), schemaDir);
|
|
246
|
+
if (rel === "")
|
|
247
|
+
return ".";
|
|
248
|
+
if (rel.startsWith("..") || rel.includes(`..${sep}`))
|
|
249
|
+
return schemaDir;
|
|
250
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
251
|
+
}
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Setup wizard + server-side introspection (resync)
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
function requireWorkspace(state) {
|
|
256
|
+
if (!state.workspace) {
|
|
257
|
+
throw new StudioHttpError(409, "Studio is in setup mode — finish the guided setup first.");
|
|
258
|
+
}
|
|
259
|
+
return state.workspace;
|
|
260
|
+
}
|
|
261
|
+
function buildSetupStatus(state) {
|
|
262
|
+
const projectDir = process.cwd();
|
|
263
|
+
if (!state.setupReason) {
|
|
264
|
+
return {
|
|
265
|
+
needed: false,
|
|
266
|
+
reason: null,
|
|
267
|
+
projectDir,
|
|
268
|
+
configPath: null,
|
|
269
|
+
outputDir: state.schemaDir,
|
|
270
|
+
configLoadError: null,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const probe = probeSetupState(projectDir);
|
|
274
|
+
return {
|
|
275
|
+
needed: true,
|
|
276
|
+
reason: probe.hasConfig ? "no-artifact" : "no-config",
|
|
277
|
+
projectDir,
|
|
278
|
+
configPath: probe.configPath,
|
|
279
|
+
outputDir: probe.outputDir,
|
|
280
|
+
configLoadError: probe.loadError,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function handleSetupConfig(state, body) {
|
|
284
|
+
if (!state.setupReason) {
|
|
285
|
+
throw new StudioHttpError(409, "Studio is already set up — edit askdb.config.ts directly.");
|
|
286
|
+
}
|
|
287
|
+
const input = parseSetupConfigBody(body);
|
|
288
|
+
const result = writeSetupConfig(process.cwd(), input);
|
|
289
|
+
// Config now exists; the artifact usually doesn't yet.
|
|
290
|
+
const probe = probeSetupState(process.cwd());
|
|
291
|
+
if (probe.hasArtifact && probe.outputDir) {
|
|
292
|
+
state.schemaDir = probe.outputDir;
|
|
293
|
+
state.workspace = loadWorkspace(probe.outputDir);
|
|
294
|
+
state.setupReason = null;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
state.setupReason = "no-artifact";
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
configPath: result.configPath,
|
|
301
|
+
envExamplePath: result.envExamplePath,
|
|
302
|
+
envVars: result.envVars,
|
|
303
|
+
installed: result.installed,
|
|
304
|
+
manualInstallCommand: result.manualInstallCommand,
|
|
305
|
+
packageJsonCreated: result.packageJsonCreated,
|
|
306
|
+
status: buildSetupStatus(state),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
async function handleSetupIntrospect(state) {
|
|
310
|
+
if (!state.setupReason) {
|
|
311
|
+
throw new StudioHttpError(409, "Studio is already set up — use Resync schema instead.");
|
|
312
|
+
}
|
|
313
|
+
// probeSetupState re-bootstraps, picking up any .env created since server start.
|
|
314
|
+
const probe = probeSetupState(process.cwd());
|
|
315
|
+
if (!probe.hasConfig) {
|
|
316
|
+
throw new StudioHttpError(409, "Write askdb.config.ts first (setup step 1).");
|
|
317
|
+
}
|
|
318
|
+
if (probe.loadError || !probe.outputDir) {
|
|
319
|
+
throw new StudioHttpError(409, `askdb.config.ts exists but could not be loaded: ${probe.loadError ?? "unknown error"}. ` +
|
|
320
|
+
"Install the project dependencies shown in setup step 1, then retry.");
|
|
321
|
+
}
|
|
322
|
+
const outDir = probe.outputDir;
|
|
323
|
+
const run = await runStudioIntrospection({
|
|
324
|
+
outDir,
|
|
325
|
+
hasExistingArtifact: existsSync(join(outDir, "schema.json")),
|
|
326
|
+
});
|
|
327
|
+
state.schemaDir = outDir;
|
|
328
|
+
state.workspace = loadWorkspace(outDir);
|
|
329
|
+
state.setupReason = null;
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
engine: run.engine,
|
|
333
|
+
schemaId: run.schemaId,
|
|
334
|
+
tables: run.tables,
|
|
335
|
+
warnings: run.warnings,
|
|
336
|
+
workspace: serializeWorkspace(state.workspace),
|
|
122
337
|
};
|
|
123
338
|
}
|
|
339
|
+
async function handleResync(state) {
|
|
340
|
+
const workspace = requireWorkspace(state);
|
|
341
|
+
// Refresh the runtime snapshot so config edits made while Studio runs are honored.
|
|
342
|
+
try {
|
|
343
|
+
bootstrapAskDbEnv({ cwd: process.cwd() });
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// Keep the snapshot the server started with when the config disappeared mid-session.
|
|
347
|
+
}
|
|
348
|
+
const run = await runStudioIntrospection({
|
|
349
|
+
outDir: state.schemaDir,
|
|
350
|
+
schemaId: workspace.physical.schemaId,
|
|
351
|
+
hasExistingArtifact: true,
|
|
352
|
+
});
|
|
353
|
+
state.workspace = loadWorkspace(state.schemaDir);
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
engine: run.engine,
|
|
357
|
+
schemaId: run.schemaId,
|
|
358
|
+
tables: run.tables,
|
|
359
|
+
warnings: run.warnings,
|
|
360
|
+
workspace: serializeWorkspace(state.workspace),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
async function buildIntrospectionPlanDto() {
|
|
364
|
+
const plan = await resolveStudioIntrospectionPlan();
|
|
365
|
+
return plan.ok
|
|
366
|
+
? { ok: true, engine: plan.engine, sourceLabel: plan.sourceLabel }
|
|
367
|
+
: { ok: false, engine: plan.engine, error: plan.error };
|
|
368
|
+
}
|
|
369
|
+
function parseSetupConfigBody(body) {
|
|
370
|
+
if (!isRecord(body)) {
|
|
371
|
+
throw new StudioHttpError(400, "Request body must be a JSON object.");
|
|
372
|
+
}
|
|
373
|
+
const databases = ["postgres", "mysql", "sqlite", "sqlserver", "prisma"];
|
|
374
|
+
// Every provider with an askdb.config.* branch; @askdb/client's provider-config-drift test asserts this matches @askdb/ai's built-in table.
|
|
375
|
+
const aiProviders = ASKDB_AI_PROVIDERS;
|
|
376
|
+
const ragStores = ["file", "memory", "pgvector"];
|
|
377
|
+
const executeProviders = ["postgres", "mysql", "sqlite", "sqlserver"];
|
|
378
|
+
if (typeof body.database !== "string" || !databases.includes(body.database)) {
|
|
379
|
+
throw new StudioHttpError(400, `\`database\` must be one of: ${databases.join(", ")}.`);
|
|
380
|
+
}
|
|
381
|
+
if (typeof body.aiProvider !== "string" ||
|
|
382
|
+
!aiProviders.includes(body.aiProvider)) {
|
|
383
|
+
throw new StudioHttpError(400, `\`aiProvider\` must be one of: ${aiProviders.join(", ")}.`);
|
|
384
|
+
}
|
|
385
|
+
if (body.ragStore !== undefined && !ragStores.includes(body.ragStore)) {
|
|
386
|
+
throw new StudioHttpError(400, `\`ragStore\` must be one of: ${ragStores.join(", ")}.`);
|
|
387
|
+
}
|
|
388
|
+
if (body.studioExecuteProvider !== undefined &&
|
|
389
|
+
!executeProviders.includes(body.studioExecuteProvider)) {
|
|
390
|
+
throw new StudioHttpError(400, `\`studioExecuteProvider\` must be one of: ${executeProviders.join(", ")}.`);
|
|
391
|
+
}
|
|
392
|
+
if (body.studioExecute !== undefined && typeof body.studioExecute !== "boolean") {
|
|
393
|
+
throw new StudioHttpError(400, "`studioExecute` must be a boolean.");
|
|
394
|
+
}
|
|
395
|
+
const optionalString = (key) => {
|
|
396
|
+
const value = body[key];
|
|
397
|
+
if (value === undefined || value === null || value === "")
|
|
398
|
+
return undefined;
|
|
399
|
+
if (typeof value !== "string")
|
|
400
|
+
throw new StudioHttpError(400, `\`${key}\` must be a string.`);
|
|
401
|
+
return value;
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
database: body.database,
|
|
405
|
+
aiProvider: body.aiProvider,
|
|
406
|
+
connectionEnv: optionalString("connectionEnv"),
|
|
407
|
+
sqliteFile: optionalString("sqliteFile"),
|
|
408
|
+
prismaSchema: optionalString("prismaSchema"),
|
|
409
|
+
aiKeyEnv: optionalString("aiKeyEnv"),
|
|
410
|
+
aiModelEnv: optionalString("aiModelEnv"),
|
|
411
|
+
schemaOut: optionalString("schemaOut"),
|
|
412
|
+
ragStore: body.ragStore,
|
|
413
|
+
pgvectorEnv: optionalString("pgvectorEnv"),
|
|
414
|
+
studioExecute: body.studioExecute,
|
|
415
|
+
studioExecuteProvider: body.studioExecuteProvider,
|
|
416
|
+
studioExecuteConnectionEnv: optionalString("studioExecuteConnectionEnv"),
|
|
417
|
+
studioExecuteSqliteFile: optionalString("studioExecuteSqliteFile"),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function saveConceptsDraft(state, concepts) {
|
|
421
|
+
saveConcepts(requireWorkspace(state), { concepts });
|
|
422
|
+
state.workspace = loadWorkspace(state.schemaDir);
|
|
423
|
+
}
|
|
424
|
+
function saveTenantPolicyDraft(state, frontmatter, body) {
|
|
425
|
+
// `saveTenantPolicy` refuses a policy `loadSchema` would reject (e.g. two root labels
|
|
426
|
+
// deriving one placeholder) before writing; that's the caller's mistake, so 400.
|
|
427
|
+
try {
|
|
428
|
+
saveTenantPolicy(requireWorkspace(state), frontmatter, body);
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
if (error instanceof SchemaParseError)
|
|
432
|
+
throw new StudioHttpError(400, error.message);
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
state.workspace = loadWorkspace(state.schemaDir);
|
|
436
|
+
}
|
|
437
|
+
function parseTenantPolicyBody(body) {
|
|
438
|
+
if (!isRecord(body) || !isRecord(body.frontmatter)) {
|
|
439
|
+
throw new StudioHttpError(400, "`frontmatter` object is required.");
|
|
440
|
+
}
|
|
441
|
+
const parsed = tenantPolicyFrontmatterSchema.safeParse(body.frontmatter);
|
|
442
|
+
if (!parsed.success) {
|
|
443
|
+
throw new StudioHttpError(400, `Invalid tenant policy: ${parsed.error.message}`);
|
|
444
|
+
}
|
|
445
|
+
const bodyText = typeof body.body === "string" ? body.body : "";
|
|
446
|
+
return { frontmatter: parsed.data, bodyText };
|
|
447
|
+
}
|
|
124
448
|
function saveDraft(state, tableId, draft) {
|
|
125
|
-
const
|
|
449
|
+
const workspace = requireWorkspace(state);
|
|
450
|
+
const table = workspace.tables.find((candidate) => candidate.physical.id === tableId);
|
|
126
451
|
if (!table)
|
|
127
452
|
throw new StudioHttpError(404, `No such table: ${tableId}`);
|
|
128
|
-
|
|
453
|
+
// Passing the file's current front-matter keeps entries for other tables' columns
|
|
454
|
+
// (e.g. a misplaced `sensitive: true`), which the draft does not carry.
|
|
455
|
+
const frontmatter = buildFrontmatter(table.physical, workspace.physical.schemaId, draft, table.parsed?.frontmatter);
|
|
129
456
|
let body = table.parsed
|
|
130
457
|
? replaceTableDescription(table.parsed.body, draft.description)
|
|
131
458
|
: buildDefaultTableBody(table.physical.name, draft.description);
|
|
@@ -135,32 +462,191 @@ function saveDraft(state, tableId, draft) {
|
|
|
135
462
|
if (draft.exampleQuestions !== undefined) {
|
|
136
463
|
body = replaceH2Section(body, "Example questions", draft.exampleQuestions);
|
|
137
464
|
}
|
|
138
|
-
saveTable(
|
|
465
|
+
saveTable(workspace, tableId, frontmatter, body);
|
|
139
466
|
state.workspace = loadWorkspace(state.schemaDir);
|
|
140
467
|
}
|
|
141
|
-
async function suggestForSource(
|
|
468
|
+
async function suggestForSource(state, source) {
|
|
142
469
|
const rt = getAskDbRuntimeConfig();
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
470
|
+
const aiConfig = ai.resolveAiConfig(rt.ai.aiEnv);
|
|
471
|
+
if (!aiConfig) {
|
|
472
|
+
throw new StudioHttpError(400, ai.keyMissingMessage("AI enrichment suggestions"));
|
|
473
|
+
}
|
|
474
|
+
const model = await ai.createLanguageModel(aiConfig);
|
|
475
|
+
const reasoningEffort = resolveReasoningEffort(rt.ai.aiEnv, "enrichment");
|
|
476
|
+
const providerOptions = reasoningEffort
|
|
477
|
+
? ai.resolveProviderOptions(aiConfig, { reasoningEffort })
|
|
478
|
+
: undefined;
|
|
479
|
+
// `loadSchema()` carries effective sensitivity (front-matter escalations included), and the
|
|
480
|
+
// prompt follows `modes.omitSensitiveFromPrompt` as `ask()` does (#625, ADR 0026).
|
|
481
|
+
requireWorkspace(state);
|
|
482
|
+
const schema = loadSchema(state.schemaDir);
|
|
483
|
+
let candidates;
|
|
484
|
+
try {
|
|
485
|
+
candidates = await suggestEnrichment(buildSuggestionTarget(schema, source), buildSuggestionContext(schema, source.tableId), model, {
|
|
486
|
+
omitSensitiveIdentifiersFromPrompt: rt.modes.omitSensitiveFromPrompt,
|
|
487
|
+
...(providerOptions ? { providerOptions } : {}),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
if (error instanceof SensitiveEnrichmentTargetError) {
|
|
492
|
+
throw new StudioHttpError(400, "This column is sensitive, and `modes.omitSensitiveFromPrompt` keeps sensitive columns out of the model's prompt, so Studio can't suggest for it.");
|
|
493
|
+
}
|
|
494
|
+
throw error;
|
|
153
495
|
}
|
|
154
|
-
const candidates = await suggestEnrichment(buildSuggestionTarget(workspace, source), buildSuggestionContext(workspace, source.tableId), model);
|
|
155
496
|
return candidates.map((candidate) => ({ text: candidate.text }));
|
|
156
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* The schema as the tenant-policy draft sends it to the model, with the sensitivity rules
|
|
500
|
+
* `ask()` applies (#324): `formatSchemaV2ForNlToSql` tags sensitive columns, or withholds
|
|
501
|
+
* them when `omitSensitive` is on. Only the physical layer goes in (names, types, keys and
|
|
502
|
+
* foreign keys), never descriptions, aliases or common query language, and untracked tables
|
|
503
|
+
* stay out as they do for `ask()`. A foreign key is listed only when both of its columns are.
|
|
504
|
+
* `warnings` names the primary and foreign keys withheld as sensitive, which the draft can't
|
|
505
|
+
* scope through.
|
|
506
|
+
*/
|
|
507
|
+
function tenantPolicySchemaForPrompt(schema, omitSensitive) {
|
|
508
|
+
const tracked = schema.tables.filter((table) => table.tracked !== false);
|
|
509
|
+
const physical = {
|
|
510
|
+
schemaId: schema.schemaId,
|
|
511
|
+
warnings: [],
|
|
512
|
+
tables: tracked.map((table) => ({
|
|
513
|
+
id: table.id,
|
|
514
|
+
name: table.name,
|
|
515
|
+
schema: table.schema,
|
|
516
|
+
sensitive: table.sensitive,
|
|
517
|
+
columns: table.columns.map((column) => ({
|
|
518
|
+
id: column.id,
|
|
519
|
+
name: column.name,
|
|
520
|
+
type: column.type,
|
|
521
|
+
nullable: column.nullable,
|
|
522
|
+
primaryKey: column.primaryKey,
|
|
523
|
+
sensitive: column.sensitive,
|
|
524
|
+
})),
|
|
525
|
+
})),
|
|
526
|
+
};
|
|
527
|
+
const { ddl } = formatSchemaV2ForNlToSql(physical, { omitSensitiveIdentifiersFromPrompt: omitSensitive });
|
|
528
|
+
// The formatter prints no foreign keys yet, so Studio lists them, mirroring the formatter's
|
|
529
|
+
// omission rule (packages/core/src/schema/v2/format.ts): a column is withheld when omission
|
|
530
|
+
// is on and it is sensitive, which `loadSchema()` sets on every column of a sensitive table.
|
|
531
|
+
// When core prints foreign keys (#349, step 5), use its lines and delete this block (ADR 0021).
|
|
532
|
+
const names = new Map();
|
|
533
|
+
const withheld = new Set();
|
|
534
|
+
for (const table of schema.tables) {
|
|
535
|
+
for (const column of table.columns) {
|
|
536
|
+
names.set(column.id, `${table.schema}.${table.name}.${column.name}`);
|
|
537
|
+
if (omitSensitive && column.sensitive)
|
|
538
|
+
withheld.add(column.id);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const nameOf = (id) => names.get(id) ?? id;
|
|
542
|
+
const listed = new Set();
|
|
543
|
+
const withheldKeys = [];
|
|
544
|
+
for (const table of tracked) {
|
|
545
|
+
for (const column of table.columns) {
|
|
546
|
+
if (!withheld.has(column.id))
|
|
547
|
+
listed.add(column.id);
|
|
548
|
+
else if (column.primaryKey)
|
|
549
|
+
withheldKeys.push(`${nameOf(column.id)} (primary key)`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const foreignKeys = [];
|
|
553
|
+
for (const fk of tracked.flatMap((table) => table.relationships ?? [])) {
|
|
554
|
+
if (listed.has(fk.from) && listed.has(fk.to))
|
|
555
|
+
foreignKeys.push(`- ${fk.from} -> ${fk.to}`);
|
|
556
|
+
else if (withheld.has(fk.from) || withheld.has(fk.to)) {
|
|
557
|
+
withheldKeys.push(`${nameOf(fk.from)} → ${nameOf(fk.to)} (foreign key)`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const warnings = withheldKeys.length > 0
|
|
561
|
+
? [
|
|
562
|
+
`Withheld from the model because they're marked sensitive: ${withheldKeys.join(", ")}. ` +
|
|
563
|
+
"If any of them carries tenant scope, add it to the policy by hand.",
|
|
564
|
+
]
|
|
565
|
+
: [];
|
|
566
|
+
return {
|
|
567
|
+
schemaId: schema.schemaId,
|
|
568
|
+
schemaDdl: foreignKeys.length > 0 ? `${ddl}\n\nForeign keys:\n${foreignKeys.join("\n")}` : ddl,
|
|
569
|
+
tableIds: tracked.map((table) => table.id),
|
|
570
|
+
warnings,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
async function suggestTenantPolicyDraft(state) {
|
|
574
|
+
const rt = getAskDbRuntimeConfig();
|
|
575
|
+
const model = await ai.createLanguageModelFromEnv(rt.ai.aiEnv);
|
|
576
|
+
if (!model) {
|
|
577
|
+
throw new StudioHttpError(400, ai.keyMissingMessage("AI tenant policy suggestion"));
|
|
578
|
+
}
|
|
579
|
+
requireWorkspace(state);
|
|
580
|
+
const { schemaId, schemaDdl, tableIds, warnings } = tenantPolicySchemaForPrompt(loadSchema(state.schemaDir), rt.modes.omitSensitiveFromPrompt);
|
|
581
|
+
const systemPrompt = `You are an expert database architect specializing in multi-tenant SaaS systems.
|
|
582
|
+
You analyze database schemas and produce a tenant-policy configuration in a specific JSON format.
|
|
583
|
+
|
|
584
|
+
The tenant policy identifies:
|
|
585
|
+
1. **roots** — Tables whose rows represent tenants (organizations, companies, accounts, etc).
|
|
586
|
+
Each root has: id (table ID like "table:public.orgs"), tenantIdColumn (column ref like "table:public.orgs#id"), label (human-readable name).
|
|
587
|
+
If a root has a parent root (e.g. sub_org belongs to org), include parent: { root: "<parent-root-id>", foreignKey: "<fk-column-ref>" }.
|
|
588
|
+
2. **hierarchy** — Edges showing parent→child relationships between roots or tenant-scoped tables.
|
|
589
|
+
Each edge: { parent: "<table-id>", child: "<table-id>", foreignKey: "<child-fk-column-ref>" }.
|
|
590
|
+
3. **scopedTables** — Tables that belong to a tenant via a direct column or a JOIN chain.
|
|
591
|
+
Each entry: { id: "<table-id>", scopeThrough: [{ root: "<root-id>", column: "<column-ref>" }] } for direct FK,
|
|
592
|
+
or { id: "<table-id>", scopeThrough: [{ root: "<root-id>", join: [{ from: "<col-ref>", to: "<col-ref>" }] }] } for indirect.
|
|
593
|
+
4. **polymorphicTables** — Tables with a type/id polymorphic association pattern.
|
|
594
|
+
Each entry: { id: "<table-id>", typeColumn: "<col-ref>", idColumn: "<col-ref>", mapping: { "<type_value>": "<target-table-id>" } }.
|
|
595
|
+
5. **globalTables** — Tables shared across all tenants (lookup tables, reference data). Array of table IDs.
|
|
596
|
+
6. **enforcement** — "strict" (reject queries on unknown tables) or "warn" (allow with warnings).
|
|
597
|
+
|
|
598
|
+
Column references use the format "table:<schema>.<table>#<column>".
|
|
599
|
+
Table IDs use the format "table:<schema>.<table>".
|
|
600
|
+
|
|
601
|
+
Available table IDs: ${JSON.stringify(tableIds)}
|
|
602
|
+
|
|
603
|
+
Return a JSON object with two keys:
|
|
604
|
+
- "frontmatter": the TenantPolicyFrontmatter object (schemaId, enforcement, roots, hierarchy?, scopedTables?, polymorphicTables?, globalTables?)
|
|
605
|
+
- "body": a markdown body string with sections "## Hierarchy", "## Scope rules", "## Sensitive interactions" explaining the policy in plain English.
|
|
606
|
+
|
|
607
|
+
Return ONLY valid JSON, no markdown fences or explanations.`;
|
|
608
|
+
const userPrompt = `Analyze this database schema and draft a complete multi-tenant policy.
|
|
609
|
+
|
|
610
|
+
Schema ID: ${schemaId}
|
|
611
|
+
|
|
612
|
+
${schemaDdl}
|
|
613
|
+
|
|
614
|
+
Look for:
|
|
615
|
+
- Tables that represent organizations, companies, accounts, tenants, or workspaces — these are roots.
|
|
616
|
+
- Foreign key columns like org_id, company_id, tenant_id, account_id that scope data to a tenant.
|
|
617
|
+
- Tables with no tenant FK that are clearly shared/global (lookup tables, enums, config).
|
|
618
|
+
- Polymorphic association patterns (type_column + id_column pointing to multiple parent tables).
|
|
619
|
+
- Multi-level hierarchies (org → team → user, company → department → employee).
|
|
620
|
+
|
|
621
|
+
If the schema has no clear multi-tenant pattern, still make your best guess at what would be the tenant root.`;
|
|
622
|
+
const result = await defaultGenerateText({
|
|
623
|
+
model,
|
|
624
|
+
instructions: systemPrompt,
|
|
625
|
+
prompt: userPrompt,
|
|
626
|
+
});
|
|
627
|
+
let parsed;
|
|
628
|
+
try {
|
|
629
|
+
parsed = JSON.parse(result.text);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
throw new StudioHttpError(500, `AI returned invalid JSON for tenant policy suggestion. Raw text: ${result.text.slice(0, 500)}`);
|
|
633
|
+
}
|
|
634
|
+
// Validate frontmatter shape
|
|
635
|
+
const validation = tenantPolicyFrontmatterSchema.safeParse(parsed.frontmatter);
|
|
636
|
+
if (!validation.success) {
|
|
637
|
+
throw new StudioHttpError(500, `AI-generated tenant policy frontmatter is invalid: ${validation.error.issues.map((i) => i.message).join("; ")}`);
|
|
638
|
+
}
|
|
639
|
+
return { frontmatter: validation.data, body: parsed.body, warnings };
|
|
640
|
+
}
|
|
157
641
|
async function askSampleQuestion(state, options) {
|
|
158
642
|
const rt = getAskDbRuntimeConfig();
|
|
159
643
|
const mockSql = rt.dev.mockSql;
|
|
160
|
-
const aiConfig = mockSql ? undefined :
|
|
644
|
+
const aiConfig = mockSql ? undefined : ai.resolveAiConfig(rt.ai.aiEnv);
|
|
161
645
|
if (!mockSql && !aiConfig) {
|
|
162
|
-
throw new StudioHttpError(400, `${
|
|
646
|
+
throw new StudioHttpError(400, `${ai.keyMissingMessage("Sample NL-to-SQL generation")} Set dev.mockSql in askdb.config to bypass the live model.`);
|
|
163
647
|
}
|
|
648
|
+
const reasoningEffort = mockSql ? undefined : resolveReasoningEffort(rt.ai.aiEnv, "nlToSql");
|
|
649
|
+
const providerOptions = aiConfig && reasoningEffort ? ai.resolveProviderOptions(aiConfig, { reasoningEffort }) : undefined;
|
|
164
650
|
const schema = loadSchema(state.schemaDir);
|
|
165
651
|
const retrievedChunks = [];
|
|
166
652
|
const usage = createRequestUsageCollector();
|
|
@@ -169,9 +655,7 @@ async function askSampleQuestion(state, options) {
|
|
|
169
655
|
: undefined;
|
|
170
656
|
const model = mockSql
|
|
171
657
|
? undefined
|
|
172
|
-
: (await
|
|
173
|
-
modelEnvVar: rt.ai.studioModel ? "ASKDB_STUDIO_MODEL" : undefined,
|
|
174
|
-
}));
|
|
658
|
+
: (await ai.createLanguageModelFromEnv(rt.ai.aiEnv));
|
|
175
659
|
const schemaProvider = "provider" in schema && typeof schema.provider === "string"
|
|
176
660
|
? schema.provider
|
|
177
661
|
: undefined;
|
|
@@ -183,6 +667,11 @@ async function askSampleQuestion(state, options) {
|
|
|
183
667
|
model,
|
|
184
668
|
dialect,
|
|
185
669
|
explain: true,
|
|
670
|
+
// `ask()` doesn't read config, so each host applies `modes.omitSensitiveFromPrompt`
|
|
671
|
+
// itself, as `@askdb/client` does (#601).
|
|
672
|
+
...(rt.modes.omitSensitiveFromPrompt ? { omitSensitiveIdentifiersFromNlToSqlPrompt: true } : {}),
|
|
673
|
+
...(options.tenantScope ? { tenantScope: options.tenantScope } : {}),
|
|
674
|
+
...(options.tenantSqlMode ? { tenantSqlMode: options.tenantSqlMode } : {}),
|
|
186
675
|
...(ragIndex
|
|
187
676
|
? {
|
|
188
677
|
retriever: async (params) => {
|
|
@@ -207,6 +696,7 @@ async function askSampleQuestion(state, options) {
|
|
|
207
696
|
}
|
|
208
697
|
: {
|
|
209
698
|
generateText: createTrackedGenerateText(usage),
|
|
699
|
+
...(providerOptions ? { providerOptions } : {}),
|
|
210
700
|
},
|
|
211
701
|
}).finally(async () => {
|
|
212
702
|
await ragIndex?.dispose();
|
|
@@ -219,6 +709,14 @@ async function askSampleQuestion(state, options) {
|
|
|
219
709
|
enabled: options.useRag,
|
|
220
710
|
chunks: options.useRag ? retrievedChunks.map(serializeRagResult) : [],
|
|
221
711
|
},
|
|
712
|
+
tenant: options.tenantScope
|
|
713
|
+
? {
|
|
714
|
+
enabled: true,
|
|
715
|
+
sqlMode: options.tenantSqlMode ?? DEFAULT_TENANT_SQL_MODE,
|
|
716
|
+
bindings: result.tenantBindings ?? [],
|
|
717
|
+
params: result.tenantParams ?? [],
|
|
718
|
+
}
|
|
719
|
+
: null,
|
|
222
720
|
usage: usage.toDto(),
|
|
223
721
|
};
|
|
224
722
|
}
|
|
@@ -227,14 +725,17 @@ async function getRagStatus(state) {
|
|
|
227
725
|
const sources = loadChunkerSourcesFromDir(state.schemaDir);
|
|
228
726
|
const chunkResult = chunkSchema(sources);
|
|
229
727
|
const lockPath = join(state.schemaDir, "schema.lock.json");
|
|
230
|
-
const lock =
|
|
728
|
+
const lock = resolveStudioRagStoreConfig(state).kind === "memory"
|
|
729
|
+
? state.ragMemoryIndex
|
|
730
|
+
: readOptionalJson(lockPath);
|
|
231
731
|
const currentHashes = Object.fromEntries(chunkResult.chunks.map((chunk) => [chunk.id, chunkContentHash(chunk.text)]));
|
|
232
732
|
const lockHashes = lock?.hashes ?? {};
|
|
233
733
|
const hashIds = Object.keys(currentHashes);
|
|
234
|
-
const store = await openStudioRagStore(state, config.dimensions);
|
|
734
|
+
const store = await openStudioRagStore(state, { dimensions: config.dimensions });
|
|
235
735
|
try {
|
|
236
736
|
const chunksIndexed = await countStudioRagStoreChunks(store, sources.schema.schemaId);
|
|
237
737
|
const stale = !lock ||
|
|
738
|
+
lock.incomplete === true ||
|
|
238
739
|
lock.embedderId !== config.embedderId ||
|
|
239
740
|
Object.keys(lockHashes).length !== hashIds.length ||
|
|
240
741
|
hashIds.some((id) => lockHashes[id] !== currentHashes[id]) ||
|
|
@@ -275,8 +776,9 @@ async function getRagStatus(state) {
|
|
|
275
776
|
updatedAt: lock?.updatedAt ?? null,
|
|
276
777
|
chunksTotal: chunkResult.chunks.length,
|
|
277
778
|
chunksIndexed,
|
|
278
|
-
|
|
279
|
-
|
|
779
|
+
// The width the last index build learned from the model, else the one configured.
|
|
780
|
+
dimensions: lock?.dimensions ?? config.dimensions ?? null,
|
|
781
|
+
expectedDimensions: config.dimensions ?? null,
|
|
280
782
|
sensitiveExcluded: chunkResult.stats.sensitiveExcluded,
|
|
281
783
|
sensitiveIncluded: chunkResult.stats.sensitiveIncluded,
|
|
282
784
|
files: fileArtifacts,
|
|
@@ -289,27 +791,40 @@ async function getRagStatus(state) {
|
|
|
289
791
|
async function indexRag(state) {
|
|
290
792
|
const config = resolveStudioRagEmbedderConfig();
|
|
291
793
|
if (!config.configured) {
|
|
292
|
-
throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
|
|
794
|
+
throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage(config));
|
|
293
795
|
}
|
|
294
796
|
const usage = createRequestUsageCollector();
|
|
295
797
|
clearIncompatibleRagStore(state, config);
|
|
296
798
|
const sources = loadChunkerSourcesFromDir(state.schemaDir);
|
|
297
|
-
const
|
|
799
|
+
const embedder = await createStudioRagEmbedder(config, usage);
|
|
298
800
|
let result;
|
|
801
|
+
let store;
|
|
299
802
|
try {
|
|
803
|
+
// A new pgvector table needs its width up front: the configured one, else the model's own.
|
|
804
|
+
const dimensions = config.dimensions ??
|
|
805
|
+
(resolveStudioRagStoreConfig(state).kind === "pgvector" ? await detectEmbeddingDimensions(embedder) : undefined);
|
|
806
|
+
store = await openStudioRagStore(state, { dimensions, provision: true });
|
|
300
807
|
result = await buildSchemaIndex({
|
|
301
808
|
schema: sources,
|
|
302
|
-
embedder
|
|
809
|
+
embedder,
|
|
303
810
|
store: store.store,
|
|
304
811
|
embedderId: config.embedderId,
|
|
305
812
|
lockFilePath: join(state.schemaDir, "schema.lock.json"),
|
|
306
813
|
});
|
|
814
|
+
if (store.kind === "memory") {
|
|
815
|
+
state.ragMemoryIndex = {
|
|
816
|
+
embedderId: config.embedderId,
|
|
817
|
+
updatedAt: new Date().toISOString(),
|
|
818
|
+
dimensions: store.store.describe?.().dimensions,
|
|
819
|
+
hashes: Object.fromEntries(result.chunks.map((chunk) => [chunk.id, chunkContentHash(chunk.text)])),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
307
822
|
}
|
|
308
823
|
catch (error) {
|
|
309
824
|
throw formatStudioRagOperationError(error, config);
|
|
310
825
|
}
|
|
311
826
|
finally {
|
|
312
|
-
await store
|
|
827
|
+
await store?.dispose();
|
|
313
828
|
}
|
|
314
829
|
return {
|
|
315
830
|
status: await getRagStatus(state),
|
|
@@ -347,7 +862,7 @@ async function queryRag(state, query) {
|
|
|
347
862
|
async function createCurrentStudioRagIndex(state, action, usage) {
|
|
348
863
|
const config = resolveStudioRagEmbedderConfig();
|
|
349
864
|
if (!config.configured) {
|
|
350
|
-
throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
|
|
865
|
+
throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage(config));
|
|
351
866
|
}
|
|
352
867
|
const status = (await getRagStatus(state));
|
|
353
868
|
if (!status.hasIndex) {
|
|
@@ -359,7 +874,7 @@ async function createCurrentStudioRagIndex(state, action, usage) {
|
|
|
359
874
|
if (!status.schemaId || typeof status.chunksTotal !== "number") {
|
|
360
875
|
throw new StudioHttpError(500, "Studio RAG status is missing schema metadata.");
|
|
361
876
|
}
|
|
362
|
-
const store = await openStudioRagStore(state, config.dimensions);
|
|
877
|
+
const store = await openStudioRagStore(state, { dimensions: config.dimensions });
|
|
363
878
|
return {
|
|
364
879
|
config,
|
|
365
880
|
status: {
|
|
@@ -377,22 +892,26 @@ async function createCurrentStudioRagIndex(state, action, usage) {
|
|
|
377
892
|
}
|
|
378
893
|
function resolveStudioRagStoreConfig(state) {
|
|
379
894
|
const rt = getAskDbRuntimeConfig();
|
|
380
|
-
const kind = rt.
|
|
895
|
+
const kind = rt.rag.store;
|
|
381
896
|
if (kind === "memory")
|
|
382
897
|
return { kind };
|
|
383
898
|
if (kind === "file") {
|
|
384
|
-
const basePath = rt.
|
|
899
|
+
const basePath = rt.rag.storeConfig.file?.basePath?.trim();
|
|
385
900
|
return { kind, basePath: basePath ? resolve(basePath) : join(state.schemaDir, "schema") };
|
|
386
901
|
}
|
|
387
902
|
const connectionString = pickFlat(rt.flat, "ASKDB_PGVECTOR_URL");
|
|
388
903
|
return {
|
|
389
904
|
kind,
|
|
390
905
|
connectionString,
|
|
391
|
-
table: rt.
|
|
906
|
+
table: rt.rag.storeConfig.pgvector?.table?.trim() || undefined,
|
|
392
907
|
indexStrategy: pickFlat(rt.flat, "ASKDB_PGVECTOR_INDEX_STRATEGY"),
|
|
393
908
|
};
|
|
394
909
|
}
|
|
395
|
-
|
|
910
|
+
/**
|
|
911
|
+
* Opens the configured store. `provision` creates the pgvector table when it's missing, at
|
|
912
|
+
* `dimensions`; without it, a pgvector store only reads and writes an existing table.
|
|
913
|
+
*/
|
|
914
|
+
async function openStudioRagStore(state, { dimensions, provision = false }) {
|
|
396
915
|
const config = resolveStudioRagStoreConfig(state);
|
|
397
916
|
if (config.kind === "memory") {
|
|
398
917
|
state.ragMemoryStore ??= createMemoryStore();
|
|
@@ -420,9 +939,19 @@ async function openStudioRagStore(state, dimensions) {
|
|
|
420
939
|
const store = pgvectorFactory({
|
|
421
940
|
connectionString: config.connectionString,
|
|
422
941
|
table: config.table,
|
|
423
|
-
dimensions,
|
|
942
|
+
...(dimensions !== undefined ? { dimensions } : {}),
|
|
424
943
|
...(config.indexStrategy ? { indexStrategy: config.indexStrategy } : {}),
|
|
425
944
|
});
|
|
945
|
+
if (provision) {
|
|
946
|
+
try {
|
|
947
|
+
await store.ensureSchema();
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
// The caller never receives this store, so close its pool here (a width refusal has opened it).
|
|
951
|
+
await store.close().catch(() => { });
|
|
952
|
+
throw error;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
426
955
|
return {
|
|
427
956
|
kind: "pgvector",
|
|
428
957
|
store,
|
|
@@ -434,6 +963,10 @@ async function openStudioRagStore(state, dimensions) {
|
|
|
434
963
|
};
|
|
435
964
|
}
|
|
436
965
|
async function countStudioRagStoreChunks(store, schemaId) {
|
|
966
|
+
// A pgvector table that doesn't exist yet holds nothing; the first index build creates it.
|
|
967
|
+
if (typeof store.store.tableDimensions === "function" && (await store.store.tableDimensions()) === undefined) {
|
|
968
|
+
return 0;
|
|
969
|
+
}
|
|
437
970
|
if (typeof store.store.count === "function") {
|
|
438
971
|
return store.store.count({ schemaId });
|
|
439
972
|
}
|
|
@@ -446,16 +979,9 @@ function pickFlat(flat, key) {
|
|
|
446
979
|
const value = flat[key];
|
|
447
980
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
448
981
|
}
|
|
449
|
-
function pickEnv(env, key) {
|
|
450
|
-
const v = env[key];
|
|
451
|
-
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
452
|
-
}
|
|
453
982
|
function resolveStudioRagEmbedderConfig() {
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
const explicitKind = pickEnv(base, "ASKDB_RAG_EMBEDDER") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER");
|
|
457
|
-
const kind = explicitKind?.toLowerCase();
|
|
458
|
-
if (kind === "mock") {
|
|
983
|
+
const embedding = getAskDbRuntimeConfig().ai.embedding;
|
|
984
|
+
if (!embedding) {
|
|
459
985
|
return {
|
|
460
986
|
kind: "mock",
|
|
461
987
|
embedderId: STUDIO_RAG_MOCK_EMBEDDER_ID,
|
|
@@ -464,72 +990,40 @@ function resolveStudioRagEmbedderConfig() {
|
|
|
464
990
|
label: "Mock lexical",
|
|
465
991
|
};
|
|
466
992
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
modelDefault: DEFAULT_EMBEDDING_MODEL,
|
|
474
|
-
});
|
|
475
|
-
if (!aiConfig && kind === undefined) {
|
|
476
|
-
return {
|
|
477
|
-
kind: "mock",
|
|
478
|
-
embedderId: STUDIO_RAG_MOCK_EMBEDDER_ID,
|
|
479
|
-
dimensions: STUDIO_RAG_MOCK_DIMENSIONS,
|
|
480
|
-
configured: true,
|
|
481
|
-
label: "Mock lexical",
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
const provider = aiConfig?.provider ?? fallbackStudioRagProvider(kind, base);
|
|
485
|
-
const model = aiConfig?.model ?? DEFAULT_EMBEDDING_MODEL;
|
|
486
|
-
const dimensionOverride = readPositiveIntegerEnv(pickEnv(base, "ASKDB_RAG_EMBEDDER_DIMENSIONS") ??
|
|
487
|
-
pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_DIMENSIONS"));
|
|
488
|
-
const dimensions = dimensionOverride ?? defaultEmbeddingDimensions(model);
|
|
993
|
+
// The env holds the embedding connection only, so no other provider's key can reach it (#345).
|
|
994
|
+
const aiConfig = ai.resolveEmbeddingConfig(embedding.env);
|
|
995
|
+
// The adapter's canonical name (`foundry` resolves to `azure`) keeps existing index ids stable.
|
|
996
|
+
const provider = aiConfig?.provider ?? embedding.provider;
|
|
997
|
+
const model = embedding.model;
|
|
998
|
+
const dimensions = embedding.dimensions;
|
|
489
999
|
return {
|
|
490
1000
|
kind: "ai-sdk",
|
|
491
1001
|
provider,
|
|
492
|
-
|
|
1002
|
+
configuredProvider: embedding.provider,
|
|
1003
|
+
connection: embedding.connection,
|
|
1004
|
+
embedderId: aiSdkEmbedderId({ provider, model, dimensions }),
|
|
493
1005
|
dimensions,
|
|
494
1006
|
configured: Boolean(aiConfig),
|
|
495
1007
|
label: `AI SDK (${provider})`,
|
|
496
1008
|
model,
|
|
497
1009
|
baseUrl: aiConfig?.baseURL,
|
|
498
1010
|
aiConfig,
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
function buildStudioRagEmbeddingEnv(kind, base) {
|
|
503
|
-
const apiKeyOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_API_KEY") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_API_KEY");
|
|
504
|
-
const modelOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_MODEL") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_MODEL");
|
|
505
|
-
const baseUrlOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_BASE_URL") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_BASE_URL");
|
|
506
|
-
return {
|
|
507
|
-
...base,
|
|
508
|
-
...(kind === "openai" ? { ASKDB_AI_PROVIDER: "openai" } : {}),
|
|
509
|
-
...(apiKeyOverride ? { ASKDB_AI_API_KEY: apiKeyOverride } : {}),
|
|
510
|
-
...(modelOverride ? { ASKDB_STUDIO_RAG_EMBEDDER_MODEL: modelOverride } : {}),
|
|
511
|
-
...(baseUrlOverride ? { ASKDB_AI_BASE_URL: baseUrlOverride } : {}),
|
|
1011
|
+
// Only an explicit ai.embedding.dimensions is sent; otherwise the model's own width applies.
|
|
1012
|
+
requestDimensions: embedding.dimensions,
|
|
512
1013
|
};
|
|
513
1014
|
}
|
|
514
|
-
function
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
return raw === "azure" || raw === "azure-openai" || raw === "foundry"
|
|
519
|
-
? "azure"
|
|
520
|
-
: "openai";
|
|
521
|
-
}
|
|
522
|
-
function studioRagAiSdkKeyMissingMessage() {
|
|
523
|
-
return ("Studio RAG AI SDK embeddings require a configured AI provider key. " +
|
|
524
|
-
"Set ASKDB_AI_API_KEY or the provider-native key, or set ASKDB_RAG_EMBEDDER=mock for the local lexical embedder.");
|
|
1015
|
+
function studioRagAiSdkKeyMissingMessage(config) {
|
|
1016
|
+
return (`Studio RAG embeddings need an API key on the ai.embedding connection ` +
|
|
1017
|
+
`("${config.connection}" in ai.providerConfig.${config.configuredProvider}). ` +
|
|
1018
|
+
'Set it in askdb.config.*, or set rag.embedder: "mock" for the local lexical embedder.');
|
|
525
1019
|
}
|
|
526
1020
|
async function createStudioRagEmbedder(config, usage) {
|
|
527
1021
|
if (config.kind === "mock")
|
|
528
1022
|
return createStudioMockEmbedder(config.dimensions);
|
|
529
1023
|
if (!config.aiConfig) {
|
|
530
|
-
throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
|
|
1024
|
+
throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage(config));
|
|
531
1025
|
}
|
|
532
|
-
const model = await
|
|
1026
|
+
const model = await ai.createEmbeddingModel(config.aiConfig, {
|
|
533
1027
|
dimensions: config.requestDimensions,
|
|
534
1028
|
});
|
|
535
1029
|
return createAiSdkEmbedder({
|
|
@@ -547,6 +1041,9 @@ async function createStudioRagEmbedder(config, usage) {
|
|
|
547
1041
|
function formatStudioRagOperationError(error, config) {
|
|
548
1042
|
if (error instanceof StudioHttpError)
|
|
549
1043
|
return error;
|
|
1044
|
+
if (error instanceof PgvectorDimensionMismatchError) {
|
|
1045
|
+
return new StudioHttpError(409, studioRagWidthMismatchMessage(error, config));
|
|
1046
|
+
}
|
|
550
1047
|
if (config.kind === "mock") {
|
|
551
1048
|
return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
|
|
552
1049
|
}
|
|
@@ -555,7 +1052,7 @@ function formatStudioRagOperationError(error, config) {
|
|
|
555
1052
|
return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
|
|
556
1053
|
}
|
|
557
1054
|
const parts = [
|
|
558
|
-
`Studio RAG embedding request failed for provider ${config.
|
|
1055
|
+
`Studio RAG embedding request failed for ai.embedding (provider ${config.configuredProvider}, connection ${config.connection}, model ${config.model}).`,
|
|
559
1056
|
];
|
|
560
1057
|
if (config.baseUrl)
|
|
561
1058
|
parts.push(`Base URL: ${config.baseUrl}.`);
|
|
@@ -568,6 +1065,20 @@ function formatStudioRagOperationError(error, config) {
|
|
|
568
1065
|
parts.push(`Error: ${error.message}`);
|
|
569
1066
|
return new StudioHttpError(502, parts.join(" "));
|
|
570
1067
|
}
|
|
1068
|
+
/** An existing pgvector table of another width: say where the new width comes from and how to resolve it, in config terms. */
|
|
1069
|
+
function studioRagWidthMismatchMessage(error, config) {
|
|
1070
|
+
const mismatch = `pgvector table "${error.table}" holds ${error.tableDimensions}-dimension vectors, but`;
|
|
1071
|
+
const rebuild = `Drop the table (DROP TABLE "${error.table}";) and build the index again`;
|
|
1072
|
+
const newTable = "point rag.storeConfig.pgvector.table at a new table";
|
|
1073
|
+
if (config.kind === "mock") {
|
|
1074
|
+
return `${mismatch} the mock embedder writes ${error.dimensions}. ${rebuild}, or ${newTable}.`;
|
|
1075
|
+
}
|
|
1076
|
+
const source = config.dimensions !== undefined
|
|
1077
|
+
? `ai.embedding.dimensions asks for ${error.dimensions}`
|
|
1078
|
+
: `embedding model ${config.model} returns ${error.dimensions}`;
|
|
1079
|
+
return (`${mismatch} ${source}. ${rebuild}, ${newTable}, ` +
|
|
1080
|
+
`or set ai.embedding.dimensions: ${error.tableDimensions} if model ${config.model} supports that width.`);
|
|
1081
|
+
}
|
|
571
1082
|
function findApiCallError(error) {
|
|
572
1083
|
const queue = [error];
|
|
573
1084
|
for (let i = 0; i < queue.length; i++) {
|
|
@@ -602,30 +1113,20 @@ function clearIncompatibleRagStore(state, config) {
|
|
|
602
1113
|
return;
|
|
603
1114
|
const embeddingsJsonPath = `${store.basePath}.embeddings.json`;
|
|
604
1115
|
const embeddings = readOptionalJson(embeddingsJsonPath);
|
|
605
|
-
if (embeddings?.dimensions === undefined
|
|
1116
|
+
if (embeddings?.dimensions === undefined)
|
|
1117
|
+
return;
|
|
1118
|
+
// The file store refuses mixed widths. When the width isn't known up front, vectors from a
|
|
1119
|
+
// different embedder may not match it, so they go too.
|
|
1120
|
+
const lockPath = join(state.schemaDir, "schema.lock.json");
|
|
1121
|
+
const compatible = config.dimensions !== undefined
|
|
1122
|
+
? embeddings.dimensions === config.dimensions
|
|
1123
|
+
: readOptionalJson(lockPath)?.embedderId === config.embedderId;
|
|
1124
|
+
if (compatible)
|
|
606
1125
|
return;
|
|
607
|
-
for (const path of [
|
|
608
|
-
`${store.basePath}.embeddings.json`,
|
|
609
|
-
`${store.basePath}.embeddings.bin`,
|
|
610
|
-
join(state.schemaDir, "schema.lock.json"),
|
|
611
|
-
]) {
|
|
1126
|
+
for (const path of [`${store.basePath}.embeddings.json`, `${store.basePath}.embeddings.bin`, lockPath]) {
|
|
612
1127
|
rmSync(path, { force: true });
|
|
613
1128
|
}
|
|
614
1129
|
}
|
|
615
|
-
function defaultEmbeddingDimensions(model) {
|
|
616
|
-
if (model === "text-embedding-3-large")
|
|
617
|
-
return 3072;
|
|
618
|
-
return 1536;
|
|
619
|
-
}
|
|
620
|
-
function readPositiveIntegerEnv(value) {
|
|
621
|
-
if (value === undefined || value.trim() === "")
|
|
622
|
-
return undefined;
|
|
623
|
-
const parsed = Number(value);
|
|
624
|
-
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
625
|
-
throw new StudioHttpError(400, `Invalid Studio RAG embedding dimensions: ${value}`);
|
|
626
|
-
}
|
|
627
|
-
return parsed;
|
|
628
|
-
}
|
|
629
1130
|
function serializeRagResult(result) {
|
|
630
1131
|
return {
|
|
631
1132
|
id: result.id,
|
|
@@ -657,6 +1158,401 @@ function stableTokenHash(token) {
|
|
|
657
1158
|
}
|
|
658
1159
|
return hash >>> 0;
|
|
659
1160
|
}
|
|
1161
|
+
// ---------------------------------------------------------------------------
|
|
1162
|
+
// Playground history helpers
|
|
1163
|
+
// ---------------------------------------------------------------------------
|
|
1164
|
+
const HISTORY_MAX_STORED = 200;
|
|
1165
|
+
const HISTORY_MAX_RETURNED = 50;
|
|
1166
|
+
const HISTORY_FILE_NAME = "playground-history.json";
|
|
1167
|
+
function playgroundHistoryPath(schemaDir) {
|
|
1168
|
+
return join(schemaDir, HISTORY_FILE_NAME);
|
|
1169
|
+
}
|
|
1170
|
+
function readPlaygroundHistory(schemaDir) {
|
|
1171
|
+
const raw = readOptionalJson(playgroundHistoryPath(schemaDir));
|
|
1172
|
+
const entries = Array.isArray(raw) ? raw : [];
|
|
1173
|
+
return { entries: entries.slice(0, HISTORY_MAX_RETURNED) };
|
|
1174
|
+
}
|
|
1175
|
+
function appendPlaygroundHistory(schemaDir, entry) {
|
|
1176
|
+
const histPath = playgroundHistoryPath(schemaDir);
|
|
1177
|
+
const raw = readOptionalJson(histPath);
|
|
1178
|
+
const existing = Array.isArray(raw)
|
|
1179
|
+
? raw
|
|
1180
|
+
: [];
|
|
1181
|
+
const newEntry = {
|
|
1182
|
+
...entry,
|
|
1183
|
+
id: randomUUID(),
|
|
1184
|
+
timestamp: new Date().toISOString(),
|
|
1185
|
+
};
|
|
1186
|
+
const updated = [newEntry, ...existing].slice(0, HISTORY_MAX_STORED);
|
|
1187
|
+
ensureSchemaDirGitignore(schemaDir);
|
|
1188
|
+
saveSchemaFile(schemaDir, HISTORY_FILE_NAME, JSON.stringify(updated, null, 2));
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Keep Playground history (questions + generated SQL, local-only) out of git.
|
|
1192
|
+
* The schema artifact directory is meant to be committed, so Studio makes sure
|
|
1193
|
+
* its `.gitignore` lists `playground-history.json` before writing the file.
|
|
1194
|
+
*
|
|
1195
|
+
* - No `.gitignore` yet: write one that also ignores `.env` files — the same
|
|
1196
|
+
* rules `askdb init` writes (plan 043) — unless the schema dir is the project
|
|
1197
|
+
* root, where ignoring `.env` is the project's call, not Studio's.
|
|
1198
|
+
* - Existing `.gitignore` without the entry: add the entry at the end and keep
|
|
1199
|
+
* every existing byte as it was.
|
|
1200
|
+
* - Both are saved with `saveSchemaFile()`, so a `.gitignore` that is a symbolic
|
|
1201
|
+
* link is refused, not written through (ADR 0028).
|
|
1202
|
+
* - Failure is non-fatal: history still saves.
|
|
1203
|
+
*/
|
|
1204
|
+
function ensureSchemaDirGitignore(schemaDir) {
|
|
1205
|
+
const gitignorePath = join(schemaDir, ".gitignore");
|
|
1206
|
+
try {
|
|
1207
|
+
if (existsSync(gitignorePath)) {
|
|
1208
|
+
// Kept as bytes: Git matches patterns byte for byte, and a non-UTF-8 file name
|
|
1209
|
+
// in an existing line must survive the rewrite.
|
|
1210
|
+
const content = readFileSync(gitignorePath);
|
|
1211
|
+
const lines = content.toString("utf8").split(/\r?\n/).map((line) => line.trim());
|
|
1212
|
+
if (lines.includes(HISTORY_FILE_NAME) || lines.includes(`/${HISTORY_FILE_NAME}`))
|
|
1213
|
+
return;
|
|
1214
|
+
const separator = content.length === 0 || content.at(-1) === 0x0a ? "" : "\n";
|
|
1215
|
+
const entry = `${separator}# AskDB Studio Playground history (local only)\n${HISTORY_FILE_NAME}\n`;
|
|
1216
|
+
saveSchemaFile(schemaDir, ".gitignore", Buffer.concat([content, Buffer.from(entry, "utf8")]));
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const lines = [
|
|
1220
|
+
"# Written by AskDB Studio.",
|
|
1221
|
+
"#",
|
|
1222
|
+
"# The schema artifact in this directory is meant to be committed: schema.json",
|
|
1223
|
+
"# plus any enrichment you author in Studio. Playground history (your questions",
|
|
1224
|
+
"# and generated SQL) is local only.",
|
|
1225
|
+
HISTORY_FILE_NAME,
|
|
1226
|
+
];
|
|
1227
|
+
if (!isProjectRootDir(schemaDir)) {
|
|
1228
|
+
lines.push("", "# Credentials: keep API keys and connection strings out of version control", "# even when they live here.", ".env", ".env.*", "!.env.example");
|
|
1229
|
+
}
|
|
1230
|
+
saveSchemaFile(schemaDir, ".gitignore", `${lines.join("\n")}\n`);
|
|
1231
|
+
}
|
|
1232
|
+
catch {
|
|
1233
|
+
// Non-fatal: the history write matters more than this defensive file.
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
function isProjectRootDir(dir) {
|
|
1237
|
+
if (existsSync(join(dir, "package.json")))
|
|
1238
|
+
return true;
|
|
1239
|
+
try {
|
|
1240
|
+
return readdirSync(dir).some((name) => name.startsWith("askdb.config."));
|
|
1241
|
+
}
|
|
1242
|
+
catch {
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
function deletePlaygroundHistoryEntry(schemaDir, id) {
|
|
1247
|
+
const histPath = playgroundHistoryPath(schemaDir);
|
|
1248
|
+
const raw = readOptionalJson(histPath);
|
|
1249
|
+
const existing = Array.isArray(raw)
|
|
1250
|
+
? raw
|
|
1251
|
+
: [];
|
|
1252
|
+
const filtered = existing.filter((e) => e.id !== id);
|
|
1253
|
+
if (filtered.length === existing.length) {
|
|
1254
|
+
return { ok: false, error: "not found" };
|
|
1255
|
+
}
|
|
1256
|
+
saveSchemaFile(schemaDir, HISTORY_FILE_NAME, JSON.stringify(filtered, null, 2));
|
|
1257
|
+
return { ok: true };
|
|
1258
|
+
}
|
|
1259
|
+
const HISTORY_LIMITS = {
|
|
1260
|
+
question: 4_000,
|
|
1261
|
+
sql: 100_000,
|
|
1262
|
+
explain: 100_000,
|
|
1263
|
+
/** Serialized JSON size for `tenantScope` / `tenantParams`. */
|
|
1264
|
+
json: 20_000,
|
|
1265
|
+
error: 2_000,
|
|
1266
|
+
ragChunkIds: 200,
|
|
1267
|
+
ragChunkId: 512,
|
|
1268
|
+
};
|
|
1269
|
+
/**
|
|
1270
|
+
* Validate a history entry and copy **only** the known fields — the file is
|
|
1271
|
+
* written inside the schema artifact directory, so nothing unexpected (or
|
|
1272
|
+
* unbounded) from the request body may be persisted.
|
|
1273
|
+
*/
|
|
1274
|
+
function parsePlaygroundHistoryEntry(body) {
|
|
1275
|
+
if (!isRecord(body)) {
|
|
1276
|
+
throw new StudioHttpError(400, "Request body must be a JSON object.");
|
|
1277
|
+
}
|
|
1278
|
+
const question = requireBoundedString(body.question, "question", HISTORY_LIMITS.question);
|
|
1279
|
+
if (question.trim() === "") {
|
|
1280
|
+
throw new StudioHttpError(400, "`question` is required.");
|
|
1281
|
+
}
|
|
1282
|
+
if (body.mode !== "full" && body.mode !== "rag") {
|
|
1283
|
+
throw new StudioHttpError(400, "`mode` must be `full` or `rag`.");
|
|
1284
|
+
}
|
|
1285
|
+
if (body.sqlMode !== "sql-only" && body.sqlMode !== "sql-params") {
|
|
1286
|
+
throw new StudioHttpError(400, "`sqlMode` must be `sql-only` or `sql-params`.");
|
|
1287
|
+
}
|
|
1288
|
+
const entry = {
|
|
1289
|
+
question,
|
|
1290
|
+
mode: body.mode,
|
|
1291
|
+
sqlMode: body.sqlMode,
|
|
1292
|
+
sql: requireBoundedString(body.sql, "sql", HISTORY_LIMITS.sql),
|
|
1293
|
+
};
|
|
1294
|
+
if (body.explain !== undefined && body.explain !== null) {
|
|
1295
|
+
entry.explain = requireBoundedString(body.explain, "explain", HISTORY_LIMITS.explain);
|
|
1296
|
+
}
|
|
1297
|
+
if (body.tenantScope !== undefined && body.tenantScope !== null) {
|
|
1298
|
+
entry.tenantScope = requireBoundedJsonObject(body.tenantScope, "tenantScope");
|
|
1299
|
+
}
|
|
1300
|
+
if (body.tenantParams !== undefined && body.tenantParams !== null) {
|
|
1301
|
+
entry.tenantParams = requireBoundedJsonObject(body.tenantParams, "tenantParams");
|
|
1302
|
+
}
|
|
1303
|
+
if (body.executionResult !== undefined && body.executionResult !== null) {
|
|
1304
|
+
const r = body.executionResult;
|
|
1305
|
+
if (!isRecord(r) ||
|
|
1306
|
+
!isNonNegativeFinite(r.rowCount) ||
|
|
1307
|
+
!isNonNegativeFinite(r.durationMs) ||
|
|
1308
|
+
typeof r.truncated !== "boolean") {
|
|
1309
|
+
throw new StudioHttpError(400, "`executionResult` must be { rowCount: number, durationMs: number, truncated: boolean, error?: string }.");
|
|
1310
|
+
}
|
|
1311
|
+
entry.executionResult = {
|
|
1312
|
+
rowCount: r.rowCount,
|
|
1313
|
+
durationMs: r.durationMs,
|
|
1314
|
+
truncated: r.truncated,
|
|
1315
|
+
...(r.error !== undefined
|
|
1316
|
+
? { error: requireBoundedString(r.error, "executionResult.error", HISTORY_LIMITS.error) }
|
|
1317
|
+
: {}),
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
if (body.ragChunkIds !== undefined && body.ragChunkIds !== null) {
|
|
1321
|
+
if (!Array.isArray(body.ragChunkIds) ||
|
|
1322
|
+
body.ragChunkIds.length > HISTORY_LIMITS.ragChunkIds ||
|
|
1323
|
+
!body.ragChunkIds.every((id) => typeof id === "string" && id.length <= HISTORY_LIMITS.ragChunkId)) {
|
|
1324
|
+
throw new StudioHttpError(400, `\`ragChunkIds\` must be an array of at most ${HISTORY_LIMITS.ragChunkIds} strings.`);
|
|
1325
|
+
}
|
|
1326
|
+
entry.ragChunkIds = [...body.ragChunkIds];
|
|
1327
|
+
}
|
|
1328
|
+
return entry;
|
|
1329
|
+
}
|
|
1330
|
+
function requireBoundedString(value, field, maxLength) {
|
|
1331
|
+
if (typeof value !== "string") {
|
|
1332
|
+
throw new StudioHttpError(400, `\`${field}\` must be a string.`);
|
|
1333
|
+
}
|
|
1334
|
+
if (value.length > maxLength) {
|
|
1335
|
+
throw new StudioHttpError(400, `\`${field}\` is too long (max ${maxLength} characters).`);
|
|
1336
|
+
}
|
|
1337
|
+
return value;
|
|
1338
|
+
}
|
|
1339
|
+
function requireBoundedJsonObject(value, field) {
|
|
1340
|
+
if (!isRecord(value)) {
|
|
1341
|
+
throw new StudioHttpError(400, `\`${field}\` must be a JSON object.`);
|
|
1342
|
+
}
|
|
1343
|
+
if (JSON.stringify(value).length > HISTORY_LIMITS.json) {
|
|
1344
|
+
throw new StudioHttpError(400, `\`${field}\` is too large (max ${HISTORY_LIMITS.json} characters as JSON).`);
|
|
1345
|
+
}
|
|
1346
|
+
return value;
|
|
1347
|
+
}
|
|
1348
|
+
function isNonNegativeFinite(value) {
|
|
1349
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
1350
|
+
}
|
|
1351
|
+
// ---------------------------------------------------------------------------
|
|
1352
|
+
// Execute endpoint helpers
|
|
1353
|
+
// ---------------------------------------------------------------------------
|
|
1354
|
+
const EXECUTE_DISABLED_MESSAGE = "Studio execute is off. To run generated SQL from the Playground, set `studio.execute.enabled: true` " +
|
|
1355
|
+
"and an execute connection (`studio.execute.databaseUrl`, or `studio.execute.file` for SQLite) in " +
|
|
1356
|
+
"askdb.config.ts, ideally for a read-only database role, then restart Studio.";
|
|
1357
|
+
function executeNotConfiguredMessage(exec) {
|
|
1358
|
+
const field = exec.provider === "sqlite" ? "studio.execute.file" : "studio.execute.databaseUrl";
|
|
1359
|
+
const base = `No execute connection configured. Set \`${field}\` in askdb.config.ts (ideally a read-only database role).`;
|
|
1360
|
+
return exec.introspectionConnectionAvailable
|
|
1361
|
+
? `${base} Studio no longer reuses the introspection connection by default; set \`studio.execute.useIntrospectionConnection: true\` to opt in.`
|
|
1362
|
+
: base;
|
|
1363
|
+
}
|
|
1364
|
+
async function getExecuteStatus(schemaDir) {
|
|
1365
|
+
const rt = getAskDbRuntimeConfig();
|
|
1366
|
+
const exec = rt.studio.execute;
|
|
1367
|
+
const { provider, databaseUrl, file } = exec;
|
|
1368
|
+
const def = EXECUTE_DRIVER_REGISTRY[provider];
|
|
1369
|
+
const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
|
|
1370
|
+
const installed = isDriverInstalled(def.packageName, projectRoot);
|
|
1371
|
+
const connectionKind = provider === "sqlite" ? "file" : "url";
|
|
1372
|
+
const configured = connectionKind === "file" ? Boolean(file) : Boolean(databaseUrl);
|
|
1373
|
+
const disabledReason = !exec.enabled
|
|
1374
|
+
? EXECUTE_DISABLED_MESSAGE
|
|
1375
|
+
: !configured
|
|
1376
|
+
? executeNotConfiguredMessage(exec)
|
|
1377
|
+
: null;
|
|
1378
|
+
return {
|
|
1379
|
+
enabled: exec.enabled,
|
|
1380
|
+
disabledReason,
|
|
1381
|
+
timeoutMs: exec.timeoutMs,
|
|
1382
|
+
maxRows: exec.maxRows,
|
|
1383
|
+
provider,
|
|
1384
|
+
label: def.label,
|
|
1385
|
+
configured,
|
|
1386
|
+
connectionKind,
|
|
1387
|
+
packageName: def.packageName,
|
|
1388
|
+
installed,
|
|
1389
|
+
installCommand: def.installCommand,
|
|
1390
|
+
canInstallFromStudio: exec.enabled,
|
|
1391
|
+
manualInstallReason: null,
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
async function installExecuteDriver(body, schemaDir) {
|
|
1395
|
+
const rt = getAskDbRuntimeConfig();
|
|
1396
|
+
if (!rt.studio.execute.enabled) {
|
|
1397
|
+
throw new StudioHttpError(403, EXECUTE_DISABLED_MESSAGE);
|
|
1398
|
+
}
|
|
1399
|
+
// Accept the configured provider or an explicit allowlisted provider. `Object.hasOwn`
|
|
1400
|
+
// (via isStudioExecuteProvider) rejects inherited keys such as "constructor".
|
|
1401
|
+
let provider = rt.studio.execute.provider;
|
|
1402
|
+
if (isRecord(body) && body.provider !== undefined) {
|
|
1403
|
+
if (!isStudioExecuteProvider(body.provider)) {
|
|
1404
|
+
throw new StudioHttpError(400, `Unknown provider: ${JSON.stringify(body.provider)}. Expected one of: ${Object.keys(EXECUTE_DRIVER_REGISTRY).join(", ")}.`);
|
|
1405
|
+
}
|
|
1406
|
+
provider = body.provider;
|
|
1407
|
+
}
|
|
1408
|
+
const def = EXECUTE_DRIVER_REGISTRY[provider];
|
|
1409
|
+
const detection = detectPackageManager(schemaDir, def.packageName);
|
|
1410
|
+
if (detection.manualReason !== null) {
|
|
1411
|
+
return {
|
|
1412
|
+
ok: false,
|
|
1413
|
+
provider,
|
|
1414
|
+
packageName: def.packageName,
|
|
1415
|
+
command: [],
|
|
1416
|
+
error: detection.manualReason,
|
|
1417
|
+
installed: false,
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
const { packageManager, projectRoot, spawnSpec } = detection;
|
|
1421
|
+
const { stdout, stderr, code } = await spawnCommand(spawnSpec, projectRoot);
|
|
1422
|
+
const installed = isDriverInstalled(def.packageName, projectRoot);
|
|
1423
|
+
return {
|
|
1424
|
+
ok: code === 0,
|
|
1425
|
+
provider,
|
|
1426
|
+
packageName: def.packageName,
|
|
1427
|
+
command: [spawnSpec.command, ...spawnSpec.args],
|
|
1428
|
+
stdout,
|
|
1429
|
+
stderr,
|
|
1430
|
+
error: code !== 0 ? `${packageManager} exited with code ${code}` : undefined,
|
|
1431
|
+
installed,
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
function detectPackageManager(cwd, packageName) {
|
|
1435
|
+
// Walk up from schemaDir to find the project root's lockfile.
|
|
1436
|
+
const projectRoot = findProjectRoot(cwd);
|
|
1437
|
+
if (!projectRoot) {
|
|
1438
|
+
return { manualReason: `Could not locate a package.json above ${cwd}. Run the install manually.` };
|
|
1439
|
+
}
|
|
1440
|
+
const pm = lockfilePackageManager(projectRoot);
|
|
1441
|
+
if (!pm) {
|
|
1442
|
+
return { manualReason: `No recognized lockfile found in ${projectRoot}. Run: pnpm add ${packageName}` };
|
|
1443
|
+
}
|
|
1444
|
+
return {
|
|
1445
|
+
manualReason: null,
|
|
1446
|
+
packageManager: pm,
|
|
1447
|
+
projectRoot,
|
|
1448
|
+
spawnSpec: packageManagerSpawnSpec(pm, packageManagerAddArgs(pm, [packageName])),
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
function findProjectRoot(startDir) {
|
|
1452
|
+
let current = resolve(startDir);
|
|
1453
|
+
for (let i = 0; i < 20; i++) {
|
|
1454
|
+
if (existsSync(join(current, "package.json")))
|
|
1455
|
+
return current;
|
|
1456
|
+
const parent = dirname(current);
|
|
1457
|
+
if (parent === current)
|
|
1458
|
+
return null;
|
|
1459
|
+
current = parent;
|
|
1460
|
+
}
|
|
1461
|
+
return null;
|
|
1462
|
+
}
|
|
1463
|
+
function spawnCommand(spec, cwd) {
|
|
1464
|
+
return new Promise((resolve) => {
|
|
1465
|
+
const proc = spawn(spec.command, spec.args, { cwd, shell: spec.shell, stdio: ["ignore", "pipe", "pipe"] });
|
|
1466
|
+
const stdoutChunks = [];
|
|
1467
|
+
const stderrChunks = [];
|
|
1468
|
+
proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
|
|
1469
|
+
proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
1470
|
+
proc.on("close", (code) => {
|
|
1471
|
+
resolve({
|
|
1472
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8").slice(0, 4096),
|
|
1473
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 4096),
|
|
1474
|
+
code: code ?? 1,
|
|
1475
|
+
});
|
|
1476
|
+
});
|
|
1477
|
+
proc.on("error", (err) => {
|
|
1478
|
+
resolve({ stdout: "", stderr: err.message, code: 1 });
|
|
1479
|
+
});
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
async function executeQuery(body, schemaDir) {
|
|
1483
|
+
const rt = getAskDbRuntimeConfig();
|
|
1484
|
+
const exec = rt.studio.execute;
|
|
1485
|
+
if (!exec.enabled) {
|
|
1486
|
+
throw new StudioHttpError(403, EXECUTE_DISABLED_MESSAGE);
|
|
1487
|
+
}
|
|
1488
|
+
if (!isRecord(body) || typeof body.sql !== "string" || body.sql.trim() === "") {
|
|
1489
|
+
throw new StudioHttpError(400, "`sql` is required.");
|
|
1490
|
+
}
|
|
1491
|
+
if (body.params !== undefined && !Array.isArray(body.params)) {
|
|
1492
|
+
throw new StudioHttpError(400, "`params` must be an array.");
|
|
1493
|
+
}
|
|
1494
|
+
const params = body.params ?? [];
|
|
1495
|
+
// Same read-only SELECT guardrail `ask()` applies, for the execute engine's
|
|
1496
|
+
// dialect — rejects multi-statement input, comments, DDL/DML keywords, …
|
|
1497
|
+
// before anything reaches a driver.
|
|
1498
|
+
let sql;
|
|
1499
|
+
try {
|
|
1500
|
+
sql = validateExecuteSql(exec.provider, body.sql, rt.nlToSql.dialect);
|
|
1501
|
+
}
|
|
1502
|
+
catch (error) {
|
|
1503
|
+
if (error instanceof SqlValidationError) {
|
|
1504
|
+
throw new StudioHttpError(400, `SQL rejected by the read-only guardrail (${error.rule}): ${error.message}`);
|
|
1505
|
+
}
|
|
1506
|
+
throw error;
|
|
1507
|
+
}
|
|
1508
|
+
const warnings = sensitiveExecuteWarnings(sql, schemaDir, executeDialectFor(exec.provider, rt.nlToSql.dialect));
|
|
1509
|
+
const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
|
|
1510
|
+
const def = EXECUTE_DRIVER_REGISTRY[exec.provider];
|
|
1511
|
+
const result = await def.execute({
|
|
1512
|
+
connectionString: exec.databaseUrl,
|
|
1513
|
+
file: exec.file,
|
|
1514
|
+
sql,
|
|
1515
|
+
params,
|
|
1516
|
+
projectRoot,
|
|
1517
|
+
timeoutMs: exec.timeoutMs,
|
|
1518
|
+
maxRows: exec.maxRows,
|
|
1519
|
+
});
|
|
1520
|
+
if (!result.ok && /^No (connection URL|SQLite file path) configured/.test(result.error)) {
|
|
1521
|
+
return { ok: false, error: executeNotConfiguredMessage(exec) };
|
|
1522
|
+
}
|
|
1523
|
+
return result.ok && warnings.length > 0 ? { ...result, warnings } : result;
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Warn (never block) when the SQL references identifiers marked `sensitive` —
|
|
1527
|
+
* the same check `ask()` runs in its default `"warn"` mode. Studio has no
|
|
1528
|
+
* strict-mode setting; hosts that need enforcement call
|
|
1529
|
+
* `validateSensitiveReferences(sql, schema, { mode: "strict" })` themselves.
|
|
1530
|
+
*/
|
|
1531
|
+
function sensitiveExecuteWarnings(sql, schemaDir, dialect) {
|
|
1532
|
+
let schema;
|
|
1533
|
+
try {
|
|
1534
|
+
schema = loadSchema(schemaDir);
|
|
1535
|
+
}
|
|
1536
|
+
catch {
|
|
1537
|
+
return [];
|
|
1538
|
+
}
|
|
1539
|
+
if (!schemaHasSensitiveIdentifiers(schema))
|
|
1540
|
+
return [];
|
|
1541
|
+
const result = validateSensitiveReferences(sql, schema, { mode: "warn", dialect });
|
|
1542
|
+
if (result.references.length === 0)
|
|
1543
|
+
return [];
|
|
1544
|
+
return [
|
|
1545
|
+
`This query reads identifiers marked sensitive: ${result.references.map(formatSensitiveReference).join(", ")}.`,
|
|
1546
|
+
];
|
|
1547
|
+
}
|
|
1548
|
+
function isLoopbackRequest(req) {
|
|
1549
|
+
const socket = req.socket;
|
|
1550
|
+
const remoteAddress = socket?.remoteAddress ?? "";
|
|
1551
|
+
return (remoteAddress === "127.0.0.1" ||
|
|
1552
|
+
remoteAddress === "::1" ||
|
|
1553
|
+
remoteAddress === "::ffff:127.0.0.1" ||
|
|
1554
|
+
remoteAddress === "localhost");
|
|
1555
|
+
}
|
|
660
1556
|
function readOptionalJson(path) {
|
|
661
1557
|
if (!existsSync(path))
|
|
662
1558
|
return undefined;
|
|
@@ -667,6 +1563,17 @@ function readOptionalJson(path) {
|
|
|
667
1563
|
return undefined;
|
|
668
1564
|
}
|
|
669
1565
|
}
|
|
1566
|
+
function parseConceptsBody(body) {
|
|
1567
|
+
if (!isRecord(body) || !Array.isArray(body.concepts)) {
|
|
1568
|
+
throw new StudioHttpError(400, "`concepts` array is required.");
|
|
1569
|
+
}
|
|
1570
|
+
for (const concept of body.concepts) {
|
|
1571
|
+
if (!isRecord(concept) || typeof concept.id !== "string" || typeof concept.label !== "string") {
|
|
1572
|
+
throw new StudioHttpError(400, "Each concept requires a string `id` and `label`.");
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
return body.concepts;
|
|
1576
|
+
}
|
|
670
1577
|
function parseTableDraftBody(body) {
|
|
671
1578
|
if (!isRecord(body) || !isRecord(body.draft)) {
|
|
672
1579
|
throw new StudioHttpError(400, "`draft` is required.");
|
|
@@ -715,9 +1622,26 @@ function parseAskBody(body) {
|
|
|
715
1622
|
if (mode !== "full" && mode !== "rag") {
|
|
716
1623
|
throw new StudioHttpError(400, "`mode` must be `full` or `rag`.");
|
|
717
1624
|
}
|
|
1625
|
+
let tenantScope;
|
|
1626
|
+
if (body.tenantScope !== undefined && body.tenantScope !== null) {
|
|
1627
|
+
const parsed = tenantScopeSchema.safeParse(body.tenantScope);
|
|
1628
|
+
if (!parsed.success) {
|
|
1629
|
+
throw new StudioHttpError(400, `Invalid tenantScope: ${parsed.error.message}`);
|
|
1630
|
+
}
|
|
1631
|
+
tenantScope = parsed.data;
|
|
1632
|
+
}
|
|
1633
|
+
let tenantSqlMode;
|
|
1634
|
+
if (typeof body.tenantSqlMode === "string") {
|
|
1635
|
+
if (body.tenantSqlMode !== "sql-only" && body.tenantSqlMode !== "sql-params") {
|
|
1636
|
+
throw new StudioHttpError(400, "`tenantSqlMode` must be `sql-only` or `sql-params`.");
|
|
1637
|
+
}
|
|
1638
|
+
tenantSqlMode = body.tenantSqlMode;
|
|
1639
|
+
}
|
|
718
1640
|
return {
|
|
719
1641
|
question: body.question.trim(),
|
|
720
1642
|
useRag: mode === "rag",
|
|
1643
|
+
tenantScope,
|
|
1644
|
+
tenantSqlMode,
|
|
721
1645
|
};
|
|
722
1646
|
}
|
|
723
1647
|
function parseRagQuery(body) {
|
|
@@ -787,8 +1711,10 @@ function createRequestUsageCollector() {
|
|
|
787
1711
|
function normalizeGenerationUsage(value) {
|
|
788
1712
|
if (!isRecord(value))
|
|
789
1713
|
return undefined;
|
|
790
|
-
|
|
791
|
-
|
|
1714
|
+
// ai@6 LanguageModelUsage uses inputTokens/outputTokens rather than the
|
|
1715
|
+
// legacy promptTokens/completionTokens names.
|
|
1716
|
+
const promptTokens = readFiniteNumber(value.promptTokens) ?? readFiniteNumber(value.inputTokens);
|
|
1717
|
+
const completionTokens = readFiniteNumber(value.completionTokens) ?? readFiniteNumber(value.outputTokens);
|
|
792
1718
|
const totalTokens = readFiniteNumber(value.totalTokens);
|
|
793
1719
|
if (promptTokens === undefined && completionTokens === undefined && totalTokens === undefined) {
|
|
794
1720
|
return undefined;
|
|
@@ -820,11 +1746,36 @@ function sumNullable(values) {
|
|
|
820
1746
|
function readFiniteNumber(value) {
|
|
821
1747
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
822
1748
|
}
|
|
1749
|
+
/** Largest JSON request body Studio accepts. */
|
|
1750
|
+
export const MAX_JSON_BODY_BYTES = 1024 * 1024;
|
|
1751
|
+
function bodyTooLarge() {
|
|
1752
|
+
return new StudioHttpError(413, `Request body is too large (limit ${MAX_JSON_BODY_BYTES} bytes).`);
|
|
1753
|
+
}
|
|
823
1754
|
async function readJson(req) {
|
|
1755
|
+
const declared = Number(req.headers["content-length"]);
|
|
1756
|
+
if (Number.isFinite(declared) && declared > MAX_JSON_BODY_BYTES) {
|
|
1757
|
+
// Node discards the unread body after the 413 response is sent.
|
|
1758
|
+
throw bodyTooLarge();
|
|
1759
|
+
}
|
|
824
1760
|
const chunks = [];
|
|
1761
|
+
let size = 0;
|
|
1762
|
+
let overflow = false;
|
|
825
1763
|
for await (const chunk of req) {
|
|
826
|
-
|
|
1764
|
+
// Keep draining (without buffering) once over the limit, so the client
|
|
1765
|
+
// can read the 413 instead of hitting a reset connection.
|
|
1766
|
+
if (overflow)
|
|
1767
|
+
continue;
|
|
1768
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
1769
|
+
size += buf.length;
|
|
1770
|
+
if (size > MAX_JSON_BODY_BYTES) {
|
|
1771
|
+
overflow = true;
|
|
1772
|
+
chunks.length = 0;
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
chunks.push(buf);
|
|
827
1776
|
}
|
|
1777
|
+
if (overflow)
|
|
1778
|
+
throw bodyTooLarge();
|
|
828
1779
|
if (chunks.length === 0)
|
|
829
1780
|
return {};
|
|
830
1781
|
try {
|
|
@@ -844,25 +1795,49 @@ function writeText(res, status, contentType, body) {
|
|
|
844
1795
|
});
|
|
845
1796
|
res.end(body);
|
|
846
1797
|
}
|
|
847
|
-
function
|
|
848
|
-
const
|
|
849
|
-
const
|
|
1798
|
+
function resolveClientFile(res, relativePath) {
|
|
1799
|
+
const root = clientDir();
|
|
1800
|
+
const filePath = resolve(root, relativePath);
|
|
1801
|
+
const fileRelativeToClientDir = relative(root, filePath);
|
|
850
1802
|
if (fileRelativeToClientDir.startsWith("..") || fileRelativeToClientDir === "") {
|
|
851
|
-
|
|
1803
|
+
writeJson(res, 404, { error: { message: "Not found" } });
|
|
1804
|
+
return null;
|
|
852
1805
|
}
|
|
853
1806
|
if (!existsSync(filePath)) {
|
|
854
|
-
|
|
1807
|
+
writeJson(res, 500, {
|
|
855
1808
|
error: {
|
|
856
1809
|
message: "Studio client assets are missing. Run `pnpm --filter @askdb/studio build` before starting Studio.",
|
|
857
1810
|
},
|
|
858
1811
|
});
|
|
1812
|
+
return null;
|
|
859
1813
|
}
|
|
1814
|
+
return filePath;
|
|
1815
|
+
}
|
|
1816
|
+
function serveClientFile(res, relativePath) {
|
|
1817
|
+
const filePath = resolveClientFile(res, relativePath);
|
|
1818
|
+
if (!filePath)
|
|
1819
|
+
return;
|
|
860
1820
|
res.writeHead(200, {
|
|
861
1821
|
"content-type": contentTypeFor(filePath),
|
|
862
1822
|
"cache-control": "no-store",
|
|
863
1823
|
});
|
|
864
1824
|
res.end(readFileSync(filePath));
|
|
865
1825
|
}
|
|
1826
|
+
/** Serve the SPA shell with this launch's session token injected. */
|
|
1827
|
+
function serveIndexHtml(res, sessionToken) {
|
|
1828
|
+
const filePath = resolveClientFile(res, "index.html");
|
|
1829
|
+
if (!filePath)
|
|
1830
|
+
return;
|
|
1831
|
+
res.writeHead(200, {
|
|
1832
|
+
"content-type": "text/html; charset=utf-8",
|
|
1833
|
+
"cache-control": "no-store",
|
|
1834
|
+
// The page carries the session token and drives SQL execution — never let
|
|
1835
|
+
// another site frame it (clickjacking).
|
|
1836
|
+
"x-frame-options": "DENY",
|
|
1837
|
+
"content-security-policy": "frame-ancestors 'none'",
|
|
1838
|
+
});
|
|
1839
|
+
res.end(injectSessionToken(readFileSync(filePath, "utf8"), sessionToken));
|
|
1840
|
+
}
|
|
866
1841
|
function contentTypeFor(path) {
|
|
867
1842
|
switch (extname(path)) {
|
|
868
1843
|
case ".html":
|