@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.
Files changed (49) hide show
  1. package/README.md +33 -29
  2. package/bin/askdb-studio.js +3 -1
  3. package/dist/bin.d.ts +1 -1
  4. package/dist/bin.d.ts.map +1 -1
  5. package/dist/bin.js +24 -3
  6. package/dist/bin.js.map +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +72 -15
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client/assets/index-Bl9jqOjj.css +2 -0
  11. package/dist/client/assets/index-CGC5sKu1.js +100 -0
  12. package/dist/client/index.html +2 -2
  13. package/dist/execute-registry.d.ts +89 -0
  14. package/dist/execute-registry.d.ts.map +1 -0
  15. package/dist/execute-registry.js +445 -0
  16. package/dist/execute-registry.js.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/introspection.d.ts +35 -0
  22. package/dist/introspection.d.ts.map +1 -0
  23. package/dist/introspection.js +106 -0
  24. package/dist/introspection.js.map +1 -0
  25. package/dist/package-manager.d.ts +44 -0
  26. package/dist/package-manager.d.ts.map +1 -0
  27. package/dist/package-manager.js +86 -0
  28. package/dist/package-manager.js.map +1 -0
  29. package/dist/prisma-install.d.ts +7 -0
  30. package/dist/prisma-install.d.ts.map +1 -0
  31. package/dist/prisma-install.js +7 -0
  32. package/dist/prisma-install.js.map +1 -0
  33. package/dist/request-guard.d.ts +44 -0
  34. package/dist/request-guard.d.ts.map +1 -0
  35. package/dist/request-guard.js +170 -0
  36. package/dist/request-guard.js.map +1 -0
  37. package/dist/server.d.ts +22 -4
  38. package/dist/server.d.ts.map +1 -1
  39. package/dist/server.js +1123 -148
  40. package/dist/server.js.map +1 -1
  41. package/dist/setup.d.ts +77 -0
  42. package/dist/setup.d.ts.map +1 -0
  43. package/dist/setup.js +511 -0
  44. package/dist/setup.js.map +1 -0
  45. package/dist/shared/api.d.ts +176 -3
  46. package/dist/shared/api.d.ts.map +1 -1
  47. package/package.json +81 -30
  48. package/dist/client/assets/index-Bsn2S5X2.css +0 -2
  49. package/dist/client/assets/index-C_RfRKkd.js +0 -8
