@askdb/studio 0.2.0-beta.35 → 0.2.0-beta.37

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,27 +1,35 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { extname, relative, join, resolve, dirname, sep } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { generateText as defaultGenerateText } from "ai";
8
- import { bootstrapAskDbEnv, getAskDbRuntimeConfig } from "@askdb/config";
8
+ import { ASKDB_AI_PROVIDERS, bootstrapAskDbEnv, getAskDbRuntimeConfig, } from "@askdb/config";
9
9
  import { createAiRegistry, resolveReasoningEffort, } 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";
10
+ import { ask, formatSensitiveReference, isBuiltInDialectId, loadSchema, schemaHasSensitiveIdentifiers, SqlValidationError, validateSensitiveReferences, suggestEnrichment, tenantScopeSchema, normalizeTenantPolicy, parseTenantPolicyMarkdown, SchemaParseError, writeTenantPolicyMarkdown, tenantPolicyFrontmatterSchema, } from "@askdb/core";
11
+ import { buildSchemaIndex, chunkContentHash, chunkSchema, aiSdkEmbedderId, createAiSdkEmbedder, createFileStore, createMemoryStore, createPgvectorStore, createRetriever, detectEmbeddingDimensions, loadChunkerSourcesFromDir, PgvectorDimensionMismatchError, } from "@askdb/rag";
16
12
  import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveConcepts, saveTable, } from "@askdb/enrich";
17
- import { EXECUTE_DRIVER_REGISTRY, isDriverInstalled } from "./execute-registry.js";
13
+ import { EXECUTE_DRIVER_REGISTRY, executeDialectFor, isDriverInstalled, isStudioExecuteProvider, validateExecuteSql, } from "./execute-registry.js";
14
+ import { lockfilePackageManager, packageManagerAddArgs, packageManagerSpawnSpec, } from "./package-manager.js";
18
15
  import { resolveStudioIntrospectionPlan, runStudioIntrospection } from "./introspection.js";
19
16
  import { probeSetupState, writeSetupConfig, SetupError } from "./setup.js";
20
- const ai = createAiRegistry([openaiProvider, azureProvider, googleProvider, anthropicProvider]);
21
- const CLIENT_DIR = fileURLToPath(new URL("./client/", import.meta.url));
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
+ }
22
31
  const STUDIO_RAG_MOCK_DIMENSIONS = 64;
23
32
  const STUDIO_RAG_MOCK_EMBEDDER_ID = `studio:mock-lexical-${STUDIO_RAG_MOCK_DIMENSIONS}`;
24
- const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
25
33
  let studioPgvectorStoreFactoryForTests;
26
34
  export function setStudioPgvectorStoreFactoryForTests(factory) {
27
35
  studioPgvectorStoreFactoryForTests = factory;
@@ -37,11 +45,24 @@ export function createStudioServer(options) {
37
45
  workspace: setupReason ? null : loadWorkspace(schemaDir),
38
46
  setupReason,
39
47
  };
40
- return createServer(async (req, res) => {
48
+ const sessionToken = createStudioSessionToken();
49
+ const server = createServer(async (req, res) => {
41
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
+ }
42
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
+ }
43
64
  if (req.method === "GET" && url.pathname === "/") {
44
- return serveClientFile(res, "index.html");
65
+ return serveIndexHtml(res, sessionToken);
45
66
  }
46
67
  if (req.method === "GET" && url.pathname.startsWith("/assets/")) {
47
68
  return serveClientFile(res, decodeURIComponent(url.pathname.slice(1)));
@@ -165,7 +186,7 @@ export function createStudioServer(options) {
165
186
  return writeJson(res, 200, result);
166
187
  }
167
188
  if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
168
- return serveClientFile(res, "index.html");
189
+ return serveIndexHtml(res, sessionToken);
169
190
  }
170
191
  writeJson(res, 404, { error: { message: "Not found" } });
171
192
  }
@@ -176,6 +197,7 @@ export function createStudioServer(options) {
176
197
  });
177
198
  }
178
199
  });
200
+ return Object.assign(server, { sessionToken });
179
201
  }
