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

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,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readdirSync, readFileSync, rmSync } 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";
@@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url";
7
7
  import { generateText as defaultGenerateText } from "ai";
8
8
  import { ASKDB_AI_PROVIDERS, bootstrapAskDbEnv, getAskDbRuntimeConfig, } from "@askdb/config";
9
9
  import { createAiRegistry, resolveReasoningEffort, } from "@askdb/ai";
10
- import { ask, formatSensitiveReference, isBuiltInDialectId, loadSchema, schemaHasSensitiveIdentifiers, SqlValidationError, validateSensitiveReferences, suggestEnrichment, tenantScopeSchema, normalizeTenantPolicy, parseTenantPolicyMarkdown, SchemaParseError, writeTenantPolicyMarkdown, tenantPolicyFrontmatterSchema, } from "@askdb/core";
10
+ import { ask, DEFAULT_TENANT_SQL_MODE, formatSchemaV2ForNlToSql, formatSensitiveReference, isBuiltInDialectId, loadSchema, saveSchemaFile, schemaHasSensitiveIdentifiers, SensitiveEnrichmentTargetError, SqlValidationError, validateSensitiveReferences, suggestEnrichment, tenantScopeSchema, SchemaParseError, tenantPolicyFrontmatterSchema, } from "@askdb/core";
11
11
  import { buildSchemaIndex, chunkContentHash, chunkSchema, aiSdkEmbedderId, createAiSdkEmbedder, createFileStore, createMemoryStore, createPgvectorStore, createRetriever, detectEmbeddingDimensions, loadChunkerSourcesFromDir, PgvectorDimensionMismatchError, } from "@askdb/rag";
12
- import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveConcepts, saveTable, } from "@askdb/enrich";
12
+ import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveConcepts, saveTable, saveTenantPolicy, } from "@askdb/enrich";
13
13
  import { EXECUTE_DRIVER_REGISTRY, executeDialectFor, isDriverInstalled, isStudioExecuteProvider, validateExecuteSql, } from "./execute-registry.js";
14
14
  import { lockfilePackageManager, packageManagerAddArgs, packageManagerSpawnSpec, } from "./package-manager.js";
15
15
  import { resolveStudioIntrospectionPlan, runStudioIntrospection } from "./introspection.js";
@@ -97,7 +97,7 @@ export function createStudioServer(options) {
97
97
  }
98
98
  if (req.method === "GET" && url.pathname === "/api/introspect/status") {
99
99
  requireWorkspace(state);
100
- return writeJson(res, 200, buildIntrospectionPlanDto());
100
+ return writeJson(res, 200, await buildIntrospectionPlanDto());
101
101
  }
