@askdb/studio 0.2.0-beta.3 → 0.2.0-beta.30

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/dist/server.js CHANGED
@@ -1,26 +1,41 @@
1
- import { existsSync, readFileSync, rmSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } 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 { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveTable, } from "@askdb/enrich";
8
+ import { bootstrapAskDbEnv, getAskDbRuntimeConfig } from "@askdb/config";
9
+ import { createAiRegistry, } from "@askdb/ai";
10
+ import { anthropicProvider } from "@askdb/ai-anthropic";
11
+ import { azureProvider } from "@askdb/ai-azure";
12
+ import { googleProvider } from "@askdb/ai-google";
13
+ import { openaiProvider } from "@askdb/ai-openai";
14
+ import { ask, isBuiltInDialectId, loadSchema, suggestEnrichment, tenantScopeSchema, writeTenantPolicyMarkdown, tenantPolicyFrontmatterSchema, } from "@askdb/core";
15
+ import { buildSchemaIndex, chunkContentHash, chunkSchema, createAiSdkEmbedder, createFileStore, createMemoryStore, createPgvectorStore, createRetriever, loadChunkerSourcesFromDir, } from "@askdb/rag";
16
+ import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveConcepts, saveTable, } from "@askdb/enrich";
17
+ import { EXECUTE_DRIVER_REGISTRY, isDriverInstalled } from "./execute-registry.js";
18
+ import { resolveStudioIntrospectionPlan, runStudioIntrospection } from "./introspection.js";
19
+ import { probeSetupState, writeSetupConfig, SetupError } from "./setup.js";
20
+ const ai = createAiRegistry([openaiProvider, azureProvider, googleProvider, anthropicProvider]);
12
21
  const CLIENT_DIR = fileURLToPath(new URL("./client/", import.meta.url));
13
22
  const STUDIO_RAG_MOCK_DIMENSIONS = 64;
14
23
  const STUDIO_RAG_MOCK_EMBEDDER_ID = `studio:mock-lexical-${STUDIO_RAG_MOCK_DIMENSIONS}`;
15
24
  const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
