@kaelio/ktx 0.10.0 → 0.12.0
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/assets/python/{kaelio_ktx-0.10.0-py3-none-any.whl → kaelio_ktx-0.12.0-py3-none-any.whl} +0 -0
- package/assets/python/manifest.json +4 -4
- package/dist/.tsbuildinfo +1 -1
- package/dist/admin.js +1 -1
- package/dist/clack.d.ts +16 -0
- package/dist/clack.js +37 -6
- package/dist/claude-code-prompt-caching.js +1 -1
- package/dist/cli-program.js +7 -3
- package/dist/cli-runtime.d.ts +2 -0
- package/dist/cli-runtime.js +14 -8
- package/dist/commands/connection-commands.js +1 -1
- package/dist/commands/ingest-commands.js +4 -4
- package/dist/commands/mcp-commands.js +12 -12
- package/dist/commands/runtime-commands.js +4 -4
- package/dist/commands/setup-commands.js +6 -5
- package/dist/commands/sl-commands.js +1 -1
- package/dist/commands/sql-commands.js +1 -1
- package/dist/commands/status-commands.js +1 -1
- package/dist/community-cta.d.ts +11 -0
- package/dist/community-cta.js +19 -0
- package/dist/connection.js +1 -1
- package/dist/connectors/clickhouse/connector.js +1 -1
- package/dist/connectors/mysql/connector.js +1 -1
- package/dist/connectors/snowflake/connector.d.ts +1 -1
- package/dist/connectors/sqlite/connector.js +2 -25
- package/dist/connectors/sqlserver/connector.js +3 -3
- package/dist/context/connections/connection-type.d.ts +1 -1
- package/dist/context/connections/read-only-sql.d.ts +1 -0
- package/dist/context/connections/read-only-sql.js +116 -2
- package/dist/context/core/git-env.d.ts +12 -1
- package/dist/context/core/git-env.js +17 -2
- package/dist/context/core/git.service.d.ts +23 -0
- package/dist/context/core/git.service.js +86 -15
- package/dist/context/ingest/adapters/historic-sql/projection.js +2 -1
- package/dist/context/ingest/adapters/looker/client.js +7 -2
- package/dist/context/ingest/adapters/looker/factory.d.ts +8 -1
- package/dist/context/ingest/adapters/looker/factory.js +9 -0
- package/dist/context/ingest/adapters/looker/mapping.js +1 -1
- package/dist/context/ingest/adapters/looker/types.d.ts +1 -1
- package/dist/context/ingest/adapters/metabase/client.d.ts +1 -1
- package/dist/context/ingest/adapters/metabase/client.js +1 -1
- package/dist/context/ingest/adapters/metabase/local-metabase.adapter.js +1 -1
- package/dist/context/ingest/adapters/metabase/mapping.js +6 -6
- package/dist/context/ingest/artifact-gates.d.ts +2 -6
- package/dist/context/ingest/artifact-gates.js +5 -47
- package/dist/context/ingest/constrained-repair.d.ts +55 -0
- package/dist/context/ingest/constrained-repair.js +167 -0
- package/dist/context/ingest/final-gate-repair.d.ts +9 -11
- package/dist/context/ingest/final-gate-repair.js +40 -128
- package/dist/context/ingest/finalization-scope.d.ts +1 -1
- package/dist/context/ingest/finalization-scope.js +15 -15
- package/dist/context/ingest/ingest-bundle.runner.d.ts +1 -0
- package/dist/context/ingest/ingest-bundle.runner.js +101 -67
- package/dist/context/ingest/isolated-diff/patch-integrator.d.ts +6 -13
- package/dist/context/ingest/isolated-diff/patch-integrator.js +32 -109
- package/dist/context/ingest/isolated-diff/textual-conflict-resolver.d.ts +8 -9
- package/dist/context/ingest/isolated-diff/textual-conflict-resolver.js +63 -141
- package/dist/context/ingest/local-bundle-runtime.d.ts +2 -0
- package/dist/context/ingest/local-bundle-runtime.js +9 -10
- package/dist/context/ingest/local-ingest.d.ts +2 -0
- package/dist/context/ingest/local-ingest.js +2 -0
- package/dist/context/ingest/memory-flow/view-model.js +1 -1
- package/dist/context/ingest/stages/stage-3-work-units.d.ts +2 -6
- package/dist/context/ingest/stages/stage-3-work-units.js +2 -1
- package/dist/context/ingest/stages/validate-wu-sources.d.ts +7 -1
- package/dist/context/ingest/stages/validate-wu-sources.js +109 -4
- package/dist/context/ingest/tools/warehouse-verification/create-warehouse-verification-tools.d.ts +2 -0
- package/dist/context/ingest/tools/warehouse-verification/create-warehouse-verification-tools.js +1 -1
- package/dist/context/ingest/tools/warehouse-verification/discover-data.tool.js +3 -3
- package/dist/context/ingest/tools/warehouse-verification/sql-execution.tool.d.ts +3 -1
- package/dist/context/ingest/tools/warehouse-verification/sql-execution.tool.js +15 -1
- package/dist/context/llm/ai-sdk-runtime.js +2 -2
- package/dist/context/llm/claude-code-runtime.js +1 -1
- package/dist/context/llm/local-config.js +1 -1
- package/dist/context/llm/runtime-tools.js +2 -2
- package/dist/context/mcp/context-tools.js +7 -7
- package/dist/context/mcp/local-project-ports.js +23 -54
- package/dist/context/memory/local-memory.js +4 -1
- package/dist/context/memory/memory-agent.service.js +1 -1
- package/dist/context/project/config.d.ts +11 -4
- package/dist/context/project/config.js +85 -30
- package/dist/context/project/driver-schemas.js +1 -1
- package/dist/context/project/mappings-yaml-schema.js +2 -2
- package/dist/context/project/project.js +12 -4
- package/dist/context/scan/description-generation.js +4 -4
- package/dist/context/scan/local-enrichment-artifacts.js +2 -1
- package/dist/context/scan/local-scan.js +2 -2
- package/dist/context/scan/local-structural-artifacts.js +5 -5
- package/dist/context/scan/relationship-benchmark-report.js +1 -1
- package/dist/context/scan/relationship-discovery.js +3 -3
- package/dist/context/scan/relationship-llm-proposal.js +3 -3
- package/dist/context/sl/local-query.js +3 -33
- package/dist/context/sl/local-sl.d.ts +0 -8
- package/dist/context/sl/local-sl.js +44 -69
- package/dist/context/sl/semantic-layer.service.d.ts +25 -8
- package/dist/context/sl/semantic-layer.service.js +109 -56
- package/dist/context/sl/source-files.d.ts +46 -0
- package/dist/context/sl/source-files.js +131 -0
- package/dist/context/sl/tools/base-semantic-layer.tool.d.ts +2 -2
- package/dist/context/sl/tools/base-semantic-layer.tool.js +2 -7
- package/dist/context/sl/tools/sl-edit-source.tool.js +10 -8
- package/dist/context/sl/tools/sl-warehouse-validation.js +55 -27
- package/dist/context/sl/tools/sl-write-source.tool.js +12 -9
- package/dist/context/sql-analysis/dialect.d.ts +2 -0
- package/dist/context/sql-analysis/dialect.js +20 -0
- package/dist/context/tools/base-tool.d.ts +6 -19
- package/dist/context/tools/base-tool.js +0 -14
- package/dist/context-build-view.js +5 -5
- package/dist/database-tree-picker.js +18 -3
- package/dist/demo-assets.js +0 -1
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +31 -23
- package/dist/errors.d.ts +31 -0
- package/dist/errors.js +44 -0
- package/dist/ingest.d.ts +1 -1
- package/dist/ingest.js +8 -2
- package/dist/io/symbols.d.ts +2 -0
- package/dist/io/symbols.js +2 -0
- package/dist/io/tty.d.ts +17 -0
- package/dist/io/tty.js +21 -0
- package/dist/links.d.ts +1 -0
- package/dist/links.js +1 -0
- package/dist/llm/embedding-health.js +1 -1
- package/dist/llm/embedding-provider.js +3 -3
- package/dist/llm/model-provider.js +1 -1
- package/dist/local-adapters.d.ts +1 -0
- package/dist/local-adapters.js +2 -2
- package/dist/local-scan-connectors.js +1 -1
- package/dist/managed-local-embeddings.js +17 -8
- package/dist/managed-mcp-daemon.js +3 -3
- package/dist/managed-python-command.d.ts +7 -0
- package/dist/managed-python-command.js +34 -8
- package/dist/managed-python-daemon.js +2 -2
- package/dist/managed-python-http.js +3 -3
- package/dist/managed-python-runtime.d.ts +30 -1
- package/dist/managed-python-runtime.js +134 -18
- package/dist/managed-uv-release.d.ts +7 -0
- package/dist/managed-uv-release.js +11 -0
- package/dist/mcp-http-server.js +4 -4
- package/dist/mcp-server-factory.js +3 -3
- package/dist/mcp-stdio-server.js +1 -1
- package/dist/memory-flow-hud.js +2 -2
- package/dist/next-steps.js +2 -2
- package/dist/prompt-navigation.d.ts +17 -0
- package/dist/prompt-navigation.js +49 -3
- package/dist/prompts/memory_agent_bundle_ingest_work_unit.md +2 -2
- package/dist/prompts/memory_agent_external_ingest.md +2 -2
- package/dist/public-ingest-copy.js +1 -1
- package/dist/public-ingest.js +3 -3
- package/dist/release-version.js +1 -1
- package/dist/runtime-requirements.js +1 -1
- package/dist/runtime.js +9 -9
- package/dist/scan.js +1 -1
- package/dist/setup-agents.js +22 -35
- package/dist/setup-banner.d.ts +20 -0
- package/dist/setup-banner.js +39 -0
- package/dist/setup-context.js +24 -15
- package/dist/setup-databases.js +31 -59
- package/dist/setup-demo-tour.js +12 -8
- package/dist/setup-embeddings.js +9 -9
- package/dist/setup-interrupt.js +1 -1
- package/dist/setup-models.d.ts +4 -1
- package/dist/setup-models.js +54 -28
- package/dist/setup-project.js +29 -5
- package/dist/setup-prompts.js +16 -5
- package/dist/setup-ready-menu.js +1 -1
- package/dist/setup-sources.js +27 -7
- package/dist/setup.d.ts +25 -0
- package/dist/setup.js +90 -19
- package/dist/skills/analytics/SKILL.md +3 -3
- package/dist/skills/dbt_ingest/SKILL.md +3 -3
- package/dist/skills/looker_ingest/SKILL.md +3 -3
- package/dist/skills/lookml_ingest/SKILL.md +7 -7
- package/dist/skills/metabase_ingest/SKILL.md +4 -4
- package/dist/skills/metricflow_ingest/SKILL.md +15 -15
- package/dist/skills/notion_synthesize/SKILL.md +1 -1
- package/dist/skills/sl/SKILL.md +3 -3
- package/dist/skills/sl_capture/SKILL.md +1 -1
- package/dist/skills/wiki_capture/SKILL.md +1 -1
- package/dist/source-mapping.js +1 -1
- package/dist/startup-profile.js +1 -1
- package/dist/status-project.d.ts +0 -2
- package/dist/status-project.js +4 -6
- package/dist/telemetry/command-hook.d.ts +24 -0
- package/dist/telemetry/command-hook.js +37 -3
- package/dist/telemetry/events.d.ts +1 -1
- package/dist/telemetry/exception.js +14 -0
- package/dist/telemetry/index.d.ts +2 -2
- package/dist/telemetry/index.js +2 -2
- package/dist/text-ingest.js +1 -1
- package/dist/tree-picker-tui.d.ts +0 -1
- package/dist/tree-picker-tui.js +2 -3
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import YAML from 'yaml';
|
|
|
2
2
|
import { noopLogger } from '../../context/core/config.js';
|
|
3
3
|
import { normalizeSemanticLayerDescriptions } from './description-normalization.js';
|
|
4
4
|
import { isOverlaySource, resolvedSourceSchema, sourceDefinitionSchema, sourceOverlaySchema } from './schemas.js';
|
|
5
|
+
import { isSlYamlPath, resolveSlSourceFile, slDeclaredSourceName, slSourceFilePath } from './source-files.js';
|
|
5
6
|
const SL_DIR_PREFIX = 'semantic-layer';
|
|
6
7
|
const CONNECTION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
7
8
|
/** @internal */
|
|
@@ -85,7 +86,7 @@ export class SemanticLayerService {
|
|
|
85
86
|
async listConnectionIds() {
|
|
86
87
|
try {
|
|
87
88
|
const result = await this.configService.listFiles(SL_DIR_PREFIX);
|
|
88
|
-
// Directories under semantic-layer/ are connectionIds. Local
|
|
89
|
+
// Directories under semantic-layer/ are connectionIds. Local ktx projects use
|
|
89
90
|
// readable ids like "warehouse" and "dbt-main", not only UUIDs.
|
|
90
91
|
return result.files
|
|
91
92
|
.map((f) => f.replace(`${SL_DIR_PREFIX}/`, '').split('/')[0])
|
|
@@ -104,8 +105,31 @@ export class SemanticLayerService {
|
|
|
104
105
|
return this.connections.listEnabledConnections(ids);
|
|
105
106
|
}
|
|
106
107
|
// ── YAML File Operations ────────────────────────────────
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// The in-file `name:` is the source's identity; the filename is only a derived
|
|
109
|
+
// label. Rewrites land on the file that already declares the name (humans may
|
|
110
|
+
// rename files freely); new sources get a derived filename. A file already
|
|
111
|
+
// sitting at the derived path that declares a name declares a *different* one
|
|
112
|
+
// (the resolver would have matched it otherwise) — fail instead of clobbering
|
|
113
|
+
// it. A nameless/unparseable file there is the broken remains of this very
|
|
114
|
+
// source (the derived path is a function of the name), so overwriting it is
|
|
115
|
+
// the repair path, not data loss.
|
|
116
|
+
async resolveWritePath(connectionId, sourceName) {
|
|
117
|
+
const existing = await resolveSlSourceFile(this.configService, connectionId, sourceName);
|
|
118
|
+
if (existing) {
|
|
119
|
+
return existing.path;
|
|
120
|
+
}
|
|
121
|
+
const path = slSourceFilePath(connectionId, sourceName);
|
|
122
|
+
let occupant = null;
|
|
123
|
+
try {
|
|
124
|
+
occupant = slDeclaredSourceName((await this.configService.readFile(path)).content);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
129
|
+
if (occupant !== null) {
|
|
130
|
+
throw new Error(`Cannot write source '${sourceName}': ${path} already defines source '${occupant}'`);
|
|
131
|
+
}
|
|
132
|
+
return path;
|
|
109
133
|
}
|
|
110
134
|
async writeSource(connectionId, source, author, authorEmail, commitMessage, options) {
|
|
111
135
|
// Writes are intentionally permissive — the agent must be able to save broken files so
|
|
@@ -140,38 +164,40 @@ export class SemanticLayerService {
|
|
|
140
164
|
this.logger.warn(`[writeSource] '${source.name}': ${danglingRefs.join('; ')}. Saving anyway.`);
|
|
141
165
|
}
|
|
142
166
|
}
|
|
143
|
-
const path = this.
|
|
167
|
+
const path = await this.resolveWritePath(connectionId, source.name);
|
|
144
168
|
const normalizedSource = normalizeSemanticLayerDescriptions(source);
|
|
145
169
|
const content = YAML.stringify(normalizedSource, { indent: 2, lineWidth: 0, version: '1.1' });
|
|
146
170
|
const message = commitMessage ?? `Update semantic layer source: ${source.name}`;
|
|
147
171
|
const result = await this.configService.writeFile(path, content, author, authorEmail, message, {
|
|
148
172
|
skipLock: options?.skipLock,
|
|
149
173
|
});
|
|
150
|
-
|
|
174
|
+
// The filename is derived from (or resolved by) the source name — surface
|
|
175
|
+
// the actual path so callers don't have to re-resolve it.
|
|
176
|
+
return { ...result, path, warnings };
|
|
151
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Raw standalone/overlay file for a source, resolved by its in-file `name:`.
|
|
180
|
+
* Returns null when no file declares the name (the source may still exist as
|
|
181
|
+
* a manifest entry under `_schema/`).
|
|
182
|
+
*/
|
|
152
183
|
async readSourceFile(connectionId, sourceName) {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
return { content: result.content, path };
|
|
184
|
+
const file = await resolveSlSourceFile(this.configService, connectionId, sourceName);
|
|
185
|
+
return file ? { content: file.content, path: file.path } : null;
|
|
156
186
|
}
|
|
157
187
|
async loadSource(connectionId, sourceName) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const result = await this.readSourceFile(connectionId, sourceName);
|
|
161
|
-
content = result.content;
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
188
|
+
const file = await this.readSourceFile(connectionId, sourceName);
|
|
189
|
+
if (!file) {
|
|
164
190
|
return null;
|
|
165
191
|
}
|
|
166
192
|
try {
|
|
167
|
-
return YAML.parse(content);
|
|
193
|
+
return YAML.parse(file.content);
|
|
168
194
|
}
|
|
169
195
|
catch (error) {
|
|
170
196
|
// Distinguish a YAML parse failure from a missing file. The file exists but
|
|
171
197
|
// its contents are unparseable — callers that treat null as "does not exist"
|
|
172
198
|
// could otherwise overwrite the broken file. Surface the parse failure via
|
|
173
199
|
// the service logger so the broken source is at least visible.
|
|
174
|
-
this.logger.warn(`[loadSource] ${
|
|
200
|
+
this.logger.warn(`[loadSource] ${file.path}: YAML parse failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
175
201
|
return null;
|
|
176
202
|
}
|
|
177
203
|
}
|
|
@@ -182,7 +208,7 @@ export class SemanticLayerService {
|
|
|
182
208
|
let allFiles;
|
|
183
209
|
try {
|
|
184
210
|
const result = await this.configService.listFiles(dir);
|
|
185
|
-
allFiles = result.files.filter((f) => f
|
|
211
|
+
allFiles = result.files.filter((f) => isSlYamlPath(f));
|
|
186
212
|
}
|
|
187
213
|
catch (e) {
|
|
188
214
|
const message = `Failed to list semantic-layer files under ${dir}: ${e instanceof Error ? e.message : String(e)}`;
|
|
@@ -283,7 +309,7 @@ export class SemanticLayerService {
|
|
|
283
309
|
let allFiles;
|
|
284
310
|
try {
|
|
285
311
|
const listing = await this.configService.listFiles(dir);
|
|
286
|
-
allFiles = listing.files.filter((f) => f
|
|
312
|
+
allFiles = listing.files.filter((f) => isSlYamlPath(f));
|
|
287
313
|
}
|
|
288
314
|
catch {
|
|
289
315
|
return result;
|
|
@@ -350,7 +376,7 @@ export class SemanticLayerService {
|
|
|
350
376
|
const schemaDir = `${SL_DIR_PREFIX}/${connectionId}/_schema`;
|
|
351
377
|
try {
|
|
352
378
|
const result = await this.configService.listFiles(schemaDir);
|
|
353
|
-
const yamlFiles = result.files.filter((f) => f
|
|
379
|
+
const yamlFiles = result.files.filter((f) => isSlYamlPath(f));
|
|
354
380
|
for (const filePath of yamlFiles) {
|
|
355
381
|
try {
|
|
356
382
|
const { content } = await this.configService.readFile(filePath);
|
|
@@ -390,7 +416,7 @@ export class SemanticLayerService {
|
|
|
390
416
|
let yamlFiles;
|
|
391
417
|
try {
|
|
392
418
|
const result = await this.configService.listFiles(schemaDir);
|
|
393
|
-
yamlFiles = result.files.filter((f) => f
|
|
419
|
+
yamlFiles = result.files.filter((f) => isSlYamlPath(f));
|
|
394
420
|
}
|
|
395
421
|
catch {
|
|
396
422
|
return null;
|
|
@@ -457,7 +483,7 @@ export class SemanticLayerService {
|
|
|
457
483
|
.filter((c) => !c.expr && !manifestColumns.has(c.name.toLowerCase()))
|
|
458
484
|
.map((c) => c.name);
|
|
459
485
|
if (absentDeclaredColumns.length > 0) {
|
|
460
|
-
errors.push(`${source.name}
|
|
486
|
+
errors.push(`${source.name}: table "${source.table}" matched manifest ${manifestLabel}, ` +
|
|
461
487
|
`but declared column(s) absent from physical table: ${absentDeclaredColumns.join(', ')}. ` +
|
|
462
488
|
`Available columns: ${[...manifestColumns.values()].join(', ')}`);
|
|
463
489
|
}
|
|
@@ -466,7 +492,7 @@ export class SemanticLayerService {
|
|
|
466
492
|
return !declared || (!declared.expr && !manifestColumns.has(grain.toLowerCase()));
|
|
467
493
|
});
|
|
468
494
|
if (missingGrainColumns.length > 0) {
|
|
469
|
-
errors.push(`${source.name}
|
|
495
|
+
errors.push(`${source.name}: grain column(s) absent from physical table "${source.table}": ${missingGrainColumns.join(', ')}`);
|
|
470
496
|
}
|
|
471
497
|
for (const column of declaredColumns) {
|
|
472
498
|
if (!column.expr) {
|
|
@@ -480,7 +506,7 @@ export class SemanticLayerService {
|
|
|
480
506
|
validMeasures: new Set(),
|
|
481
507
|
});
|
|
482
508
|
if (missing.length > 0) {
|
|
483
|
-
errors.push(`${source.name}
|
|
509
|
+
errors.push(`${source.name}: computed column "${column.name}" references unknown column(s): ${missing.join(', ')}`);
|
|
484
510
|
}
|
|
485
511
|
}
|
|
486
512
|
for (const segment of source.segments ?? []) {
|
|
@@ -492,7 +518,7 @@ export class SemanticLayerService {
|
|
|
492
518
|
validMeasures: new Set(),
|
|
493
519
|
});
|
|
494
520
|
if (missing.length > 0) {
|
|
495
|
-
errors.push(`${source.name}
|
|
521
|
+
errors.push(`${source.name}: segment "${segment.name}" references unknown column(s): ${missing.join(', ')}`);
|
|
496
522
|
}
|
|
497
523
|
}
|
|
498
524
|
for (const measure of source.measures ?? []) {
|
|
@@ -504,7 +530,7 @@ export class SemanticLayerService {
|
|
|
504
530
|
validMeasures: measureNames,
|
|
505
531
|
});
|
|
506
532
|
if (exprMissing.length > 0) {
|
|
507
|
-
errors.push(`${source.name}
|
|
533
|
+
errors.push(`${source.name}: measure "${measure.name}" references unknown column(s): ${exprMissing.join(', ')}`);
|
|
508
534
|
}
|
|
509
535
|
if (measure.filter) {
|
|
510
536
|
const filterMissing = missingLocalExpressionRefs({
|
|
@@ -515,7 +541,7 @@ export class SemanticLayerService {
|
|
|
515
541
|
validMeasures: new Set(),
|
|
516
542
|
});
|
|
517
543
|
if (filterMissing.length > 0) {
|
|
518
|
-
errors.push(`${source.name}
|
|
544
|
+
errors.push(`${source.name}: measure "${measure.name}" filter references unknown column(s): ${filterMissing.join(', ')}`);
|
|
519
545
|
}
|
|
520
546
|
}
|
|
521
547
|
}
|
|
@@ -525,7 +551,7 @@ export class SemanticLayerService {
|
|
|
525
551
|
continue;
|
|
526
552
|
}
|
|
527
553
|
if (!validOutputColumns.has(parsed.localColumn.toLowerCase())) {
|
|
528
|
-
errors.push(`${source.name}
|
|
554
|
+
errors.push(`${source.name}: join to "${join.to}" references local column ` +
|
|
529
555
|
`"${parsed.localColumn}" that is not a valid output column`);
|
|
530
556
|
}
|
|
531
557
|
const targetSource = sourcesByName.get(join.to.toLowerCase()) ??
|
|
@@ -533,7 +559,7 @@ export class SemanticLayerService {
|
|
|
533
559
|
if (targetSource) {
|
|
534
560
|
const targetColumns = new Set(targetSource.columns.map((c) => c.name.toLowerCase()));
|
|
535
561
|
if (!targetColumns.has(parsed.targetColumn.toLowerCase())) {
|
|
536
|
-
errors.push(`${source.name}
|
|
562
|
+
errors.push(`${source.name}: join to "${join.to}" references target column ` +
|
|
537
563
|
`"${parsed.targetColumn}" that does not exist on the target source`);
|
|
538
564
|
}
|
|
539
565
|
}
|
|
@@ -548,41 +574,28 @@ export class SemanticLayerService {
|
|
|
548
574
|
}
|
|
549
575
|
return SemanticLayerService.mapDialect(connection.connectionType);
|
|
550
576
|
}
|
|
551
|
-
async listSourceNames(connectionId) {
|
|
552
|
-
const dir = `${SL_DIR_PREFIX}/${connectionId}`;
|
|
553
|
-
try {
|
|
554
|
-
const result = await this.configService.listFiles(dir);
|
|
555
|
-
return result.files.filter((f) => f.endsWith('.yaml')).map((f) => f.replace(`${dir}/`, '').replace('.yaml', ''));
|
|
556
|
-
}
|
|
557
|
-
catch {
|
|
558
|
-
return [];
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
577
|
async listFilesForConnection(connectionId) {
|
|
562
578
|
const dir = `${SL_DIR_PREFIX}/${connectionId}`;
|
|
563
579
|
try {
|
|
564
580
|
const result = await this.configService.listFiles(dir, true);
|
|
565
|
-
return result.files.filter((f) => f
|
|
581
|
+
return result.files.filter((f) => isSlYamlPath(f));
|
|
566
582
|
}
|
|
567
583
|
catch {
|
|
568
584
|
return [];
|
|
569
585
|
}
|
|
570
586
|
}
|
|
571
|
-
async readFileByPath(connectionId, relativePath) {
|
|
572
|
-
const fullPath = `${SL_DIR_PREFIX}/${connectionId}/${relativePath}`;
|
|
573
|
-
const result = await this.configService.readFile(fullPath);
|
|
574
|
-
return {
|
|
575
|
-
content: result.content,
|
|
576
|
-
readOnly: relativePath.startsWith('_schema/'),
|
|
577
|
-
};
|
|
578
|
-
}
|
|
579
587
|
async deleteSource(connectionId, sourceName, author, authorEmail) {
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
588
|
+
const file = await resolveSlSourceFile(this.configService, connectionId, sourceName);
|
|
589
|
+
if (!file) {
|
|
590
|
+
// `deleteFile` returns null for a missing path, which would let a no-op
|
|
591
|
+
// delete read as success. Distinguish the two real cases instead.
|
|
592
|
+
if (await this.isManifestBacked(connectionId, sourceName)) {
|
|
593
|
+
throw new Error(`Source '${sourceName}' is defined by the scan manifest (_schema/) and has no overlay file to delete. ` +
|
|
594
|
+
`Rescan the connection to remove it from the manifest.`);
|
|
595
|
+
}
|
|
596
|
+
throw new Error(`Semantic-layer source not found: ${connectionId}/${sourceName}`);
|
|
597
|
+
}
|
|
598
|
+
return this.configService.deleteFile(file.path, author, authorEmail, `Delete semantic layer source: ${sourceName}`);
|
|
586
599
|
}
|
|
587
600
|
/**
|
|
588
601
|
* Validate the semantic layer state that *would* exist if `proposedSource`
|
|
@@ -639,6 +652,16 @@ export class SemanticLayerService {
|
|
|
639
652
|
// any column-without-type errors via the warehouse probe.
|
|
640
653
|
}
|
|
641
654
|
merged.push(toPush);
|
|
655
|
+
// A join target the engine cannot resolve fails every downstream gate and
|
|
656
|
+
// query with the error attributed to the phantom target. Reject it here,
|
|
657
|
+
// on the source that declares it, while the writing agent can still fix it.
|
|
658
|
+
const missingJoinTargets = findMissingJoinTargets(toPush.joins, merged.map((s) => s.name));
|
|
659
|
+
const joinTargetErrors = missingJoinTargets.map((missing) => `${toPush.name}: ${formatMissingJoinTarget(missing)}. Declare joins only to existing ` +
|
|
660
|
+
`semantic-layer sources in this connection, or drop the join and keep the relationship ` +
|
|
661
|
+
`in a column description.`);
|
|
662
|
+
if (joinTargetErrors.length > 0) {
|
|
663
|
+
return { errors: [...loadErrors, ...joinTargetErrors], warnings: [], perSourceWarnings: {} };
|
|
664
|
+
}
|
|
642
665
|
const validatable = merged.filter((s) => s.table != null || s.sql != null);
|
|
643
666
|
if (validatable.length === 0) {
|
|
644
667
|
return { errors: loadErrors, warnings: [], perSourceWarnings: {} };
|
|
@@ -704,7 +727,7 @@ export class SemanticLayerService {
|
|
|
704
727
|
catch {
|
|
705
728
|
return [];
|
|
706
729
|
}
|
|
707
|
-
const schemaFiles = files.filter((file) => /^semantic-layer\/[^/]+\/_schema
|
|
730
|
+
const schemaFiles = files.filter((file) => /^semantic-layer\/[^/]+\/_schema\//.test(file) && isSlYamlPath(file));
|
|
708
731
|
const entries = [];
|
|
709
732
|
for (const filePath of schemaFiles) {
|
|
710
733
|
const connectionId = filePath.split('/')[1];
|
|
@@ -732,7 +755,7 @@ export class SemanticLayerService {
|
|
|
732
755
|
let allFiles;
|
|
733
756
|
try {
|
|
734
757
|
const result = await this.configService.listFiles(dir);
|
|
735
|
-
allFiles = result.files.filter((f) => f
|
|
758
|
+
allFiles = result.files.filter((f) => isSlYamlPath(f));
|
|
736
759
|
}
|
|
737
760
|
catch {
|
|
738
761
|
return warnings;
|
|
@@ -883,7 +906,7 @@ export class SemanticLayerService {
|
|
|
883
906
|
const tables = new Map();
|
|
884
907
|
try {
|
|
885
908
|
const result = await this.configService.listFiles(dir);
|
|
886
|
-
const yamlFiles = result.files.filter((f) => f
|
|
909
|
+
const yamlFiles = result.files.filter((f) => isSlYamlPath(f));
|
|
887
910
|
for (const filePath of yamlFiles) {
|
|
888
911
|
try {
|
|
889
912
|
const { content } = await this.configService.readFile(filePath);
|
|
@@ -1182,6 +1205,36 @@ function parseJoinColumns(on, sourceName, targetName) {
|
|
|
1182
1205
|
}
|
|
1183
1206
|
return { localColumn: left.column, targetColumn: right.column };
|
|
1184
1207
|
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Join targets that do not exactly match a known source name. The Python
|
|
1210
|
+
* engine resolves `joins[].to` by exact name within one connection's source
|
|
1211
|
+
* set (`engine._collect_orphan_join_target_errors`) and `query()` raises on a
|
|
1212
|
+
* miss, so anything looser here — case-insensitive matches, table refs,
|
|
1213
|
+
* sources in other connections — would pass this gate and then fail
|
|
1214
|
+
* query/validation as an orphan join target.
|
|
1215
|
+
*/
|
|
1216
|
+
export function findMissingJoinTargets(joins, knownSourceNames) {
|
|
1217
|
+
const known = new Set();
|
|
1218
|
+
const canonicalByLower = new Map();
|
|
1219
|
+
for (const name of knownSourceNames) {
|
|
1220
|
+
known.add(name);
|
|
1221
|
+
canonicalByLower.set(name.toLowerCase(), name);
|
|
1222
|
+
}
|
|
1223
|
+
const missing = [];
|
|
1224
|
+
for (const join of joins ?? []) {
|
|
1225
|
+
if (known.has(join.to)) {
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
missing.push({ to: join.to, caseMismatch: canonicalByLower.get(join.to.toLowerCase()) ?? null });
|
|
1229
|
+
}
|
|
1230
|
+
return missing;
|
|
1231
|
+
}
|
|
1232
|
+
export function formatMissingJoinTarget(missing) {
|
|
1233
|
+
const hint = missing.caseMismatch
|
|
1234
|
+
? `; join targets are case-sensitive — the source is named "${missing.caseMismatch}"`
|
|
1235
|
+
: '';
|
|
1236
|
+
return `join target "${missing.to}" does not exist${hint}`;
|
|
1237
|
+
}
|
|
1185
1238
|
/**
|
|
1186
1239
|
* Returns one message per measure-level segment reference that doesn't resolve to
|
|
1187
1240
|
* a segment defined on the source. Array is empty when every reference checks out.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { KtxFileStorePort } from '../../context/core/file-store.js';
|
|
2
|
+
export declare function assertSafeConnectionId(connectionId: string): string;
|
|
3
|
+
export declare function isSafeConnectionId(connectionId: string | undefined): connectionId is string;
|
|
4
|
+
export declare function sourceNameFromPath(path: string): string;
|
|
5
|
+
export declare function isSlYamlPath(path: string): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Derive the filename for a semantic-layer source. Total over all possible
|
|
8
|
+
* source names — never throws.
|
|
9
|
+
*
|
|
10
|
+
* Names that are already safe lowercase snake_case become `<name>.yaml`;
|
|
11
|
+
* anything else becomes `<slug>-<8 hex of sha256(name)>.yaml`. The two ranges
|
|
12
|
+
* are disjoint and the mapping is injective: safe filenames contain no `-`,
|
|
13
|
+
* hashed filenames always end in `-<8 hex>`, and slugs are lowercased so names
|
|
14
|
+
* differing only by case get distinct hashes instead of colliding paths on
|
|
15
|
+
* case-insensitive filesystems (macOS APFS, Windows).
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export declare function slSourceFileName(sourceName: string): string;
|
|
20
|
+
export declare function slSourceFilePath(connectionId: string, sourceName: string): string;
|
|
21
|
+
export interface SlSourceFile {
|
|
22
|
+
path: string;
|
|
23
|
+
content: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function slSourceNameForFile(path: string, content: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* The `name:` a semantic-layer YAML file declares, or null when the file is
|
|
28
|
+
* nameless or so broken even the name is unrecoverable. Null is how
|
|
29
|
+
* `writeSource` tells a genuine name conflict at a derived path apart from the
|
|
30
|
+
* broken remains of the source being written, which a rewrite must repair
|
|
31
|
+
* rather than refuse.
|
|
32
|
+
*
|
|
33
|
+
* Uses `parseDocument`, not `parse`: a file with a syntax error below the
|
|
34
|
+
* `name:` line still parses into a partial tree whose top-level `name:` is
|
|
35
|
+
* intact. `parse` would throw on the same input and drop the source to its
|
|
36
|
+
* filename — wrong for human-renamed files, whose filename is not the name.
|
|
37
|
+
*/
|
|
38
|
+
export declare function slDeclaredSourceName(content: string): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Find the standalone/overlay file that defines `sourceName` for a connection.
|
|
41
|
+
* Returns null when no file declares the name (the source may still exist as a
|
|
42
|
+
* manifest entry under `_schema/`). Throws when more than one file declares the
|
|
43
|
+
* same name — that breaks the one-file-per-name invariant and must be repaired
|
|
44
|
+
* by hand rather than silently picking one.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveSlSourceFile(fileStore: Pick<KtxFileStorePort, 'listFiles' | 'readFile'>, connectionId: string, sourceName: string): Promise<SlSourceFile | null>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import YAML from 'yaml';
|
|
3
|
+
// Semantic-layer source identity lives in the file's `name:` field, which mirrors
|
|
4
|
+
// the warehouse identifier verbatim (Snowflake's uppercase `SIGNED_UP`, `EVENT$LOG`).
|
|
5
|
+
// The filename is a derived label and never participates in identity: reads resolve
|
|
6
|
+
// a source by scanning the connection directory and matching `name:`, and writes
|
|
7
|
+
// reuse the resolved file's path, so files can be freely renamed by humans without
|
|
8
|
+
// changing which source they define.
|
|
9
|
+
function assertSafePathToken(kind, value) {
|
|
10
|
+
if (value.trim().length === 0 ||
|
|
11
|
+
value.includes('..') ||
|
|
12
|
+
value.includes('\\') ||
|
|
13
|
+
value.startsWith('/') ||
|
|
14
|
+
value.startsWith('.') ||
|
|
15
|
+
value.includes('//')) {
|
|
16
|
+
throw new Error(`Unsafe ${kind}: ${value}`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export function assertSafeConnectionId(connectionId) {
|
|
21
|
+
if (!isSafeConnectionId(connectionId)) {
|
|
22
|
+
throw new Error(`Unsafe connection id: ${connectionId}`);
|
|
23
|
+
}
|
|
24
|
+
return assertSafePathToken('connection id', connectionId);
|
|
25
|
+
}
|
|
26
|
+
export function isSafeConnectionId(connectionId) {
|
|
27
|
+
return typeof connectionId === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId);
|
|
28
|
+
}
|
|
29
|
+
export function sourceNameFromPath(path) {
|
|
30
|
+
return (path
|
|
31
|
+
.split('/')
|
|
32
|
+
.at(-1)
|
|
33
|
+
?.replace(/\.ya?ml$/, '') ?? path);
|
|
34
|
+
}
|
|
35
|
+
// The one predicate for "this path is a semantic-layer YAML file". ktx itself
|
|
36
|
+
// always writes `.yaml` (see `slSourceFileName`), but humans rename freely and
|
|
37
|
+
// the dbt ecosystem's habit is `.yml`, so every reader must accept both — a
|
|
38
|
+
// listing that recognizes only one extension makes the same file visible to
|
|
39
|
+
// some entry points and invisible to others.
|
|
40
|
+
export function isSlYamlPath(path) {
|
|
41
|
+
return path.endsWith('.yaml') || path.endsWith('.yml');
|
|
42
|
+
}
|
|
43
|
+
// Windows refuses these basenames regardless of extension — a genuinely universal
|
|
44
|
+
// filesystem invariant, so the static list is acceptable.
|
|
45
|
+
const WINDOWS_RESERVED_BASENAME = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$/;
|
|
46
|
+
const SAFE_FILE_BASENAME = /^[a-z0-9][a-z0-9_]{0,63}$/;
|
|
47
|
+
/**
|
|
48
|
+
* Derive the filename for a semantic-layer source. Total over all possible
|
|
49
|
+
* source names — never throws.
|
|
50
|
+
*
|
|
51
|
+
* Names that are already safe lowercase snake_case become `<name>.yaml`;
|
|
52
|
+
* anything else becomes `<slug>-<8 hex of sha256(name)>.yaml`. The two ranges
|
|
53
|
+
* are disjoint and the mapping is injective: safe filenames contain no `-`,
|
|
54
|
+
* hashed filenames always end in `-<8 hex>`, and slugs are lowercased so names
|
|
55
|
+
* differing only by case get distinct hashes instead of colliding paths on
|
|
56
|
+
* case-insensitive filesystems (macOS APFS, Windows).
|
|
57
|
+
*
|
|
58
|
+
* @internal
|
|
59
|
+
*/
|
|
60
|
+
export function slSourceFileName(sourceName) {
|
|
61
|
+
if (SAFE_FILE_BASENAME.test(sourceName) && !WINDOWS_RESERVED_BASENAME.test(sourceName)) {
|
|
62
|
+
return `${sourceName}.yaml`;
|
|
63
|
+
}
|
|
64
|
+
const slug = sourceName
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(/[^a-z0-9_]+/g, '_')
|
|
67
|
+
.replace(/_+/g, '_')
|
|
68
|
+
.replace(/^_+|_+$/g, '')
|
|
69
|
+
.slice(0, 64);
|
|
70
|
+
const hash = createHash('sha256').update(sourceName, 'utf-8').digest('hex').slice(0, 8);
|
|
71
|
+
return `${slug || 'src'}-${hash}.yaml`;
|
|
72
|
+
}
|
|
73
|
+
export function slSourceFilePath(connectionId, sourceName) {
|
|
74
|
+
return `semantic-layer/${assertSafeConnectionId(connectionId)}/${slSourceFileName(sourceName)}`;
|
|
75
|
+
}
|
|
76
|
+
// Same keying as `loadLocalSlSourceRecords`: the in-file `name:` is the identity;
|
|
77
|
+
// the filename is only a fallback for files so broken that even the `name:` is
|
|
78
|
+
// unrecoverable, or genuinely nameless ones. A file left mid-edit with a syntax
|
|
79
|
+
// error below its `name:` line keeps its declared identity (see
|
|
80
|
+
// `slDeclaredSourceName`), so a human-renamed source is still addressed by name
|
|
81
|
+
// while broken instead of silently reverting to its filename.
|
|
82
|
+
export function slSourceNameForFile(path, content) {
|
|
83
|
+
return slDeclaredSourceName(content) ?? sourceNameFromPath(path);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The `name:` a semantic-layer YAML file declares, or null when the file is
|
|
87
|
+
* nameless or so broken even the name is unrecoverable. Null is how
|
|
88
|
+
* `writeSource` tells a genuine name conflict at a derived path apart from the
|
|
89
|
+
* broken remains of the source being written, which a rewrite must repair
|
|
90
|
+
* rather than refuse.
|
|
91
|
+
*
|
|
92
|
+
* Uses `parseDocument`, not `parse`: a file with a syntax error below the
|
|
93
|
+
* `name:` line still parses into a partial tree whose top-level `name:` is
|
|
94
|
+
* intact. `parse` would throw on the same input and drop the source to its
|
|
95
|
+
* filename — wrong for human-renamed files, whose filename is not the name.
|
|
96
|
+
*/
|
|
97
|
+
export function slDeclaredSourceName(content) {
|
|
98
|
+
let doc;
|
|
99
|
+
try {
|
|
100
|
+
doc = YAML.parseDocument(content);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const name = doc.get('name');
|
|
106
|
+
return typeof name === 'string' && name.length > 0 ? name : null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Find the standalone/overlay file that defines `sourceName` for a connection.
|
|
110
|
+
* Returns null when no file declares the name (the source may still exist as a
|
|
111
|
+
* manifest entry under `_schema/`). Throws when more than one file declares the
|
|
112
|
+
* same name — that breaks the one-file-per-name invariant and must be repaired
|
|
113
|
+
* by hand rather than silently picking one.
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveSlSourceFile(fileStore, connectionId, sourceName) {
|
|
116
|
+
const dir = `semantic-layer/${assertSafeConnectionId(connectionId)}`;
|
|
117
|
+
const schemaDir = `${dir}/_schema`;
|
|
118
|
+
const listed = await fileStore.listFiles(dir);
|
|
119
|
+
const paths = listed.files.filter((file) => isSlYamlPath(file) && !file.startsWith(`${schemaDir}/`)).sort();
|
|
120
|
+
const matches = [];
|
|
121
|
+
for (const path of paths) {
|
|
122
|
+
const raw = await fileStore.readFile(path);
|
|
123
|
+
if (slSourceNameForFile(path, raw.content) === sourceName) {
|
|
124
|
+
matches.push({ path, content: raw.content });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (matches.length > 1) {
|
|
128
|
+
throw new Error(`Multiple semantic-layer files declare source "${sourceName}": ${matches.map((match) => match.path).join(', ')}`);
|
|
129
|
+
}
|
|
130
|
+
return matches[0] ?? null;
|
|
131
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { z } from 'zod';
|
|
2
2
|
import type { GitAuthorResolverPort } from '../../../context/tools/authors.js';
|
|
3
3
|
import type { ToolContext, ToolOutput } from '../../../context/tools/base-tool.js';
|
|
4
4
|
import { BaseTool } from '../../../context/tools/base-tool.js';
|
|
@@ -21,7 +21,7 @@ export interface BaseSemanticLayerToolDeps {
|
|
|
21
21
|
slSearchService: SlSearchService;
|
|
22
22
|
authorResolver: GitAuthorResolverPort;
|
|
23
23
|
}
|
|
24
|
-
export declare abstract class BaseSemanticLayerTool<TInput extends
|
|
24
|
+
export declare abstract class BaseSemanticLayerTool<TInput extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>> extends BaseTool<TInput> {
|
|
25
25
|
protected readonly semanticLayerService: SemanticLayerService;
|
|
26
26
|
protected readonly slSearchService: SlSearchService;
|
|
27
27
|
protected readonly authorResolver: GitAuthorResolverPort;
|
|
@@ -14,13 +14,8 @@ export class BaseSemanticLayerTool extends BaseTool {
|
|
|
14
14
|
}
|
|
15
15
|
async readSourceYaml(connectionId, sourceName, context) {
|
|
16
16
|
const semanticLayerService = context?.session?.semanticLayerService ?? this.semanticLayerService;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return content;
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
17
|
+
const file = await semanticLayerService.readSourceFile(connectionId, sourceName);
|
|
18
|
+
return file?.content ?? null;
|
|
24
19
|
}
|
|
25
20
|
buildMarkdown(success, errors, sourceName, extra) {
|
|
26
21
|
const parts = [];
|
|
@@ -91,14 +91,8 @@ If no source exists yet, use sl_write_source instead — this tool will reject t
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
// Read existing source
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const { content } = await semanticLayerService.readSourceFile(connectionId, sourceName);
|
|
97
|
-
currentYaml = content;
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
currentYaml = null;
|
|
101
|
-
}
|
|
94
|
+
const currentFile = await semanticLayerService.readSourceFile(connectionId, sourceName);
|
|
95
|
+
const currentYaml = currentFile?.content ?? null;
|
|
102
96
|
if (!currentYaml) {
|
|
103
97
|
const manifestBacked = await semanticLayerService.isManifestBacked(connectionId, sourceName);
|
|
104
98
|
if (manifestBacked) {
|
|
@@ -138,6 +132,14 @@ If no source exists yet, use sl_write_source instead — this tool will reject t
|
|
|
138
132
|
catch (e) {
|
|
139
133
|
return this.buildOutput(false, [`YAML parse error after edits: ${e}`], sourceName);
|
|
140
134
|
}
|
|
135
|
+
// The in-file `name:` is the source's identity — an edited name would make
|
|
136
|
+
// writeSource create a second source instead of updating this one.
|
|
137
|
+
if (source.name !== sourceName) {
|
|
138
|
+
return this.buildOutput(false, [
|
|
139
|
+
`Edits change "name:" from "${sourceName}" to "${source.name ?? '<missing>'}" — renaming is not supported. ` +
|
|
140
|
+
`Delete the source and recreate it under the new name.`,
|
|
141
|
+
], sourceName);
|
|
142
|
+
}
|
|
141
143
|
source = normalizeSemanticLayerDescriptions(source, { fillMissing: !!context.session?.ingest });
|
|
142
144
|
// Re-serialize and write
|
|
143
145
|
const updatedYaml = YAML.stringify(source, { indent: 2, lineWidth: 0, version: '1.1' });
|