dsh-lh-data 0.1.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.
Files changed (89) hide show
  1. package/README.md +332 -0
  2. package/cordis.patch.yml +7 -0
  3. package/lib/admin-contract.d.ts +274 -0
  4. package/lib/admin-http.d.ts +31 -0
  5. package/lib/admin-validate.d.ts +87 -0
  6. package/lib/admin.d.ts +60 -0
  7. package/lib/client.js +2969 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/datasource/columns.d.ts +13 -0
  10. package/lib/datasource/connection.d.ts +33 -0
  11. package/lib/datasource/connector/base.d.ts +21 -0
  12. package/lib/datasource/connector/mysql.d.ts +23 -0
  13. package/lib/datasource/connector/postgresql.d.ts +23 -0
  14. package/lib/datasource/crypto.d.ts +13 -0
  15. package/lib/datasource/driver.d.ts +39 -0
  16. package/lib/datasource/errors.d.ts +25 -0
  17. package/lib/datasource/importer.d.ts +38 -0
  18. package/lib/datasource/source-sql.d.ts +11 -0
  19. package/lib/datasource/source-store.d.ts +28 -0
  20. package/lib/datasource/types.d.ts +119 -0
  21. package/lib/db.d.ts +91 -0
  22. package/lib/http-common.d.ts +31 -0
  23. package/lib/http.d.ts +13 -0
  24. package/lib/index.d.ts +50 -0
  25. package/lib/index.js +5735 -0
  26. package/lib/parse.d.ts +41 -0
  27. package/lib/preview.d.ts +75 -0
  28. package/lib/render.d.ts +100 -0
  29. package/lib/scope-registry.d.ts +47 -0
  30. package/lib/scope.d.ts +56 -0
  31. package/lib/sql.d.ts +133 -0
  32. package/lib/store.d.ts +174 -0
  33. package/lib/table.d.ts +38 -0
  34. package/lib/tooling.d.ts +267 -0
  35. package/lib/tools/datasource.d.ts +16 -0
  36. package/lib/tools/import.d.ts +21 -0
  37. package/lib/tools/read.d.ts +77 -0
  38. package/lib/tools/registry.d.ts +12 -0
  39. package/lib/tools/write.d.ts +37 -0
  40. package/lib/view.d.ts +138 -0
  41. package/package.json +61 -0
  42. package/src/admin-contract.ts +351 -0
  43. package/src/admin-http.ts +254 -0
  44. package/src/admin-validate.ts +393 -0
  45. package/src/admin.ts +558 -0
  46. package/src/client/index.ts +403 -0
  47. package/src/client/settings/CreateForm.tsx +186 -0
  48. package/src/client/settings/DataSourceForm.tsx +278 -0
  49. package/src/client/settings/DataSourcesPanel.tsx +301 -0
  50. package/src/client/settings/DatasetEditor.tsx +245 -0
  51. package/src/client/settings/DatasetTable.tsx +110 -0
  52. package/src/client/settings/DatasetsPanel.tsx +207 -0
  53. package/src/client/settings/RowsPanel.tsx +196 -0
  54. package/src/client/settings/Section.tsx +41 -0
  55. package/src/client/settings/SourceTablesPanel.tsx +226 -0
  56. package/src/client/settings/api.ts +154 -0
  57. package/src/client/settings/styles.ts +125 -0
  58. package/src/datasource/columns.ts +56 -0
  59. package/src/datasource/connection.ts +124 -0
  60. package/src/datasource/connector/base.ts +54 -0
  61. package/src/datasource/connector/mysql.ts +187 -0
  62. package/src/datasource/connector/postgresql.ts +212 -0
  63. package/src/datasource/crypto.ts +61 -0
  64. package/src/datasource/driver.ts +108 -0
  65. package/src/datasource/errors.ts +69 -0
  66. package/src/datasource/importer.ts +262 -0
  67. package/src/datasource/index.ts +62 -0
  68. package/src/datasource/source-sql.ts +64 -0
  69. package/src/datasource/source-store.ts +152 -0
  70. package/src/datasource/types.ts +133 -0
  71. package/src/db.ts +277 -0
  72. package/src/http-common.ts +91 -0
  73. package/src/http.ts +130 -0
  74. package/src/index.ts +486 -0
  75. package/src/parse.ts +213 -0
  76. package/src/preview.ts +198 -0
  77. package/src/render.ts +294 -0
  78. package/src/scope-registry.ts +111 -0
  79. package/src/scope.ts +159 -0
  80. package/src/sql.ts +491 -0
  81. package/src/store.ts +412 -0
  82. package/src/table.ts +160 -0
  83. package/src/tooling.ts +551 -0
  84. package/src/tools/datasource.ts +378 -0
  85. package/src/tools/import.ts +282 -0
  86. package/src/tools/read.ts +536 -0
  87. package/src/tools/registry.ts +56 -0
  88. package/src/tools/write.ts +241 -0
  89. package/src/view.ts +371 -0
