mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-28 12:11:02 +02:00
* fix: read semantic sources safely
* test: retarget reindex per-scope error case to a broken manifest
Reading a broken standalone source was made non-fatal in de1f1a8d (it is
surfaced for repair instead of throwing), so the reindex per-scope error
test no longer captured an error. Point it at a corrupt manifest shard,
which is the remaining fatal read failure the per-scope catch must
isolate, and assert the captured error names the offending file.
* fix(sl): decouple semantic-layer file names from warehouse naming rules
The in-file `name:` field is now the sole source identity; the filename is
a derived label that never participates in identity. This removes the
"Unsafe semantic-layer source name" failure class entirely: any warehouse
identifier (Snowflake's uppercase SIGNED_UP, EVENT$LOG, dotted names) can
be read, overlaid, edited, and deleted.
- New `source-files.ts`: one total filename derivation (safe lowercase
names verbatim; otherwise slug + sha256-hash suffix, immune to
case-insensitive-filesystem collisions) and one by-name file resolver.
- Reads resolve by name everywhere; the path-from-name fast path and
`assertSafeSourceName` are gone.
- Writes resolve-then-write: rewrites land on the file that declares the
name (human renames survive); new sources get a derived filename; a
derived path occupied by a different source fails instead of clobbering.
- `readSourceFile` returns null for missing files instead of forcing every
caller to launder IO errors; `deleteSource` distinguishes manifest-backed
sources from not-found instead of silently succeeding.
- `sl_write_source` accepts verbatim warehouse identifiers (snake_case is
now a recommendation for new sources) and rejects sourceName/source.name
mismatches; `sl_edit_source` rejects name-changing edits.
- Ingest projection commits, gate-repair allowlists, and touched-source
derivation use resolved paths / in-file names instead of interpolating
`<connId>/<name>.yaml`.
- Collapsed the five parallel path derivations and duplicated path-token
helpers onto the shared module; dropped dead service methods.
* fix(sl): resolve sources by declared name end-to-end and gate warehouse SQL with the parser-backed validator
- Key broken/renamed semantic-layer files by their recoverable in-file
name (slSourceNameForFile) so mid-edit sources stay reachable under
their real identity in reads, listings, and search
- Derive finalization touched sources from composed-source diffs and
recover deleted files' declared names from the pre-change commit
instead of parsing hash-derived filenames
- Resolve revert/rollback paths against history (listFilesAtCommit) so
human-renamed files are restored where they lived at preHead
- Validate ingest sql_execution through the daemon's sqlglot
validateReadOnly in the connection's dialect, sharing one
driver-to-dialect map (sql-analysis/dialect.ts) across MCP and ingest
- Harden the local read-only SQL backstop: accept leading comments,
reject smuggled second statements, and strip trailing
semicolons/comments before row-limit wrapping
143 lines
5.2 KiB
TypeScript
143 lines
5.2 KiB
TypeScript
import { isSlYamlPath } from '../../context/sl/source-files.js';
|
|
import type { SemanticLayerSource } from '../../context/sl/types.js';
|
|
import type { TouchedSlSource } from '../../context/tools/touched-sl-sources.js';
|
|
import type { IngestReportFinalizationMismatch } from './reports.js';
|
|
|
|
interface DeriveTouchedSourcesInput {
|
|
changedPaths: string[];
|
|
beforeSourcesByConnection: Map<string, SemanticLayerSource[]>;
|
|
afterSourcesByConnection: Map<string, SemanticLayerSource[]>;
|
|
}
|
|
|
|
interface DeriveTouchedSourcesResult {
|
|
touchedSources: TouchedSlSource[];
|
|
unresolvedPaths: string[];
|
|
}
|
|
|
|
interface CompareFinalizationDeclarationsInput {
|
|
declaredTouchedSources: TouchedSlSource[];
|
|
derivedTouchedSources: TouchedSlSource[];
|
|
declaredChangedWikiPageKeys: string[];
|
|
derivedChangedWikiPageKeys: string[];
|
|
}
|
|
|
|
function uniqueSorted(values: string[]): string[] {
|
|
return [...new Set(values.filter((value) => value.length > 0))].sort();
|
|
}
|
|
|
|
function touchedKey(source: TouchedSlSource): string {
|
|
return `${source.connectionId}:${source.sourceName}`;
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((entry) => stableJson(entry)).join(',')}]`;
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const record = value as Record<string, unknown>;
|
|
return `{${Object.keys(record)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
|
|
.join(',')}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function changedSourceNames(
|
|
beforeSources: SemanticLayerSource[],
|
|
afterSources: SemanticLayerSource[],
|
|
): string[] {
|
|
const before = new Map(beforeSources.map((source) => [source.name, stableJson(source)]));
|
|
const after = new Map(afterSources.map((source) => [source.name, stableJson(source)]));
|
|
return uniqueSorted(
|
|
uniqueSorted([...before.keys(), ...after.keys()]).filter(
|
|
(sourceName) => before.get(sourceName) !== after.get(sourceName),
|
|
),
|
|
);
|
|
}
|
|
|
|
export function deriveFinalizationWikiPageKeys(paths: string[]): string[] {
|
|
return uniqueSorted(
|
|
paths
|
|
.filter((path) => path.startsWith('wiki/global/') && path.endsWith('.md'))
|
|
.filter((path) => !path.slice('wiki/global/'.length, -'.md'.length).includes('/'))
|
|
.map((path) => path.slice('wiki/global/'.length, -'.md'.length)),
|
|
);
|
|
}
|
|
|
|
// Source identity is the in-file `name:`; filenames are derived labels (see
|
|
// source-files.ts), so a changed path — manifest shard or standalone file —
|
|
// cannot be mapped to a source by parsing its filename. Instead, every changed
|
|
// semantic-layer file is attributed through the before/after diff of its
|
|
// connection's composed sources. A changed file whose connection diff is empty
|
|
// cannot be attributed to any source and is surfaced as unresolved.
|
|
export function deriveFinalizationTouchedSources(input: DeriveTouchedSourcesInput): DeriveTouchedSourcesResult {
|
|
const touched = new Map<string, TouchedSlSource>();
|
|
const unresolvedPaths: string[] = [];
|
|
|
|
const pathsByConnection = new Map<string, string[]>();
|
|
for (const path of input.changedPaths) {
|
|
if (!path.startsWith('semantic-layer/') || !isSlYamlPath(path)) {
|
|
continue;
|
|
}
|
|
const connectionId = path.split('/')[1] ?? '';
|
|
if (!connectionId) {
|
|
unresolvedPaths.push(path);
|
|
continue;
|
|
}
|
|
pathsByConnection.set(connectionId, [...(pathsByConnection.get(connectionId) ?? []), path]);
|
|
}
|
|
|
|
for (const [connectionId, paths] of pathsByConnection) {
|
|
const changedNames = changedSourceNames(
|
|
input.beforeSourcesByConnection.get(connectionId) ?? [],
|
|
input.afterSourcesByConnection.get(connectionId) ?? [],
|
|
);
|
|
if (changedNames.length === 0) {
|
|
unresolvedPaths.push(...paths);
|
|
continue;
|
|
}
|
|
for (const sourceName of changedNames) {
|
|
touched.set(`${connectionId}:${sourceName}`, { connectionId, sourceName });
|
|
}
|
|
}
|
|
|
|
return {
|
|
touchedSources: [...touched.values()].sort((left, right) =>
|
|
touchedKey(left).localeCompare(touchedKey(right)),
|
|
),
|
|
unresolvedPaths: uniqueSorted(unresolvedPaths),
|
|
};
|
|
}
|
|
|
|
export function compareFinalizationDeclarations(
|
|
input: CompareFinalizationDeclarationsInput,
|
|
): IngestReportFinalizationMismatch[] {
|
|
const mismatches: IngestReportFinalizationMismatch[] = [];
|
|
const declaredSl = new Set(input.declaredTouchedSources.map(touchedKey));
|
|
const derivedSl = new Set(input.derivedTouchedSources.map(touchedKey));
|
|
const declaredWiki = new Set(input.declaredChangedWikiPageKeys);
|
|
const derivedWiki = new Set(input.derivedChangedWikiPageKeys);
|
|
|
|
for (const key of [...derivedSl].sort()) {
|
|
if (!declaredSl.has(key)) {
|
|
mismatches.push({ artifactKind: 'sl', key, direction: 'missing_from_adapter_declaration' });
|
|
}
|
|
}
|
|
for (const key of [...declaredSl].sort()) {
|
|
if (!derivedSl.has(key)) {
|
|
mismatches.push({ artifactKind: 'sl', key, direction: 'extra_in_adapter_declaration' });
|
|
}
|
|
}
|
|
for (const key of [...derivedWiki].sort()) {
|
|
if (!declaredWiki.has(key)) {
|
|
mismatches.push({ artifactKind: 'wiki', key, direction: 'missing_from_adapter_declaration' });
|
|
}
|
|
}
|
|
for (const key of [...declaredWiki].sort()) {
|
|
if (!derivedWiki.has(key)) {
|
|
mismatches.push({ artifactKind: 'wiki', key, direction: 'extra_in_adapter_declaration' });
|
|
}
|
|
}
|
|
return mismatches;
|
|
}
|