102
102
  if (req.method === "POST" && url.pathname === "/api/introspect") {
103
103
  if (!isLoopbackRequest(req)) {
@@ -122,7 +122,7 @@ export function createStudioServer(options) {
122
122
  if (req.method === "POST" && url.pathname === "/api/tenant-policy") {
123
123
  const body = await readJson(req);
124
124
  const { frontmatter, bodyText } = parseTenantPolicyBody(body);
125
- saveTenantPolicy(state, frontmatter, bodyText);
125
+ saveTenantPolicyDraft(state, frontmatter, bodyText);
126
126
  return writeJson(res, 200, serializeWorkspace(requireWorkspace(state)));
127
127
  }
128
128
  if (req.method === "POST" && url.pathname === "/api/suggest-tenant-policy") {
@@ -132,7 +132,7 @@ export function createStudioServer(options) {
132
132
  if (req.method === "POST" && url.pathname === "/api/suggest") {
133
133
  const body = await readJson(req);
134
134
  const source = parseSuggestSource(body);
135
- const candidates = await suggestForSource(requireWorkspace(state), source);
135
+ const candidates = await suggestForSource(state, source);
136
136
  return writeJson(res, 200, { candidates });
137
137
  }
138
138
  if (req.method === "GET" && url.pathname === "/api/rag/status") {
@@ -360,8 +360,8 @@ async function handleResync(state) {
360
360
  workspace: serializeWorkspace(state.workspace),
361
361
  };
362
362
  }
363
- function buildIntrospectionPlanDto() {
364
- const plan = resolveStudioIntrospectionPlan();
363
+ async function buildIntrospectionPlanDto() {
364
+ const plan = await resolveStudioIntrospectionPlan();
365
365
  return plan.ok
366
366
  ? { ok: true, engine: plan.engine, sourceLabel: plan.sourceLabel }
367
367
  : { ok: false, engine: plan.engine, error: plan.error };
@@ -421,23 +421,17 @@ function saveConceptsDraft(state, concepts) {
421
421
  saveConcepts(requireWorkspace(state), { concepts });
422
422
  state.workspace = loadWorkspace(state.schemaDir);
423
423
  }
424
- function saveTenantPolicy(state, frontmatter, body) {
425
- const workspace = requireWorkspace(state);
426
- const filePath = join(state.schemaDir, "tenant-policy.md");
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.
424
+ function saveTenantPolicyDraft(state, frontmatter, body) {
425
+ // `saveTenantPolicy` refuses a policy `loadSchema` would reject (e.g. two root labels
426
+ // deriving one placeholder) before writing; that's the caller's mistake, so 400.
431
427
  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))));
428
+ saveTenantPolicy(requireWorkspace(state), frontmatter, body);
434
429
  }
435
430
  catch (error) {
436
431
  if (error instanceof SchemaParseError)
437
432
  throw new StudioHttpError(400, error.message);
438
433
  throw error;
439
434
  }
440
- writeFileSync(filePath, md, "utf8");
441
435
  state.workspace = loadWorkspace(state.schemaDir);
442
436
  }
443
437
  function parseTenantPolicyBody(body) {
@@ -471,7 +465,7 @@ function saveDraft(state, tableId, draft) {
471
465
  saveTable(workspace, tableId, frontmatter, body);
472
466
  state.workspace = loadWorkspace(state.schemaDir);
473
467
  }
474
- async function suggestForSource(workspace, source) {
468
+ async function suggestForSource(state, source) {
475
469
  const rt = getAskDbRuntimeConfig();
476
470
  const aiConfig = ai.resolveAiConfig(rt.ai.aiEnv);
477
471
  if (!aiConfig) {
@@ -482,37 +476,108 @@ async function suggestForSource(workspace, source) {
482
476
  const providerOptions = reasoningEffort
483
477
  ? ai.resolveProviderOptions(aiConfig, { reasoningEffort })
484
478
  : undefined;
485
- const candidates = await suggestEnrichment(buildSuggestionTarget(workspace, source), buildSuggestionContext(workspace, source.tableId), model, providerOptions ? { providerOptions } : undefined);
479
+ // `loadSchema()` carries effective sensitivity (front-matter escalations included), and the
480
+ // prompt follows `modes.omitSensitiveFromPrompt` as `ask()` does (#625, ADR 0026).
481
+ requireWorkspace(state);
482
+ const schema = loadSchema(state.schemaDir);
483
+ let candidates;
484
+ try {
485
+ candidates = await suggestEnrichment(buildSuggestionTarget(schema, source), buildSuggestionContext(schema, source.tableId), model, {
486
+ omitSensitiveIdentifiersFromPrompt: rt.modes.omitSensitiveFromPrompt,
487
+ ...(providerOptions ? { providerOptions } : {}),
488
+ });
489
+ }
490
+ catch (error) {
491
+ if (error instanceof SensitiveEnrichmentTargetError) {
492
+ throw new StudioHttpError(400, "This column is sensitive, and `modes.omitSensitiveFromPrompt` keeps sensitive columns out of the model's prompt, so Studio can't suggest for it.");
493
+ }
494
+ throw error;
495
+ }
486
496
  return candidates.map((candidate) => ({ text: candidate.text }));
487
497
  }
498
+ /**
499
+ * The schema as the tenant-policy draft sends it to the model, with the sensitivity rules
500
+ * `ask()` applies (#324): `formatSchemaV2ForNlToSql` tags sensitive columns, or withholds
501
+ * them when `omitSensitive` is on. Only the physical layer goes in (names, types, keys and
502
+ * foreign keys), never descriptions, aliases or common query language, and untracked tables
503
+ * stay out as they do for `ask()`. A foreign key is listed only when both of its columns are.
504
+ * `warnings` names the primary and foreign keys withheld as sensitive, which the draft can't
505
+ * scope through.
506
+ */
507
+ function tenantPolicySchemaForPrompt(schema, omitSensitive) {
508
+ const tracked = schema.tables.filter((table) => table.tracked !== false);
509
+ const physical = {
510
+ schemaId: schema.schemaId,
511
+ warnings: [],
512
+ tables: tracked.map((table) => ({
513
+ id: table.id,
514
+ name: table.name,
515
+ schema: table.schema,
516
+ sensitive: table.sensitive,
517
+ columns: table.columns.map((column) => ({
518
+ id: column.id,
519
+ name: column.name,
520
+ type: column.type,
521
+ nullable: column.nullable,
522
+ primaryKey: column.primaryKey,
523
+ sensitive: column.sensitive,
524
+ })),
525
+ })),
526
+ };
527
+ const { ddl } = formatSchemaV2ForNlToSql(physical, { omitSensitiveIdentifiersFromPrompt: omitSensitive });
528
+ // The formatter prints no foreign keys yet, so Studio lists them, mirroring the formatter's
529
+ // omission rule (packages/core/src/schema/v2/format.ts): a column is withheld when omission
530
+ // is on and it is sensitive, which `loadSchema()` sets on every column of a sensitive table.
531
+ // When core prints foreign keys (#349, step 5), use its lines and delete this block (ADR 0021).
532
+ const names = new Map();
533
+ const withheld = new Set();
534
+ for (const table of schema.tables) {
535
+ for (const column of table.columns) {
536
+ names.set(column.id, `${table.schema}.${table.name}.${column.name}`);
537
+ if (omitSensitive && column.sensitive)
538
+ withheld.add(column.id);
539
+ }
540
+ }
541
+ const nameOf = (id) => names.get(id) ?? id;
542
+ const listed = new Set();
543
+ const withheldKeys = [];
544
+ for (const table of tracked) {
545
+ for (const column of table.columns) {
546
+ if (!withheld.has(column.id))
547
+ listed.add(column.id);
548
+ else if (column.primaryKey)
549
+ withheldKeys.push(`${nameOf(column.id)} (primary key)`);
550
+ }
551
+ }
552
+ const foreignKeys = [];
553
+ for (const fk of tracked.flatMap((table) => table.relationships ?? [])) {
554
+ if (listed.has(fk.from) && listed.has(fk.to))
555
+ foreignKeys.push(`- ${fk.from} -> ${fk.to}`);
556
+ else if (withheld.has(fk.from) || withheld.has(fk.to)) {
557
+ withheldKeys.push(`${nameOf(fk.from)} → ${nameOf(fk.to)} (foreign key)`);
558
+ }
559
+ }
560
+ const warnings = withheldKeys.length > 0
561
+ ? [
562
+ `Withheld from the model because they're marked sensitive: ${withheldKeys.join(", ")}. ` +
563
+ "If any of them carries tenant scope, add it to the policy by hand.",
564
+ ]
565
+ : [];
566
+ return {
567
+ schemaId: schema.schemaId,
568
+ schemaDdl: foreignKeys.length > 0 ? `${ddl}\n\nForeign keys:\n${foreignKeys.join("\n")}` : ddl,
569
+ tableIds: tracked.map((table) => table.id),
570
+ warnings,
571
+ };
572
+ }
488
573
  async function suggestTenantPolicyDraft(state) {
489
574
  const rt = getAskDbRuntimeConfig();
490
575
  const model = await ai.createLanguageModelFromEnv(rt.ai.aiEnv);
491
576
  if (!model) {
492
577
  throw new StudioHttpError(400, ai.keyMissingMessage("AI tenant policy suggestion"));
493
578
  }
494
- // Build a compact DDL-like representation of the schema for the prompt
495
- const workspace = requireWorkspace(state);
496
- const schemaId = workspace.physical.schemaId;
497
- const schemaDdl = workspace.tables
498
- .map((t) => {
499
- const cols = t.physical.columns
500
- .map((c) => {
501
- const parts = [c.name, c.type];
502
- if (c.primaryKey)
503
- parts.push("PRIMARY KEY");
504
- if (!c.nullable)
505
- parts.push("NOT NULL");
506
- return ` ${parts.join(" ")}`;
507
- })
508
- .join(",\n");
509
- const rels = (t.physical.relationships ?? [])
510
- .map((r) => ` -- FK: ${r.from} -> ${r.to}`)
511
- .join("\n");
512
- return `CREATE TABLE ${t.physical.schema}.${t.physical.name} (\n${cols}\n);\n${rels}`.trim();
513
- })
514
- .join("\n\n");
515
- const tableIds = workspace.tables.map((t) => t.physical.id);
579
+ requireWorkspace(state);
580
+ const { schemaId, schemaDdl, tableIds, warnings } = tenantPolicySchemaForPrompt(loadSchema(state.schemaDir), rt.modes.omitSensitiveFromPrompt);
516
581
  const systemPrompt = `You are an expert database architect specializing in multi-tenant SaaS systems.
517
582
  You analyze database schemas and produce a tenant-policy configuration in a specific JSON format.
518
583
 
@@ -571,15 +636,14 @@ If the schema has no clear multi-tenant pattern, still make your best guess at w
571
636
  if (!validation.success) {
572
637
  throw new StudioHttpError(500, `AI-generated tenant policy frontmatter is invalid: ${validation.error.issues.map((i) => i.message).join("; ")}`);
573
638
  }
574
- parsed.frontmatter = validation.data;
575
- return parsed;
639
+ return { frontmatter: validation.data, body: parsed.body, warnings };
576
640
  }
577
641
  async function askSampleQuestion(state, options) {
578
642
  const rt = getAskDbRuntimeConfig();
579
643
  const mockSql = rt.dev.mockSql;
580
644
  const aiConfig = mockSql ? undefined : ai.resolveAiConfig(rt.ai.aiEnv);
581
645
  if (!mockSql && !aiConfig) {
582
- throw new StudioHttpError(400, `${ai.keyMissingMessage("Sample NL-to-SQL generation")} Set ASKDB_MOCK_SQL to bypass the live model.`);
646
+ throw new StudioHttpError(400, `${ai.keyMissingMessage("Sample NL-to-SQL generation")} Set dev.mockSql in askdb.config to bypass the live model.`);
583
647
  }
584
648
  const reasoningEffort = mockSql ? undefined : resolveReasoningEffort(rt.ai.aiEnv, "nlToSql");
585
649
  const providerOptions = aiConfig && reasoningEffort ? ai.resolveProviderOptions(aiConfig, { reasoningEffort }) : undefined;
@@ -603,6 +667,9 @@ async function askSampleQuestion(state, options) {
603
667
  model,
604
668
  dialect,
605
669
  explain: true,
670
+ // `ask()` doesn't read config, so each host applies `modes.omitSensitiveFromPrompt`
671
+ // itself, as `@askdb/client` does (#601).
672
+ ...(rt.modes.omitSensitiveFromPrompt ? { omitSensitiveIdentifiersFromNlToSqlPrompt: true } : {}),
606
673
  ...(options.tenantScope ? { tenantScope: options.tenantScope } : {}),
607
674
  ...(options.tenantSqlMode ? { tenantSqlMode: options.tenantSqlMode } : {}),
608
675
  ...(ragIndex
@@ -645,7 +712,7 @@ async function askSampleQuestion(state, options) {
645
712
  tenant: options.tenantScope
646
713
  ? {
647
714
  enabled: true,
648
- sqlMode: options.tenantSqlMode ?? "sql-only",
715
+ sqlMode: options.tenantSqlMode ?? DEFAULT_TENANT_SQL_MODE,
649
716
  bindings: result.tenantBindings ?? [],
650
717
  params: result.tenantParams ?? [],
651
718
  }
@@ -1118,7 +1185,7 @@ function appendPlaygroundHistory(schemaDir, entry) {
1118
1185
  };
1119
1186
  const updated = [newEntry, ...existing].slice(0, HISTORY_MAX_STORED);
1120
1187
  ensureSchemaDirGitignore(schemaDir);
1121
- writeFileSync(histPath, JSON.stringify(updated, null, 2), "utf8");
1188
+ saveSchemaFile(schemaDir, HISTORY_FILE_NAME, JSON.stringify(updated, null, 2));
1122
1189
  }
1123
1190
  /**
1124
1191
  * Keep Playground history (questions + generated SQL, local-only) out of git.
@@ -1128,19 +1195,25 @@ function appendPlaygroundHistory(schemaDir, entry) {
1128
1195
  * - No `.gitignore` yet: write one that also ignores `.env` files — the same
1129
1196
  * rules `askdb init` writes (plan 043) — unless the schema dir is the project
1130
1197
  * root, where ignoring `.env` is the project's call, not Studio's.
1131
- * - Existing `.gitignore` without the entry: append the entry; never rewrite.
1198
+ * - Existing `.gitignore` without the entry: add the entry at the end and keep
1199
+ * every existing byte as it was.
1200
+ * - Both are saved with `saveSchemaFile()`, so a `.gitignore` that is a symbolic
1201
+ * link is refused, not written through (ADR 0028).
1132
1202
  * - Failure is non-fatal: history still saves.
1133
1203
  */
1134
1204
  function ensureSchemaDirGitignore(schemaDir) {
1135
1205
  const gitignorePath = join(schemaDir, ".gitignore");
1136
1206
  try {
1137
1207
  if (existsSync(gitignorePath)) {
1138
- const content = readFileSync(gitignorePath, "utf8");
1139
- const lines = content.split(/\r?\n/).map((line) => line.trim());
1208
+ // Kept as bytes: Git matches patterns byte for byte, and a non-UTF-8 file name
1209
+ // in an existing line must survive the rewrite.
1210
+ const content = readFileSync(gitignorePath);
1211
+ const lines = content.toString("utf8").split(/\r?\n/).map((line) => line.trim());
1140
1212
  if (lines.includes(HISTORY_FILE_NAME) || lines.includes(`/${HISTORY_FILE_NAME}`))
1141
1213
  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");
1214
+ const separator = content.length === 0 || content.at(-1) === 0x0a ? "" : "\n";
1215
+ const entry = `${separator}# AskDB Studio Playground history (local only)\n${HISTORY_FILE_NAME}\n`;
1216
+ saveSchemaFile(schemaDir, ".gitignore", Buffer.concat([content, Buffer.from(entry, "utf8")]));
1144
1217
  return;
1145
1218
  }
1146
1219
  const lines = [
@@ -1154,7 +1227,7 @@ function ensureSchemaDirGitignore(schemaDir) {
1154
1227
  if (!isProjectRootDir(schemaDir)) {
1155
1228
  lines.push("", "# Credentials: keep API keys and connection strings out of version control", "# even when they live here.", ".env", ".env.*", "!.env.example");
1156
1229
  }
1157
- writeFileSync(gitignorePath, `${lines.join("\n")}\n`, "utf8");
1230
+ saveSchemaFile(schemaDir, ".gitignore", `${lines.join("\n")}\n`);
1158
1231
  }
1159
1232
  catch {
1160
1233
  // Non-fatal: the history write matters more than this defensive file.
@@ -1180,7 +1253,7 @@ function deletePlaygroundHistoryEntry(schemaDir, id) {
1180
1253
  if (filtered.length === existing.length) {
1181
1254
  return { ok: false, error: "not found" };
1182
1255
  }
1183
- writeFileSync(histPath, JSON.stringify(filtered, null, 2), "utf8");
1256
+ saveSchemaFile(schemaDir, HISTORY_FILE_NAME, JSON.stringify(filtered, null, 2));
1184
1257
  return { ok: true };
1185
1258
  }
1186
1259
  const HISTORY_LIMITS = {