package/src/parse.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * 文件解析与类型推断 —— 移植自 `agentic-data-mini` 的 `src/lib/utils/fileParser.ts`。
3
+ *
4
+ * 保留的关键行为(设计文档 §4):
5
+ * - XLSX:`XLSX.read({ type: 'buffer', cellDates: true })` + `sheet_to_json({ raw: true, defval: null })`,
6
+ * Date → `YYYY-MM-DD`;默认第一个 sheet。
7
+ * - CSV:`Papa.parse({ header: true, skipEmptyLines: true, dynamicTyping: true, transformHeader: trim })`。
8
+ * - 列名:**保留原名(含中文)**,SQL 中双引号包裹;空列名 → `column`;重名追加 `_2`。
9
+ * - 类型推断:编码/号码类列名强制 text;13+ 位整数或 `xE+12` 科学计数法强制 text(防精度丢失);
10
+ * 其余 numeric>80% / boolean>90% / date 正则>70%。
11
+ */
12
+
13
+ import Papa from 'papaparse'
14
+ import * as XLSX from 'xlsx'
15
+
16
+ export type ColumnType = 'text' | 'numeric' | 'boolean' | 'date'
17
+
18
+ export interface ColumnInfo {
19
+ /** 原始表头(如「物料编码」)。 */
20
+ name: string
21
+ /** SQL 中使用的列名(与 name 相同,或去重后加 `_2`)。 */
22
+ sanitizedName: string
23
+ type: ColumnType
24
+ nullable: boolean
25
+ sample: unknown[]
26
+ description?: string
27
+ }
28
+
29
+ export interface ParsedFile {
30
+ headers: string[]
31
+ rows: Record<string, unknown>[]
32
+ rowCount: number
33
+ columns: ColumnInfo[]
34
+ }
35
+
36
+ export const DEFAULT_SAMPLE_ROWS = 100
37
+
38
+ /** 列名消毒:trim,空列名回落 `column`,其余保留原名。 */
39
+ export function sanitizeColumnName(name: string): string {
40
+ const trimmed = name.trim()
41
+ return trimmed.length === 0 ? 'column' : trimmed
42
+ }
43
+
44
+ /** 重名列追加 `_2` / `_3`(原地修改)。 */
45
+ export function deduplicateColumnNames(columns: ColumnInfo[]): void {
46
+ const nameCounts = new Map<string, number>()
47
+ for (const column of columns) {
48
+ nameCounts.set(column.sanitizedName, (nameCounts.get(column.sanitizedName) ?? 0) + 1)
49
+ }
50
+ const seen = new Map<string, number>()
51
+ for (const column of columns) {
52
+ const name = column.sanitizedName
53
+ if ((nameCounts.get(name) ?? 0) > 1) {
54
+ const index = (seen.get(name) ?? 0) + 1
55
+ seen.set(name, index)
56
+ column.sanitizedName = `${name}_${index}`
57
+ }
58
+ }
59
+ }
60
+
61
+ function generateColumnDescription(name: string, type: ColumnType, sample: unknown[]): string {
62
+ const typeDesc: Record<ColumnType, string> = {
63
+ text: '文本字段',
64
+ numeric: '数值字段',
65
+ boolean: '布尔字段',
66
+ date: '日期字段',
67
+ }
68
+ let description: string = typeDesc[type] ?? '字段'
69
+ const first = sample[0]
70
+ if (first !== null && first !== undefined) {
71
+ description += `,示例: "${String(first).slice(0, 30)}"`
72
+ }
73
+ void name
74
+ return description
75
+ }
76
+
77
+ const CHINESE_CODE_PATTERNS = ['编码', '编号', '代码', '代号', '账号', '证件号', '邮编', '区号']
78
+ const ENGLISH_CODE_PATTERNS = ['code', 'sku', 'ean', 'upc', 'isbn', 'issn', 'postal', 'zip']
79
+
80
+ /** 编码/号码类列:即使值是数字也应存 text(无数学含义)。 */
81
+ export function isCodeOrIdField(columnName: string): boolean {
82
+ const name = columnName.toLowerCase().trim()
83
+ for (const pattern of CHINESE_CODE_PATTERNS) {
84
+ if (name.includes(pattern)) return true
85
+ }
86
+ for (const pattern of ENGLISH_CODE_PATTERNS) {
87
+ if (new RegExp(`(^|_)${pattern}(_|$)`, 'i').test(name) || name === pattern) return true
88
+ }
89
+ if (name.includes('phone') || name.includes('tel') || name.includes('mobile')
90
+ || name.includes('电话') || name.includes('手机')) {
91
+ return true
92
+ }
93
+ return false
94
+ }
95
+
96
+ /** 大数判定:13+ 位整数或 `1.78E+12` 这类科学计数法。 */
97
+ export function isLargeNumber(value: unknown): boolean {
98
+ if (value === null || value === undefined || value === '') return false
99
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 1e12) return true
100
+ const text = String(value)
101
+ if (/^\d{13,}$/.test(text)) return true
102
+ const sci = text.match(/^(\d+\.?\d*)E\+(\d+)$/i)
103
+ if (sci !== null && Number.parseInt(sci[2]!, 10) >= 12) return true
104
+ return false
105
+ }
106
+
107
+ /** 把行内的 Date 对象格式化为 `YYYY-MM-DD`。 */
108
+ function formatDateValues(rows: Record<string, unknown>[]): void {
109
+ for (const row of rows) {
110
+ for (const key of Object.keys(row)) {
111
+ if (row[key] instanceof Date) {
112
+ row[key] = (row[key] as Date).toISOString().slice(0, 10)
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ /** 从采样值 + 列名推断列类型;列名优先级最高。 */
119
+ export function inferColumnType(values: unknown[], columnName?: string): ColumnType {
120
+ if (columnName !== undefined && isCodeOrIdField(columnName)) return 'text'
121
+ const nonNull = values.filter(value => value !== null && value !== undefined && value !== '')
122
+ if (nonNull.length === 0) return 'text'
123
+ if (nonNull.some(value => isLargeNumber(value))) return 'text'
124
+
125
+ const numericCount = nonNull.filter((value) => {
126
+ const n = Number(value)
127
+ return !Number.isNaN(n) && value !== '' && value !== true && value !== false
128
+ }).length
129
+ if (numericCount / nonNull.length > 0.8) return 'numeric'
130
+
131
+ const boolCount = nonNull.filter(
132
+ value => value === true || value === false || value === 'true' || value === 'false' || value === '1' || value === '0',
133
+ ).length
134
+ if (boolCount / nonNull.length > 0.9) return 'boolean'
135
+
136
+ const datePattern = /^\d{4}[-/]\d{2}[-/]\d{2}|^\d{2}[-/]\d{2}[-/]\d{4}/
137
+ const dateCount = nonNull.filter(value => typeof value === 'string' && datePattern.test(value)).length
138
+ if (dateCount / nonNull.length > 0.7) return 'date'
139
+
140
+ return 'text'
141
+ }
142
+
143
+ function buildColumns(
144
+ headers: string[],
145
+ rows: Record<string, unknown>[],
146
+ sampleRows: number,
147
+ ): ColumnInfo[] {
148
+ const columns: ColumnInfo[] = headers.map((header) => {
149
+ const sample = rows.map(row => row[header]).slice(0, sampleRows)
150
+ const type = inferColumnType(sample, header)
151
+ const allValues = rows.map(row => row[header])
152
+ const nullCount = allValues.filter(value => value === null || value === undefined || value === '').length
153
+ const sanitizedName = sanitizeColumnName(header)
154
+ return {
155
+ name: header,
156
+ sanitizedName,
157
+ type,
158
+ // 有空值或采样不足都按可空处理。
159
+ nullable: nullCount > 0 || allValues.length < rows.length,
160
+ sample: sample.slice(0, 5),
161
+ description: generateColumnDescription(header, type, sample.slice(0, 5)),
162
+ }
163
+ })
164
+ deduplicateColumnNames(columns)
165
+ return columns
166
+ }
167
+
168
+ /** 用去重后的列名重写行键。 */
169
+ function renameRows(headers: string[], rows: Record<string, unknown>[], columns: ColumnInfo[]): Record<string, unknown>[] {
170
+ return rows.map((row) => {
171
+ const next: Record<string, unknown> = {}
172
+ headers.forEach((header, index) => {
173
+ next[columns[index]!.sanitizedName] = row[header]
174
+ })
175
+ return next
176
+ })
177
+ }
178
+
179
+ export function parseCSV(buffer: Buffer, sampleRows: number = DEFAULT_SAMPLE_ROWS): ParsedFile {
180
+ const content = buffer.toString('utf-8')
181
+ const result = Papa.parse<Record<string, unknown>>(content, {
182
+ header: true,
183
+ skipEmptyLines: true,
184
+ dynamicTyping: true,
185
+ transformHeader: header => header.trim(),
186
+ })
187
+ if (result.errors.length > 0 && result.data.length === 0) {
188
+ throw new Error(`CSV parse error: ${result.errors[0]?.message ?? 'unknown'}`)
189
+ }
190
+ const headers = result.meta.fields ?? []
191
+ const rows = result.data
192
+ if (headers.length === 0) return { headers, rows: [], rowCount: 0, columns: [] }
193
+ const columns = buildColumns(headers, rows, sampleRows)
194
+ return { headers, rows: renameRows(headers, rows, columns), rowCount: rows.length, columns }
195
+ }
196
+
197
+ export function parseXLSX(buffer: Buffer, sheetName?: string, sampleRows: number = DEFAULT_SAMPLE_ROWS): ParsedFile {
198
+ const workbook = XLSX.read(buffer, { type: 'buffer', cellDates: true })
199
+ const selected = sheetName === undefined || sheetName.trim().length === 0
200
+ ? workbook.SheetNames[0]
201
+ : workbook.SheetNames.find(name => name === sheetName || name.toLowerCase() === sheetName.trim().toLowerCase())
202
+ if (selected === undefined) {
203
+ throw new Error(`工作表不存在:${sheetName}(可用:${workbook.SheetNames.join(', ') || '(空)'})`)
204
+ }
205
+ const worksheet = workbook.Sheets[selected]
206
+ if (worksheet === undefined) throw new Error(`工作表不可读取:${selected}`)
207
+ const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(worksheet, { raw: true, defval: null })
208
+ formatDateValues(rows)
209
+ if (rows.length === 0) return { headers: [], rows: [], rowCount: 0, columns: [] }
210
+ const headers = Object.keys(rows[0] as Record<string, unknown>)
211
+ const columns = buildColumns(headers, rows, sampleRows)
212
+ return { headers, rows: renameRows(headers, rows, columns), rowCount: rows.length, columns }
213
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * 模型可见片段的构造 —— 设计文档 §5.3。
3
+ *
4
+ * 这里只做两件事:把结果裁成「前几行 + 可选尾几行」的预览,以及基于**全量数据**
5
+ * 生成类型化摘要(不是只看预览行)。摘要让模型在不读全表的情况下也能下结论,
6
+ * 从而不再需要逐页翻数据。
7
+ */
8
+
9
+ import type { Database, Row } from './db'
10
+ import type { ColumnType } from './parse'
11
+ import { quoteIdentifier } from './sql'
12
+
13
+ export type PreviewStrategy = 'head' | 'head-tail'
14
+
15
+ export interface PreviewOptions {
16
+ previewRows: number
17
+ previewStrategy: PreviewStrategy
18
+ previewColumns: number
19
+ summaryEnabled: boolean
20
+ /** 参与摘要的列数上限(宽表保护)。 */
21
+ summaryMaxColumns: number
22
+ /** 文本列取 top 值的列数上限。 */
23
+ summaryMaxTextColumns: number
24
+ }
25
+
26
+ /** 一列的统计摘要;字段按类型给出,缺失即不适用。 */
27
+ export interface ColumnSummary {
28
+ name: string
29
+ type: ColumnType
30
+ /** 非空值个数。 */
31
+ count: number
32
+ min?: number | string | null
33
+ max?: number | string | null
34
+ avg?: number | null
35
+ sum?: number | null
36
+ /** distinct 取值数(文本列)。 */
37
+ distinct?: number
38
+ /** 高频取值(文本列,distinct 过多时省略)。 */
39
+ top?: { value: string; count: number }[]
40
+ /** 布尔列为真的行数。 */
41
+ trueCount?: number
42
+ }
43
+
44
+ export interface PreviewSlice {
45
+ rows: Row[]
46
+ columns: string[]
47
+ /** 是否折叠了列(结果列数超过 previewColumns)。 */
48
+ columnTruncated: boolean
49
+ /** 头尾之间是否跳过了行。 */
50
+ gap: boolean
51
+ /** 被跳过的行数。 */
52
+ skipped: number
53
+ }
54
+
55
+ /** 头/尾各取多少行;`tail > 0` 时渲染层会在中间插入省略提示。 */
56
+ export function previewSlicePlan(totalRows: number, options: PreviewOptions): { head: number; tail: number } {
57
+ const limit = Math.max(1, Math.floor(options.previewRows))
58
+ const rows = Math.max(0, Math.floor(totalRows))
59
+ if (rows <= limit || options.previewStrategy !== 'head-tail') {
60
+ return { head: Math.min(limit, rows), tail: 0 }
61
+ }
62
+ const tail = Math.ceil(limit / 2)
63
+ const head = Math.max(1, limit - tail)
64
+ return { head, tail }
65
+ }
66
+
67
+ /** 裁剪预览列(超宽表只保留前 N 列)。 */
68
+ export function previewColumns(columns: string[], options: PreviewOptions): { columns: string[]; truncated: boolean } {
69
+ const limit = Math.max(1, Math.floor(options.previewColumns))
70
+ if (columns.length <= limit) return { columns, truncated: false }
71
+ return { columns: columns.slice(0, limit), truncated: true }
72
+ }
73
+
74
+ /** 拼装一次预览切片(头行 + 可选尾行),同时报告跳过了多少行。 */
75
+ export function buildPreviewSlice(
76
+ headRows: Row[],
77
+ tailRows: Row[],
78
+ columns: string[],
79
+ totalRows: number,
80
+ options: PreviewOptions,
81
+ ): PreviewSlice {
82
+ const { head, tail } = previewSlicePlan(totalRows, options)
83
+ const rows = tail > 0 ? [...headRows, ...tailRows] : headRows
84
+ const skipped = Math.max(0, Math.floor(totalRows) - rows.length)
85
+ const projected = previewColumns(columns, options)
86
+ const sliced = projected.columns.length === columns.length
87
+ ? rows
88
+ : rows.map(row => {
89
+ const picked: Row = {}
90
+ for (const name of projected.columns) picked[name] = row[name]
91
+ return picked
92
+ })
93
+ return {
94
+ rows: sliced,
95
+ columns: projected.columns,
96
+ columnTruncated: projected.truncated,
97
+ gap: tail > 0 && skipped > 0,
98
+ skipped,
99
+ }
100
+ }
101
+
102
+ /** 单元格按字符数截断,返回文本与被截断标记。 */
103
+ export function truncateCell(value: unknown, max: number): { text: string; truncated: boolean } {
104
+ if (value === null || value === undefined) return { text: '', truncated: false }
105
+ if (value instanceof Uint8Array) return { text: `<${value.length} bytes>`, truncated: false }
106
+ const raw = typeof value === 'object'
107
+ ? safeStringify(value)
108
+ : String(value)
109
+ const flat = raw.replace(/\r?\n/g, ' ')
110
+ if (flat.length <= max) return { text: flat, truncated: false }
111
+ return { text: `${flat.slice(0, Math.max(0, max - 1))}…`, truncated: true }
112
+ }
113
+
114
+ function safeStringify(value: unknown): string {
115
+ try {
116
+ return JSON.stringify(value) ?? ''
117
+ } catch {
118
+ return '[object]'
119
+ }
120
+ }
121
+
122
+ function toNumber(value: unknown): number | null {
123
+ if (value === null || value === undefined) return null
124
+ const n = typeof value === 'number' ? value : Number(value)
125
+ return Number.isFinite(n) ? n : null
126
+ }
127
+
128
+ /**
129
+ * 基于全表生成列摘要:numeric/date/boolean 走一次聚合扫描,
130
+ * 文本列额外做 distinct 计数与 top 取值(受列数上限保护)。
131
+ */
132
+ export async function summarizeColumns(
133
+ db: Database,
134
+ tableName: string,
135
+ columns: { name: string; type: ColumnType }[],
136
+ options: PreviewOptions,
137
+ ): Promise<ColumnSummary[]> {
138
+ if (!options.summaryEnabled || columns.length === 0) return []
139
+ const targets = columns.slice(0, Math.max(1, Math.floor(options.summaryMaxColumns)))
140
+ const table = quoteIdentifier(tableName)
141
+
142
+ const projections: string[] = ['COUNT(*) AS _rows']
143
+ targets.forEach((column, index) => {
144
+ const col = quoteIdentifier(column.name)
145
+ const base = `c${index}`
146
+ if (column.type === 'boolean') {
147
+ projections.push(`COUNT(${col}) AS cnt_${base}`)
148
+ projections.push(`SUM(CASE WHEN ${col} = 1 THEN 1 ELSE 0 END) AS true_${base}`)
149
+ return
150
+ }
151
+ projections.push(`COUNT(${col}) AS cnt_${base}`)
152
+ projections.push(`MIN(${col}) AS min_${base}`)
153
+ projections.push(`MAX(${col}) AS max_${base}`)
154
+ if (column.type === 'numeric') {
155
+ projections.push(`AVG(${col}) AS avg_${base}`)
156
+ projections.push(`SUM(${col}) AS sum_${base}`)
157
+ }
158
+ })
159
+
160
+ const aggregate = await db.prepare(`SELECT ${projections.join(', ')} FROM ${table}`).get()
161
+ const totalRows = toNumber(aggregate?._rows) ?? 0
162
+
163
+ const summaries: ColumnSummary[] = targets.map((column, index) => {
164
+ const base = `c${index}`
165
+ const count = toNumber(aggregate?.[`cnt_${base}`]) ?? 0
166
+ const summary: ColumnSummary = { name: column.name, type: column.type, count }
167
+ if (column.type === 'boolean') {
168
+ summary.trueCount = toNumber(aggregate?.[`true_${base}`]) ?? 0
169
+ return summary
170
+ }
171
+ const min = aggregate?.[`min_${base}`]
172
+ const max = aggregate?.[`max_${base}`]
173
+ if (min !== null && min !== undefined) summary.min = min as number | string
174
+ if (max !== null && max !== undefined) summary.max = max as number | string
175
+ if (column.type === 'numeric') {
176
+ summary.avg = toNumber(aggregate?.[`avg_${base}`])
177
+ summary.sum = toNumber(aggregate?.[`sum_${base}`])
178
+ }
179
+ return summary
180
+ })
181
+
182
+ // 文本列的取值分布:每列两次查询,受 summaryMaxTextColumns 限制。
183
+ const textColumns = summaries.filter(summary => summary.type === 'text')
184
+ for (const summary of textColumns.slice(0, Math.max(0, Math.floor(options.summaryMaxTextColumns)))) {
185
+ const col = quoteIdentifier(summary.name)
186
+ const distinctRow = await db.prepare(`SELECT COUNT(DISTINCT ${col}) AS d FROM ${table}`).get()
187
+ const distinct = toNumber(distinctRow?.d) ?? 0
188
+ summary.distinct = distinct
189
+ // 高基数列(编码/姓名/备注)给 top 值没有信息量,还浪费上下文。
190
+ if (distinct === 0 || distinct > Math.max(20, totalRows * 0.3)) continue
191
+ const topRows = await db
192
+ .prepare(`SELECT ${col} AS v, COUNT(*) AS n FROM ${table} WHERE ${col} IS NOT NULL GROUP BY ${col} ORDER BY n DESC, v ASC LIMIT 5`)
193
+ .all()
194
+ summary.top = topRows.map(row => ({ value: String(row.v), count: toNumber(row.n) ?? 0 }))
195
+ }
196
+
197
+ return summaries
198
+ }