@hachej/boring-data-catalog 0.1.13

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 ADDED
@@ -0,0 +1,298 @@
1
+ # @hachej/boring-data-catalog
2
+
3
+ <div align="center">
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ </div>
8
+
9
+ A configurable data catalog plugin for the workbench — left sidebar tab with a searchable, faceted list and a visualization panel to explore rows. Built on [`@hachej/boring-data-explorer`](../data-explorer/README.md).
10
+
11
+ ```bash
12
+ git clone https://github.com/hachej/boring-ui.git && cd boring-ui && pnpm install
13
+ ```
14
+
15
+ > **Note:** This plugin is workspace-private (`"private": true`) — install from source within the monorepo.
16
+
17
+ ---
18
+
19
+ ## TL;DR
20
+
21
+ **The Problem**: You have a data source (customers, invoices, time series, whatever) and you want users to browse it from a sidebar tab, click rows to open detail visualizations, and let the agent open specific rows programmatically. But wiring up a catalog tab + explorer panel + surface resolver + agent tool is repetitive.
22
+
23
+ **The Solution**: `@hachej/boring-data-catalog` gives you a one-call plugin factory: pass in an `ExplorerDataSource` adapter and configure labels, facets, and behavior. It contributes a left tab, a visualization panel, a catalog, and a surface resolver — all wired up.
24
+
25
+ ### Why Use @hachej/boring-data-catalog?
26
+
27
+ | Feature | What It Does |
28
+ |---------|--------------|
29
+ | **Left sidebar catalog tab** | Persistent sidebar listing rows from your adapter, with search and facet filters |
30
+ | **Visualization panel** | Click a row → opens a detail panel that also shows an explorer table |
31
+ | **Surface resolver** | Agent can open a specific row via `openSurface` with `DATA_CATALOG_ROW_SURFACE_KIND` |
32
+ | **Agent tool** | Server plugin ships a `search_catalog` tool the agent uses to find rows before opening them |
33
+ | **Pre-wired with data-explorer** | Search + facets + drag-out behavior comes for free |
34
+ | **Customizable** | Swap the visualization component, customize facets/groupBy/onSelect, or pick which contributions to include |
35
+
36
+ ---
37
+
38
+ ## Quick Example
39
+
40
+ **Frontend (workbench):**
41
+
42
+ ```ts
43
+ import { createDataCatalogPlugin } from "@hachej/boring-data-catalog/front"
44
+ import type { ExplorerDataSource } from "@hachej/boring-data-explorer/shared"
45
+
46
+ // Your data adapter
47
+ const adapter: ExplorerDataSource = {
48
+ async search({ query, filters, limit, offset }) {
49
+ // fetch from your backend
50
+ return { items: [...], total, hasMore: ... }
51
+ },
52
+ }
53
+
54
+ // One plugin = left tab + visualization panel + catalog + surface resolver
55
+ const catalogPlugin = createDataCatalogPlugin({
56
+ id: "customers",
57
+ label: "Customers",
58
+ adapter,
59
+ facets: [
60
+ { key: "industry", label: "Industry", formatValue: (v) => v },
61
+ { key: "region", label: "Region", order: ["US", "EU", "APAC"], formatValue: (v) => v },
62
+ ],
63
+ groupBy: "industry",
64
+ })
65
+ ```
66
+
67
+ Add `catalogPlugin` to your `WorkspaceProvider` plugins array.
68
+
69
+ **Server (agent runtime):**
70
+
71
+ ```ts
72
+ import { createDataCatalogServerPlugin } from "@hachej/boring-data-catalog/server"
73
+ import { ExplorerDataSource } from "@hachej/boring-data-explorer/shared"
74
+
75
+ const catalogServerPlugin = createDataCatalogServerPlugin({
76
+ label: "Customers",
77
+ adapter, // same ExplorerDataSource as front
78
+ defaultLimit: 20,
79
+ maxLimit: 50,
80
+ })
81
+ ```
82
+
83
+ Add `catalogServerPlugin` to `createAgentApp` `plugins` array.
84
+
85
+ Now agents can search the catalog via the tool and open specific rows via the UI bridge `openSurface` command:
86
+ ```
87
+ openSurface({ kind: DATA_CATALOG_ROW_SURFACE_KIND, target: row.id, meta: { catalogId, row } })
88
+ ```
89
+
90
+ ---
91
+
92
+ ## What It Contributes
93
+
94
+ One call to `createDataCatalogPlugin()` contributes four front registrations:
95
+
96
+ | Contribution | What | Toggle |
97
+ |--------|------|--------|
98
+ | **Left tab** | Sidebar tab with searchable, faceted table | `includeLeftTab` (default: true) |
99
+ | **Visualization panel** | Center panel for exploring a selected row's context | `includeVisualizationPanel` (default: true) |
100
+ | **Catalog** | Command-palette-searchable catalog entry | `includeCatalog` (default: true) |
101
+ | **Surface resolver** | Maps `openSurface` calls to panel open | `includeSurfaceResolver` (default: true if visualization panel is on) |
102
+
103
+ You can disable any of these with the `include*` flags. For example, a plugin that only contributes a left tab:
104
+
105
+ ```ts
106
+ createDataCatalogPlugin({
107
+ id: "metrics",
108
+ label: "Metrics",
109
+ adapter,
110
+ includeLeftTab: true,
111
+ includeVisualizationPanel: false,
112
+ includeCatalog: false,
113
+ includeSurfaceResolver: false,
114
+ })
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Configuration
120
+
121
+ ```ts
122
+ interface CreateDataCatalogPluginOptions {
123
+ pluginId?: string
124
+ id: string
125
+ label: string
126
+ adapter: ExplorerDataSource // required — your data source
127
+ facets?: FacetConfig[] // facet filter definitions
128
+ groupBy?: string // group key for the left tab rows
129
+ getDragPayload?: (row) => DragPayload | null
130
+ onSelect?: (row, context) => void // custom row click handler
131
+ emptyState?: ReactNode
132
+ searchPlaceholder?: string
133
+ pageSize?: number
134
+ debounceMs?: number
135
+ leftTabId?: string
136
+ leftTabTitle?: string
137
+ leftTabIcon?: IconType
138
+ catalogId?: string
139
+ catalogLabel?: string
140
+ visualizationPanelId?: string
141
+ visualizationTitle?: string
142
+ visualizationIcon?: IconType
143
+ visualizationComponent?: ComponentType<PaneProps<DataCatalogVisualizationParams>>
144
+ includeLeftTab?: boolean // default true
145
+ includeCatalog?: boolean // default true
146
+ includeVisualizationPanel?: boolean // default true
147
+ includeSurfaceResolver?: boolean // default true (if visualization panel on)
148
+ source?: "builtin" | "app" // panel attribution
149
+ }
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Architecture
155
+
156
+ ```
157
+ ┌───────────────────────────────────────┐
158
+ │ Workspace Left Sidebar │
159
+ │ ┌───────────────────────────────┐ │
160
+ │ │ 📊 Customers (left tab) │ │
161
+ │ │ ┌───────────────────────────┐ │ │
162
+ │ │ │ [Search: "acme..."] │ │ │
163
+ │ │ │ [Industry: Tech ▼] │ │ │
164
+ │ │ │ ───────────────────────── │ │ │
165
+ │ │ │ US: Acme Corp [T] ✓ │ │ │
166
+ │ │ │ EU: Acme GmbH [T] │ │ │
167
+ │ │ │ APAC: Acme KK [L] │ │ │
168
+ │ │ └─────────────────────────── │ │ │
169
+ │ └───────────────────────────────┘ │
170
+ └───────────────┬───────────────────────┘
171
+ │ click row → open panel
172
+
173
+ ┌───────────────────────────────────────┐
174
+ │ Center Panel (visualization) │
175
+ │ ┌───────────────────────────────┐ │
176
+ │ │ Acme Corp │ │
177
+ │ │ US: Acme Corp [T] │ │
178
+ │ │ ┌───────────────────────────┐│ │
179
+ │ │ │ [Search...] ││ │
180
+ │ │ │ [filtered by row context] ││ │
181
+ │ │ │ ...explorer table... ││ │
182
+ │ │ └───────────────────────────┘│ │
183
+ │ └───────────────────────────────┘ │
184
+ └───────────────────────────────────────┘
185
+ ```
186
+
187
+ The agent can open any row programmatically:
188
+ ```
189
+ POST /api/v1/ui/commands
190
+ { kind: "openSurface", params: {
191
+ surfaceKind: "data-catalog-row",
192
+ target: <row.id>,
193
+ meta: { catalogId: "customers", row: { title: "Acme Corp", ... } }
194
+ }}
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Installation
200
+
201
+ ```bash
202
+ # From source (workspace-only — not published to npm)
203
+ cd boring-ui/plugins/data-catalog && pnpm install && pnpm build
204
+ ```
205
+
206
+ ---
207
+
208
+ ## How @hachej/boring-data-catalog Compares
209
+
210
+ | Feature | @hachej/boring-data-catalog | Custom sidebar + table | Embedded BI tool |
211
+ |---------|------------------------------|-----------------------|------------------|
212
+ | Catalog tab + panel | ✅ One plugin call | ❌ DIY each piece | ⚠️ Configuration-heavy |
213
+ | Agent tool + bridge | ✅ search + openSurface | ❌ DIY | ❌ |
214
+ | Wiring effort | ✅ ~10 lines | ❌ Hours | ❌ Days |
215
+ | Data source flexibility | ✅ Any backend via ExplorerDataSource | ⚠️ Custom per source | ⚠️ Vendor-defined |
216
+ | Workbench integration | ✅ Drag-to-panel, exec_ui | ⚠️ Manual | ❌ None |
217
+ | Customizable outputs | ✅ Toggle left-tab / panel / catalog / resolver | ❌ All or nothing | ❌ |
218
+
219
+ **When to use @hachej/boring-data-catalog:**
220
+ - You want a sidebar catalog tab that lets users browse your data
221
+ - You want the agent to search and open specific rows in panels
222
+ - You want a left-tab + visualization panel combo with minimal code
223
+
224
+ **When it might not fit:**
225
+ - You only need a standalone table (use `@hachej/boring-data-explorer` directly)
226
+ - You want a full BI dashboard with charting (embed a dedicated BI tool)
227
+ - You need real-time data streaming (not supported in v1)
228
+
229
+ ---
230
+
231
+ ## Package Surfaces
232
+
233
+ | Import | Environment | What You Get |
234
+ |--------|-------------|--------------|
235
+ | `@hachej/boring-data-catalog/front` | Browser | `createDataCatalogPlugin()`, types, hooks, surface resolver |
236
+ | `@hachej/boring-data-catalog/server` | Node | `createDataCatalogServerPlugin()` — agent tool + skill prompt |
237
+ | `@hachej/boring-data-catalog/shared` | Any | `DATA_CATALOG_PLUGIN_ID`, `DATA_CATALOG_ROW_SURFACE_KIND`, constants |
238
+
239
+ ---
240
+
241
+ ## Dependencies
242
+
243
+ | Package | Required | Why |
244
+ |---------|----------|-----|
245
+ | `@hachej/boring-data-explorer` | ✅ Yes | Core table component and `ExplorerDataSource` adapter |
246
+ | `@hachej/boring-workspace` | ✅ peerDependency | Plugin system and panel registry |
247
+ | `lucide-react` | ✅ Yes | Catalog icons (Database, BarChart3) |
248
+
249
+ ---
250
+
251
+ ## Troubleshooting
252
+
253
+ | Error | Cause | Fix |
254
+ |-------|-------|-----|
255
+ | Catalog tab doesn't appear | Front plugin not in workspace | Add `createDataCatalogPlugin()` to `WorkspaceProvider` plugins |
256
+ | Clicking a row does nothing | `adapter.search()` is broken or returns nothing | Test your adapter independently |
257
+ | Agent tool not found | Server plugin not registered | Add `createDataCatalogServerPlugin()` to agent app `plugins` |
258
+ | Agent can't open rows | Surface resolver disabled | Set `includeSurfaceResolver: true` (on by default) |
259
+ | Icons not rendering | Invalid lucide icon name | Check `leftTabIcon` / `visualizationIcon` against [lucide.dev](https://lucide.dev/icons/) |
260
+
261
+ ---
262
+
263
+ ## Limitations
264
+
265
+ - **Workspace-private** — `"private": true` in package.json. Not published to npm. Install from source within the monorepo.
266
+ - **Single data source per plugin** — Each call to `createDataCatalogPlugin()` wires to one `ExplorerDataSource`. For multiple catalogs, instantiate the plugin multiple times with different adapters and labels.
267
+ - **No charting or visualization** — The visualization panel shows another explorer table, not charts. For visualizations, provide a custom `visualizationComponent`.
268
+ - **No server-side caching** — Each search triggers a fresh `search()` call. Cache at your adapter level if needed.
269
+ - **Real-time data** — Not supported in v1. Search uses debounce and pagination but no live streaming.
270
+
271
+ ---
272
+
273
+ ## FAQ
274
+
275
+ **Q: How do I use multiple data sources?**
276
+ A: Call `createDataCatalogPlugin()` once per data source, each with a different `id`, `label`, and `adapter`. For a static source list, implement a small `ExplorerDataSource` whose `search()` filters and slices an in-memory `ExplorerItem[]`.
277
+
278
+ **Q: How does the agent open a specific row?**
279
+ A: The catalog registers a surface resolver. The agent uses the UI bridge `openSurface` command: `{ kind: DATA_CATALOG_ROW_SURFACE_KIND, target: row.id, meta: { catalogId, row } }`.
280
+
281
+ **Q: Can I customize the visualization panel?**
282
+ A: Yes. Pass a custom `visualizationComponent` to `createDataCatalogPlugin()`. It receives `PaneProps<DataCatalogVisualizationParams>` with the selected row and context.
283
+
284
+ **Q: What if my data source is a REST API, not a database?**
285
+ A: The `ExplorerDataSource` adapter is backend-agnostic. Implement `search()` (and optionally `fetchFacets()`) to hit your REST endpoint.
286
+
287
+ **Q: Can I disable outputs I don't need?**
288
+ A: Yes. Set `includeLeftTab`, `includeVisualizationPanel`, `includeCatalog`, or `includeSurfaceResolver` to `false`. A left-tab-only plugin is a perfectly valid output.
289
+
290
+ ---
291
+
292
+ *About Contributions:* Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via `gh` and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.
293
+
294
+ ---
295
+
296
+ ## License
297
+
298
+ MIT
@@ -0,0 +1,105 @@
1
+ import { BoringFrontSurfaceResolverRegistration, BoringFrontFactoryWithId } from '@hachej/boring-workspace/plugin';
2
+ import { ReactNode, ComponentType } from 'react';
3
+ import { ExplorerDataSource, FacetConfig, ExplorerItem, DragPayload } from '@hachej/boring-data-explorer/shared';
4
+ import { LeftTabParams, WorkspaceBridge, PanelConfig, PaneProps } from '@hachej/boring-workspace';
5
+ export { DATA_CATALOG_DEFAULT_TOOL_NAME, DATA_CATALOG_PLUGIN_ID, DATA_CATALOG_ROW_SURFACE_KIND } from '../shared/index.js';
6
+ export { DataExplorerProps } from '@hachej/boring-data-explorer/front';
7
+
8
+ interface DataCatalogVisualizationParams {
9
+ row?: ExplorerItem;
10
+ query?: string;
11
+ }
12
+ interface OpenDataCatalogVisualizationOptions {
13
+ catalogId: string;
14
+ surfaceKind?: string;
15
+ title?: string;
16
+ params?: Record<string, unknown>;
17
+ }
18
+ interface DataCatalogSelectContext {
19
+ params?: LeftTabParams;
20
+ bridge?: WorkspaceBridge;
21
+ }
22
+ interface CreateDataCatalogContributionsOptions {
23
+ /**
24
+ * Base contribution id. Defaults catalog id to this value and left tab /
25
+ * visualization panel ids to derived names.
26
+ */
27
+ id?: string;
28
+ label?: string;
29
+ adapter: ExplorerDataSource;
30
+ facets?: FacetConfig[];
31
+ groupBy?: string;
32
+ getDragPayload?: (row: ExplorerItem) => DragPayload | null | undefined;
33
+ onSelect?: (row: ExplorerItem, context: DataCatalogSelectContext) => void;
34
+ emptyState?: ReactNode;
35
+ searchPlaceholder?: string;
36
+ pageSize?: number;
37
+ debounceMs?: number;
38
+ leftTabId?: string;
39
+ leftTabTitle?: string;
40
+ leftTabIcon?: PanelConfig["icon"];
41
+ includeLeftTab?: boolean;
42
+ catalogId?: string;
43
+ catalogLabel?: string;
44
+ includeCatalog?: boolean;
45
+ visualizationPanelId?: string;
46
+ visualizationTitle?: string;
47
+ visualizationIcon?: PanelConfig["icon"];
48
+ visualizationComponent?: ComponentType<PaneProps<DataCatalogVisualizationParams>>;
49
+ includeVisualizationPanel?: boolean;
50
+ surfaceKind?: string;
51
+ surfaceResolverId?: string;
52
+ includeSurfaceResolver?: boolean;
53
+ source?: PanelConfig["source"];
54
+ }
55
+ interface CreateDataCatalogPluginOptions extends CreateDataCatalogContributionsOptions {
56
+ pluginId?: string;
57
+ }
58
+ interface DataCatalogResolvedQuery {
59
+ query?: string;
60
+ controlled: boolean;
61
+ }
62
+ interface DataCatalogVisualizationState extends DataCatalogResolvedQuery {
63
+ row?: ExplorerItem;
64
+ title: string;
65
+ }
66
+
67
+ declare function dataCatalogPanelInstanceId(rowId: string, prefix?: string): string;
68
+ declare function openDataCatalogVisualization(row: ExplorerItem, options: OpenDataCatalogVisualizationOptions): void;
69
+ declare function createDataCatalogOpenHandler(options: OpenDataCatalogVisualizationOptions): (row: ExplorerItem) => void;
70
+
71
+ interface CreateDataCatalogSurfaceResolverOptions {
72
+ id: string;
73
+ catalogId: string;
74
+ visualizationPanelId: string;
75
+ visualizationTitle: string;
76
+ panelIdPrefix?: string;
77
+ surfaceKind?: string;
78
+ surfaceResolverId?: string;
79
+ source?: string;
80
+ }
81
+ declare function createDataCatalogSurfaceResolver(options: CreateDataCatalogSurfaceResolverOptions): BoringFrontSurfaceResolverRegistration;
82
+
83
+ declare function readDataCatalogRow(value: unknown): ExplorerItem | undefined;
84
+ declare function resolveDataCatalogQuery(params: LeftTabParams | DataCatalogVisualizationParams | undefined): string | undefined;
85
+ declare function resolveDataCatalogControlledQuery(params: LeftTabParams | DataCatalogVisualizationParams | undefined): DataCatalogResolvedQuery;
86
+ declare function resolveDataCatalogVisualizationState(params: DataCatalogVisualizationParams | undefined, fallbackTitle: string): DataCatalogVisualizationState;
87
+ declare function useDataCatalogQuery(params: LeftTabParams | DataCatalogVisualizationParams | undefined): DataCatalogResolvedQuery;
88
+ declare function useDataCatalogVisualizationState(params: DataCatalogVisualizationParams | undefined, fallbackTitle: string): DataCatalogVisualizationState;
89
+ declare function useDataCatalogOpenVisualization(options: OpenDataCatalogVisualizationOptions): (row: ExplorerItem) => void;
90
+
91
+ /**
92
+ * Builds a `BoringFrontFactoryWithId` for the data-catalog plugin.
93
+ * The factory captures `options` in a closure and registers the
94
+ * configured left tab, visualization panel, catalog entry, and
95
+ * surface resolver imperatively when the workspace calls it.
96
+ *
97
+ * Each contribution is opt-out via the `include*` flags so host apps
98
+ * can compose a subset (e.g. catalog-only without a visualization
99
+ * panel). The returned factory carries its `pluginId` + `pluginLabel`
100
+ * so it can be passed directly to `WorkspaceProvider.plugins` (the
101
+ * shell captures via the front factory API).
102
+ */
103
+ declare function createDataCatalogPlugin(options: CreateDataCatalogPluginOptions): BoringFrontFactoryWithId;
104
+
105
+ export { type CreateDataCatalogPluginOptions, type CreateDataCatalogSurfaceResolverOptions, type DataCatalogResolvedQuery, type DataCatalogSelectContext, type DataCatalogVisualizationParams, type DataCatalogVisualizationState, type OpenDataCatalogVisualizationOptions, createDataCatalogOpenHandler, createDataCatalogPlugin, createDataCatalogSurfaceResolver, dataCatalogPanelInstanceId, openDataCatalogVisualization, readDataCatalogRow, resolveDataCatalogControlledQuery, resolveDataCatalogQuery, resolveDataCatalogVisualizationState, useDataCatalogOpenVisualization, useDataCatalogQuery, useDataCatalogVisualizationState };
@@ -0,0 +1,294 @@
1
+ "use client";
2
+
3
+ // src/front/index.tsx
4
+ import { BarChart3, Database } from "lucide-react";
5
+ import { PanelChrome } from "@hachej/boring-workspace";
6
+ import {
7
+ definePlugin
8
+ } from "@hachej/boring-workspace/plugin";
9
+ import { DataExplorer } from "@hachej/boring-data-explorer/front";
10
+
11
+ // src/shared/constants.ts
12
+ var DATA_CATALOG_PLUGIN_ID = "data-catalog";
13
+ var DATA_CATALOG_DEFAULT_TOOL_NAME = "query_data_catalog";
14
+ var DATA_CATALOG_ROW_SURFACE_KIND = "data-catalog.open-row";
15
+
16
+ // src/front/openVisualization.ts
17
+ import { postUiCommand } from "@hachej/boring-workspace";
18
+ function stableHash(input) {
19
+ let hash = 2166136261;
20
+ for (let i = 0; i < input.length; i++) {
21
+ hash ^= input.charCodeAt(i);
22
+ hash = Math.imul(hash, 16777619);
23
+ }
24
+ return (hash >>> 0).toString(36);
25
+ }
26
+ function slugForPanelId(input) {
27
+ const slug = input.trim().replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
28
+ return slug || "row";
29
+ }
30
+ function dataCatalogPanelInstanceId(rowId, prefix = "data") {
31
+ const hash = stableHash(rowId);
32
+ const slug = slugForPanelId(rowId);
33
+ const safePrefix = slugForPanelId(prefix);
34
+ const suffix = `${slug}-${hash}`;
35
+ const prefixBudget = Math.max(0, 64 - suffix.length - 1);
36
+ const trimmedPrefix = safePrefix.slice(0, prefixBudget);
37
+ return trimmedPrefix ? `${trimmedPrefix}-${suffix}` : suffix.slice(0, 64);
38
+ }
39
+ function openDataCatalogVisualization(row, options) {
40
+ const meta = {
41
+ ...options.params ?? {},
42
+ // Keep routing keys authoritative even when callers pass extra params.
43
+ row,
44
+ catalogId: options.catalogId,
45
+ ...options.title ? { title: options.title } : {}
46
+ };
47
+ postUiCommand({
48
+ kind: "openSurface",
49
+ params: {
50
+ kind: options.surfaceKind ?? DATA_CATALOG_ROW_SURFACE_KIND,
51
+ target: row.id,
52
+ meta
53
+ }
54
+ });
55
+ }
56
+ function createDataCatalogOpenHandler(options) {
57
+ return (row) => openDataCatalogVisualization(row, options);
58
+ }
59
+
60
+ // src/front/surfaceResolver.ts
61
+ function isExplorerItem(value) {
62
+ if (!value || typeof value !== "object") return false;
63
+ const row = value;
64
+ return typeof row.id === "string" && typeof row.title === "string";
65
+ }
66
+ function stringMeta(meta, key) {
67
+ const value = meta[key];
68
+ return typeof value === "string" && value.length > 0 ? value : void 0;
69
+ }
70
+ function createDataCatalogSurfaceResolver(options) {
71
+ const kind = options.surfaceKind ?? DATA_CATALOG_ROW_SURFACE_KIND;
72
+ const panelIdPrefix = options.panelIdPrefix ?? options.id;
73
+ return {
74
+ id: options.surfaceResolverId ?? `${options.id}-row`,
75
+ kind,
76
+ source: options.source,
77
+ resolve(request) {
78
+ if (request.kind !== kind) return void 0;
79
+ const meta = request.meta ?? {};
80
+ const catalogId = stringMeta(meta, "catalogId");
81
+ if (catalogId && catalogId !== options.catalogId) return void 0;
82
+ const row = isExplorerItem(meta.row) ? meta.row : void 0;
83
+ const {
84
+ catalogId: _catalogId,
85
+ row: _row,
86
+ title: _title,
87
+ ...extraParams
88
+ } = meta;
89
+ return {
90
+ id: dataCatalogPanelInstanceId(request.target, panelIdPrefix),
91
+ component: options.visualizationPanelId,
92
+ title: stringMeta(meta, "title") ?? row?.title ?? options.visualizationTitle,
93
+ params: {
94
+ ...extraParams,
95
+ ...row ? { row } : { query: request.target }
96
+ },
97
+ score: 0
98
+ };
99
+ }
100
+ };
101
+ }
102
+
103
+ // src/front/hooks.ts
104
+ import { useCallback, useMemo } from "react";
105
+ function readDataCatalogRow(value) {
106
+ if (!value || typeof value !== "object") return void 0;
107
+ const row = value;
108
+ if (typeof row.id !== "string" || typeof row.title !== "string") return void 0;
109
+ return row;
110
+ }
111
+ function resolveDataCatalogQuery(params) {
112
+ if (!params) return void 0;
113
+ if ("searchQuery" in params && typeof params.searchQuery === "string") {
114
+ return params.searchQuery;
115
+ }
116
+ if ("query" in params && typeof params.query === "string") return params.query;
117
+ return void 0;
118
+ }
119
+ function resolveDataCatalogControlledQuery(params) {
120
+ const query = resolveDataCatalogQuery(params);
121
+ return { query, controlled: query !== void 0 };
122
+ }
123
+ function resolveDataCatalogVisualizationState(params, fallbackTitle) {
124
+ const row = readDataCatalogRow(params?.row);
125
+ const query = resolveDataCatalogQuery(params) ?? row?.id;
126
+ return {
127
+ row,
128
+ query,
129
+ controlled: query !== void 0,
130
+ title: row?.title ?? fallbackTitle
131
+ };
132
+ }
133
+ function useDataCatalogQuery(params) {
134
+ return useMemo(() => resolveDataCatalogControlledQuery(params), [params]);
135
+ }
136
+ function useDataCatalogVisualizationState(params, fallbackTitle) {
137
+ return useMemo(
138
+ () => resolveDataCatalogVisualizationState(params, fallbackTitle),
139
+ [fallbackTitle, params]
140
+ );
141
+ }
142
+ function useDataCatalogOpenVisualization(options) {
143
+ const { catalogId, surfaceKind, title, params } = options;
144
+ return useCallback(
145
+ (row) => openDataCatalogVisualization(row, { catalogId, surfaceKind, title, params }),
146
+ [catalogId, surfaceKind, title, params]
147
+ );
148
+ }
149
+
150
+ // src/front/index.tsx
151
+ import { jsx, jsxs } from "react/jsx-runtime";
152
+ function createDataCatalogPlugin(options) {
153
+ const id = options.id ?? DATA_CATALOG_PLUGIN_ID;
154
+ const label = options.label ?? "Data";
155
+ const catalogId = options.catalogId ?? id;
156
+ const catalogLabel = options.catalogLabel ?? label;
157
+ const leftTabId = options.leftTabId ?? `${id}-tab`;
158
+ const leftTabTitle = options.leftTabTitle ?? label;
159
+ const visualizationPanelId = options.visualizationPanelId ?? `${id}-visualization`;
160
+ const visualizationTitle = options.visualizationTitle ?? `${label} View`;
161
+ const surfaceKind = options.surfaceKind ?? DATA_CATALOG_ROW_SURFACE_KIND;
162
+ const source = options.source ?? "app";
163
+ const includeVisualizationPanel = options.includeVisualizationPanel ?? true;
164
+ const includeLeftTab = options.includeLeftTab ?? true;
165
+ const includeCatalog = options.includeCatalog ?? true;
166
+ const includeSurfaceResolver = options.includeSurfaceResolver ?? (includeVisualizationPanel && !options.onSelect);
167
+ const emptyState = options.emptyState ?? "No data found";
168
+ const searchPlaceholder = options.searchPlaceholder ?? `Search ${label.toLowerCase()}...`;
169
+ const onSelect = options.onSelect ?? (includeVisualizationPanel ? createDataCatalogOpenHandler({ catalogId, surfaceKind }) : () => {
170
+ });
171
+ function DataCatalogLeftTab({ params, className }) {
172
+ const { query, controlled } = useDataCatalogQuery(params);
173
+ const bridge = params?.bridge;
174
+ const handleSelect = (row) => onSelect(row, { params, bridge });
175
+ return /* @__PURE__ */ jsx(
176
+ DataExplorer,
177
+ {
178
+ adapter: options.adapter,
179
+ facets: options.facets,
180
+ groupBy: options.groupBy,
181
+ onActivate: handleSelect,
182
+ getDragPayload: options.getDragPayload,
183
+ emptyState,
184
+ searchPlaceholder,
185
+ query: controlled ? query : void 0,
186
+ searchable: !controlled,
187
+ pageSize: options.pageSize,
188
+ debounceMs: options.debounceMs,
189
+ className: className ?? "h-full"
190
+ }
191
+ );
192
+ }
193
+ function DefaultVisualizationPanel({
194
+ params,
195
+ api: panelApi,
196
+ className
197
+ }) {
198
+ const { row, query, controlled, title } = useDataCatalogVisualizationState(
199
+ params,
200
+ visualizationTitle
201
+ );
202
+ const handleSelect = (nextRow) => onSelect(nextRow, { params });
203
+ return /* @__PURE__ */ jsxs(
204
+ PanelChrome,
205
+ {
206
+ title,
207
+ icon: options.visualizationIcon ?? BarChart3,
208
+ panelApi,
209
+ className,
210
+ children: [
211
+ row ? /* @__PURE__ */ jsxs("div", { className: "border-b border-border/60 px-3 py-2", children: [
212
+ /* @__PURE__ */ jsx("div", { className: "truncate text-sm font-medium text-foreground", children: row.title }),
213
+ /* @__PURE__ */ jsx("div", { className: "truncate text-xs text-muted-foreground", children: row.subtitle ?? row.id })
214
+ ] }) : null,
215
+ /* @__PURE__ */ jsx(
216
+ DataExplorer,
217
+ {
218
+ adapter: options.adapter,
219
+ facets: options.facets,
220
+ groupBy: options.groupBy,
221
+ onActivate: handleSelect,
222
+ getDragPayload: options.getDragPayload,
223
+ emptyState,
224
+ searchPlaceholder,
225
+ query: controlled ? query : void 0,
226
+ searchable: !controlled,
227
+ pageSize: options.pageSize,
228
+ debounceMs: options.debounceMs,
229
+ className: "h-full"
230
+ }
231
+ )
232
+ ]
233
+ }
234
+ );
235
+ }
236
+ const leftTab = includeLeftTab ? {
237
+ id: leftTabId,
238
+ title: leftTabTitle,
239
+ icon: options.leftTabIcon ?? Database,
240
+ component: DataCatalogLeftTab,
241
+ source,
242
+ chromeless: true,
243
+ panelId: leftTabId
244
+ } : void 0;
245
+ const visualizationPanel = includeVisualizationPanel ? {
246
+ id: visualizationPanelId,
247
+ label: visualizationTitle,
248
+ icon: options.visualizationIcon ?? BarChart3,
249
+ component: options.visualizationComponent ?? DefaultVisualizationPanel,
250
+ placement: "center",
251
+ source
252
+ } : void 0;
253
+ const catalog = includeCatalog ? {
254
+ id: catalogId,
255
+ label: catalogLabel,
256
+ adapter: options.adapter,
257
+ onSelect: (row) => onSelect(row, {})
258
+ } : void 0;
259
+ const resolver = includeSurfaceResolver ? createDataCatalogSurfaceResolver({
260
+ id,
261
+ catalogId,
262
+ visualizationPanelId,
263
+ visualizationTitle,
264
+ panelIdPrefix: id,
265
+ surfaceKind,
266
+ surfaceResolverId: options.surfaceResolverId,
267
+ source
268
+ }) : void 0;
269
+ return definePlugin({
270
+ id,
271
+ label,
272
+ leftTabs: leftTab ? [leftTab] : [],
273
+ panels: visualizationPanel ? [visualizationPanel] : [],
274
+ catalogs: catalog ? [catalog] : [],
275
+ surfaceResolvers: resolver ? [resolver] : []
276
+ });
277
+ }
278
+ export {
279
+ DATA_CATALOG_DEFAULT_TOOL_NAME,
280
+ DATA_CATALOG_PLUGIN_ID,
281
+ DATA_CATALOG_ROW_SURFACE_KIND,
282
+ createDataCatalogOpenHandler,
283
+ createDataCatalogPlugin,
284
+ createDataCatalogSurfaceResolver,
285
+ dataCatalogPanelInstanceId,
286
+ openDataCatalogVisualization,
287
+ readDataCatalogRow,
288
+ resolveDataCatalogControlledQuery,
289
+ resolveDataCatalogQuery,
290
+ resolveDataCatalogVisualizationState,
291
+ useDataCatalogOpenVisualization,
292
+ useDataCatalogQuery,
293
+ useDataCatalogVisualizationState
294
+ };
@@ -0,0 +1,29 @@
1
+ import { ExplorerDataSource, SearchResult } from '@hachej/boring-data-explorer/shared';
2
+ import { WorkspaceServerPlugin } from '@hachej/boring-workspace/server';
3
+ import { AgentTool } from '@hachej/boring-workspace';
4
+
5
+ interface DataCatalogAgentToolOptions {
6
+ name?: string;
7
+ label?: string;
8
+ adapter: ExplorerDataSource;
9
+ defaultLimit?: number;
10
+ maxLimit?: number;
11
+ }
12
+ interface DataCatalogSkillOptions {
13
+ label?: string;
14
+ toolName?: string;
15
+ surfaceKind?: string;
16
+ guidance?: string;
17
+ }
18
+ interface DataCatalogServerPluginOptions extends DataCatalogAgentToolOptions, DataCatalogSkillOptions {
19
+ id?: string;
20
+ }
21
+ declare function formatDataCatalogSearchResult(query: string, result: SearchResult): string;
22
+ declare function createDataCatalogAgentTool(options: DataCatalogAgentToolOptions): AgentTool;
23
+ declare function createDataCatalogSkillPrompt(options?: DataCatalogSkillOptions): string;
24
+ declare function createDataCatalogServerPlugin(options: DataCatalogServerPluginOptions): WorkspaceServerPlugin & {
25
+ agentTools: AgentTool[];
26
+ systemPrompt: string;
27
+ };
28
+
29
+ export { type DataCatalogAgentToolOptions, type DataCatalogServerPluginOptions, type DataCatalogSkillOptions, createDataCatalogAgentTool, createDataCatalogServerPlugin, createDataCatalogSkillPrompt, formatDataCatalogSearchResult };
@@ -0,0 +1,127 @@
1
+ // src/server/index.ts
2
+ import {
3
+ defineServerPlugin
4
+ } from "@hachej/boring-workspace/server";
5
+
6
+ // src/shared/constants.ts
7
+ var DATA_CATALOG_PLUGIN_ID = "data-catalog";
8
+ var DATA_CATALOG_DEFAULT_TOOL_NAME = "query_data_catalog";
9
+ var DATA_CATALOG_ROW_SURFACE_KIND = "data-catalog.open-row";
10
+
11
+ // src/server/index.ts
12
+ function textResult(text, details) {
13
+ return { content: [{ type: "text", text }], details };
14
+ }
15
+ function errorResult(text) {
16
+ return { content: [{ type: "text", text }], isError: true };
17
+ }
18
+ function clampLimit(value, fallback, max) {
19
+ const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
20
+ if (!Number.isFinite(numeric)) return fallback;
21
+ return Math.max(1, Math.min(max, Math.floor(numeric)));
22
+ }
23
+ function normalizeLimitOptions(options) {
24
+ const rawMax = options.maxLimit ?? 50;
25
+ const maxLimit = typeof rawMax === "number" && Number.isFinite(rawMax) ? Math.max(1, Math.floor(rawMax)) : 50;
26
+ const rawDefault = options.defaultLimit ?? 20;
27
+ const defaultLimit = typeof rawDefault === "number" && Number.isFinite(rawDefault) ? Math.max(1, Math.min(maxLimit, Math.floor(rawDefault))) : Math.min(20, maxLimit);
28
+ return { defaultLimit, maxLimit };
29
+ }
30
+ function formatBadge(row) {
31
+ const parts = [
32
+ row.leading?.code,
33
+ ...(row.trailing ?? []).map((badge) => badge.code),
34
+ row.meta
35
+ ].filter(Boolean);
36
+ return parts.length > 0 ? ` [${parts.join(", ")}]` : "";
37
+ }
38
+ function formatDataCatalogSearchResult(query, result) {
39
+ if (result.items.length === 0) {
40
+ return `No ${query ? `results for "${query}"` : "catalog results"}.`;
41
+ }
42
+ const lines = result.items.map((row) => {
43
+ const subtitle = row.subtitle ? ` \u2014 ${row.subtitle}` : "";
44
+ return `${row.id}: ${row.title}${subtitle}${formatBadge(row)}`;
45
+ });
46
+ const total = Number.isFinite(result.total) ? result.total : result.items.length;
47
+ return `Found ${total} results (showing ${result.items.length}):
48
+
49
+ ${lines.join("\n")}`;
50
+ }
51
+ function createDataCatalogAgentTool(options) {
52
+ const name = options.name ?? DATA_CATALOG_DEFAULT_TOOL_NAME;
53
+ const label = options.label ?? "data catalog";
54
+ const { defaultLimit, maxLimit } = normalizeLimitOptions(options);
55
+ return {
56
+ name,
57
+ description: `Search the ${label}. Use this before opening data visualizations or asking for a specific dataset.`,
58
+ parameters: {
59
+ type: "object",
60
+ properties: {
61
+ query: {
62
+ type: "string",
63
+ description: "Search keywords for datasets, series, tables, or metrics."
64
+ },
65
+ limit: {
66
+ type: "number",
67
+ description: `Maximum number of results. Default ${defaultLimit}, max ${maxLimit}.`,
68
+ minimum: 1,
69
+ maximum: maxLimit
70
+ }
71
+ },
72
+ required: ["query"],
73
+ additionalProperties: false
74
+ },
75
+ async execute(params, ctx) {
76
+ const query = String(params.query ?? "").trim();
77
+ if (!query) return errorResult("query is required");
78
+ const limit = clampLimit(params.limit, defaultLimit, maxLimit);
79
+ try {
80
+ const result = await options.adapter.search({
81
+ query,
82
+ filters: {},
83
+ limit,
84
+ offset: 0,
85
+ signal: ctx.abortSignal
86
+ });
87
+ return textResult(formatDataCatalogSearchResult(query, result), result);
88
+ } catch (error) {
89
+ return errorResult(error instanceof Error ? error.message : String(error));
90
+ }
91
+ }
92
+ };
93
+ }
94
+ function createDataCatalogSkillPrompt(options = {}) {
95
+ const label = options.label ?? "data catalog";
96
+ const toolName = options.toolName ?? DATA_CATALOG_DEFAULT_TOOL_NAME;
97
+ const surfaceKind = options.surfaceKind ?? DATA_CATALOG_ROW_SURFACE_KIND;
98
+ const guidance = options.guidance?.trim();
99
+ return [
100
+ "## Data Catalog Plugin",
101
+ "",
102
+ `Use \`${toolName}\` to search the ${label} before referencing datasets, series, tables, or metrics.`,
103
+ `When you need to show a catalog row to the user, use the workspace UI bridge \`openSurface\` command with \`{ kind: '${surfaceKind}', target: row.id, meta: { catalogId, row } }\` so the client plugin resolver chooses the panel.`,
104
+ guidance ? "" : void 0,
105
+ guidance || void 0
106
+ ].filter((line) => line !== void 0).join("\n");
107
+ }
108
+ function createDataCatalogServerPlugin(options) {
109
+ const tool = createDataCatalogAgentTool(options);
110
+ return defineServerPlugin({
111
+ id: options.id ?? DATA_CATALOG_PLUGIN_ID,
112
+ label: options.label ?? "Data Catalog",
113
+ agentTools: [tool],
114
+ systemPrompt: createDataCatalogSkillPrompt({
115
+ label: options.label,
116
+ toolName: tool.name,
117
+ surfaceKind: options.surfaceKind,
118
+ guidance: options.guidance
119
+ })
120
+ });
121
+ }
122
+ export {
123
+ createDataCatalogAgentTool,
124
+ createDataCatalogServerPlugin,
125
+ createDataCatalogSkillPrompt,
126
+ formatDataCatalogSearchResult
127
+ };
@@ -0,0 +1,5 @@
1
+ declare const DATA_CATALOG_PLUGIN_ID = "data-catalog";
2
+ declare const DATA_CATALOG_DEFAULT_TOOL_NAME = "query_data_catalog";
3
+ declare const DATA_CATALOG_ROW_SURFACE_KIND = "data-catalog.open-row";
4
+
5
+ export { DATA_CATALOG_DEFAULT_TOOL_NAME, DATA_CATALOG_PLUGIN_ID, DATA_CATALOG_ROW_SURFACE_KIND };
@@ -0,0 +1,9 @@
1
+ // src/shared/constants.ts
2
+ var DATA_CATALOG_PLUGIN_ID = "data-catalog";
3
+ var DATA_CATALOG_DEFAULT_TOOL_NAME = "query_data_catalog";
4
+ var DATA_CATALOG_ROW_SURFACE_KIND = "data-catalog.open-row";
5
+ export {
6
+ DATA_CATALOG_DEFAULT_TOOL_NAME,
7
+ DATA_CATALOG_PLUGIN_ID,
8
+ DATA_CATALOG_ROW_SURFACE_KIND
9
+ };
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@hachej/boring-data-catalog",
3
+ "version": "0.1.13",
4
+ "type": "module",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "description": "Plugin BUILDER for Boring workspace data catalogs. Consumers call `createDataCatalogPlugin(options)` to bind their own adapter/facets and get a configured BoringFrontFactoryWithId. NOT a direct `defaultPluginPackages` entry (no default export, options required) \u2014 wrap in an app-local plugin module that default-exports the built factory, then list that wrapper in `defaultPluginPackages`.",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/hachej/boring-ui"
11
+ },
12
+ "homepage": "https://github.com/hachej/boring-ui",
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/front/index.d.ts",
19
+ "import": "./dist/front/index.js"
20
+ },
21
+ "./front": {
22
+ "types": "./dist/front/index.d.ts",
23
+ "import": "./dist/front/index.js"
24
+ },
25
+ "./server": {
26
+ "types": "./dist/server/index.d.ts",
27
+ "import": "./dist/server/index.js"
28
+ },
29
+ "./shared": {
30
+ "types": "./dist/shared/index.d.ts",
31
+ "import": "./dist/shared/index.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "sideEffects": false,
36
+ "scripts": {
37
+ "build": "pnpm --filter @hachej/boring-data-explorer build && pnpm --filter @hachej/boring-workspace build && tsup",
38
+ "typecheck": "pnpm --filter @hachej/boring-data-explorer build && pnpm --filter @hachej/boring-workspace build && tsc --noEmit",
39
+ "test": "pnpm --filter @hachej/boring-data-explorer build && pnpm --filter @hachej/boring-workspace build && vitest run --passWithNoTests",
40
+ "lint": "pnpm run typecheck",
41
+ "clean": "rm -rf dist .tsbuildinfo"
42
+ },
43
+ "peerDependencies": {
44
+ "@hachej/boring-workspace": "workspace:*",
45
+ "react": "^18.0.0 || ^19.0.0",
46
+ "react-dom": "^18.0.0 || ^19.0.0"
47
+ },
48
+ "dependencies": {
49
+ "@hachej/boring-data-explorer": "workspace:*",
50
+ "lucide-react": "^1.8.0"
51
+ },
52
+ "devDependencies": {
53
+ "@hachej/boring-workspace": "workspace:*",
54
+ "@testing-library/jest-dom": "^6.9.1",
55
+ "@testing-library/react": "^16.3.2",
56
+ "@types/node": "^22.15.3",
57
+ "@types/react": "^19.0.0",
58
+ "@types/react-dom": "^19.0.0",
59
+ "@vitejs/plugin-react": "^4.0.0",
60
+ "jsdom": "^29.0.2",
61
+ "react": "^19.0.0",
62
+ "react-dom": "^19.0.0",
63
+ "tsup": "^8.0.0",
64
+ "typescript": "^5.4.0",
65
+ "vitest": "^2.0.0"
66
+ }
67
+ }