Files
ss-tools/frontend/src/lib/models/DatasetsHubModel.svelte.ts
busya 8c10632494 feat(semantic): curator-driven protocol hardening — decision memory + relation repair
- Add @RATIONALE/@REJECTED to 103+ C4/C5 contracts across backend core, services, API routes, and frontend models
- Fix 109 unresolved @RELATION edges (Auth.*, SupersetClient.*, AgentChat.*, ADR cross-refs)
- Add 13 @ingroup tags for DSA/HCA attention grouping
- Repair 29 stale graph edges via index rebuild
- Update .kilo agent prompts and skills for GRACE-Poly v2.6 compliance
- Git integration: merge routes, branch lifecycle, remote providers, UX components
- 0 broken anchor pairs, index rebuilt with 0 parse warnings
2026-07-02 08:53:19 +03:00

297 lines
13 KiB
TypeScript

// #region Datasets.HubModel [C:4] [TYPE Model] [SEMANTICS datasets,list,filter,pagination,bulk-action,model]
// @ingroup Datasets
// @BRIEF Screen model for the datasets hub — manages list state, selection, detail preview, modals, and WebSocket updates.
// @INVARIANT Changing filter or search resets pagination to page 1.
// @INVARIANT selectedIds is always a subset of loaded datasets — orphan IDs cleaned after each load.
// @INVARIANT Detail panel opens only when selectedDatasetId is truthy; closing clears detailData.
// @STATE idle — No environment selected.
// @STATE loading — Dataset list is loading.
// @STATE loaded — Datasets loaded, list rendered.
// @STATE empty — No datasets match current filters.
// @STATE error — Load failed, error shown.
// @ACTION loadDatasets(filter?, page?) — Fetches paginated list with current search/filter.
// @ACTION loadDetail(datasetId) — Fetches detail preview for selected dataset.
// @ACTION setFilter(filterKey) — Sets active filter, resets pagination, reloads.
// @ACTION setSearch(query) — Debounced search with pagination reset.
// @ACTION setPage(n) — Navigates to page.
// @ACTION toggleSelect(dataset, checked) — Toggles single dataset in selection set.
// @ACTION selectAll() / clearAll() — Bulk selection operations.
// @ACTION handleAction(dataset, action) — Opens map-columns or generate-docs modal.
// @ACTION handleBulkMapColumns() — Runs bulk map-columns task.
// @ACTION handleBulkGenerateDocs() — Runs bulk generate-docs task.
// @ACTION selectDataset(id) — Opens detail preview panel.
// @ACTION closeDetail() — Closes detail preview panel.
// @RELATION DEPENDS_ON -> [ApiModule]
// @RELATION CALLS -> [CotLogger]
// @INVARIANT DECOMPOSITION GATE: 20+ atoms. If model exceeds 400 lines or 40 methods, split into submodels.
// @RATIONALE Datasets.HubModel centralizes all dataset-hub state (list, selection, detail preview, modals, mobile) into one testable class. Model-first ensures cross-widget invariants (filter → pagination reset, selection ↔ bulk action visibility, mobile slide-over ↔ detail) are verifiable without DOM rendering. The SvelteSet-backed selection set provides O(1) add/delete/has without Svelte 5 reactivity surprises.
// @REJECTED Component-first state management rejected — scattering filter/selection/modal state across event handlers in +page.svelte creates invisible cross-widget coupling that LLM agents cannot reliably maintain. Multiple smaller models (DatasetsListModel, SelectionModel, MappingModalModel) rejected — the decomposition overhead (cross-model synchronization, extra connections) exceeds benefit at current complexity. Re-evaluate at 40+ methods via @INVARIANT DECOMPOSITION GATE.
import { SvelteSet } from 'svelte/reactivity';
import { api } from '$lib/api.js';
import { log } from '$lib/cot-logger';
import { debounce } from '$lib/utils/debounce.js';
// ── Types ───────────────────────────────────────────────────────
type ScreenState = 'idle' | 'loading' | 'loaded' | 'empty' | 'error';
interface DatasetItem {
id: string;
table_name: string;
schema: string;
database: string;
column_count: number;
metric_count: number;
mappedFields: { total: number; mapped: number } | null;
lastTask: { status: string; id: string } | null;
}
interface DatasetStats {
total?: number;
mapped?: number;
[key: string]: unknown;
}
interface MapColumnsResponse {
task_id: string;
[key: string]: unknown;
}
export class DatasetsHubModel {
// ── Environment ───────────────────────────────────────────────
selectedEnv: string | null = $state(null);
// ── List state ────────────────────────────────────────────────
datasets: DatasetItem[] = $state([]);
stats: DatasetStats | null = $state(null);
screenState: ScreenState = $state('idle');
dsError: string | null = $state(null);
page: number = $state(1);
total: number = $state(0);
totalPages: number = $state(1);
pageSize: number = $state(10);
searchQuery: string = $state('');
activeFilter: string | null = $state(null);
// ── Selection ─────────────────────────────────────────────────
selectedIds: Set<string> = $state(new SvelteSet());
// ── Detail preview ────────────────────────────────────────────
selectedDatasetId: string | null = $state(null);
detailData: Record<string, unknown> | null = $state(null);
detailLoading: boolean = $state(false);
detailError: string | null = $state(null);
// ── Modals ────────────────────────────────────────────────────
showMapColumnsModal: boolean = $state(false);
showGenerateDocsModal: boolean = $state(false);
mapSourceType: string = $state('sqllab');
mapDatabases: unknown[] = $state([]);
mapDatabaseId: string | null = $state(null);
mapSqlQuery: string = $state('');
mapFileData: unknown = $state(null);
llmProvider: string = $state('');
llmOptions: Record<string, unknown> = $state({});
// ── Mobile ────────────────────────────────────────────────────
isMobile: boolean = $state(false);
mobileSlideOpen: boolean = $state(false);
// ── Derived ───────────────────────────────────────────────────
isLoading = $derived(this.screenState === 'loading');
hasSelection = $derived(this.selectedIds.size > 0);
selectionCount = $derived(this.selectedIds.size);
// ── Private ───────────────────────────────────────────────────
private _ws: unknown = null;
private _debouncedSearch: ReturnType<typeof debounce>;
constructor() {
this._debouncedSearch = debounce((q: string) => {
this.searchQuery = q;
this.page = 1;
this.loadDatasets();
}, 300);
}
// ── Actions ───────────────────────────────────────────────────
async loadDatasets(filter?: string | null, p?: number): Promise<void> {
if (!this.selectedEnv) return;
log('DatasetsHubModel', 'REASON', 'Loading datasets', {
env: this.selectedEnv, filter: filter ?? this.activeFilter, page: p ?? this.page,
});
this.screenState = 'loading';
this.dsError = null;
try {
const resp = await api.getDatasets(this.selectedEnv, {
search: this.searchQuery || undefined,
filter: (filter ?? this.activeFilter) ?? undefined,
page: p ?? this.page,
page_size: this.pageSize,
});
this.datasets = (resp.datasets as DatasetItem[]).map((d: { id: string; table_name: string; schema: string; database: string; column_count?: number; metric_count?: number; mapped_fields?: { total: number; mapped: number }; last_task?: { status: string; task_id: string } }) => ({
id: d.id,
table_name: d.table_name,
schema: d.schema,
database: d.database,
column_count: d.mapped_fields?.total ?? 0,
metric_count: d.metric_count ?? 0,
mappedFields: d.mapped_fields ? { total: d.mapped_fields.total, mapped: d.mapped_fields.mapped } : null,
lastTask: d.last_task ? { status: d.last_task.status, id: d.last_task.task_id } : null,
}));
this.stats = resp.stats ?? null;
this.total = resp.total ?? 0;
this.totalPages = resp.total_pages ?? 1;
this.page = resp.page ?? 1;
// Clean orphan selection IDs
const keepIds = new SvelteSet<string>();
this.datasets.forEach(d => { if (this.selectedIds.has(d.id)) keepIds.add(d.id); });
this.selectedIds = keepIds;
this.screenState = this.datasets.length === 0 ? 'empty' : 'loaded';
log('DatasetsHubModel', 'REFLECT', 'Datasets loaded', {
count: this.datasets.length, total: this.total, page: this.page,
});
} catch (e: unknown) {
this.dsError = e instanceof Error ? e.message : 'Failed to load datasets';
this.screenState = 'error';
log('DatasetsHubModel', 'EXPLORE', 'Failed to load datasets', { env: this.selectedEnv }, String(this.dsError));
}
}
setFilter(filterKey: string): void {
this.activeFilter = filterKey;
this.page = 1;
this.loadDatasets(filterKey, 1);
}
setSearch(q: string): void {
this._debouncedSearch(q);
}
setPage(p: number): void {
this.page = p;
this.loadDatasets(undefined, p);
}
toggleSelect(dataset: DatasetItem, checked: boolean | 'all' | 'clear-all'): void {
if (checked === 'all') {
this.datasets.forEach(d => this.selectedIds.add(d.id));
this.selectedIds = new SvelteSet(this.selectedIds);
} else if (checked === 'clear-all') {
this.selectedIds = new SvelteSet();
} else if (checked) {
this.selectedIds.add(dataset.id);
this.selectedIds = new SvelteSet(this.selectedIds);
} else {
this.selectedIds.delete(dataset.id);
this.selectedIds = new SvelteSet(this.selectedIds);
}
}
selectAll(): void { this.toggleSelect({ id: '' } as DatasetItem, 'all'); }
clearAll(): void { this.toggleSelect({ id: '' } as DatasetItem, 'clear-all'); }
async loadDetail(datasetId: string): Promise<void> {
if (!datasetId) return;
this.detailLoading = true;
this.detailError = null;
try {
const d = await api.getDatasetDetail(this.selectedEnv!, datasetId);
this.detailData = { ...d, schema: (d as { schema_name?: string; schema?: string }).schema_name ?? (d as { schema_name?: string; schema?: string }).schema };
} catch (e: unknown) {
this.detailError = e instanceof Error ? e.message : 'Failed to load detail';
} finally {
this.detailLoading = false;
}
}
selectDataset(id: string): void {
this.selectedDatasetId = id;
this.detailData = null;
this.detailError = null;
if (this.isMobile) this.mobileSlideOpen = true;
this.loadDetail(id);
}
closeDetail(): void {
this.selectedDatasetId = null;
this.detailData = null;
this.detailError = null;
this.mobileSlideOpen = false;
}
handleAction(dataset: DatasetItem, action: string): void {
if (action === 'map_columns') {
this.selectedIds = new SvelteSet([dataset.id]);
this.showMapColumnsModal = true;
this.mapSourceType = 'sqllab';
this.mapDatabaseId = null;
this.mapSqlQuery = '';
this.mapFileData = null;
if (this.selectedEnv) {
api.getEnvironmentDatabases(this.selectedEnv).then((dbs: unknown) => { this.mapDatabases = (dbs as unknown[]) ?? []; }).catch(() => {});
}
} else if (action === 'generate_docs') {
this.selectedIds = new SvelteSet([dataset.id]);
this.showGenerateDocsModal = true;
this.llmProvider = '';
this.llmOptions = {};
}
}
async handleBulkMapColumns(): Promise<void> {
if (this.selectedIds.size === 0) return;
if (this.mapSourceType === 'sqllab' && !this.mapDatabaseId) return;
if (this.mapSourceType === 'xlsx' && (!this.mapFileData || (this.mapFileData as unknown[]).length === 0)) return;
try {
let fileData = null;
if (this.mapSourceType === 'xlsx' && (this.mapFileData as unknown[])?.length)
fileData = (this.mapFileData as unknown[])[0] as { name: string };
const resp = await api.postApi('/datasets/map-columns', {
env_id: this.selectedEnv,
dataset_ids: [...this.selectedIds],
source_type: this.mapSourceType,
database_id: this.mapDatabaseId,
sql_query: this.mapSqlQuery,
file_data: fileData?.name,
}) as MapColumnsResponse;
log('DatasetsHubModel', 'REFLECT', 'Bulk map columns started', { taskId: resp.task_id });
this.showMapColumnsModal = false;
this.selectedIds = new SvelteSet();
} catch (e: unknown) {
log('DatasetsHubModel', 'EXPLORE', 'Bulk map columns failed', {}, String(e));
}
}
async handleBulkGenerateDocs(): Promise<void> {
if (this.selectedIds.size === 0) return;
try {
const resp = await api.postApi('/datasets/generate-docs', {
env_id: this.selectedEnv,
dataset_ids: [...this.selectedIds],
llm_provider: this.llmProvider,
llm_options: this.llmOptions,
}) as MapColumnsResponse;
log('DatasetsHubModel', 'REFLECT', 'Bulk docs generation started', { taskId: resp.task_id });
this.showGenerateDocsModal = false;
this.selectedIds = new SvelteSet();
} catch (e: unknown) {
log('DatasetsHubModel', 'EXPLORE', 'Bulk docs failed', {}, String(e));
}
}
}
// #endregion Datasets.HubModel