@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/README.md +12 -27
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +16 -7
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +16 -0
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/index-xswUSrtO.js +100 -0
- package/dist/client/index.html +1 -1
- package/dist/execute-registry.d.ts +54 -0
- package/dist/execute-registry.d.ts.map +1 -1
- package/dist/execute-registry.js +237 -41
- package/dist/execute-registry.js.map +1 -1
- package/dist/introspection.d.ts +6 -6
- package/dist/introspection.d.ts.map +1 -1
- package/dist/introspection.js +15 -69
- package/dist/introspection.js.map +1 -1
- package/dist/package-manager.d.ts +31 -0
- package/dist/package-manager.d.ts.map +1 -0
- package/dist/package-manager.js +54 -0
- package/dist/package-manager.js.map +1 -0
- package/dist/request-guard.d.ts +44 -0
- package/dist/request-guard.d.ts.map +1 -0
- package/dist/request-guard.js +170 -0
- package/dist/request-guard.js.map +1 -0
- package/dist/server.d.ts +13 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +450 -196
- package/dist/server.js.map +1 -1
- package/dist/setup.d.ts +4 -2
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +69 -85
- package/dist/setup.js.map +1 -1
- package/dist/shared/api.d.ts +25 -3
- package/dist/shared/api.d.ts.map +1 -1
- package/package.json +50 -43
- package/dist/client/assets/index-BvbATe8r.js +0 -101
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 {
|
|
11
|
-
import {
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 ?? "
|
|
204
|
-
aiProvider: aiConfig?.provider ??
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
671
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
828
|
+
const kind = rt.rag.store;
|
|
773
829
|
if (kind === "memory")
|
|
774
830
|
return { kind };
|
|
775
831
|
if (kind === "file") {
|
|
776
|
-
const basePath = rt.
|
|
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.
|
|
839
|
+
table: rt.rag.storeConfig.pgvector?.table?.trim() || undefined,
|
|
784
840
|
indexStrategy: pickFlat(rt.flat, "ASKDB_PGVECTOR_INDEX_STRATEGY"),
|
|
785
841
|
};
|
|
786
842
|
}
|
|
787
|
-
|
|
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
|
-
|
|
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
|
|
848
|
-
|
|
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
|
-
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
1101
|
-
throw new StudioHttpError(400, "`sqlMode`
|
|
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
|
-
|
|
1104
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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 =
|
|
1333
|
+
provider = body.provider;
|
|
1149
1334
|
}
|
|
1150
1335
|
const def = EXECUTE_DRIVER_REGISTRY[provider];
|
|
1151
|
-
const
|
|
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
|
-
|
|
1163
|
-
const { stdout, stderr, code } = await spawnCommand(
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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(
|
|
1390
|
+
function spawnCommand(spec, cwd) {
|
|
1240
1391
|
return new Promise((resolve) => {
|
|
1241
|
-
const proc = spawn(command, args, { cwd, shell:
|
|
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
|
|
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
|
-
|
|
1416
|
+
throw new StudioHttpError(400, "`sql` is required.");
|
|
1263
1417
|
}
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1496
|
-
const
|
|
1497
|
-
const
|
|
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
|
-
|
|
1730
|
+
writeJson(res, 404, { error: { message: "Not found" } });
|
|
1731
|
+
return null;
|
|
1500
1732
|
}
|
|
1501
1733
|
if (!existsSync(filePath)) {
|
|
1502
|
-
|
|
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":
|