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.
- package/README.md +332 -0
- package/cordis.patch.yml +7 -0
- package/lib/admin-contract.d.ts +274 -0
- package/lib/admin-http.d.ts +31 -0
- package/lib/admin-validate.d.ts +87 -0
- package/lib/admin.d.ts +60 -0
- package/lib/client.js +2969 -0
- package/lib/client.js.map +1 -0
- package/lib/datasource/columns.d.ts +13 -0
- package/lib/datasource/connection.d.ts +33 -0
- package/lib/datasource/connector/base.d.ts +21 -0
- package/lib/datasource/connector/mysql.d.ts +23 -0
- package/lib/datasource/connector/postgresql.d.ts +23 -0
- package/lib/datasource/crypto.d.ts +13 -0
- package/lib/datasource/driver.d.ts +39 -0
- package/lib/datasource/errors.d.ts +25 -0
- package/lib/datasource/importer.d.ts +38 -0
- package/lib/datasource/source-sql.d.ts +11 -0
- package/lib/datasource/source-store.d.ts +28 -0
- package/lib/datasource/types.d.ts +119 -0
- package/lib/db.d.ts +91 -0
- package/lib/http-common.d.ts +31 -0
- package/lib/http.d.ts +13 -0
- package/lib/index.d.ts +50 -0
- package/lib/index.js +5735 -0
- package/lib/parse.d.ts +41 -0
- package/lib/preview.d.ts +75 -0
- package/lib/render.d.ts +100 -0
- package/lib/scope-registry.d.ts +47 -0
- package/lib/scope.d.ts +56 -0
- package/lib/sql.d.ts +133 -0
- package/lib/store.d.ts +174 -0
- package/lib/table.d.ts +38 -0
- package/lib/tooling.d.ts +267 -0
- package/lib/tools/datasource.d.ts +16 -0
- package/lib/tools/import.d.ts +21 -0
- package/lib/tools/read.d.ts +77 -0
- package/lib/tools/registry.d.ts +12 -0
- package/lib/tools/write.d.ts +37 -0
- package/lib/view.d.ts +138 -0
- package/package.json +61 -0
- package/src/admin-contract.ts +351 -0
- package/src/admin-http.ts +254 -0
- package/src/admin-validate.ts +393 -0
- package/src/admin.ts +558 -0
- package/src/client/index.ts +403 -0
- package/src/client/settings/CreateForm.tsx +186 -0
- package/src/client/settings/DataSourceForm.tsx +278 -0
- package/src/client/settings/DataSourcesPanel.tsx +301 -0
- package/src/client/settings/DatasetEditor.tsx +245 -0
- package/src/client/settings/DatasetTable.tsx +110 -0
- package/src/client/settings/DatasetsPanel.tsx +207 -0
- package/src/client/settings/RowsPanel.tsx +196 -0
- package/src/client/settings/Section.tsx +41 -0
- package/src/client/settings/SourceTablesPanel.tsx +226 -0
- package/src/client/settings/api.ts +154 -0
- package/src/client/settings/styles.ts +125 -0
- package/src/datasource/columns.ts +56 -0
- package/src/datasource/connection.ts +124 -0
- package/src/datasource/connector/base.ts +54 -0
- package/src/datasource/connector/mysql.ts +187 -0
- package/src/datasource/connector/postgresql.ts +212 -0
- package/src/datasource/crypto.ts +61 -0
- package/src/datasource/driver.ts +108 -0
- package/src/datasource/errors.ts +69 -0
- package/src/datasource/importer.ts +262 -0
- package/src/datasource/index.ts +62 -0
- package/src/datasource/source-sql.ts +64 -0
- package/src/datasource/source-store.ts +152 -0
- package/src/datasource/types.ts +133 -0
- package/src/db.ts +277 -0
- package/src/http-common.ts +91 -0
- package/src/http.ts +130 -0
- package/src/index.ts +486 -0
- package/src/parse.ts +213 -0
- package/src/preview.ts +198 -0
- package/src/render.ts +294 -0
- package/src/scope-registry.ts +111 -0
- package/src/scope.ts +159 -0
- package/src/sql.ts +491 -0
- package/src/store.ts +412 -0
- package/src/table.ts +160 -0
- package/src/tooling.ts +551 -0
- package/src/tools/datasource.ts +378 -0
- package/src/tools/import.ts +282 -0
- package/src/tools/read.ts +536 -0
- package/src/tools/registry.ts +56 -0
- package/src/tools/write.ts +241 -0
- package/src/view.ts +371 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 写工具:`dataset_insert` / `dataset_update` / `dataset_delete` / `dataset_drop`。
|
|
3
|
+
*
|
|
4
|
+
* 四个工具都是破坏性的,默认由 `src/index.ts` 的 `tools/pre-execute` 监听器返回
|
|
5
|
+
* `{ kind: 'ask' }` 交给 `ctx.approval` 人工确认(无审批通道即拒绝,fail-closed)。
|
|
6
|
+
* 这里再兜一层 `readOnly` 检查,避免门禁被绕过时的静默写入。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolveDataset, type DataServices } from '../store'
|
|
10
|
+
import { countRows, deleteRow, dropDatasetTable, insertRows, sanitizeRowData, updateRow } from '../table'
|
|
11
|
+
import { toolDef, ToolError, type ToolDefinition, type ToolExec } from '../tooling'
|
|
12
|
+
|
|
13
|
+
const DATASET_PARAM = {
|
|
14
|
+
type: 'string',
|
|
15
|
+
required: true,
|
|
16
|
+
description: '数据集的 datasetId 或登记名(来自 dataset_list)。不要传物理表名。',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export interface InsertOutput {
|
|
20
|
+
datasetId: string
|
|
21
|
+
name: string
|
|
22
|
+
inserted: number
|
|
23
|
+
rowCount: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UpdateOutput {
|
|
27
|
+
datasetId: string
|
|
28
|
+
rowId: number
|
|
29
|
+
updated: boolean
|
|
30
|
+
changedColumns: string[]
|
|
31
|
+
rowCount: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DeleteOutput {
|
|
35
|
+
datasetId: string
|
|
36
|
+
rowId: number
|
|
37
|
+
deleted: boolean
|
|
38
|
+
rowCount: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DropOutput {
|
|
42
|
+
datasetId: string
|
|
43
|
+
name: string
|
|
44
|
+
dropped: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 门禁之外再兜一层:只读模式下任何写操作都必须是错误。 */
|
|
48
|
+
function assertWritable(services: DataServices): void {
|
|
49
|
+
if (services.cfg.readOnly) {
|
|
50
|
+
throw new ToolError('dsh-lh-data 处于只读模式(readOnly=true),已拒绝写操作', 'READ_ONLY')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createInsertTool(services: DataServices): ToolDefinition {
|
|
55
|
+
return toolDef({
|
|
56
|
+
name: 'dataset_insert',
|
|
57
|
+
description: [
|
|
58
|
+
'向数据集批量追加行。rows 是对象数组,键必须与 dataset_schema 列出的列名一致(未知列会被拒绝,_row_id / _uploaded_at 由系统维护,不要传)。',
|
|
59
|
+
'写入前建议先 dataset_schema 确认列名与类型;值会按列类型自动转换(布尔→0/1、数值→数字、日期→ISO 字符串、对象→JSON)。',
|
|
60
|
+
'这是写操作,会触发人工确认。',
|
|
61
|
+
].join(' '),
|
|
62
|
+
parameters: {
|
|
63
|
+
dataset: DATASET_PARAM,
|
|
64
|
+
rows: {
|
|
65
|
+
type: 'array',
|
|
66
|
+
required: true,
|
|
67
|
+
items: { type: 'object', additionalProperties: true },
|
|
68
|
+
description: '要插入的行;键为列名,值为单元格内容。',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
output: {
|
|
72
|
+
schema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
properties: {
|
|
76
|
+
datasetId: { type: 'string' },
|
|
77
|
+
name: { type: 'string' },
|
|
78
|
+
inserted: { type: 'integer' },
|
|
79
|
+
rowCount: { type: 'integer' },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
render: (_args, value: InsertOutput) => [
|
|
83
|
+
{ type: 'text', text: `已向 ${value.name}(${value.datasetId})插入 ${value.inserted} 行,当前共 ${value.rowCount} 行。` },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
presentCall: args => ({ card: 'generic', title: `插入 ${args.rows.length} 行`, kind: 'write' }),
|
|
87
|
+
execute: async (args, exec: ToolExec): Promise<InsertOutput> => {
|
|
88
|
+
assertWritable(services)
|
|
89
|
+
if (args.rows.length === 0) throw new ToolError('rows 不能为空', 'EMPTY_ROWS')
|
|
90
|
+
if (args.rows.length > services.cfg.maxInsertRows) {
|
|
91
|
+
throw new ToolError(`单次最多插入 ${services.cfg.maxInsertRows} 行,本次 ${args.rows.length} 行`, 'TOO_MANY_ROWS')
|
|
92
|
+
}
|
|
93
|
+
const { scope, record, db } = await resolveDataset(services, exec, args.dataset, { requireReady: true })
|
|
94
|
+
|
|
95
|
+
const rows: Record<string, unknown>[] = []
|
|
96
|
+
const unknown = new Set<string>()
|
|
97
|
+
for (const row of args.rows) {
|
|
98
|
+
const { values, unknownColumns } = sanitizeRowData(row, record.columns)
|
|
99
|
+
for (const column of unknownColumns) unknown.add(column)
|
|
100
|
+
rows.push(values)
|
|
101
|
+
}
|
|
102
|
+
if (unknown.size > 0) {
|
|
103
|
+
throw new ToolError(`未知列:${[...unknown].join(', ')}(用 dataset_schema 查看可用列名)`, 'UNKNOWN_COLUMN')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const inserted = await insertRows(db, record.tableName, record.columns, rows, {
|
|
107
|
+
batchSize: services.cfg.batchSize,
|
|
108
|
+
signal: exec.signal,
|
|
109
|
+
})
|
|
110
|
+
const rowCount = await countRows(db, record.tableName)
|
|
111
|
+
await services.store.update(scope.scopeKey, record.id, { rowCount })
|
|
112
|
+
return { datasetId: record.id, name: record.name, inserted, rowCount }
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createUpdateTool(services: DataServices): ToolDefinition {
|
|
118
|
+
return toolDef({
|
|
119
|
+
name: 'dataset_update',
|
|
120
|
+
description: [
|
|
121
|
+
'按系统列 _row_id 更新单行(_row_id 来自 dataset_query 的结果)。data 是要修改的列及其新值,键必须是 dataset_schema 中已存在的列名。',
|
|
122
|
+
'只更新这一行;批量修改请逐行调用。这是写操作,会触发人工确认。',
|
|
123
|
+
].join(' '),
|
|
124
|
+
parameters: {
|
|
125
|
+
dataset: DATASET_PARAM,
|
|
126
|
+
rowId: { type: 'integer', required: true, description: '要更新的行的 _row_id(来自 dataset_query)。' },
|
|
127
|
+
data: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
required: true,
|
|
130
|
+
additionalProperties: true,
|
|
131
|
+
description: '要写入的列值对象,如 { "单价": 12.5, "备注": "已核" }。',
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
output: {
|
|
135
|
+
schema: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
additionalProperties: false,
|
|
138
|
+
properties: {
|
|
139
|
+
datasetId: { type: 'string' },
|
|
140
|
+
rowId: { type: 'integer' },
|
|
141
|
+
updated: { type: 'boolean' },
|
|
142
|
+
changedColumns: { type: 'array', items: { type: 'string' } },
|
|
143
|
+
rowCount: { type: 'integer' },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
render: (_args, value: UpdateOutput) => [
|
|
147
|
+
{
|
|
148
|
+
type: 'text',
|
|
149
|
+
text: `已更新 ${value.datasetId} 的第 ${value.rowId} 行(${value.changedColumns.join(', ')})。数据集共 ${value.rowCount} 行。`,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
presentCall: args => ({ card: 'generic', title: `更新行 ${args.rowId}`, kind: 'write' }),
|
|
154
|
+
execute: async (args, exec): Promise<UpdateOutput> => {
|
|
155
|
+
assertWritable(services)
|
|
156
|
+
const { record, db } = await resolveDataset(services, exec, args.dataset, { requireReady: true })
|
|
157
|
+
const changes = await updateRow(db, record.tableName, record.columns, args.rowId, args.data)
|
|
158
|
+
if (changes === 0) throw new ToolError(`未找到 _row_id = ${args.rowId} 的行`, 'ROW_NOT_FOUND')
|
|
159
|
+
const rowCount = await countRows(db, record.tableName)
|
|
160
|
+
await services.store.update(record.scopeKey, record.id, { rowCount })
|
|
161
|
+
const changedColumns = Object.keys(args.data).filter(
|
|
162
|
+
key => !(key === '_row_id' || key === '_uploaded_at') && record.columns.some(column => column.sanitizedName === key),
|
|
163
|
+
)
|
|
164
|
+
return { datasetId: record.id, rowId: args.rowId, updated: true, changedColumns, rowCount }
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createDeleteTool(services: DataServices): ToolDefinition {
|
|
170
|
+
return toolDef({
|
|
171
|
+
name: 'dataset_delete',
|
|
172
|
+
description: [
|
|
173
|
+
'按系统列 _row_id 删除单行(_row_id 来自 dataset_query 的结果)。删除不可恢复。',
|
|
174
|
+
'这是写操作,会触发人工确认;清空整个数据集请用 dataset_drop。',
|
|
175
|
+
].join(' '),
|
|
176
|
+
parameters: {
|
|
177
|
+
dataset: DATASET_PARAM,
|
|
178
|
+
rowId: { type: 'integer', required: true, description: '要删除的行的 _row_id(来自 dataset_query)。' },
|
|
179
|
+
},
|
|
180
|
+
output: {
|
|
181
|
+
schema: {
|
|
182
|
+
type: 'object',
|
|
183
|
+
additionalProperties: false,
|
|
184
|
+
properties: {
|
|
185
|
+
datasetId: { type: 'string' },
|
|
186
|
+
rowId: { type: 'integer' },
|
|
187
|
+
deleted: { type: 'boolean' },
|
|
188
|
+
rowCount: { type: 'integer' },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
render: (_args, value: DeleteOutput) => [
|
|
192
|
+
{ type: 'text', text: `已删除 ${value.datasetId} 的第 ${value.rowId} 行,剩余 ${value.rowCount} 行。` },
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
presentCall: args => ({ card: 'generic', title: `删除行 ${args.rowId}`, kind: 'write' }),
|
|
196
|
+
execute: async (args, exec): Promise<DeleteOutput> => {
|
|
197
|
+
assertWritable(services)
|
|
198
|
+
const { record, db } = await resolveDataset(services, exec, args.dataset, { requireReady: true })
|
|
199
|
+
const changes = await deleteRow(db, record.tableName, args.rowId)
|
|
200
|
+
if (changes === 0) throw new ToolError(`未找到 _row_id = ${args.rowId} 的行`, 'ROW_NOT_FOUND')
|
|
201
|
+
const rowCount = await countRows(db, record.tableName)
|
|
202
|
+
await services.store.update(record.scopeKey, record.id, { rowCount })
|
|
203
|
+
return { datasetId: record.id, rowId: args.rowId, deleted: true, rowCount }
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function createDropTool(services: DataServices): ToolDefinition {
|
|
209
|
+
return toolDef({
|
|
210
|
+
name: 'dataset_drop',
|
|
211
|
+
description: [
|
|
212
|
+
'删除整个数据集:连同物理表与元数据一起删除,不可恢复。',
|
|
213
|
+
'只影响当前工作区;删除前建议 dataset_list 确认目标,误删需重新导入源文件。这是写操作,会触发人工确认。',
|
|
214
|
+
].join(' '),
|
|
215
|
+
parameters: {
|
|
216
|
+
dataset: DATASET_PARAM,
|
|
217
|
+
},
|
|
218
|
+
output: {
|
|
219
|
+
schema: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
additionalProperties: false,
|
|
222
|
+
properties: {
|
|
223
|
+
datasetId: { type: 'string' },
|
|
224
|
+
name: { type: 'string' },
|
|
225
|
+
dropped: { type: 'boolean' },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
render: (_args, value: DropOutput) => [
|
|
229
|
+
{ type: 'text', text: `已删除数据集 ${value.name}(${value.datasetId})。` },
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
presentCall: args => ({ card: 'generic', title: `删除数据集:${args.dataset}`, kind: 'write' }),
|
|
233
|
+
execute: async (args, exec): Promise<DropOutput> => {
|
|
234
|
+
assertWritable(services)
|
|
235
|
+
const { scope, record, db } = await resolveDataset(services, exec, args.dataset)
|
|
236
|
+
await dropDatasetTable(db, record.tableName)
|
|
237
|
+
await services.store.remove(scope.scopeKey, record.id)
|
|
238
|
+
return { datasetId: record.id, name: record.name, dropped: true }
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
}
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 查询结果视图注册中心 —— 设计文档 §6。
|
|
3
|
+
*
|
|
4
|
+
* 视图是**主机侧的句柄**:`viewId` 之外的一切(物理表名、count/page 语句)都留在这里,
|
|
5
|
+
* 既不出现在模型上下文里,也不出现在 HTTP 响应里。前端只能拿 `viewId` + 分页参数换页。
|
|
6
|
+
*
|
|
7
|
+
* 生命周期:视图元数据持久化到数据库表(`lh_views`,与 `datasets` 同库同 scope),
|
|
8
|
+
* 进程重启 / 插件重载 / 热更新后仍能继续翻页查看,永不过期、不被容量淘汰。
|
|
9
|
+
* 翻页行数据始终实时查询原始数据表,因此底层数据可能被并发写入改变
|
|
10
|
+
* (前端卡片常驻软警告「数据可能已发生变化」)。
|
|
11
|
+
*
|
|
12
|
+
* 内部仍保留 `Map` 作为热缓存,`get()` / `page()` / `revoke()` 同步操作缓存(翻页 O(1)),
|
|
13
|
+
* 仅 `create` / `revoke` 会写库,启动时 `loadAll()` 从各库预载一次。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomBytes } from 'node:crypto'
|
|
17
|
+
import { debugLog } from './tooling'
|
|
18
|
+
import { validateOrderBy } from './sql'
|
|
19
|
+
import type { Database, Row } from './db'
|
|
20
|
+
import type { ScopeRegistry } from './scope-registry'
|
|
21
|
+
|
|
22
|
+
/** 视图能看到的列(列名保持原始表头,含中文)。 */
|
|
23
|
+
export interface ViewColumn {
|
|
24
|
+
name: string
|
|
25
|
+
type: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 派生分页语句所需的三件套,排序可按前端请求重排。 */
|
|
29
|
+
export interface ViewSqlParts {
|
|
30
|
+
/** 已校验、无 LIMIT/OFFSET 的基础语句。 */
|
|
31
|
+
baseSql: string
|
|
32
|
+
/** 模型(或结构化参数)给出的排序项,已引号化。 */
|
|
33
|
+
baseOrder?: string
|
|
34
|
+
/** 兜底排序键(通常 `_row_id`);拿不到时为空,此时不保证翻页稳定。 */
|
|
35
|
+
tie?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CreateViewInput extends ViewSqlParts {
|
|
39
|
+
scopeKey: string
|
|
40
|
+
datasetId: string
|
|
41
|
+
name: string
|
|
42
|
+
/** 物理表名:只在本模块内部持有,绝不外发。 */
|
|
43
|
+
tableName: string
|
|
44
|
+
columns: ViewColumn[]
|
|
45
|
+
countSql: string
|
|
46
|
+
totalRows: number
|
|
47
|
+
/** 该视图最多可翻到的行数(已与 maxQueryRows 取小)。 */
|
|
48
|
+
rowCap: number
|
|
49
|
+
/** 允许前端排序的列名(已登记列的白名单)。 */
|
|
50
|
+
sortable: string[]
|
|
51
|
+
stable: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 注册后的视图(内部形态)。 */
|
|
55
|
+
export interface RegisteredView extends CreateViewInput {
|
|
56
|
+
viewId: string
|
|
57
|
+
createdAt: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 交给前端的描述符(`output.presentationMeta` 的载荷)。 */
|
|
61
|
+
export interface ViewDescriptor {
|
|
62
|
+
kind: 'dataset-view'
|
|
63
|
+
viewId: string
|
|
64
|
+
endpoint: string
|
|
65
|
+
datasetId: string
|
|
66
|
+
name: string
|
|
67
|
+
columns: ViewColumn[]
|
|
68
|
+
totalRows: number
|
|
69
|
+
pageSize: number
|
|
70
|
+
maxPageSize: number
|
|
71
|
+
stable: boolean
|
|
72
|
+
sortable: string[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ViewOptions {
|
|
76
|
+
/** HTTP 路由前缀,用于拼 `endpoint`。 */
|
|
77
|
+
routePrefix: string
|
|
78
|
+
defaultPageSize: number
|
|
79
|
+
maxPageSize: number
|
|
80
|
+
/** 视图可服务行数的硬上限。 */
|
|
81
|
+
maxViewRows: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 持久化所需的外部依赖(以回调注入,避免与 store 形成值循环依赖)。 */
|
|
85
|
+
export interface ViewDeps {
|
|
86
|
+
/** 已登记工作区枚举(`loadAll` 遍历用)。 */
|
|
87
|
+
scopes: ScopeRegistry
|
|
88
|
+
/** 按 scope 取得对应业务库连接。 */
|
|
89
|
+
database: (scopeKey: string) => Promise<Database>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type SortOrder = 'asc' | 'desc'
|
|
93
|
+
|
|
94
|
+
export interface PageRequest {
|
|
95
|
+
page?: number
|
|
96
|
+
pageSize?: number
|
|
97
|
+
sort?: string
|
|
98
|
+
order?: SortOrder
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 一次分页查询的 SQL 与参数(执行仍由调用方持库连接完成)。 */
|
|
102
|
+
export interface PageStatement {
|
|
103
|
+
sql: string
|
|
104
|
+
params: [number, number]
|
|
105
|
+
page: number
|
|
106
|
+
pageSize: number
|
|
107
|
+
totalRows: number
|
|
108
|
+
totalPages: number
|
|
109
|
+
stable: boolean
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class ViewError extends Error {
|
|
113
|
+
constructor(
|
|
114
|
+
readonly code: 'VIEW_NOT_FOUND' | 'BAD_REQUEST',
|
|
115
|
+
readonly status: 400 | 404,
|
|
116
|
+
message: string,
|
|
117
|
+
) {
|
|
118
|
+
super(message)
|
|
119
|
+
this.name = 'ViewError'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const VIEW_ID_BYTES = 12
|
|
124
|
+
|
|
125
|
+
/** 持久化视图表的建表语句(与 datasets 同库,per-scope)。 */
|
|
126
|
+
const LH_VIEWS_SCHEMA = `
|
|
127
|
+
CREATE TABLE IF NOT EXISTS lh_views (
|
|
128
|
+
view_id TEXT PRIMARY KEY,
|
|
129
|
+
scope_key TEXT NOT NULL,
|
|
130
|
+
dataset_id TEXT NOT NULL,
|
|
131
|
+
name TEXT NOT NULL,
|
|
132
|
+
table_name TEXT NOT NULL,
|
|
133
|
+
base_sql TEXT NOT NULL,
|
|
134
|
+
base_order TEXT,
|
|
135
|
+
tie TEXT,
|
|
136
|
+
count_sql TEXT NOT NULL,
|
|
137
|
+
columns TEXT NOT NULL,
|
|
138
|
+
total_rows INTEGER NOT NULL,
|
|
139
|
+
row_cap INTEGER NOT NULL,
|
|
140
|
+
sortable TEXT NOT NULL,
|
|
141
|
+
stable INTEGER NOT NULL,
|
|
142
|
+
created_at INTEGER NOT NULL
|
|
143
|
+
);
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_lh_views_scope ON lh_views(scope_key);
|
|
145
|
+
`
|
|
146
|
+
|
|
147
|
+
function makeViewId(): string {
|
|
148
|
+
return `vw_${randomBytes(VIEW_ID_BYTES).toString('hex')}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function clampInteger(value: unknown, fallback: number): number {
|
|
152
|
+
const n = typeof value === 'number' ? value : Number(value)
|
|
153
|
+
if (!Number.isFinite(n)) return fallback
|
|
154
|
+
return Math.floor(n)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 拼排序子句:前端排序 > 模型排序 > 兜底键。 */
|
|
158
|
+
export function composeOrderBy(parts: ViewSqlParts, sort?: { column: string; order: SortOrder }): string {
|
|
159
|
+
const terms: string[] = []
|
|
160
|
+
if (sort !== undefined) terms.push(validateOrderBy(`${sort.column} ${sort.order}`))
|
|
161
|
+
if (parts.baseOrder !== undefined && parts.baseOrder.length > 0) terms.push(parts.baseOrder)
|
|
162
|
+
if (parts.tie !== undefined && parts.tie.length > 0) terms.push(parts.tie)
|
|
163
|
+
return terms.join(', ')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export class ViewRegistry {
|
|
167
|
+
private readonly views = new Map<string, RegisteredView>()
|
|
168
|
+
private readonly options: ViewOptions
|
|
169
|
+
private readonly deps: ViewDeps
|
|
170
|
+
|
|
171
|
+
// 注意:本仓库的构建链(tsdown/rolldown)在「类含 getter + 构造函数参数属性」的
|
|
172
|
+
// 组合下会丢掉参数属性的赋值,因此这里显式声明字段并在构造函数里赋值。
|
|
173
|
+
constructor(options: ViewOptions, deps: ViewDeps) {
|
|
174
|
+
this.options = options
|
|
175
|
+
this.deps = deps
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 当前存活视图数(测试与日志用)。 */
|
|
179
|
+
get size(): number {
|
|
180
|
+
return this.views.size
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 确保某 scope 的业务库已存在视图表(幂等),返回库连接。 */
|
|
184
|
+
private async ensureTable(scopeKey: string): Promise<Database> {
|
|
185
|
+
const db = await this.deps.database(scopeKey)
|
|
186
|
+
await db.exec(LH_VIEWS_SCHEMA)
|
|
187
|
+
return db
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 把一行 DB 记录还原成内部视图对象。 */
|
|
191
|
+
private static fromRow(row: Row): RegisteredView {
|
|
192
|
+
let columns: ViewColumn[] = []
|
|
193
|
+
try {
|
|
194
|
+
const parsed: unknown = JSON.parse(String(row.columns ?? '[]'))
|
|
195
|
+
if (Array.isArray(parsed)) columns = parsed as ViewColumn[]
|
|
196
|
+
} catch {
|
|
197
|
+
columns = []
|
|
198
|
+
}
|
|
199
|
+
let sortable: string[] = []
|
|
200
|
+
try {
|
|
201
|
+
const parsed: unknown = JSON.parse(String(row.sortable ?? '[]'))
|
|
202
|
+
if (Array.isArray(parsed)) sortable = parsed as string[]
|
|
203
|
+
} catch {
|
|
204
|
+
sortable = []
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
viewId: String(row.view_id),
|
|
208
|
+
scopeKey: String(row.scope_key),
|
|
209
|
+
datasetId: String(row.dataset_id),
|
|
210
|
+
name: String(row.name),
|
|
211
|
+
tableName: String(row.table_name),
|
|
212
|
+
baseSql: String(row.base_sql),
|
|
213
|
+
baseOrder: row.base_order === null || row.base_order === undefined ? undefined : String(row.base_order),
|
|
214
|
+
tie: row.tie === null || row.tie === undefined ? undefined : String(row.tie),
|
|
215
|
+
countSql: String(row.count_sql),
|
|
216
|
+
columns,
|
|
217
|
+
totalRows: Number(row.total_rows ?? 0),
|
|
218
|
+
rowCap: Number(row.row_cap ?? 0),
|
|
219
|
+
sortable,
|
|
220
|
+
stable: Number(row.stable ?? 0) === 1,
|
|
221
|
+
createdAt: Number(row.created_at ?? 0),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 注册一个视图:双写内存缓存与数据库表,返回给前端的描述符。 */
|
|
226
|
+
async create(input: CreateViewInput): Promise<ViewDescriptor> {
|
|
227
|
+
const viewId = makeViewId()
|
|
228
|
+
const now = Date.now()
|
|
229
|
+
const total = Math.max(0, Math.floor(input.totalRows))
|
|
230
|
+
const view: RegisteredView = {
|
|
231
|
+
...input,
|
|
232
|
+
totalRows: total,
|
|
233
|
+
viewId,
|
|
234
|
+
createdAt: now,
|
|
235
|
+
}
|
|
236
|
+
// 先写内存,保证创建后立即可读(如本会话内翻页)。
|
|
237
|
+
this.views.set(viewId, view)
|
|
238
|
+
|
|
239
|
+
const db = await this.ensureTable(input.scopeKey)
|
|
240
|
+
await db.prepare(
|
|
241
|
+
`INSERT INTO lh_views
|
|
242
|
+
(view_id, scope_key, dataset_id, name, table_name, base_sql, base_order, tie, count_sql, columns, total_rows, row_cap, sortable, stable, created_at)
|
|
243
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
244
|
+
).run(
|
|
245
|
+
view.viewId,
|
|
246
|
+
view.scopeKey,
|
|
247
|
+
view.datasetId,
|
|
248
|
+
view.name,
|
|
249
|
+
view.tableName,
|
|
250
|
+
view.baseSql,
|
|
251
|
+
view.baseOrder ?? null,
|
|
252
|
+
view.tie ?? null,
|
|
253
|
+
view.countSql,
|
|
254
|
+
JSON.stringify(view.columns),
|
|
255
|
+
view.totalRows,
|
|
256
|
+
view.rowCap,
|
|
257
|
+
JSON.stringify(view.sortable),
|
|
258
|
+
view.stable ? 1 : 0,
|
|
259
|
+
view.createdAt,
|
|
260
|
+
)
|
|
261
|
+
debugLog('view:create', {
|
|
262
|
+
viewId,
|
|
263
|
+
scopeKey: view.scopeKey,
|
|
264
|
+
totalRows: view.totalRows,
|
|
265
|
+
rowCap: input.rowCap,
|
|
266
|
+
maxViewRows: this.options.maxViewRows,
|
|
267
|
+
})
|
|
268
|
+
return {
|
|
269
|
+
kind: 'dataset-view',
|
|
270
|
+
viewId,
|
|
271
|
+
endpoint: `${this.options.routePrefix}/views/${viewId}/rows`,
|
|
272
|
+
datasetId: view.datasetId,
|
|
273
|
+
name: view.name,
|
|
274
|
+
columns: view.columns,
|
|
275
|
+
totalRows: view.totalRows,
|
|
276
|
+
pageSize: this.options.defaultPageSize,
|
|
277
|
+
maxPageSize: this.options.maxPageSize,
|
|
278
|
+
stable: view.stable,
|
|
279
|
+
sortable: view.sortable,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 取一个已注册的视图(同步读内存缓存;启动已 loadAll 预载)。 */
|
|
284
|
+
get(viewId: string): RegisteredView | undefined {
|
|
285
|
+
return this.views.get(viewId)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 主动释放(前端卸载卡片 / DELETE)。双删:内存缓存 + 数据库行。 */
|
|
289
|
+
async revoke(viewId: string): Promise<boolean> {
|
|
290
|
+
const view = this.views.get(viewId)
|
|
291
|
+
if (view === undefined) return false
|
|
292
|
+
this.views.delete(viewId)
|
|
293
|
+
const db = await this.ensureTable(view.scopeKey)
|
|
294
|
+
await db.prepare('DELETE FROM lh_views WHERE view_id = ?').run(viewId)
|
|
295
|
+
return true
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 仅清空内存热缓存(不删库表,否则持久化失效)。 */
|
|
299
|
+
clear(): void {
|
|
300
|
+
this.views.clear()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 启动时预载:遍历所有已登记 scope,把各库 `lh_views` 表读进内存。
|
|
305
|
+
* 必须在注册路由前完成,避免早期请求漏命中。
|
|
306
|
+
*/
|
|
307
|
+
async loadAll(): Promise<void> {
|
|
308
|
+
const entries = await this.deps.scopes.list()
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
const db = await this.ensureTable(entry.scopeKey)
|
|
311
|
+
const rows = await db.prepare('SELECT * FROM lh_views WHERE scope_key = ?').all(entry.scopeKey)
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
const view = ViewRegistry.fromRow(row)
|
|
314
|
+
this.views.set(view.viewId, view)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
debugLog('view:loadAll', { scopes: entries.length, views: this.views.size })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 解析分页参数并产出可直接执行的语句。
|
|
322
|
+
* 排序只接受视图创建时登记的白名单列,越界一律 `BAD_REQUEST`。
|
|
323
|
+
*/
|
|
324
|
+
page(viewId: string, request: PageRequest = {}): PageStatement {
|
|
325
|
+
const view = this.get(viewId)
|
|
326
|
+
if (view === undefined) {
|
|
327
|
+
throw new ViewError('VIEW_NOT_FOUND', 404, '视图不存在或接口不可用(请重新查询)')
|
|
328
|
+
}
|
|
329
|
+
const maxPageSize = Math.max(1, Math.floor(this.options.maxPageSize))
|
|
330
|
+
const pageSize = Math.min(Math.max(1, clampInteger(request.pageSize, this.options.defaultPageSize)), maxPageSize)
|
|
331
|
+
const totalRows = view.totalRows
|
|
332
|
+
// 分页边界:命中数、模型声明的上限(rowCap)、全局硬上限三者取小。
|
|
333
|
+
const servable = Math.max(0, Math.min(totalRows, view.rowCap, this.options.maxViewRows))
|
|
334
|
+
const totalPages = Math.max(1, Math.ceil(servable / pageSize))
|
|
335
|
+
const page = Math.min(Math.max(1, clampInteger(request.page, 1)), totalPages)
|
|
336
|
+
debugLog('view:page', {
|
|
337
|
+
viewId,
|
|
338
|
+
page,
|
|
339
|
+
pageSize,
|
|
340
|
+
totalRows,
|
|
341
|
+
servable,
|
|
342
|
+
totalPages,
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
let sort: { column: string; order: SortOrder } | undefined
|
|
346
|
+
if (typeof request.sort === 'string' && request.sort.length > 0) {
|
|
347
|
+
if (!view.sortable.includes(request.sort)) {
|
|
348
|
+
throw new ViewError('BAD_REQUEST', 400, `不允许按该列排序:${request.sort}`)
|
|
349
|
+
}
|
|
350
|
+
const order: SortOrder = request.order === 'desc' ? 'desc' : 'asc'
|
|
351
|
+
sort = { column: request.sort, order }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const orderBy = composeOrderBy(view, sort)
|
|
355
|
+
const sql = `${view.baseSql}${orderBy.length > 0 ? ` ORDER BY ${orderBy}` : ''} LIMIT ? OFFSET ?`
|
|
356
|
+
return {
|
|
357
|
+
sql,
|
|
358
|
+
params: [pageSize, (page - 1) * pageSize],
|
|
359
|
+
page,
|
|
360
|
+
pageSize,
|
|
361
|
+
totalRows,
|
|
362
|
+
totalPages,
|
|
363
|
+
stable: view.stable,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 视图注册中心的构造入口(`DataServices.views`)。 */
|
|
369
|
+
export function createViewRegistry(options: ViewOptions, deps: ViewDeps): ViewRegistry {
|
|
370
|
+
return new ViewRegistry(options, deps)
|
|
371
|
+
}
|