package/dist/server.js CHANGED
@@ -1,56 +1,138 @@
1
- import { existsSync, readFileSync, rmSync } from "node:fs";
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 { ask, askDbAiKeyMissingMessage, createAskDbEmbeddingModel, createAskDbLanguageModelFromEnv, isBuiltInDialectId, loadSchema, resolveAskDbAiConfig, resolveAskDbEmbeddingConfig, suggestEnrichment, } from "@askdb/core";
8
- import { buildSchemaIndex, chunkContentHash, chunkSchema, createRetriever, loadChunkerSourcesFromDir, } from "@askdb/rag";
9
- import { createAiSdkEmbedder } from "@askdb/rag/embedders/ai-sdk";
10
- import { createFileStore } from "@askdb/rag/stores/file";
11
- import { createMemoryStore } from "@askdb/rag/stores/memory";
12
- import { createPgvectorStore } from "@askdb/rag/stores/pgvector";
13
- import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveTable, } from "@askdb/enrich";
14
- const CLIENT_DIR = fileURLToPath(new URL("./client/", import.meta.url));
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
- if (!existsSync(schemaDir)) {
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
- return createServer(async (req, res) => {
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 serveClientFile(res, "index.html");
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.workspace));
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.workspace));
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.workspace, source);
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 serveClientFile(res, "index.html");
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 resolveAskDbAiConfig(rt.ai.aiEnv, {
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 ?? "gpt-4o-mini",
108
- aiProvider: aiConfig?.provider ?? "openai",
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 table = state.workspace.tables.find((candidate) => candidate.physical.id === tableId);
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
- const frontmatter = buildFrontmatter(table.physical, state.workspace.physical.schemaId, draft);
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(state.workspace, tableId, frontmatter, body);
465
+ saveTable(workspace, tableId, frontmatter, body);
139
466
  state.workspace = loadWorkspace(state.schemaDir);
140
467
  }
141
- async function suggestForSource(workspace, source) {
468
+ async function suggestForSource(state, source) {
142
469
  const rt = getAskDbRuntimeConfig();
143
- const modelEnvVar = rt.ai.studioModel
144
- ? "ASKDB_STUDIO_MODEL"
145
- : rt.ai.tuiModel
146
- ? "ASKDB_TUI_MODEL"
147
- : undefined;
148
- const model = await createAskDbLanguageModelFromEnv(rt.ai.aiEnv, {
149
- modelEnvVar,
150
- });
151
- if (!model) {
152
- throw new StudioHttpError(400, askDbAiKeyMissingMessage("AI enrichment suggestions"));
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 : resolveAskDbAiConfig(rt.ai.aiEnv);
644
+ const aiConfig = mockSql ? undefined : ai.resolveAiConfig(rt.ai.aiEnv);
161
645
  if (!mockSql && !aiConfig) {
162
- throw new StudioHttpError(400, `${askDbAiKeyMissingMessage("Sample NL-to-SQL generation")} Set ASKDB_MOCK_SQL to bypass the live model.`);
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 createAskDbLanguageModelFromEnv(rt.ai.aiEnv, {
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 = readOptionalJson(lockPath);
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
- dimensions: config.dimensions,
279
- expectedDimensions: config.dimensions,
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 store = await openStudioRagStore(state, config.dimensions);
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: await createStudioRagEmbedder(config, usage),
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.dispose();
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.structured.rag.store;
895
+ const kind = rt.rag.store;
381
896
  if (kind === "memory")
382
897
  return { kind };
383
898
  if (kind === "file") {
384
- const basePath = rt.structured.rag.storeConfig.file?.basePath?.trim();
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.structured.rag.storeConfig.pgvector?.table?.trim() || undefined,
906
+ table: rt.rag.storeConfig.pgvector?.table?.trim() || undefined,
392
907
  indexStrategy: pickFlat(rt.flat, "ASKDB_PGVECTOR_INDEX_STRATEGY"),
393
908
  };
394
909
  }
395
- async function openStudioRagStore(state, dimensions) {
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 rt = getAskDbRuntimeConfig();
455
- const base = rt.ai.aiEnv;
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
- if (kind !== undefined && kind !== "ai-sdk" && kind !== "openai") {
468
- throw new StudioHttpError(400, `Unsupported Studio RAG embedder: ${kind}`);
469
- }
470
- const env = buildStudioRagEmbeddingEnv(kind, base);
471
- const aiConfig = resolveAskDbEmbeddingConfig(env, {
472
- modelEnvVar: "ASKDB_STUDIO_RAG_EMBEDDER_MODEL",
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
- embedderId: `ai-sdk:${provider}:${model}:${dimensions}`,
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
- requestDimensions: dimensionOverride,
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 fallbackStudioRagProvider(kind, base) {
515
- if (kind === "openai")
516
- return "openai";
517
- const raw = (pickEnv(base, "ASKDB_AI_PROVIDER") ?? "").toLowerCase();
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 createAskDbEmbeddingModel(config.aiConfig, {
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.provider}, model ${config.model}.`,
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 || embeddings.dimensions === config.dimensions)
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
- const promptTokens = readFiniteNumber(value.promptTokens);
791
- const completionTokens = readFiniteNumber(value.completionTokens);
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
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
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 serveClientFile(res, relativePath) {
848
- const filePath = resolve(CLIENT_DIR, relativePath);
849
- const fileRelativeToClientDir = relative(CLIENT_DIR, filePath);
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
- return writeJson(res, 404, { error: { message: "Not found" } });
1803
+ writeJson(res, 404, { error: { message: "Not found" } });
1804
+ return null;
852
1805
  }
853
1806
  if (!existsSync(filePath)) {
854
- return writeJson(res, 500, {
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":