// #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 = $state(new SvelteSet()); // ── Detail preview ──────────────────────────────────────────── selectedDatasetId: string | null = $state(null); detailData: Record | 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 = $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; constructor() { this._debouncedSearch = debounce((q: string) => { this.searchQuery = q; this.page = 1; this.loadDatasets(); }, 300); } // ── Actions ─────────────────────────────────────────────────── async loadDatasets(filter?: string | null, p?: number): Promise { 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(); 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 { 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 { 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 { 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