25
+ let studioPgvectorStoreFactoryForTests;
26
+ export function setStudioPgvectorStoreFactoryForTests(factory) {
27
+ studioPgvectorStoreFactoryForTests = factory;
28
+ }
16
29
  export function createStudioServer(options) {
17
30
  const schemaDir = resolve(options.schema);
18
- if (!existsSync(schemaDir)) {
31
+ const setupReason = options.setupReason ?? null;
32
+ if (!setupReason && !existsSync(schemaDir)) {
19
33
  throw new Error(`schema directory not found: ${schemaDir}`);
20
34
  }
21
35
  const state = {
22
36
  schemaDir,
23
- workspace: loadWorkspace(schemaDir),
37
+ workspace: setupReason ? null : loadWorkspace(schemaDir),
38
+ setupReason,
24
39
  };
25
40
  return createServer(async (req, res) => {
26
41
  try {
@@ -31,39 +46,122 @@ export function createStudioServer(options) {
31
46
  if (req.method === "GET" && url.pathname.startsWith("/assets/")) {
32
47
  return serveClientFile(res, decodeURIComponent(url.pathname.slice(1)));
33
48
  }
49
+ if (req.method === "GET" && url.pathname === "/api/setup/status") {
50
+ return writeJson(res, 200, buildSetupStatus(state));
51
+ }
52
+ if (req.method === "POST" && url.pathname === "/api/setup/config") {
53
+ if (!isLoopbackRequest(req)) {
54
+ return writeJson(res, 403, { error: { message: "Setup is only available from loopback clients." } });
55
+ }
56
+ const body = await readJson(req);
57
+ const result = handleSetupConfig(state, body);
58
+ return writeJson(res, 200, result);
59
+ }
60
+ if (req.method === "POST" && url.pathname === "/api/setup/introspect") {
61
+ if (!isLoopbackRequest(req)) {
62
+ return writeJson(res, 403, { error: { message: "Setup is only available from loopback clients." } });
63
+ }
64
+ const result = await handleSetupIntrospect(state);
65
+ return writeJson(res, 200, result);
66
+ }
67
+ // Everything below needs a loaded workspace — route setup-mode clients to the wizard.
68
+ if (state.setupReason && url.pathname.startsWith("/api/")) {
69
+ return writeJson(res, 409, {
70
+ error: { message: "Studio needs setup. Open Studio in the browser to finish the guided setup." },
71
+ setup: buildSetupStatus(state),
72
+ });
73
+ }
34
74
  if (req.method === "GET" && url.pathname === "/api/workspace") {
35
- return writeJson(res, 200, serializeWorkspace(state.workspace));
75
+ return writeJson(res, 200, serializeWorkspace(requireWorkspace(state)));
76
+ }
77
+ if (req.method === "GET" && url.pathname === "/api/introspect/status") {
78
+ requireWorkspace(state);
79
+ return writeJson(res, 200, buildIntrospectionPlanDto());
80
+ }
81
+ if (req.method === "POST" && url.pathname === "/api/introspect") {
82
+ if (!isLoopbackRequest(req)) {
83
+ return writeJson(res, 403, { error: { message: "Resync is only available from loopback clients." } });
84
+ }
85
+ const result = await handleResync(state);
86
+ return writeJson(res, 200, result);
36
87
  }
37
88
  if (req.method === "POST" && url.pathname.startsWith("/api/tables/")) {
38
89
  const tableId = decodeURIComponent(url.pathname.slice("/api/tables/".length));
39
90
  const body = await readJson(req);
40
91
  const draft = parseTableDraftBody(body);
41
92
  saveDraft(state, tableId, draft);
42
- return writeJson(res, 200, serializeWorkspace(state.workspace));
93
+ return writeJson(res, 200, serializeWorkspace(requireWorkspace(state)));
94
+ }
95
+ if (req.method === "POST" && url.pathname === "/api/concepts") {
96
+ const body = await readJson(req);
97
+ const concepts = parseConceptsBody(body);
98
+ saveConceptsDraft(state, concepts);
99
+ return writeJson(res, 200, serializeWorkspace(requireWorkspace(state)));
100
+ }
101
+ if (req.method === "POST" && url.pathname === "/api/tenant-policy") {
102
+ const body = await readJson(req);
103
+ const { frontmatter, bodyText } = parseTenantPolicyBody(body);
104
+ saveTenantPolicy(state, frontmatter, bodyText);
105
+ return writeJson(res, 200, serializeWorkspace(requireWorkspace(state)));
106
+ }
107
+ if (req.method === "POST" && url.pathname === "/api/suggest-tenant-policy") {
108
+ const draft = await suggestTenantPolicyDraft(state);
109
+ return writeJson(res, 200, draft);
43
110
  }
44
111
  if (req.method === "POST" && url.pathname === "/api/suggest") {
45
112
  const body = await readJson(req);
46
113
  const source = parseSuggestSource(body);
47
- const candidates = await suggestForSource(state.workspace, source);
114
+ const candidates = await suggestForSource(requireWorkspace(state), source);
48
115
  return writeJson(res, 200, { candidates });
49
116
  }
50
117
  if (req.method === "GET" && url.pathname === "/api/rag/status") {
51
- return writeJson(res, 200, getRagStatus(state.schemaDir));
118
+ return writeJson(res, 200, await getRagStatus(state));
52
119
  }
53
120
  if (req.method === "POST" && url.pathname === "/api/rag/index") {
54
- const result = await indexRag(state.schemaDir);
121
+ const result = await indexRag(state);
55
122
  return writeJson(res, 200, result);
56
123
  }
57
124
  if (req.method === "POST" && url.pathname === "/api/rag/query") {
58
125
  const body = await readJson(req);
59
126
  const query = parseRagQuery(body);
60
- const result = await queryRag(state.schemaDir, query);
127
+ const result = await queryRag(state, query);
61
128
  return writeJson(res, 200, result);
62
129
  }
63
130
  if (req.method === "POST" && url.pathname === "/api/ask") {
64
131
  const body = await readJson(req);
65
132
  const options = parseAskBody(body);
66
- const result = await askSampleQuestion(state.schemaDir, options);
133
+ const result = await askSampleQuestion(state, options);
134
+ return writeJson(res, 200, result);
135
+ }
136
+ if (req.method === "GET" && url.pathname === "/api/history") {
137
+ return writeJson(res, 200, readPlaygroundHistory(state.schemaDir));
138
+ }
139
+ if (req.method === "POST" && url.pathname === "/api/history") {
140
+ const body = await readJson(req);
141
+ const entry = parsePlaygroundHistoryEntry(body);
142
+ appendPlaygroundHistory(state.schemaDir, entry);
143
+ return writeJson(res, 200, { ok: true });
144
+ }
145
+ if (req.method === "DELETE" && url.pathname.startsWith("/api/history/")) {
146
+ const id = decodeURIComponent(url.pathname.slice("/api/history/".length));
147
+ const result = deletePlaygroundHistoryEntry(state.schemaDir, id);
148
+ return writeJson(res, 200, result);
149
+ }
150
+ if (req.method === "GET" && url.pathname === "/api/execute/status") {
151
+ const result = await getExecuteStatus(state.schemaDir);
152
+ return writeJson(res, 200, result);
153
+ }
154
+ if (req.method === "POST" && url.pathname === "/api/execute/install-driver") {
155
+ if (!isLoopbackRequest(req)) {
156
+ return writeJson(res, 403, { error: { message: "Driver install is only available from loopback clients." } });
157
+ }
158
+ const body = await readJson(req);
159
+ const result = await installExecuteDriver(body, state.schemaDir);
160
+ return writeJson(res, 200, result);
161
+ }
162
+ if (req.method === "POST" && url.pathname === "/api/execute") {
163
+ const body = await readJson(req);
164
+ const result = await executeQuery(body, state.schemaDir);
67
165
  return writeJson(res, 200, result);
68
166
  }
69
167
  if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
@@ -72,7 +170,7 @@ export function createStudioServer(options) {
72
170
  writeJson(res, 404, { error: { message: "Not found" } });
73
171
  }
74
172
  catch (error) {
75
- const status = error instanceof StudioHttpError ? error.status : 500;
173
+ const status = error instanceof StudioHttpError || error instanceof SetupError ? error.status : 500;
76
174
  writeJson(res, status, {
77
175
  error: { message: error instanceof Error ? error.message : String(error) },
78
176
  });
@@ -83,9 +181,7 @@ export function serializeWorkspace(workspace) {
83
181
  const rt = getAskDbRuntimeConfig();
84
182
  const aiConfig = (() => {
85
183
  try {
86
- return resolveAskDbAiConfig(rt.ai.aiEnv, {
87
- modelEnvVar: rt.ai.studioModel ? "ASKDB_STUDIO_MODEL" : undefined,
88
- });
184
+ return ai.resolveAiConfig(rt.ai.aiEnv);
89
185
  }
90
186
  catch {
91
187
  // A misconfigured AI env (e.g. azure without resourceName) shouldn't crash the workspace
@@ -93,9 +189,15 @@ export function serializeWorkspace(workspace) {
93
189
  return undefined;
94
190
  }
95
191
  })();
192
+ const schema = loadSchema(workspace.schemaDir);
193
+ const schemaProvider = "provider" in schema && typeof schema.provider === "string" ? schema.provider : undefined;
194
+ const dialect = rt.nlToSql.dialect ??
195
+ (schemaProvider && isBuiltInDialectId(schemaProvider) ? schemaProvider : "postgres");
96
196
  return {
97
197
  schemaDir: workspace.schemaDir,
198
+ schemaPathRelative: toRelativeSchemaPath(workspace.schemaDir),
98
199
  schemaId: workspace.physical.schemaId,
200
+ dialect,
99
201
  warnings: workspace.warnings,
100
202
  aiConfigured: Boolean(aiConfig),
101
203
  model: aiConfig?.model ?? "gpt-4o-mini",
@@ -113,13 +215,192 @@ export function serializeWorkspace(workspace) {
113
215
  };
114
216
  }),
115
217
  concepts: workspace.concepts?.frontmatter.concepts ?? [],
218
+ tenantPolicy: schema.tenantPolicy ?? null,
219
+ };
220
+ }
221
+ function toRelativeSchemaPath(schemaDir) {
222
+ const rel = relative(process.cwd(), schemaDir);
223
+ if (rel === "")
224
+ return ".";
225
+ if (rel.startsWith("..") || rel.includes(`..${sep}`))
226
+ return schemaDir;
227
+ return rel.startsWith(".") ? rel : `./${rel}`;
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // Setup wizard + server-side introspection (resync)
231
+ // ---------------------------------------------------------------------------
232
+ function requireWorkspace(state) {
233
+ if (!state.workspace) {
234
+ throw new StudioHttpError(409, "Studio is in setup mode — finish the guided setup first.");
235
+ }
236
+ return state.workspace;
237
+ }
238
+ function buildSetupStatus(state) {
239
+ const projectDir = process.cwd();
240
+ if (!state.setupReason) {
241
+ return {
242
+ needed: false,
243
+ reason: null,
244
+ projectDir,
245
+ configPath: null,
246
+ outputDir: state.schemaDir,
247
+ configLoadError: null,
248
+ };
249
+ }
250
+ const probe = probeSetupState(projectDir);
251
+ return {
252
+ needed: true,
253
+ reason: probe.hasConfig ? "no-artifact" : "no-config",
254
+ projectDir,
255
+ configPath: probe.configPath,
256
+ outputDir: probe.outputDir,
257
+ configLoadError: probe.loadError,
258
+ };
259
+ }
260
+ function handleSetupConfig(state, body) {
261
+ if (!state.setupReason) {
262
+ throw new StudioHttpError(409, "Studio is already set up — edit askdb.config.ts directly.");
263
+ }
264
+ const input = parseSetupConfigBody(body);
265
+ const result = writeSetupConfig(process.cwd(), input);
266
+ // Config now exists; the artifact usually doesn't yet.
267
+ const probe = probeSetupState(process.cwd());
268
+ if (probe.hasArtifact && probe.outputDir) {
269
+ state.schemaDir = probe.outputDir;
270
+ state.workspace = loadWorkspace(probe.outputDir);
271
+ state.setupReason = null;
272
+ }
273
+ else {
274
+ state.setupReason = "no-artifact";
275
+ }
276
+ return {
277
+ configPath: result.configPath,
278
+ envExamplePath: result.envExamplePath,
279
+ envVars: result.envVars,
280
+ installed: result.installed,
281
+ manualInstallCommand: result.manualInstallCommand,
282
+ packageJsonCreated: result.packageJsonCreated,
283
+ status: buildSetupStatus(state),
284
+ };
285
+ }
286
+ async function handleSetupIntrospect(state) {
287
+ if (!state.setupReason) {
288
+ throw new StudioHttpError(409, "Studio is already set up — use Resync schema instead.");
289
+ }
290
+ // probeSetupState re-bootstraps, picking up any .env created since server start.
291
+ const probe = probeSetupState(process.cwd());
292
+ if (!probe.hasConfig) {
293
+ throw new StudioHttpError(409, "Write askdb.config.ts first (setup step 1).");
294
+ }
295
+ if (probe.loadError || !probe.outputDir) {
296
+ throw new StudioHttpError(409, `askdb.config.ts exists but could not be loaded: ${probe.loadError ?? "unknown error"}. ` +
297
+ "Install the project dependencies shown in setup step 1, then retry.");
298
+ }
299
+ const outDir = probe.outputDir;
300
+ const run = await runStudioIntrospection({
301
+ outDir,
302
+ hasExistingArtifact: existsSync(join(outDir, "schema.json")),
303
+ });
304
+ state.schemaDir = outDir;
305
+ state.workspace = loadWorkspace(outDir);
306
+ state.setupReason = null;
307
+ return {
308
+ ok: true,
309
+ engine: run.engine,
310
+ schemaId: run.schemaId,
311
+ tables: run.tables,
312
+ warnings: run.warnings,
313
+ workspace: serializeWorkspace(state.workspace),
314
+ };
315
+ }
316
+ async function handleResync(state) {
317
+ const workspace = requireWorkspace(state);
318
+ // Refresh the runtime snapshot so config edits made while Studio runs are honored.
319
+ try {
320
+ bootstrapAskDbEnv({ cwd: process.cwd() });
321
+ }
322
+ catch {
323
+ // Keep the snapshot the server started with when the config disappeared mid-session.
324
+ }
325
+ const run = await runStudioIntrospection({
326
+ outDir: state.schemaDir,
327
+ schemaId: workspace.physical.schemaId,
328
+ hasExistingArtifact: true,
329
+ });
330
+ state.workspace = loadWorkspace(state.schemaDir);
331
+ return {
332
+ ok: true,
333
+ engine: run.engine,
334
+ schemaId: run.schemaId,
335
+ tables: run.tables,
336
+ warnings: run.warnings,
337
+ workspace: serializeWorkspace(state.workspace),
116
338
  };
117
339
  }
340
+ function buildIntrospectionPlanDto() {
341
+ const plan = resolveStudioIntrospectionPlan();
342
+ return plan.ok
343
+ ? { ok: true, engine: plan.engine, sourceLabel: plan.sourceLabel }
344
+ : { ok: false, engine: plan.engine, error: plan.error };
345
+ }
346
+ function parseSetupConfigBody(body) {
347
+ if (!isRecord(body)) {
348
+ throw new StudioHttpError(400, "Request body must be a JSON object.");
349
+ }
350
+ const databases = ["postgres", "mysql", "sqlite", "sqlserver", "prisma"];
351
+ const aiProviders = ["openai", "anthropic", "google", "azure"];
352
+ if (typeof body.database !== "string" || !databases.includes(body.database)) {
353
+ throw new StudioHttpError(400, `\`database\` must be one of: ${databases.join(", ")}.`);
354
+ }
355
+ if (typeof body.aiProvider !== "string" ||
356
+ !aiProviders.includes(body.aiProvider)) {
357
+ throw new StudioHttpError(400, `\`aiProvider\` must be one of: ${aiProviders.join(", ")}.`);
358
+ }
359
+ const optionalString = (key) => {
360
+ const value = body[key];
361
+ if (value === undefined || value === null || value === "")
362
+ return undefined;
363
+ if (typeof value !== "string")
364
+ throw new StudioHttpError(400, `\`${key}\` must be a string.`);
365
+ return value;
366
+ };
367
+ return {
368
+ database: body.database,
369
+ aiProvider: body.aiProvider,
370
+ connectionEnv: optionalString("connectionEnv"),
371
+ sqliteFile: optionalString("sqliteFile"),
372
+ prismaSchema: optionalString("prismaSchema"),
373
+ aiKeyEnv: optionalString("aiKeyEnv"),
374
+ schemaOut: optionalString("schemaOut"),
375
+ };
376
+ }
377
+ function saveConceptsDraft(state, concepts) {
378
+ saveConcepts(requireWorkspace(state), { concepts });
379
+ state.workspace = loadWorkspace(state.schemaDir);
380
+ }
381
+ function saveTenantPolicy(state, frontmatter, body) {
382
+ const filePath = join(state.schemaDir, "tenant-policy.md");
383
+ const md = writeTenantPolicyMarkdown(frontmatter, body);
384
+ writeFileSync(filePath, md, "utf8");
385
+ state.workspace = loadWorkspace(state.schemaDir);
386
+ }
387
+ function parseTenantPolicyBody(body) {
388
+ if (!isRecord(body) || !isRecord(body.frontmatter)) {
389
+ throw new StudioHttpError(400, "`frontmatter` object is required.");
390
+ }
391
+ const parsed = tenantPolicyFrontmatterSchema.safeParse(body.frontmatter);
392
+ if (!parsed.success) {
393
+ throw new StudioHttpError(400, `Invalid tenant policy: ${parsed.error.message}`);
394
+ }
395
+ const bodyText = typeof body.body === "string" ? body.body : "";
396
+ return { frontmatter: parsed.data, bodyText };
397
+ }
118
398
  function saveDraft(state, tableId, draft) {
119
- const table = state.workspace.tables.find((candidate) => candidate.physical.id === tableId);
399
+ const workspace = requireWorkspace(state);
400
+ const table = workspace.tables.find((candidate) => candidate.physical.id === tableId);
120
401
  if (!table)
121
402
  throw new StudioHttpError(404, `No such table: ${tableId}`);
122
- const frontmatter = buildFrontmatter(table.physical, state.workspace.physical.schemaId, draft);
403
+ const frontmatter = buildFrontmatter(table.physical, workspace.physical.schemaId, draft);
123
404
  let body = table.parsed
124
405
  ? replaceTableDescription(table.parsed.body, draft.description)
125
406
  : buildDefaultTableBody(table.physical.name, draft.description);
@@ -129,43 +410,123 @@ function saveDraft(state, tableId, draft) {
129
410
  if (draft.exampleQuestions !== undefined) {
130
411
  body = replaceH2Section(body, "Example questions", draft.exampleQuestions);
131
412
  }
132
- saveTable(state.workspace, tableId, frontmatter, body);
413
+ saveTable(workspace, tableId, frontmatter, body);
133
414
  state.workspace = loadWorkspace(state.schemaDir);
134
415
  }
135
416
  async function suggestForSource(workspace, source) {
136
417
  const rt = getAskDbRuntimeConfig();
137
- const modelEnvVar = rt.ai.studioModel
138
- ? "ASKDB_STUDIO_MODEL"
139
- : rt.ai.tuiModel
140
- ? "ASKDB_TUI_MODEL"
141
- : undefined;
142
- const model = await createAskDbLanguageModelFromEnv(rt.ai.aiEnv, {
143
- modelEnvVar,
144
- });
418
+ const model = await ai.createLanguageModelFromEnv(rt.ai.aiEnv);
145
419
  if (!model) {
146
- throw new StudioHttpError(400, askDbAiKeyMissingMessage("AI enrichment suggestions"));
420
+ throw new StudioHttpError(400, ai.keyMissingMessage("AI enrichment suggestions"));
147
421
  }
148
422
  const candidates = await suggestEnrichment(buildSuggestionTarget(workspace, source), buildSuggestionContext(workspace, source.tableId), model);
149
423
  return candidates.map((candidate) => ({ text: candidate.text }));
150
424
  }
151
- async function askSampleQuestion(schemaDir, options) {
425
+ async function suggestTenantPolicyDraft(state) {
426
+ const rt = getAskDbRuntimeConfig();
427
+ const model = await ai.createLanguageModelFromEnv(rt.ai.aiEnv);
428
+ if (!model) {
429
+ throw new StudioHttpError(400, ai.keyMissingMessage("AI tenant policy suggestion"));
430
+ }
431
+ // Build a compact DDL-like representation of the schema for the prompt
432
+ const workspace = requireWorkspace(state);
433
+ const schemaId = workspace.physical.schemaId;
434
+ const schemaDdl = workspace.tables
435
+ .map((t) => {
436
+ const cols = t.physical.columns
437
+ .map((c) => {
438
+ const parts = [c.name, c.type];
439
+ if (c.primaryKey)
440
+ parts.push("PRIMARY KEY");
441
+ if (!c.nullable)
442
+ parts.push("NOT NULL");
443
+ return ` ${parts.join(" ")}`;
444
+ })
445
+ .join(",\n");
446
+ const rels = (t.physical.relationships ?? [])
447
+ .map((r) => ` -- FK: ${r.from} -> ${r.to}`)
448
+ .join("\n");
449
+ return `CREATE TABLE ${t.physical.schema}.${t.physical.name} (\n${cols}\n);\n${rels}`.trim();
450
+ })
451
+ .join("\n\n");
452
+ const tableIds = workspace.tables.map((t) => t.physical.id);
453
+ const systemPrompt = `You are an expert database architect specializing in multi-tenant SaaS systems.
454
+ You analyze database schemas and produce a tenant-policy configuration in a specific JSON format.
455
+
456
+ The tenant policy identifies:
457
+ 1. **roots** — Tables whose rows represent tenants (organizations, companies, accounts, etc).
458
+ Each root has: id (table ID like "table:public.orgs"), tenantIdColumn (column ref like "table:public.orgs#id"), label (human-readable name).
459
+ If a root has a parent root (e.g. sub_org belongs to org), include parent: { root: "<parent-root-id>", foreignKey: "<fk-column-ref>" }.
460
+ 2. **hierarchy** — Edges showing parent→child relationships between roots or tenant-scoped tables.
461
+ Each edge: { parent: "<table-id>", child: "<table-id>", foreignKey: "<child-fk-column-ref>" }.
462
+ 3. **scopedTables** — Tables that belong to a tenant via a direct column or a JOIN chain.
463
+ Each entry: { id: "<table-id>", scopeThrough: [{ root: "<root-id>", column: "<column-ref>" }] } for direct FK,
464
+ or { id: "<table-id>", scopeThrough: [{ root: "<root-id>", join: [{ from: "<col-ref>", to: "<col-ref>" }] }] } for indirect.
465
+ 4. **polymorphicTables** — Tables with a type/id polymorphic association pattern.
466
+ Each entry: { id: "<table-id>", typeColumn: "<col-ref>", idColumn: "<col-ref>", mapping: { "<type_value>": "<target-table-id>" } }.
467
+ 5. **globalTables** — Tables shared across all tenants (lookup tables, reference data). Array of table IDs.
468
+ 6. **enforcement** — "strict" (reject queries on unknown tables) or "warn" (allow with warnings).
469
+
470
+ Column references use the format "table:<schema>.<table>#<column>".
471
+ Table IDs use the format "table:<schema>.<table>".
472
+
473
+ Available table IDs: ${JSON.stringify(tableIds)}
474
+
475
+ Return a JSON object with two keys:
476
+ - "frontmatter": the TenantPolicyFrontmatter object (schemaId, enforcement, roots, hierarchy?, scopedTables?, polymorphicTables?, globalTables?)
477
+ - "body": a markdown body string with sections "## Hierarchy", "## Scope rules", "## Sensitive interactions" explaining the policy in plain English.
478
+
479
+ Return ONLY valid JSON, no markdown fences or explanations.`;
480
+ const userPrompt = `Analyze this database schema and draft a complete multi-tenant policy.
481
+
482
+ Schema ID: ${schemaId}
483
+
484
+ ${schemaDdl}
485
+
486
+ Look for:
487
+ - Tables that represent organizations, companies, accounts, tenants, or workspaces — these are roots.
488
+ - Foreign key columns like org_id, company_id, tenant_id, account_id that scope data to a tenant.
489
+ - Tables with no tenant FK that are clearly shared/global (lookup tables, enums, config).
490
+ - Polymorphic association patterns (type_column + id_column pointing to multiple parent tables).
491
+ - Multi-level hierarchies (org → team → user, company → department → employee).
492
+
493
+ If the schema has no clear multi-tenant pattern, still make your best guess at what would be the tenant root.`;
494
+ const result = await defaultGenerateText({
495
+ model,
496
+ system: systemPrompt,
497
+ prompt: userPrompt,
498
+ });
499
+ let parsed;
500
+ try {
501
+ parsed = JSON.parse(result.text);
502
+ }
503
+ catch {
504
+ throw new StudioHttpError(500, `AI returned invalid JSON for tenant policy suggestion. Raw text: ${result.text.slice(0, 500)}`);
505
+ }
506
+ // Validate frontmatter shape
507
+ const validation = tenantPolicyFrontmatterSchema.safeParse(parsed.frontmatter);
508
+ if (!validation.success) {
509
+ throw new StudioHttpError(500, `AI-generated tenant policy frontmatter is invalid: ${validation.error.issues.map((i) => i.message).join("; ")}`);
510
+ }
511
+ parsed.frontmatter = validation.data;
512
+ return parsed;
513
+ }
514
+ async function askSampleQuestion(state, options) {
152
515
  const rt = getAskDbRuntimeConfig();
153
516
  const mockSql = rt.dev.mockSql;
154
- const aiConfig = mockSql ? undefined : resolveAskDbAiConfig(rt.ai.aiEnv);
517
+ const aiConfig = mockSql ? undefined : ai.resolveAiConfig(rt.ai.aiEnv);
155
518
  if (!mockSql && !aiConfig) {
156
- throw new StudioHttpError(400, `${askDbAiKeyMissingMessage("Sample NL-to-SQL generation")} Set ASKDB_MOCK_SQL to bypass the live model.`);
519
+ throw new StudioHttpError(400, `${ai.keyMissingMessage("Sample NL-to-SQL generation")} Set ASKDB_MOCK_SQL to bypass the live model.`);
157
520
  }
158
- const schema = loadSchema(schemaDir);
521
+ const schema = loadSchema(state.schemaDir);
159
522
  const retrievedChunks = [];
160
523
  const usage = createRequestUsageCollector();
161
524
  const ragIndex = options.useRag
162
- ? await createCurrentStudioRagIndex(schemaDir, "using RAG for sample generation", usage)
525
+ ? await createCurrentStudioRagIndex(state, "using RAG for sample generation", usage)
163
526
  : undefined;
164
527
  const model = mockSql
165
528
  ? undefined
166
- : (await createAskDbLanguageModelFromEnv(rt.ai.aiEnv, {
167
- modelEnvVar: rt.ai.studioModel ? "ASKDB_STUDIO_MODEL" : undefined,
168
- }));
529
+ : (await ai.createLanguageModelFromEnv(rt.ai.aiEnv));
169
530
  const schemaProvider = "provider" in schema && typeof schema.provider === "string"
170
531
  ? schema.provider
171
532
  : undefined;
@@ -177,6 +538,8 @@ async function askSampleQuestion(schemaDir, options) {
177
538
  model,
178
539
  dialect,
179
540
  explain: true,
541
+ ...(options.tenantScope ? { tenantScope: options.tenantScope } : {}),
542
+ ...(options.tenantSqlMode ? { tenantSqlMode: options.tenantSqlMode } : {}),
180
543
  ...(ragIndex
181
544
  ? {
182
545
  retriever: async (params) => {
@@ -185,7 +548,7 @@ async function askSampleQuestion(schemaDir, options) {
185
548
  results = await ragIndex.retriever(params);
186
549
  }
187
550
  catch (error) {
188
- throw formatStudioRagEmbeddingError(error, ragIndex.config);
551
+ throw formatStudioRagOperationError(error, ragIndex.config);
189
552
  }
190
553
  retrievedChunks.splice(0, retrievedChunks.length, ...results);
191
554
  return results;
@@ -202,6 +565,8 @@ async function askSampleQuestion(schemaDir, options) {
202
565
  : {
203
566
  generateText: createTrackedGenerateText(usage),
204
567
  },
568
+ }).finally(async () => {
569
+ await ragIndex?.dispose();
205
570
  });
206
571
  return {
207
572
  sql: result.sql,
@@ -211,90 +576,115 @@ async function askSampleQuestion(schemaDir, options) {
211
576
  enabled: options.useRag,
212
577
  chunks: options.useRag ? retrievedChunks.map(serializeRagResult) : [],
213
578
  },
579
+ tenant: options.tenantScope
580
+ ? {
581
+ enabled: true,
582
+ sqlMode: options.tenantSqlMode ?? "sql-only",
583
+ bindings: result.tenantBindings ?? [],
584
+ params: result.tenantParams ?? [],
585
+ }
586
+ : null,
214
587
  usage: usage.toDto(),
215
588
  };
216
589
  }
217
- function getRagStatus(schemaDir) {
590
+ async function getRagStatus(state) {
218
591
  const config = resolveStudioRagEmbedderConfig();
219
- const sources = loadChunkerSourcesFromDir(schemaDir);
592
+ const sources = loadChunkerSourcesFromDir(state.schemaDir);
220
593
  const chunkResult = chunkSchema(sources);
221
- const lockPath = join(schemaDir, "schema.lock.json");
222
- const embeddingsJsonPath = join(schemaDir, "schema.embeddings.json");
223
- const embeddingsBinPath = join(schemaDir, "schema.embeddings.bin");
594
+ const lockPath = join(state.schemaDir, "schema.lock.json");
224
595
  const lock = readOptionalJson(lockPath);
225
- const embeddings = readOptionalJson(embeddingsJsonPath);
226
596
  const currentHashes = Object.fromEntries(chunkResult.chunks.map((chunk) => [chunk.id, chunkContentHash(chunk.text)]));
227
597
  const lockHashes = lock?.hashes ?? {};
228
598
  const hashIds = Object.keys(currentHashes);
229
- const stale = !lock ||
230
- !existsSync(embeddingsJsonPath) ||
231
- !existsSync(embeddingsBinPath) ||
232
- lock.embedderId !== config.embedderId ||
233
- embeddings?.dimensions !== config.dimensions ||
234
- Object.keys(lockHashes).length !== hashIds.length ||
235
- hashIds.some((id) => lockHashes[id] !== currentHashes[id]);
236
- return {
237
- schemaId: sources.schema.schemaId,
238
- embedder: {
239
- kind: config.kind,
240
- label: config.label,
241
- configured: config.configured,
242
- expectedId: config.embedderId,
243
- indexedId: lock?.embedderId ?? null,
244
- provider: config.kind === "ai-sdk" ? config.provider : null,
245
- model: config.kind === "ai-sdk" ? config.model : null,
246
- baseUrl: config.kind === "ai-sdk" ? config.baseUrl ?? null : null,
247
- },
248
- embedderId: lock?.embedderId ?? config.embedderId,
249
- expectedEmbedderId: config.embedderId,
250
- hasIndex: Boolean(lock && existsSync(embeddingsJsonPath) && existsSync(embeddingsBinPath)),
251
- stale,
252
- updatedAt: lock?.updatedAt ?? null,
253
- chunksTotal: chunkResult.chunks.length,
254
- chunksIndexed: Array.isArray(embeddings?.records) ? embeddings.records.length : 0,
255
- dimensions: typeof embeddings?.dimensions === "number" ? embeddings.dimensions : config.dimensions,
256
- expectedDimensions: config.dimensions,
257
- sensitiveExcluded: chunkResult.stats.sensitiveExcluded,
258
- sensitiveIncluded: chunkResult.stats.sensitiveIncluded,
259
- files: {
260
- lock: existsSync(lockPath),
261
- embeddingsJson: existsSync(embeddingsJsonPath),
262
- embeddingsBin: existsSync(embeddingsBinPath),
263
- },
264
- };
599
+ const store = await openStudioRagStore(state, config.dimensions);
600
+ try {
601
+ const chunksIndexed = await countStudioRagStoreChunks(store, sources.schema.schemaId);
602
+ const stale = !lock ||
603
+ lock.embedderId !== config.embedderId ||
604
+ Object.keys(lockHashes).length !== hashIds.length ||
605
+ hashIds.some((id) => lockHashes[id] !== currentHashes[id]) ||
606
+ chunksIndexed !== hashIds.length;
607
+ const fileArtifacts = store.kind === "file" && store.basePath
608
+ ? {
609
+ lock: existsSync(lockPath),
610
+ embeddingsJson: existsSync(`${store.basePath}.embeddings.json`),
611
+ embeddingsBin: existsSync(`${store.basePath}.embeddings.bin`),
612
+ }
613
+ : {
614
+ lock: existsSync(lockPath),
615
+ embeddingsJson: false,
616
+ embeddingsBin: false,
617
+ };
618
+ return {
619
+ schemaId: sources.schema.schemaId,
620
+ store: {
621
+ kind: store.kind,
622
+ basePath: store.basePath ?? null,
623
+ table: store.table ?? null,
624
+ indexStrategy: store.indexStrategy ?? null,
625
+ },
626
+ embedder: {
627
+ kind: config.kind,
628
+ label: config.label,
629
+ configured: config.configured,
630
+ expectedId: config.embedderId,
631
+ indexedId: lock?.embedderId ?? null,
632
+ provider: config.kind === "ai-sdk" ? config.provider : null,
633
+ model: config.kind === "ai-sdk" ? config.model : null,
634
+ baseUrl: config.kind === "ai-sdk" ? config.baseUrl ?? null : null,
635
+ },
636
+ embedderId: lock?.embedderId ?? config.embedderId,
637
+ expectedEmbedderId: config.embedderId,
638
+ hasIndex: Boolean(lock && chunksIndexed > 0),
639
+ stale,
640
+ updatedAt: lock?.updatedAt ?? null,
641
+ chunksTotal: chunkResult.chunks.length,
642
+ chunksIndexed,
643
+ dimensions: config.dimensions,
644
+ expectedDimensions: config.dimensions,
645
+ sensitiveExcluded: chunkResult.stats.sensitiveExcluded,
646
+ sensitiveIncluded: chunkResult.stats.sensitiveIncluded,
647
+ files: fileArtifacts,
648
+ };
649
+ }
650
+ finally {
651
+ await store.dispose();
652
+ }
265
653
  }
266
- async function indexRag(schemaDir) {
654
+ async function indexRag(state) {
267
655
  const config = resolveStudioRagEmbedderConfig();
268
656
  if (!config.configured) {
269
657
  throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
270
658
  }
271
659
  const usage = createRequestUsageCollector();
272
- clearIncompatibleRagStore(schemaDir, config);
273
- const sources = loadChunkerSourcesFromDir(schemaDir);
274
- const store = createFileStore({ basePath: join(schemaDir, "schema") });
660
+ clearIncompatibleRagStore(state, config);
661
+ const sources = loadChunkerSourcesFromDir(state.schemaDir);
662
+ const store = await openStudioRagStore(state, config.dimensions);
275
663
  let result;
276
664
  try {
277
665
  result = await buildSchemaIndex({
278
666
  schema: sources,
279
667
  embedder: await createStudioRagEmbedder(config, usage),
280
- store,
668
+ store: store.store,
281
669
  embedderId: config.embedderId,
282
- lockFilePath: join(schemaDir, "schema.lock.json"),
670
+ lockFilePath: join(state.schemaDir, "schema.lock.json"),
283
671
  });
284
672
  }
285
673
  catch (error) {
286
- throw formatStudioRagEmbeddingError(error, config);
674
+ throw formatStudioRagOperationError(error, config);
675
+ }
676
+ finally {
677
+ await store.dispose();
287
678
  }
288
- store.flush();
289
679
  return {
290
- status: getRagStatus(schemaDir),
680
+ status: await getRagStatus(state),
291
681
  stats: result.stats,
292
682
  usage: usage.toDto(),
293
683
  };
294
684
  }
295
- async function queryRag(schemaDir, query) {
685
+ async function queryRag(state, query) {
296
686
  const usage = createRequestUsageCollector();
297
- const ragIndex = await createCurrentStudioRagIndex(schemaDir, "querying chunks", usage);
687
+ const ragIndex = await createCurrentStudioRagIndex(state, "querying chunks", usage);
298
688
  let results;
299
689
  try {
300
690
  results = await ragIndex.retriever({
@@ -307,7 +697,10 @@ async function queryRag(schemaDir, query) {
307
697
  });
308
698
  }
309
699
  catch (error) {
310
- throw formatStudioRagEmbeddingError(error, ragIndex.config);
700
+ throw formatStudioRagOperationError(error, ragIndex.config);
701
+ }
702
+ finally {
703
+ await ragIndex.dispose();
311
704
  }
312
705
  return {
313
706
  question: query.question,
@@ -316,12 +709,12 @@ async function queryRag(schemaDir, query) {
316
709
  usage: usage.toDto(),
317
710
  };
318
711
  }
319
- async function createCurrentStudioRagIndex(schemaDir, action, usage) {
712
+ async function createCurrentStudioRagIndex(state, action, usage) {
320
713
  const config = resolveStudioRagEmbedderConfig();
321
714
  if (!config.configured) {
322
715
  throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
323
716
  }
324
- const status = getRagStatus(schemaDir);
717
+ const status = (await getRagStatus(state));
325
718
  if (!status.hasIndex) {
326
719
  throw new StudioHttpError(400, `Build the RAG index before ${action}.`);
327
720
  }
@@ -331,6 +724,7 @@ async function createCurrentStudioRagIndex(schemaDir, action, usage) {
331
724
  if (!status.schemaId || typeof status.chunksTotal !== "number") {
332
725
  throw new StudioHttpError(500, "Studio RAG status is missing schema metadata.");
333
726
  }
727
+ const store = await openStudioRagStore(state, config.dimensions);
334
728
  return {
335
729
  config,
336
730
  status: {
@@ -339,10 +733,85 @@ async function createCurrentStudioRagIndex(schemaDir, action, usage) {
339
733
  },
340
734
  retriever: createRetriever({
341
735
  embedder: await createStudioRagEmbedder(config, usage),
342
- store: createFileStore({ basePath: join(schemaDir, "schema") }),
736
+ store: store.store,
343
737
  }),
738
+ dispose: async () => {
739
+ await store.dispose();
740
+ },
344
741
  };
345
742
  }
743
+ function resolveStudioRagStoreConfig(state) {
744
+ const rt = getAskDbRuntimeConfig();
745
+ const kind = rt.structured.rag.store;
746
+ if (kind === "memory")
747
+ return { kind };
748
+ if (kind === "file") {
749
+ const basePath = rt.structured.rag.storeConfig.file?.basePath?.trim();
750
+ return { kind, basePath: basePath ? resolve(basePath) : join(state.schemaDir, "schema") };
751
+ }
752
+ const connectionString = pickFlat(rt.flat, "ASKDB_PGVECTOR_URL");
753
+ return {
754
+ kind,
755
+ connectionString,
756
+ table: rt.structured.rag.storeConfig.pgvector?.table?.trim() || undefined,
757
+ indexStrategy: pickFlat(rt.flat, "ASKDB_PGVECTOR_INDEX_STRATEGY"),
758
+ };
759
+ }
760
+ async function openStudioRagStore(state, dimensions) {
761
+ const config = resolveStudioRagStoreConfig(state);
762
+ if (config.kind === "memory") {
763
+ state.ragMemoryStore ??= createMemoryStore();
764
+ return {
765
+ kind: "memory",
766
+ store: state.ragMemoryStore,
767
+ dispose: async () => { },
768
+ };
769
+ }
770
+ if (config.kind === "file") {
771
+ const store = createFileStore({ basePath: config.basePath });
772
+ return {
773
+ kind: "file",
774
+ store,
775
+ basePath: config.basePath,
776
+ dispose: async () => {
777
+ store.flush();
778
+ },
779
+ };
780
+ }
781
+ if (!config.connectionString) {
782
+ throw new StudioHttpError(400, 'Studio pgvector RAG requires `ASKDB_PGVECTOR_URL` via `askdb.config.ts`.');
783
+ }
784
+ const pgvectorFactory = studioPgvectorStoreFactoryForTests ?? createPgvectorStore;
785
+ const store = pgvectorFactory({
786
+ connectionString: config.connectionString,
787
+ table: config.table,
788
+ dimensions,
789
+ ...(config.indexStrategy ? { indexStrategy: config.indexStrategy } : {}),
790
+ });
791
+ await store.ensureSchema();
792
+ return {
793
+ kind: "pgvector",
794
+ store,
795
+ table: config.table,
796
+ indexStrategy: config.indexStrategy,
797
+ dispose: async () => {
798
+ await store.close();
799
+ },
800
+ };
801
+ }
802
+ async function countStudioRagStoreChunks(store, schemaId) {
803
+ if (typeof store.store.count === "function") {
804
+ return store.store.count({ schemaId });
805
+ }
806
+ if (typeof store.store.size === "function") {
807
+ return store.store.size();
808
+ }
809
+ return 0;
810
+ }
811
+ function pickFlat(flat, key) {
812
+ const value = flat[key];
813
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
814
+ }
346
815
  function pickEnv(env, key) {
347
816
  const v = env[key];
348
817
  return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
@@ -350,7 +819,7 @@ function pickEnv(env, key) {
350
819
  function resolveStudioRagEmbedderConfig() {
351
820
  const rt = getAskDbRuntimeConfig();
352
821
  const base = rt.ai.aiEnv;
353
- const explicitKind = pickEnv(base, "ASKDB_RAG_EMBEDDER") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER");
822
+ const explicitKind = pickEnv(base, "ASKDB_RAG_EMBEDDER");
354
823
  const kind = explicitKind?.toLowerCase();
355
824
  if (kind === "mock") {
356
825
  return {
@@ -365,8 +834,8 @@ function resolveStudioRagEmbedderConfig() {
365
834
  throw new StudioHttpError(400, `Unsupported Studio RAG embedder: ${kind}`);
366
835
  }
367
836
  const env = buildStudioRagEmbeddingEnv(kind, base);
368
- const aiConfig = resolveAskDbEmbeddingConfig(env, {
369
- modelEnvVar: "ASKDB_STUDIO_RAG_EMBEDDER_MODEL",
837
+ const aiConfig = ai.resolveEmbeddingConfig(env, {
838
+ modelEnvVar: "ASKDB_RAG_EMBEDDER_MODEL",
370
839
  modelDefault: DEFAULT_EMBEDDING_MODEL,
371
840
  });
372
841
  if (!aiConfig && kind === undefined) {
@@ -380,8 +849,7 @@ function resolveStudioRagEmbedderConfig() {
380
849
  }
381
850
  const provider = aiConfig?.provider ?? fallbackStudioRagProvider(kind, base);
382
851
  const model = aiConfig?.model ?? DEFAULT_EMBEDDING_MODEL;
383
- const dimensionOverride = readPositiveIntegerEnv(pickEnv(base, "ASKDB_RAG_EMBEDDER_DIMENSIONS") ??
384
- pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_DIMENSIONS"));
852
+ const dimensionOverride = readPositiveIntegerEnv(pickEnv(base, "ASKDB_RAG_EMBEDDER_DIMENSIONS"));
385
853
  const dimensions = dimensionOverride ?? defaultEmbeddingDimensions(model);
386
854
  return {
387
855
  kind: "ai-sdk",
@@ -397,14 +865,12 @@ function resolveStudioRagEmbedderConfig() {
397
865
  };
398
866
  }
399
867
  function buildStudioRagEmbeddingEnv(kind, base) {
400
- const apiKeyOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_API_KEY") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_API_KEY");
401
- const modelOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_MODEL") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_MODEL");
402
- const baseUrlOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_BASE_URL") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_BASE_URL");
868
+ const apiKeyOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_API_KEY");
869
+ const baseUrlOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_BASE_URL");
403
870
  return {
404
871
  ...base,
405
872
  ...(kind === "openai" ? { ASKDB_AI_PROVIDER: "openai" } : {}),
406
873
  ...(apiKeyOverride ? { ASKDB_AI_API_KEY: apiKeyOverride } : {}),
407
- ...(modelOverride ? { ASKDB_STUDIO_RAG_EMBEDDER_MODEL: modelOverride } : {}),
408
874
  ...(baseUrlOverride ? { ASKDB_AI_BASE_URL: baseUrlOverride } : {}),
409
875
  };
410
876
  }
@@ -426,7 +892,7 @@ async function createStudioRagEmbedder(config, usage) {
426
892
  if (!config.aiConfig) {
427
893
  throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
428
894
  }
429
- const model = await createAskDbEmbeddingModel(config.aiConfig, {
895
+ const model = await ai.createEmbeddingModel(config.aiConfig, {
430
896
  dimensions: config.requestDimensions,
431
897
  });
432
898
  return createAiSdkEmbedder({
@@ -441,11 +907,16 @@ async function createStudioRagEmbedder(config, usage) {
441
907
  },
442
908
  });
443
909
  }
444
- function formatStudioRagEmbeddingError(error, config) {
910
+ function formatStudioRagOperationError(error, config) {
911
+ if (error instanceof StudioHttpError)
912
+ return error;
445
913
  if (config.kind === "mock") {
446
914
  return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
447
915
  }
448
916
  const apiError = findApiCallError(error);
917
+ if (!apiError) {
918
+ return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
919
+ }
449
920
  const parts = [
450
921
  `Studio RAG embedding request failed for provider ${config.provider}, model ${config.model}.`,
451
922
  ];
@@ -488,15 +959,18 @@ function truncateForMessage(value) {
488
959
  return undefined;
489
960
  return trimmed.length > 500 ? `${trimmed.slice(0, 500)}...` : trimmed;
490
961
  }
491
- function clearIncompatibleRagStore(schemaDir, config) {
492
- const embeddingsJsonPath = join(schemaDir, "schema.embeddings.json");
962
+ function clearIncompatibleRagStore(state, config) {
963
+ const store = resolveStudioRagStoreConfig(state);
964
+ if (store.kind !== "file")
965
+ return;
966
+ const embeddingsJsonPath = `${store.basePath}.embeddings.json`;
493
967
  const embeddings = readOptionalJson(embeddingsJsonPath);
494
968
  if (embeddings?.dimensions === undefined || embeddings.dimensions === config.dimensions)
495
969
  return;
496
970
  for (const path of [
497
- join(schemaDir, "schema.embeddings.json"),
498
- join(schemaDir, "schema.embeddings.bin"),
499
- join(schemaDir, "schema.lock.json"),
971
+ `${store.basePath}.embeddings.json`,
972
+ `${store.basePath}.embeddings.bin`,
973
+ join(state.schemaDir, "schema.lock.json"),
500
974
  ]) {
501
975
  rmSync(path, { force: true });
502
976
  }
@@ -546,6 +1020,234 @@ function stableTokenHash(token) {
546
1020
  }
547
1021
  return hash >>> 0;
548
1022
  }
1023
+ // ---------------------------------------------------------------------------
1024
+ // Playground history helpers
1025
+ // ---------------------------------------------------------------------------
1026
+ const HISTORY_MAX_STORED = 200;
1027
+ const HISTORY_MAX_RETURNED = 50;
1028
+ function playgroundHistoryPath(schemaDir) {
1029
+ return join(schemaDir, "playground-history.json");
1030
+ }
1031
+ function readPlaygroundHistory(schemaDir) {
1032
+ const raw = readOptionalJson(playgroundHistoryPath(schemaDir));
1033
+ const entries = Array.isArray(raw) ? raw : [];
1034
+ return { entries: entries.slice(0, HISTORY_MAX_RETURNED) };
1035
+ }
1036
+ function appendPlaygroundHistory(schemaDir, entry) {
1037
+ const histPath = playgroundHistoryPath(schemaDir);
1038
+ const raw = readOptionalJson(histPath);
1039
+ const existing = Array.isArray(raw)
1040
+ ? raw
1041
+ : [];
1042
+ const newEntry = {
1043
+ ...entry,
1044
+ id: randomUUID(),
1045
+ timestamp: new Date().toISOString(),
1046
+ };
1047
+ const updated = [newEntry, ...existing].slice(0, HISTORY_MAX_STORED);
1048
+ writeFileSync(histPath, JSON.stringify(updated, null, 2), "utf8");
1049
+ }
1050
+ function deletePlaygroundHistoryEntry(schemaDir, id) {
1051
+ const histPath = playgroundHistoryPath(schemaDir);
1052
+ const raw = readOptionalJson(histPath);
1053
+ const existing = Array.isArray(raw)
1054
+ ? raw
1055
+ : [];
1056
+ const filtered = existing.filter((e) => e.id !== id);
1057
+ if (filtered.length === existing.length) {
1058
+ return { ok: false, error: "not found" };
1059
+ }
1060
+ writeFileSync(histPath, JSON.stringify(filtered, null, 2), "utf8");
1061
+ return { ok: true };
1062
+ }
1063
+ function parsePlaygroundHistoryEntry(body) {
1064
+ if (!isRecord(body)) {
1065
+ throw new StudioHttpError(400, "Request body must be a JSON object.");
1066
+ }
1067
+ if (typeof body.question !== "string" || body.question.trim() === "") {
1068
+ throw new StudioHttpError(400, "`question` is required.");
1069
+ }
1070
+ if (body.mode !== "full" && body.mode !== "rag") {
1071
+ throw new StudioHttpError(400, "`mode` must be `full` or `rag`.");
1072
+ }
1073
+ if (typeof body.sqlMode !== "string") {
1074
+ throw new StudioHttpError(400, "`sqlMode` is required.");
1075
+ }
1076
+ if (typeof body.sql !== "string") {
1077
+ throw new StudioHttpError(400, "`sql` is required.");
1078
+ }
1079
+ return body;
1080
+ }
1081
+ // ---------------------------------------------------------------------------
1082
+ // Execute endpoint helpers
1083
+ // ---------------------------------------------------------------------------
1084
+ async function getExecuteStatus(schemaDir) {
1085
+ const rt = getAskDbRuntimeConfig();
1086
+ const { provider, databaseUrl, file } = rt.studio.execute;
1087
+ const def = EXECUTE_DRIVER_REGISTRY[provider];
1088
+ const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
1089
+ const installed = isDriverInstalled(def.packageName, projectRoot);
1090
+ const connectionKind = provider === "sqlite" ? "file" : "url";
1091
+ const configured = connectionKind === "file" ? Boolean(file) : Boolean(databaseUrl);
1092
+ return {
1093
+ provider,
1094
+ label: def.label,
1095
+ configured,
1096
+ connectionKind,
1097
+ packageName: def.packageName,
1098
+ installed,
1099
+ installCommand: def.installCommand,
1100
+ canInstallFromStudio: true,
1101
+ manualInstallReason: null,
1102
+ };
1103
+ }
1104
+ async function installExecuteDriver(body, schemaDir) {
1105
+ const rt = getAskDbRuntimeConfig();
1106
+ const configuredProvider = rt.studio.execute.provider;
1107
+ // Accept the configured provider or an explicit allowlisted provider.
1108
+ let provider = configuredProvider;
1109
+ if (isRecord(body) && typeof body.provider === "string") {
1110
+ const requested = body.provider;
1111
+ if (!(requested in EXECUTE_DRIVER_REGISTRY)) {
1112
+ return {
1113
+ ok: false,
1114
+ provider: configuredProvider,
1115
+ packageName: EXECUTE_DRIVER_REGISTRY[configuredProvider].packageName,
1116
+ command: [],
1117
+ error: `Unknown provider: ${body.provider}`,
1118
+ installed: false,
1119
+ };
1120
+ }
1121
+ provider = requested;
1122
+ }
1123
+ const def = EXECUTE_DRIVER_REGISTRY[provider];
1124
+ const { packageManager, command, args, manualReason } = detectPackageManager(schemaDir, def.packageName);
1125
+ if (manualReason) {
1126
+ return {
1127
+ ok: false,
1128
+ provider,
1129
+ packageName: def.packageName,
1130
+ command: [],
1131
+ error: manualReason,
1132
+ installed: false,
1133
+ };
1134
+ }
1135
+ // Run the install.
1136
+ const { stdout, stderr, code } = await spawnCommand(command, args, schemaDir);
1137
+ const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
1138
+ const installed = isDriverInstalled(def.packageName, projectRoot);
1139
+ return {
1140
+ ok: code === 0,
1141
+ provider,
1142
+ packageName: def.packageName,
1143
+ command: [command, ...args],
1144
+ stdout,
1145
+ stderr,
1146
+ error: code !== 0 ? `${packageManager} exited with code ${code}` : undefined,
1147
+ installed,
1148
+ };
1149
+ }
1150
+ function detectPackageManager(cwd, packageName) {
1151
+ // Walk up from schemaDir to find the project root's lockfile.
1152
+ const projectRoot = findProjectRoot(cwd);
1153
+ if (!projectRoot) {
1154
+ return {
1155
+ packageManager: "",
1156
+ command: "",
1157
+ args: [],
1158
+ manualReason: `Could not locate a package.json above ${cwd}. Run the install manually.`,
1159
+ };
1160
+ }
1161
+ if (existsSync(join(projectRoot, "pnpm-lock.yaml"))) {
1162
+ return {
1163
+ packageManager: "pnpm",
1164
+ command: "pnpm",
1165
+ args: ["add", packageName],
1166
+ manualReason: null,
1167
+ };
1168
+ }
1169
+ if (existsSync(join(projectRoot, "package-lock.json"))) {
1170
+ return {
1171
+ packageManager: "npm",
1172
+ command: "npm",
1173
+ args: ["install", packageName],
1174
+ manualReason: null,
1175
+ };
1176
+ }
1177
+ if (existsSync(join(projectRoot, "yarn.lock"))) {
1178
+ return {
1179
+ packageManager: "yarn",
1180
+ command: "yarn",
1181
+ args: ["add", packageName],
1182
+ manualReason: null,
1183
+ };
1184
+ }
1185
+ if (existsSync(join(projectRoot, "bun.lockb")) || existsSync(join(projectRoot, "bun.lock"))) {
1186
+ return {
1187
+ packageManager: "bun",
1188
+ command: "bun",
1189
+ args: ["add", packageName],
1190
+ manualReason: null,
1191
+ };
1192
+ }
1193
+ return {
1194
+ packageManager: "",
1195
+ command: "",
1196
+ args: [],
1197
+ manualReason: `No recognized lockfile found in ${projectRoot}. Run: pnpm add ${packageName}`,
1198
+ };
1199
+ }
1200
+ function findProjectRoot(startDir) {
1201
+ let current = resolve(startDir);
1202
+ for (let i = 0; i < 20; i++) {
1203
+ if (existsSync(join(current, "package.json")))
1204
+ return current;
1205
+ const parent = dirname(current);
1206
+ if (parent === current)
1207
+ return null;
1208
+ current = parent;
1209
+ }
1210
+ return null;
1211
+ }
1212
+ function spawnCommand(command, args, cwd) {
1213
+ return new Promise((resolve) => {
1214
+ const proc = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
1215
+ const stdoutChunks = [];
1216
+ const stderrChunks = [];
1217
+ proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
1218
+ proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
1219
+ proc.on("close", (code) => {
1220
+ resolve({
1221
+ stdout: Buffer.concat(stdoutChunks).toString("utf8").slice(0, 4096),
1222
+ stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 4096),
1223
+ code: code ?? 1,
1224
+ });
1225
+ });
1226
+ proc.on("error", (err) => {
1227
+ resolve({ stdout: "", stderr: err.message, code: 1 });
1228
+ });
1229
+ });
1230
+ }
1231
+ async function executeQuery(body, schemaDir) {
1232
+ const rt = getAskDbRuntimeConfig();
1233
+ const { provider, databaseUrl, file } = rt.studio.execute;
1234
+ if (!isRecord(body) || typeof body.sql !== "string" || body.sql.trim() === "") {
1235
+ return { ok: false, error: "`sql` is required." };
1236
+ }
1237
+ const sql = body.sql;
1238
+ const params = Array.isArray(body.params) ? body.params : [];
1239
+ const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
1240
+ const def = EXECUTE_DRIVER_REGISTRY[provider];
1241
+ return def.execute({ connectionString: databaseUrl, file, sql, params, projectRoot });
1242
+ }
1243
+ function isLoopbackRequest(req) {
1244
+ const socket = req.socket;
1245
+ const remoteAddress = socket?.remoteAddress ?? "";
1246
+ return (remoteAddress === "127.0.0.1" ||
1247
+ remoteAddress === "::1" ||
1248
+ remoteAddress === "::ffff:127.0.0.1" ||
1249
+ remoteAddress === "localhost");
1250
+ }
549
1251
  function readOptionalJson(path) {
550
1252
  if (!existsSync(path))
551
1253
  return undefined;
@@ -556,6 +1258,17 @@ function readOptionalJson(path) {
556
1258
  return undefined;
557
1259
  }
558
1260
  }
1261
+ function parseConceptsBody(body) {
1262
+ if (!isRecord(body) || !Array.isArray(body.concepts)) {
1263
+ throw new StudioHttpError(400, "`concepts` array is required.");
1264
+ }
1265
+ for (const concept of body.concepts) {
1266
+ if (!isRecord(concept) || typeof concept.id !== "string" || typeof concept.label !== "string") {
1267
+ throw new StudioHttpError(400, "Each concept requires a string `id` and `label`.");
1268
+ }
1269
+ }
1270
+ return body.concepts;
1271
+ }
559
1272
  function parseTableDraftBody(body) {
560
1273
  if (!isRecord(body) || !isRecord(body.draft)) {
561
1274
  throw new StudioHttpError(400, "`draft` is required.");
@@ -604,9 +1317,26 @@ function parseAskBody(body) {
604
1317
  if (mode !== "full" && mode !== "rag") {
605
1318
  throw new StudioHttpError(400, "`mode` must be `full` or `rag`.");
606
1319
  }
1320
+ let tenantScope;
1321
+ if (body.tenantScope !== undefined && body.tenantScope !== null) {
1322
+ const parsed = tenantScopeSchema.safeParse(body.tenantScope);
1323
+ if (!parsed.success) {
1324
+ throw new StudioHttpError(400, `Invalid tenantScope: ${parsed.error.message}`);
1325
+ }
1326
+ tenantScope = parsed.data;
1327
+ }
1328
+ let tenantSqlMode;
1329
+ if (typeof body.tenantSqlMode === "string") {
1330
+ if (body.tenantSqlMode !== "sql-only" && body.tenantSqlMode !== "sql-params") {
1331
+ throw new StudioHttpError(400, "`tenantSqlMode` must be `sql-only` or `sql-params`.");
1332
+ }
1333
+ tenantSqlMode = body.tenantSqlMode;
1334
+ }
607
1335
  return {
608
1336
  question: body.question.trim(),
609
1337
  useRag: mode === "rag",
1338
+ tenantScope,
1339
+ tenantSqlMode,
610
1340
  };
611
1341
  }
612
1342
  function parseRagQuery(body) {
@@ -676,8 +1406,10 @@ function createRequestUsageCollector() {
676
1406
  function normalizeGenerationUsage(value) {
677
1407
  if (!isRecord(value))
678
1408
  return undefined;
679
- const promptTokens = readFiniteNumber(value.promptTokens);
680
- const completionTokens = readFiniteNumber(value.completionTokens);
1409
+ // ai@6 LanguageModelUsage uses inputTokens/outputTokens rather than the
1410
+ // legacy promptTokens/completionTokens names.
1411
+ const promptTokens = readFiniteNumber(value.promptTokens) ?? readFiniteNumber(value.inputTokens);
1412
+ const completionTokens = readFiniteNumber(value.completionTokens) ?? readFiniteNumber(value.outputTokens);
681
1413
  const totalTokens = readFiniteNumber(value.totalTokens);
682
1414
  if (promptTokens === undefined && completionTokens === undefined && totalTokens === undefined) {
683
1415
  return undefined;