@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/README.md +9 -9
- package/dist/cli.js +20 -10
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/index-CgbreUa6.js +100 -0
- package/dist/client/assets/index-DpkWiK_X.css +2 -0
- package/dist/client/index.html +2 -2
- package/dist/execute-registry.d.ts +2 -1
- package/dist/execute-registry.d.ts.map +1 -1
- package/dist/execute-registry.js +7 -2
- package/dist/execute-registry.js.map +1 -1
- package/dist/introspection.d.ts +2 -2
- package/dist/introspection.d.ts.map +1 -1
- package/dist/introspection.js +28 -6
- package/dist/introspection.js.map +1 -1
- package/dist/package-manager.d.ts +16 -3
- package/dist/package-manager.d.ts.map +1 -1
- package/dist/package-manager.js +40 -8
- package/dist/package-manager.js.map +1 -1
- package/dist/prisma-install.d.ts +7 -0
- package/dist/prisma-install.d.ts.map +1 -0
- package/dist/prisma-install.js +7 -0
- package/dist/prisma-install.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +127 -54
- package/dist/server.js.map +1 -1
- package/dist/setup.d.ts +2 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +66 -17
- package/dist/setup.js.map +1 -1
- package/dist/shared/api.d.ts +5 -0
- package/dist/shared/api.d.ts.map +1 -1
- package/package.json +16 -12
- package/dist/client/assets/index-CULOwR3W.css +0 -2
- package/dist/client/assets/index-xswUSrtO.js +0 -100
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
const
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1139
|
-
|
|
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 ===
|
|
1143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1256
|
+
saveSchemaFile(schemaDir, HISTORY_FILE_NAME, JSON.stringify(filtered, null, 2));
|
|
1184
1257
|
return { ok: true };
|
|
1185
1258
|
}
|
|
1186
1259
|
const HISTORY_LIMITS = {
|