180
202
  export function serializeWorkspace(workspace) {
181
203
  const rt = getAskDbRuntimeConfig();
@@ -200,8 +222,8 @@ export function serializeWorkspace(workspace) {
200
222
  dialect,
201
223
  warnings: workspace.warnings,
202
224
  aiConfigured: Boolean(aiConfig),
203
- model: aiConfig?.model ?? "gpt-4o-mini",
204
- aiProvider: aiConfig?.provider ?? "openai",
225
+ model: aiConfig?.model ?? rt.ai.language.model ?? "provider default",
226
+ aiProvider: aiConfig?.provider ?? rt.ai.language.provider,
205
227
  tables: workspace.tables.map((table) => {
206
228
  const draft = buildTableDraft(table.physical, table.parsed);
207
229
  return {
@@ -212,6 +234,7 @@ export function serializeWorkspace(workspace) {
212
234
  missingColumnIds: workspace.warnings
213
235
  .filter((warning) => warning.kind === "missing_column_md" && warning.tableId === table.physical.id)
214
236
  .map((warning) => warning.columnId),
237
+ escalatedByOtherFiles: table.escalatedByOtherFiles ?? [],
215
238
  };
216
239
  }),
217
240
  concepts: workspace.concepts?.frontmatter.concepts ?? [],
@@ -348,7 +371,8 @@ function parseSetupConfigBody(body) {
348
371
  throw new StudioHttpError(400, "Request body must be a JSON object.");
349
372
  }
350
373
  const databases = ["postgres", "mysql", "sqlite", "sqlserver", "prisma"];
351
- const aiProviders = ["openai", "anthropic", "google", "azure", "foundry"];
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;
352
376
  const ragStores = ["file", "memory", "pgvector"];
353
377
  const executeProviders = ["postgres", "mysql", "sqlite", "sqlserver"];
354
378
  if (typeof body.database !== "string" || !databases.includes(body.database)) {
@@ -398,8 +422,21 @@ function saveConceptsDraft(state, concepts) {
398
422
  state.workspace = loadWorkspace(state.schemaDir);
399
423
  }
400
424
  function saveTenantPolicy(state, frontmatter, body) {
425
+ const workspace = requireWorkspace(state);
401
426
  const filePath = join(state.schemaDir, "tenant-policy.md");
402
427
  const md = writeTenantPolicyMarkdown(frontmatter, body);
428
+ // Run core's own load-time validation before writing: a policy that `loadSchema` would
429
+ // reject (e.g. two root labels deriving one placeholder) must not reach disk, or every
430
+ // later request fails until the file is fixed by hand.
431
+ try {
432
+ const physical = workspace.physical;
433
+ normalizeTenantPolicy(parseTenantPolicyMarkdown(md, filePath), new Set(physical.tables.map((table) => table.id)), new Set(physical.tables.flatMap((table) => table.columns.map((column) => column.id))));
434
+ }
435
+ catch (error) {
436
+ if (error instanceof SchemaParseError)
437
+ throw new StudioHttpError(400, error.message);
438
+ throw error;
439
+ }
403
440
  writeFileSync(filePath, md, "utf8");
404
441
  state.workspace = loadWorkspace(state.schemaDir);
405
442
  }
@@ -419,7 +456,9 @@ function saveDraft(state, tableId, draft) {
419
456
  const table = workspace.tables.find((candidate) => candidate.physical.id === tableId);
420
457
  if (!table)
421
458
  throw new StudioHttpError(404, `No such table: ${tableId}`);
422
- const frontmatter = buildFrontmatter(table.physical, workspace.physical.schemaId, draft);
459
+ // Passing the file's current front-matter keeps entries for other tables' columns
460
+ // (e.g. a misplaced `sensitive: true`), which the draft does not carry.
461
+ const frontmatter = buildFrontmatter(table.physical, workspace.physical.schemaId, draft, table.parsed?.frontmatter);
423
462
  let body = table.parsed
424
463
  ? replaceTableDescription(table.parsed.body, draft.description)
425
464
  : buildDefaultTableBody(table.physical.name, draft.description);
@@ -619,14 +658,17 @@ async function getRagStatus(state) {
619
658
  const sources = loadChunkerSourcesFromDir(state.schemaDir);
620
659
  const chunkResult = chunkSchema(sources);
621
660
  const lockPath = join(state.schemaDir, "schema.lock.json");
622
- const lock = readOptionalJson(lockPath);
661
+ const lock = resolveStudioRagStoreConfig(state).kind === "memory"
662
+ ? state.ragMemoryIndex
663
+ : readOptionalJson(lockPath);
623
664
  const currentHashes = Object.fromEntries(chunkResult.chunks.map((chunk) => [chunk.id, chunkContentHash(chunk.text)]));
624
665
  const lockHashes = lock?.hashes ?? {};
625
666
  const hashIds = Object.keys(currentHashes);
626
- const store = await openStudioRagStore(state, config.dimensions);
667
+ const store = await openStudioRagStore(state, { dimensions: config.dimensions });
627
668
  try {
628
669
  const chunksIndexed = await countStudioRagStoreChunks(store, sources.schema.schemaId);
629
670
  const stale = !lock ||
671
+ lock.incomplete === true ||
630
672
  lock.embedderId !== config.embedderId ||
631
673
  Object.keys(lockHashes).length !== hashIds.length ||
632
674
  hashIds.some((id) => lockHashes[id] !== currentHashes[id]) ||
@@ -667,8 +709,9 @@ async function getRagStatus(state) {
667
709
  updatedAt: lock?.updatedAt ?? null,
668
710
  chunksTotal: chunkResult.chunks.length,
669
711
  chunksIndexed,
670
- dimensions: config.dimensions,
671
- expectedDimensions: config.dimensions,
712
+ // The width the last index build learned from the model, else the one configured.
713
+ dimensions: lock?.dimensions ?? config.dimensions ?? null,
714
+ expectedDimensions: config.dimensions ?? null,
672
715
  sensitiveExcluded: chunkResult.stats.sensitiveExcluded,
673
716
  sensitiveIncluded: chunkResult.stats.sensitiveIncluded,
674
717
  files: fileArtifacts,
@@ -681,27 +724,40 @@ async function getRagStatus(state) {
681
724
  async function indexRag(state) {
682
725
  const config = resolveStudioRagEmbedderConfig();
683
726
  if (!config.configured) {
684
- throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
727
+ throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage(config));
685
728
  }
686
729
  const usage = createRequestUsageCollector();
687
730
  clearIncompatibleRagStore(state, config);
688
731
  const sources = loadChunkerSourcesFromDir(state.schemaDir);
689
- const store = await openStudioRagStore(state, config.dimensions);
732
+ const embedder = await createStudioRagEmbedder(config, usage);
690
733
  let result;
734
+ let store;
691
735
  try {
736
+ // A new pgvector table needs its width up front: the configured one, else the model's own.
737
+ const dimensions = config.dimensions ??
738
+ (resolveStudioRagStoreConfig(state).kind === "pgvector" ? await detectEmbeddingDimensions(embedder) : undefined);
739
+ store = await openStudioRagStore(state, { dimensions, provision: true });
692
740
  result = await buildSchemaIndex({
693
741
  schema: sources,
694
- embedder: await createStudioRagEmbedder(config, usage),
742
+ embedder,
695
743
  store: store.store,
696
744
  embedderId: config.embedderId,
697
745
  lockFilePath: join(state.schemaDir, "schema.lock.json"),
698
746
  });
747
+ if (store.kind === "memory") {
748
+ state.ragMemoryIndex = {
749
+ embedderId: config.embedderId,
750
+ updatedAt: new Date().toISOString(),
751
+ dimensions: store.store.describe?.().dimensions,
752
+ hashes: Object.fromEntries(result.chunks.map((chunk) => [chunk.id, chunkContentHash(chunk.text)])),
753
+ };
754
+ }
699
755
  }
700
756
  catch (error) {
701
757
  throw formatStudioRagOperationError(error, config);
702
758
  }
703
759
  finally {
704
- await store.dispose();
760
+ await store?.dispose();
705
761
  }
706
762
  return {
707
763
  status: await getRagStatus(state),
@@ -739,7 +795,7 @@ async function queryRag(state, query) {
739
795
  async function createCurrentStudioRagIndex(state, action, usage) {
740
796
  const config = resolveStudioRagEmbedderConfig();
741
797
  if (!config.configured) {
742
- throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
798
+ throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage(config));
743
799
  }
744
800
  const status = (await getRagStatus(state));
745
801
  if (!status.hasIndex) {
@@ -751,7 +807,7 @@ async function createCurrentStudioRagIndex(state, action, usage) {
751
807
  if (!status.schemaId || typeof status.chunksTotal !== "number") {
752
808
  throw new StudioHttpError(500, "Studio RAG status is missing schema metadata.");
753
809
  }
754
- const store = await openStudioRagStore(state, config.dimensions);
810
+ const store = await openStudioRagStore(state, { dimensions: config.dimensions });
755
811
  return {
756
812
  config,
757
813
  status: {
@@ -769,22 +825,26 @@ async function createCurrentStudioRagIndex(state, action, usage) {
769
825
  }
770
826
  function resolveStudioRagStoreConfig(state) {
771
827
  const rt = getAskDbRuntimeConfig();
772
- const kind = rt.structured.rag.store;
828
+ const kind = rt.rag.store;
773
829
  if (kind === "memory")
774
830
  return { kind };
775
831
  if (kind === "file") {
776
- const basePath = rt.structured.rag.storeConfig.file?.basePath?.trim();
832
+ const basePath = rt.rag.storeConfig.file?.basePath?.trim();
777
833
  return { kind, basePath: basePath ? resolve(basePath) : join(state.schemaDir, "schema") };
778
834
  }
779
835
  const connectionString = pickFlat(rt.flat, "ASKDB_PGVECTOR_URL");
780
836
  return {
781
837
  kind,
782
838
  connectionString,
783
- table: rt.structured.rag.storeConfig.pgvector?.table?.trim() || undefined,
839
+ table: rt.rag.storeConfig.pgvector?.table?.trim() || undefined,
784
840
  indexStrategy: pickFlat(rt.flat, "ASKDB_PGVECTOR_INDEX_STRATEGY"),
785
841
  };
786
842
  }
787
- async function openStudioRagStore(state, dimensions) {
843
+ /**
844
+ * Opens the configured store. `provision` creates the pgvector table when it's missing, at
845
+ * `dimensions`; without it, a pgvector store only reads and writes an existing table.
846
+ */
847
+ async function openStudioRagStore(state, { dimensions, provision = false }) {
788
848
  const config = resolveStudioRagStoreConfig(state);
789
849
  if (config.kind === "memory") {
790
850
  state.ragMemoryStore ??= createMemoryStore();
@@ -812,10 +872,19 @@ async function openStudioRagStore(state, dimensions) {
812
872
  const store = pgvectorFactory({
813
873
  connectionString: config.connectionString,
814
874
  table: config.table,
815
- dimensions,
875
+ ...(dimensions !== undefined ? { dimensions } : {}),
816
876
  ...(config.indexStrategy ? { indexStrategy: config.indexStrategy } : {}),
817
877
  });
818
- await store.ensureSchema();
878
+ if (provision) {
879
+ try {
880
+ await store.ensureSchema();
881
+ }
882
+ catch (error) {
883
+ // The caller never receives this store, so close its pool here (a width refusal has opened it).
884
+ await store.close().catch(() => { });
885
+ throw error;
886
+ }
887
+ }
819
888
  return {
820
889
  kind: "pgvector",
821
890
  store,
@@ -827,6 +896,10 @@ async function openStudioRagStore(state, dimensions) {
827
896
  };
828
897
  }
829
898
  async function countStudioRagStoreChunks(store, schemaId) {
899
+ // A pgvector table that doesn't exist yet holds nothing; the first index build creates it.
900
+ if (typeof store.store.tableDimensions === "function" && (await store.store.tableDimensions()) === undefined) {
901
+ return 0;
902
+ }
830
903
  if (typeof store.store.count === "function") {
831
904
  return store.store.count({ schemaId });
832
905
  }
@@ -839,33 +912,9 @@ function pickFlat(flat, key) {
839
912
  const value = flat[key];
840
913
  return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
841
914
  }
842
- function pickEnv(env, key) {
843
- const v = env[key];
844
- return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
845
- }
846
915
  function resolveStudioRagEmbedderConfig() {
847
- const rt = getAskDbRuntimeConfig();
848
- const base = rt.ai.aiEnv;
849
- const explicitKind = pickEnv(base, "ASKDB_RAG_EMBEDDER");
850
- const kind = explicitKind?.toLowerCase();
851
- if (kind === "mock") {
852
- return {
853
- kind: "mock",
854
- embedderId: STUDIO_RAG_MOCK_EMBEDDER_ID,
855
- dimensions: STUDIO_RAG_MOCK_DIMENSIONS,
856
- configured: true,
857
- label: "Mock lexical",
858
- };
859
- }
860
- if (kind !== undefined && kind !== "ai-sdk" && kind !== "openai") {
861
- throw new StudioHttpError(400, `Unsupported Studio RAG embedder: ${kind}`);
862
- }
863
- const env = buildStudioRagEmbeddingEnv(kind, base);
864
- const aiConfig = ai.resolveEmbeddingConfig(env, {
865
- modelEnvVar: "ASKDB_RAG_EMBEDDER_MODEL",
866
- modelDefault: DEFAULT_EMBEDDING_MODEL,
867
- });
868
- if (!aiConfig && kind === undefined) {
916
+ const embedding = getAskDbRuntimeConfig().ai.embedding;
917
+ if (!embedding) {
869
918
  return {
870
919
  kind: "mock",
871
920
  embedderId: STUDIO_RAG_MOCK_EMBEDDER_ID,
@@ -874,50 +923,38 @@ function resolveStudioRagEmbedderConfig() {
874
923
  label: "Mock lexical",
875
924
  };
876
925
  }
877
- const provider = aiConfig?.provider ?? fallbackStudioRagProvider(kind, base);
878
- const model = aiConfig?.model ?? DEFAULT_EMBEDDING_MODEL;
879
- const dimensionOverride = readPositiveIntegerEnv(pickEnv(base, "ASKDB_RAG_EMBEDDER_DIMENSIONS"));
880
- const dimensions = dimensionOverride ?? defaultEmbeddingDimensions(model);
926
+ // The env holds the embedding connection only, so no other provider's key can reach it (#345).
927
+ const aiConfig = ai.resolveEmbeddingConfig(embedding.env);
928
+ // The adapter's canonical name (`foundry` resolves to `azure`) keeps existing index ids stable.
929
+ const provider = aiConfig?.provider ?? embedding.provider;
930
+ const model = embedding.model;
931
+ const dimensions = embedding.dimensions;
881
932
  return {
882
933
  kind: "ai-sdk",
883
934
  provider,
884
- embedderId: `ai-sdk:${provider}:${model}:${dimensions}`,
935
+ configuredProvider: embedding.provider,
936
+ connection: embedding.connection,
937
+ embedderId: aiSdkEmbedderId({ provider, model, dimensions }),
885
938
  dimensions,
886
939
  configured: Boolean(aiConfig),
887
940
  label: `AI SDK (${provider})`,
888
941
  model,
889
942
  baseUrl: aiConfig?.baseURL,
890
943
  aiConfig,
891
- requestDimensions: dimensionOverride,
892
- };
893
- }
894
- function buildStudioRagEmbeddingEnv(kind, base) {
895
- const apiKeyOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_API_KEY");
896
- const baseUrlOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_BASE_URL");
897
- return {
898
- ...base,
899
- ...(kind === "openai" ? { ASKDB_AI_PROVIDER: "openai" } : {}),
900
- ...(apiKeyOverride ? { ASKDB_AI_API_KEY: apiKeyOverride } : {}),
901
- ...(baseUrlOverride ? { ASKDB_AI_BASE_URL: baseUrlOverride } : {}),
944
+ // Only an explicit ai.embedding.dimensions is sent; otherwise the model's own width applies.
945
+ requestDimensions: embedding.dimensions,
902
946
  };
903
947
  }
904
- function fallbackStudioRagProvider(kind, base) {
905
- if (kind === "openai")
906
- return "openai";
907
- const raw = (pickEnv(base, "ASKDB_AI_PROVIDER") ?? "").toLowerCase();
908
- return raw === "azure" || raw === "azure-openai" || raw === "foundry"
909
- ? "azure"
910
- : "openai";
911
- }
912
- function studioRagAiSdkKeyMissingMessage() {
913
- return ("Studio RAG AI SDK embeddings require a configured AI provider key. " +
914
- "Set ASKDB_AI_API_KEY or the provider-native key, or set ASKDB_RAG_EMBEDDER=mock for the local lexical embedder.");
948
+ function studioRagAiSdkKeyMissingMessage(config) {
949
+ return (`Studio RAG embeddings need an API key on the ai.embedding connection ` +
950
+ `("${config.connection}" in ai.providerConfig.${config.configuredProvider}). ` +
951
+ 'Set it in askdb.config.*, or set rag.embedder: "mock" for the local lexical embedder.');
915
952
  }
916
953
  async function createStudioRagEmbedder(config, usage) {
917
954
  if (config.kind === "mock")
918
955
  return createStudioMockEmbedder(config.dimensions);
919
956
  if (!config.aiConfig) {
920
- throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
957
+ throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage(config));
921
958
  }
922
959
  const model = await ai.createEmbeddingModel(config.aiConfig, {
923
960
  dimensions: config.requestDimensions,
@@ -937,6 +974,9 @@ async function createStudioRagEmbedder(config, usage) {
937
974
  function formatStudioRagOperationError(error, config) {
938
975
  if (error instanceof StudioHttpError)
939
976
  return error;
977
+ if (error instanceof PgvectorDimensionMismatchError) {
978
+ return new StudioHttpError(409, studioRagWidthMismatchMessage(error, config));
979
+ }
940
980
  if (config.kind === "mock") {
941
981
  return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
942
982
  }
@@ -945,7 +985,7 @@ function formatStudioRagOperationError(error, config) {
945
985
  return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
946
986
  }
947
987
  const parts = [
948
- `Studio RAG embedding request failed for provider ${config.provider}, model ${config.model}.`,
988
+ `Studio RAG embedding request failed for ai.embedding (provider ${config.configuredProvider}, connection ${config.connection}, model ${config.model}).`,
949
989
  ];
950
990
  if (config.baseUrl)
951
991
  parts.push(`Base URL: ${config.baseUrl}.`);
@@ -958,6 +998,20 @@ function formatStudioRagOperationError(error, config) {
958
998
  parts.push(`Error: ${error.message}`);
959
999
  return new StudioHttpError(502, parts.join(" "));
960
1000
  }
1001
+ /** An existing pgvector table of another width: say where the new width comes from and how to resolve it, in config terms. */
1002
+ function studioRagWidthMismatchMessage(error, config) {
1003
+ const mismatch = `pgvector table "${error.table}" holds ${error.tableDimensions}-dimension vectors, but`;
1004
+ const rebuild = `Drop the table (DROP TABLE "${error.table}";) and build the index again`;
1005
+ const newTable = "point rag.storeConfig.pgvector.table at a new table";
1006
+ if (config.kind === "mock") {
1007
+ return `${mismatch} the mock embedder writes ${error.dimensions}. ${rebuild}, or ${newTable}.`;
1008
+ }
1009
+ const source = config.dimensions !== undefined
1010
+ ? `ai.embedding.dimensions asks for ${error.dimensions}`
1011
+ : `embedding model ${config.model} returns ${error.dimensions}`;
1012
+ return (`${mismatch} ${source}. ${rebuild}, ${newTable}, ` +
1013
+ `or set ai.embedding.dimensions: ${error.tableDimensions} if model ${config.model} supports that width.`);
1014
+ }
961
1015
  function findApiCallError(error) {
962
1016
  const queue = [error];
963
1017
  for (let i = 0; i < queue.length; i++) {
@@ -992,30 +1046,20 @@ function clearIncompatibleRagStore(state, config) {
992
1046
  return;
993
1047
  const embeddingsJsonPath = `${store.basePath}.embeddings.json`;
994
1048
  const embeddings = readOptionalJson(embeddingsJsonPath);
995
- if (embeddings?.dimensions === undefined || embeddings.dimensions === config.dimensions)
1049
+ if (embeddings?.dimensions === undefined)
1050
+ return;
1051
+ // The file store refuses mixed widths. When the width isn't known up front, vectors from a
1052
+ // different embedder may not match it, so they go too.
1053
+ const lockPath = join(state.schemaDir, "schema.lock.json");
1054
+ const compatible = config.dimensions !== undefined
1055
+ ? embeddings.dimensions === config.dimensions
1056
+ : readOptionalJson(lockPath)?.embedderId === config.embedderId;
1057
+ if (compatible)
996
1058
  return;
997
- for (const path of [
998
- `${store.basePath}.embeddings.json`,
999
- `${store.basePath}.embeddings.bin`,
1000
- join(state.schemaDir, "schema.lock.json"),
1001
- ]) {
1059
+ for (const path of [`${store.basePath}.embeddings.json`, `${store.basePath}.embeddings.bin`, lockPath]) {
1002
1060
  rmSync(path, { force: true });
1003
1061
  }
1004
1062
  }
1005
- function defaultEmbeddingDimensions(model) {
1006
- if (model === "text-embedding-3-large")
1007
- return 3072;
1008
- return 1536;
1009
- }
1010
- function readPositiveIntegerEnv(value) {
1011
- if (value === undefined || value.trim() === "")
1012
- return undefined;
1013
- const parsed = Number(value);
1014
- if (!Number.isInteger(parsed) || parsed <= 0) {
1015
- throw new StudioHttpError(400, `Invalid Studio RAG embedding dimensions: ${value}`);
1016
- }
1017
- return parsed;
1018
- }
1019
1063
  function serializeRagResult(result) {
1020
1064
  return {
1021
1065
  id: result.id,
@@ -1052,8 +1096,9 @@ function stableTokenHash(token) {
1052
1096
  // ---------------------------------------------------------------------------
1053
1097
  const HISTORY_MAX_STORED = 200;
1054
1098
  const HISTORY_MAX_RETURNED = 50;
1099
+ const HISTORY_FILE_NAME = "playground-history.json";
1055
1100
  function playgroundHistoryPath(schemaDir) {
1056
- return join(schemaDir, "playground-history.json");
1101
+ return join(schemaDir, HISTORY_FILE_NAME);
1057
1102
  }
1058
1103
  function readPlaygroundHistory(schemaDir) {
1059
1104
  const raw = readOptionalJson(playgroundHistoryPath(schemaDir));
@@ -1072,8 +1117,59 @@ function appendPlaygroundHistory(schemaDir, entry) {
1072
1117
  timestamp: new Date().toISOString(),
1073
1118
  };
1074
1119
  const updated = [newEntry, ...existing].slice(0, HISTORY_MAX_STORED);
1120
+ ensureSchemaDirGitignore(schemaDir);
1075
1121
  writeFileSync(histPath, JSON.stringify(updated, null, 2), "utf8");
1076
1122
  }
1123
+ /**
1124
+ * Keep Playground history (questions + generated SQL, local-only) out of git.
1125
+ * The schema artifact directory is meant to be committed, so Studio makes sure
1126
+ * its `.gitignore` lists `playground-history.json` before writing the file.
1127
+ *
1128
+ * - No `.gitignore` yet: write one that also ignores `.env` files — the same
1129
+ * rules `askdb init` writes (plan 043) — unless the schema dir is the project
1130
+ * root, where ignoring `.env` is the project's call, not Studio's.
1131
+ * - Existing `.gitignore` without the entry: append the entry; never rewrite.
1132
+ * - Failure is non-fatal: history still saves.
1133
+ */
1134
+ function ensureSchemaDirGitignore(schemaDir) {
1135
+ const gitignorePath = join(schemaDir, ".gitignore");
1136
+ try {
1137
+ if (existsSync(gitignorePath)) {
1138
+ const content = readFileSync(gitignorePath, "utf8");
1139
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
1140
+ if (lines.includes(HISTORY_FILE_NAME) || lines.includes(`/${HISTORY_FILE_NAME}`))
1141
+ return;
1142
+ const separator = content === "" || content.endsWith("\n") ? "" : "\n";
1143
+ appendFileSync(gitignorePath, `${separator}# AskDB Studio Playground history (local only)\n${HISTORY_FILE_NAME}\n`, "utf8");
1144
+ return;
1145
+ }
1146
+ const lines = [
1147
+ "# Written by AskDB Studio.",
1148
+ "#",
1149
+ "# The schema artifact in this directory is meant to be committed: schema.json",
1150
+ "# plus any enrichment you author in Studio. Playground history (your questions",
1151
+ "# and generated SQL) is local only.",
1152
+ HISTORY_FILE_NAME,
1153
+ ];
1154
+ if (!isProjectRootDir(schemaDir)) {
1155
+ lines.push("", "# Credentials: keep API keys and connection strings out of version control", "# even when they live here.", ".env", ".env.*", "!.env.example");
1156
+ }
1157
+ writeFileSync(gitignorePath, `${lines.join("\n")}\n`, "utf8");
1158
+ }
1159
+ catch {
1160
+ // Non-fatal: the history write matters more than this defensive file.
1161
+ }
1162
+ }
1163
+ function isProjectRootDir(dir) {
1164
+ if (existsSync(join(dir, "package.json")))
1165
+ return true;
1166
+ try {
1167
+ return readdirSync(dir).some((name) => name.startsWith("askdb.config."));
1168
+ }
1169
+ catch {
1170
+ return false;
1171
+ }
1172
+ }
1077
1173
  function deletePlaygroundHistoryEntry(schemaDir, id) {
1078
1174
  const histPath = playgroundHistoryPath(schemaDir);
1079
1175
  const raw = readOptionalJson(histPath);
@@ -1087,36 +1183,130 @@ function deletePlaygroundHistoryEntry(schemaDir, id) {
1087
1183
  writeFileSync(histPath, JSON.stringify(filtered, null, 2), "utf8");
1088
1184
  return { ok: true };
1089
1185
  }
1186
+ const HISTORY_LIMITS = {
1187
+ question: 4_000,
1188
+ sql: 100_000,
1189
+ explain: 100_000,
1190
+ /** Serialized JSON size for `tenantScope` / `tenantParams`. */
1191
+ json: 20_000,
1192
+ error: 2_000,
1193
+ ragChunkIds: 200,
1194
+ ragChunkId: 512,
1195
+ };
1196
+ /**
1197
+ * Validate a history entry and copy **only** the known fields — the file is
1198
+ * written inside the schema artifact directory, so nothing unexpected (or
1199
+ * unbounded) from the request body may be persisted.
1200
+ */
1090
1201
  function parsePlaygroundHistoryEntry(body) {
1091
1202
  if (!isRecord(body)) {
1092
1203
  throw new StudioHttpError(400, "Request body must be a JSON object.");
1093
1204
  }
1094
- if (typeof body.question !== "string" || body.question.trim() === "") {
1205
+ const question = requireBoundedString(body.question, "question", HISTORY_LIMITS.question);
1206
+ if (question.trim() === "") {
1095
1207
  throw new StudioHttpError(400, "`question` is required.");
1096
1208
  }
1097
1209
  if (body.mode !== "full" && body.mode !== "rag") {
1098
1210
  throw new StudioHttpError(400, "`mode` must be `full` or `rag`.");
1099
1211
  }
1100
- if (typeof body.sqlMode !== "string") {
1101
- throw new StudioHttpError(400, "`sqlMode` is required.");
1212
+ if (body.sqlMode !== "sql-only" && body.sqlMode !== "sql-params") {
1213
+ throw new StudioHttpError(400, "`sqlMode` must be `sql-only` or `sql-params`.");
1102
1214
  }
1103
- if (typeof body.sql !== "string") {
1104
- throw new StudioHttpError(400, "`sql` is required.");
1215
+ const entry = {
1216
+ question,
1217
+ mode: body.mode,
1218
+ sqlMode: body.sqlMode,
1219
+ sql: requireBoundedString(body.sql, "sql", HISTORY_LIMITS.sql),
1220
+ };
1221
+ if (body.explain !== undefined && body.explain !== null) {
1222
+ entry.explain = requireBoundedString(body.explain, "explain", HISTORY_LIMITS.explain);
1223
+ }
1224
+ if (body.tenantScope !== undefined && body.tenantScope !== null) {
1225
+ entry.tenantScope = requireBoundedJsonObject(body.tenantScope, "tenantScope");
1226
+ }
1227
+ if (body.tenantParams !== undefined && body.tenantParams !== null) {
1228
+ entry.tenantParams = requireBoundedJsonObject(body.tenantParams, "tenantParams");
1229
+ }
1230
+ if (body.executionResult !== undefined && body.executionResult !== null) {
1231
+ const r = body.executionResult;
1232
+ if (!isRecord(r) ||
1233
+ !isNonNegativeFinite(r.rowCount) ||
1234
+ !isNonNegativeFinite(r.durationMs) ||
1235
+ typeof r.truncated !== "boolean") {
1236
+ throw new StudioHttpError(400, "`executionResult` must be { rowCount: number, durationMs: number, truncated: boolean, error?: string }.");
1237
+ }
1238
+ entry.executionResult = {
1239
+ rowCount: r.rowCount,
1240
+ durationMs: r.durationMs,
1241
+ truncated: r.truncated,
1242
+ ...(r.error !== undefined
1243
+ ? { error: requireBoundedString(r.error, "executionResult.error", HISTORY_LIMITS.error) }
1244
+ : {}),
1245
+ };
1246
+ }
1247
+ if (body.ragChunkIds !== undefined && body.ragChunkIds !== null) {
1248
+ if (!Array.isArray(body.ragChunkIds) ||
1249
+ body.ragChunkIds.length > HISTORY_LIMITS.ragChunkIds ||
1250
+ !body.ragChunkIds.every((id) => typeof id === "string" && id.length <= HISTORY_LIMITS.ragChunkId)) {
1251
+ throw new StudioHttpError(400, `\`ragChunkIds\` must be an array of at most ${HISTORY_LIMITS.ragChunkIds} strings.`);
1252
+ }
1253
+ entry.ragChunkIds = [...body.ragChunkIds];
1105
1254
  }
1106
- return body;
1255
+ return entry;
1256
+ }
1257
+ function requireBoundedString(value, field, maxLength) {
1258
+ if (typeof value !== "string") {
1259
+ throw new StudioHttpError(400, `\`${field}\` must be a string.`);
1260
+ }
1261
+ if (value.length > maxLength) {
1262
+ throw new StudioHttpError(400, `\`${field}\` is too long (max ${maxLength} characters).`);
1263
+ }
1264
+ return value;
1265
+ }
1266
+ function requireBoundedJsonObject(value, field) {
1267
+ if (!isRecord(value)) {
1268
+ throw new StudioHttpError(400, `\`${field}\` must be a JSON object.`);
1269
+ }
1270
+ if (JSON.stringify(value).length > HISTORY_LIMITS.json) {
1271
+ throw new StudioHttpError(400, `\`${field}\` is too large (max ${HISTORY_LIMITS.json} characters as JSON).`);
1272
+ }
1273
+ return value;
1274
+ }
1275
+ function isNonNegativeFinite(value) {
1276
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
1107
1277
  }
1108
1278
  // ---------------------------------------------------------------------------
1109
1279
  // Execute endpoint helpers
1110
1280
  // ---------------------------------------------------------------------------
1281
+ const EXECUTE_DISABLED_MESSAGE = "Studio execute is off. To run generated SQL from the Playground, set `studio.execute.enabled: true` " +
1282
+ "and an execute connection (`studio.execute.databaseUrl`, or `studio.execute.file` for SQLite) in " +
1283
+ "askdb.config.ts, ideally for a read-only database role, then restart Studio.";
1284
+ function executeNotConfiguredMessage(exec) {
1285
+ const field = exec.provider === "sqlite" ? "studio.execute.file" : "studio.execute.databaseUrl";
1286
+ const base = `No execute connection configured. Set \`${field}\` in askdb.config.ts (ideally a read-only database role).`;
1287
+ return exec.introspectionConnectionAvailable
1288
+ ? `${base} Studio no longer reuses the introspection connection by default; set \`studio.execute.useIntrospectionConnection: true\` to opt in.`
1289
+ : base;
1290
+ }
1111
1291
  async function getExecuteStatus(schemaDir) {
1112
1292
  const rt = getAskDbRuntimeConfig();
1113
- const { provider, databaseUrl, file } = rt.studio.execute;
1293
+ const exec = rt.studio.execute;
1294
+ const { provider, databaseUrl, file } = exec;
1114
1295
  const def = EXECUTE_DRIVER_REGISTRY[provider];
1115
1296
  const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
1116
1297
  const installed = isDriverInstalled(def.packageName, projectRoot);
1117
1298
  const connectionKind = provider === "sqlite" ? "file" : "url";
1118
1299
  const configured = connectionKind === "file" ? Boolean(file) : Boolean(databaseUrl);
1300
+ const disabledReason = !exec.enabled
1301
+ ? EXECUTE_DISABLED_MESSAGE
1302
+ : !configured
1303
+ ? executeNotConfiguredMessage(exec)
1304
+ : null;
1119
1305
  return {
1306
+ enabled: exec.enabled,
1307
+ disabledReason,
1308
+ timeoutMs: exec.timeoutMs,
1309
+ maxRows: exec.maxRows,
1120
1310
  provider,
1121
1311
  label: def.label,
1122
1312
  configured,
@@ -1124,50 +1314,44 @@ async function getExecuteStatus(schemaDir) {
1124
1314
  packageName: def.packageName,
1125
1315
  installed,
1126
1316
  installCommand: def.installCommand,
1127
- canInstallFromStudio: true,
1317
+ canInstallFromStudio: exec.enabled,
1128
1318
  manualInstallReason: null,
1129
1319
  };
1130
1320
  }
1131
1321
  async function installExecuteDriver(body, schemaDir) {
1132
1322
  const rt = getAskDbRuntimeConfig();
1133
- const configuredProvider = rt.studio.execute.provider;
1134
- // Accept the configured provider or an explicit allowlisted provider.
1135
- let provider = configuredProvider;
1136
- if (isRecord(body) && typeof body.provider === "string") {
1137
- const requested = body.provider;
1138
- if (!(requested in EXECUTE_DRIVER_REGISTRY)) {
1139
- return {
1140
- ok: false,
1141
- provider: configuredProvider,
1142
- packageName: EXECUTE_DRIVER_REGISTRY[configuredProvider].packageName,
1143
- command: [],
1144
- error: `Unknown provider: ${body.provider}`,
1145
- installed: false,
1146
- };
1323
+ if (!rt.studio.execute.enabled) {
1324
+ throw new StudioHttpError(403, EXECUTE_DISABLED_MESSAGE);
1325
+ }
1326
+ // Accept the configured provider or an explicit allowlisted provider. `Object.hasOwn`
1327
+ // (via isStudioExecuteProvider) rejects inherited keys such as "constructor".
1328
+ let provider = rt.studio.execute.provider;
1329
+ if (isRecord(body) && body.provider !== undefined) {
1330
+ if (!isStudioExecuteProvider(body.provider)) {
1331
+ throw new StudioHttpError(400, `Unknown provider: ${JSON.stringify(body.provider)}. Expected one of: ${Object.keys(EXECUTE_DRIVER_REGISTRY).join(", ")}.`);
1147
1332
  }
1148
- provider = requested;
1333
+ provider = body.provider;
1149
1334
  }
1150
1335
  const def = EXECUTE_DRIVER_REGISTRY[provider];
1151
- const { packageManager, command, args, manualReason } = detectPackageManager(schemaDir, def.packageName);
1152
- if (manualReason) {
1336
+ const detection = detectPackageManager(schemaDir, def.packageName);
1337
+ if (detection.manualReason !== null) {
1153
1338
  return {
1154
1339
  ok: false,
1155
1340
  provider,
1156
1341
  packageName: def.packageName,
1157
1342
  command: [],
1158
- error: manualReason,
1343
+ error: detection.manualReason,
1159
1344
  installed: false,
1160
1345
  };
1161
1346
  }
1162
- // Run the install.
1163
- const { stdout, stderr, code } = await spawnCommand(command, args, schemaDir);
1164
- const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
1347
+ const { packageManager, projectRoot, spawnSpec } = detection;
1348
+ const { stdout, stderr, code } = await spawnCommand(spawnSpec, projectRoot);
1165
1349
  const installed = isDriverInstalled(def.packageName, projectRoot);
1166
1350
  return {
1167
1351
  ok: code === 0,
1168
1352
  provider,
1169
1353
  packageName: def.packageName,
1170
- command: [command, ...args],
1354
+ command: [spawnSpec.command, ...spawnSpec.args],
1171
1355
  stdout,
1172
1356
  stderr,
1173
1357
  error: code !== 0 ? `${packageManager} exited with code ${code}` : undefined,
@@ -1178,50 +1362,17 @@ function detectPackageManager(cwd, packageName) {
1178
1362
  // Walk up from schemaDir to find the project root's lockfile.
1179
1363
  const projectRoot = findProjectRoot(cwd);
1180
1364
  if (!projectRoot) {
1181
- return {
1182
- packageManager: "",
1183
- command: "",
1184
- args: [],
1185
- manualReason: `Could not locate a package.json above ${cwd}. Run the install manually.`,
1186
- };
1187
- }
1188
- if (existsSync(join(projectRoot, "pnpm-lock.yaml"))) {
1189
- return {
1190
- packageManager: "pnpm",
1191
- command: "pnpm",
1192
- args: ["add", packageName],
1193
- manualReason: null,
1194
- };
1195
- }
1196
- if (existsSync(join(projectRoot, "package-lock.json"))) {
1197
- return {
1198
- packageManager: "npm",
1199
- command: "npm",
1200
- args: ["install", packageName],
1201
- manualReason: null,
1202
- };
1203
- }
1204
- if (existsSync(join(projectRoot, "yarn.lock"))) {
1205
- return {
1206
- packageManager: "yarn",
1207
- command: "yarn",
1208
- args: ["add", packageName],
1209
- manualReason: null,
1210
- };
1365
+ return { manualReason: `Could not locate a package.json above ${cwd}. Run the install manually.` };
1211
1366
  }
1212
- if (existsSync(join(projectRoot, "bun.lockb")) || existsSync(join(projectRoot, "bun.lock"))) {
1213
- return {
1214
- packageManager: "bun",
1215
- command: "bun",
1216
- args: ["add", packageName],
1217
- manualReason: null,
1218
- };
1367
+ const pm = lockfilePackageManager(projectRoot);
1368
+ if (!pm) {
1369
+ return { manualReason: `No recognized lockfile found in ${projectRoot}. Run: pnpm add ${packageName}` };
1219
1370
  }
1220
1371
  return {
1221
- packageManager: "",
1222
- command: "",
1223
- args: [],
1224
- manualReason: `No recognized lockfile found in ${projectRoot}. Run: pnpm add ${packageName}`,
1372
+ manualReason: null,
1373
+ packageManager: pm,
1374
+ projectRoot,
1375
+ spawnSpec: packageManagerSpawnSpec(pm, packageManagerAddArgs(pm, [packageName])),
1225
1376
  };
1226
1377
  }
1227
1378
  function findProjectRoot(startDir) {
@@ -1236,9 +1387,9 @@ function findProjectRoot(startDir) {
1236
1387
  }
1237
1388
  return null;
1238
1389
  }
1239
- function spawnCommand(command, args, cwd) {
1390
+ function spawnCommand(spec, cwd) {
1240
1391
  return new Promise((resolve) => {
1241
- const proc = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
1392
+ const proc = spawn(spec.command, spec.args, { cwd, shell: spec.shell, stdio: ["ignore", "pipe", "pipe"] });
1242
1393
  const stdoutChunks = [];
1243
1394
  const stderrChunks = [];
1244
1395
  proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
@@ -1257,15 +1408,69 @@ function spawnCommand(command, args, cwd) {
1257
1408
  }
1258
1409
  async function executeQuery(body, schemaDir) {
1259
1410
  const rt = getAskDbRuntimeConfig();
1260
- const { provider, databaseUrl, file } = rt.studio.execute;
1411
+ const exec = rt.studio.execute;
1412
+ if (!exec.enabled) {
1413
+ throw new StudioHttpError(403, EXECUTE_DISABLED_MESSAGE);
1414
+ }
1261
1415
  if (!isRecord(body) || typeof body.sql !== "string" || body.sql.trim() === "") {
1262
- return { ok: false, error: "`sql` is required." };
1416
+ throw new StudioHttpError(400, "`sql` is required.");
1263
1417
  }
1264
- const sql = body.sql;
1265
- const params = Array.isArray(body.params) ? body.params : [];
1418
+ if (body.params !== undefined && !Array.isArray(body.params)) {
1419
+ throw new StudioHttpError(400, "`params` must be an array.");
1420
+ }
1421
+ const params = body.params ?? [];
1422
+ // Same read-only SELECT guardrail `ask()` applies, for the execute engine's
1423
+ // dialect — rejects multi-statement input, comments, DDL/DML keywords, …
1424
+ // before anything reaches a driver.
1425
+ let sql;
1426
+ try {
1427
+ sql = validateExecuteSql(exec.provider, body.sql, rt.nlToSql.dialect);
1428
+ }
1429
+ catch (error) {
1430
+ if (error instanceof SqlValidationError) {
1431
+ throw new StudioHttpError(400, `SQL rejected by the read-only guardrail (${error.rule}): ${error.message}`);
1432
+ }
1433
+ throw error;
1434
+ }
1435
+ const warnings = sensitiveExecuteWarnings(sql, schemaDir, executeDialectFor(exec.provider, rt.nlToSql.dialect));
1266
1436
  const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
1267
- const def = EXECUTE_DRIVER_REGISTRY[provider];
1268
- return def.execute({ connectionString: databaseUrl, file, sql, params, projectRoot });
1437
+ const def = EXECUTE_DRIVER_REGISTRY[exec.provider];
1438
+ const result = await def.execute({
1439
+ connectionString: exec.databaseUrl,
1440
+ file: exec.file,
1441
+ sql,
1442
+ params,
1443
+ projectRoot,
1444
+ timeoutMs: exec.timeoutMs,
1445
+ maxRows: exec.maxRows,
1446
+ });
1447
+ if (!result.ok && /^No (connection URL|SQLite file path) configured/.test(result.error)) {
1448
+ return { ok: false, error: executeNotConfiguredMessage(exec) };
1449
+ }
1450
+ return result.ok && warnings.length > 0 ? { ...result, warnings } : result;
1451
+ }
1452
+ /**
1453
+ * Warn (never block) when the SQL references identifiers marked `sensitive` —
1454
+ * the same check `ask()` runs in its default `"warn"` mode. Studio has no
1455
+ * strict-mode setting; hosts that need enforcement call
1456
+ * `validateSensitiveReferences(sql, schema, { mode: "strict" })` themselves.
1457
+ */
1458
+ function sensitiveExecuteWarnings(sql, schemaDir, dialect) {
1459
+ let schema;
1460
+ try {
1461
+ schema = loadSchema(schemaDir);
1462
+ }
1463
+ catch {
1464
+ return [];
1465
+ }
1466
+ if (!schemaHasSensitiveIdentifiers(schema))
1467
+ return [];
1468
+ const result = validateSensitiveReferences(sql, schema, { mode: "warn", dialect });
1469
+ if (result.references.length === 0)
1470
+ return [];
1471
+ return [
1472
+ `This query reads identifiers marked sensitive: ${result.references.map(formatSensitiveReference).join(", ")}.`,
1473
+ ];
1269
1474
  }
1270
1475
  function isLoopbackRequest(req) {
1271
1476
  const socket = req.socket;
@@ -1468,11 +1673,36 @@ function sumNullable(values) {
1468
1673
  function readFiniteNumber(value) {
1469
1674
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1470
1675
  }
1676
+ /** Largest JSON request body Studio accepts. */
1677
+ export const MAX_JSON_BODY_BYTES = 1024 * 1024;
1678
+ function bodyTooLarge() {
1679
+ return new StudioHttpError(413, `Request body is too large (limit ${MAX_JSON_BODY_BYTES} bytes).`);
1680
+ }
1471
1681
  async function readJson(req) {
1682
+ const declared = Number(req.headers["content-length"]);
1683
+ if (Number.isFinite(declared) && declared > MAX_JSON_BODY_BYTES) {
1684
+ // Node discards the unread body after the 413 response is sent.
1685
+ throw bodyTooLarge();
1686
+ }
1472
1687
  const chunks = [];
1688
+ let size = 0;
1689
+ let overflow = false;
1473
1690
  for await (const chunk of req) {
1474
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
1691
+ // Keep draining (without buffering) once over the limit, so the client
1692
+ // can read the 413 instead of hitting a reset connection.
1693
+ if (overflow)
1694
+ continue;
1695
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
1696
+ size += buf.length;
1697
+ if (size > MAX_JSON_BODY_BYTES) {
1698
+ overflow = true;
1699
+ chunks.length = 0;
1700
+ continue;
1701
+ }
1702
+ chunks.push(buf);
1475
1703
  }
1704
+ if (overflow)
1705
+ throw bodyTooLarge();
1476
1706
  if (chunks.length === 0)
1477
1707
  return {};
1478
1708
  try {
@@ -1492,25 +1722,49 @@ function writeText(res, status, contentType, body) {
1492
1722
  });
1493
1723
  res.end(body);
1494
1724
  }
1495
- function serveClientFile(res, relativePath) {
1496
- const filePath = resolve(CLIENT_DIR, relativePath);
1497
- const fileRelativeToClientDir = relative(CLIENT_DIR, filePath);
1725
+ function resolveClientFile(res, relativePath) {
1726
+ const root = clientDir();
1727
+ const filePath = resolve(root, relativePath);
1728
+ const fileRelativeToClientDir = relative(root, filePath);
1498
1729
  if (fileRelativeToClientDir.startsWith("..") || fileRelativeToClientDir === "") {
1499
- return writeJson(res, 404, { error: { message: "Not found" } });
1730
+ writeJson(res, 404, { error: { message: "Not found" } });
1731
+ return null;
1500
1732
  }
1501
1733
  if (!existsSync(filePath)) {
1502
- return writeJson(res, 500, {
1734
+ writeJson(res, 500, {
1503
1735
  error: {
1504
1736
  message: "Studio client assets are missing. Run `pnpm --filter @askdb/studio build` before starting Studio.",
1505
1737
  },
1506
1738
  });
1739
+ return null;
1507
1740
  }
1741
+ return filePath;
1742
+ }
1743
+ function serveClientFile(res, relativePath) {
1744
+ const filePath = resolveClientFile(res, relativePath);
1745
+ if (!filePath)
1746
+ return;
1508
1747
  res.writeHead(200, {
1509
1748
  "content-type": contentTypeFor(filePath),
1510
1749
  "cache-control": "no-store",
1511
1750
  });
1512
1751
  res.end(readFileSync(filePath));
1513
1752
  }
1753
+ /** Serve the SPA shell with this launch's session token injected. */
1754
+ function serveIndexHtml(res, sessionToken) {
1755
+ const filePath = resolveClientFile(res, "index.html");
1756
+ if (!filePath)
1757
+ return;
1758
+ res.writeHead(200, {
1759
+ "content-type": "text/html; charset=utf-8",
1760
+ "cache-control": "no-store",
1761
+ // The page carries the session token and drives SQL execution — never let
1762
+ // another site frame it (clickjacking).
1763
+ "x-frame-options": "DENY",
1764
+ "content-security-policy": "frame-ancestors 'none'",
1765
+ });
1766
+ res.end(injectSessionToken(readFileSync(filePath, "utf8"), sessionToken));
1767
+ }
1514
1768
  function contentTypeFor(path) {
1515
1769
  switch (extname(path)) {
1516
1770
  case ".html":