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
package/src/db.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* libSQL 客户端与生命周期。
|
|
3
|
+
*
|
|
4
|
+
* 移植自 `agentic-data-mini` 的 `src/lib/primitives/db.ts`,保留其关键行为:
|
|
5
|
+
* URL 优先级、裸路径补 `file:`、启动 PRAGMA(WAL + 外键)、日志不打印 authToken。
|
|
6
|
+
* 插件侧新增:按 scope 解析库文件(`perWorkspace`)+ `closeAllDatabases()` 供
|
|
7
|
+
* `ctx.effect()` 在 HMR / 卸载时确定性释放(设计文档 §8)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createClient, type Client, type ResultSet } from '@libsql/client'
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import { mkdirSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
/** 无损 JSON 值(与 dsh 的 `snapshotJsonValue` 边界一致)。 */
|
|
17
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
|
|
18
|
+
|
|
19
|
+
/** 一行查询结果:键为列名,值保证是无损 JSON。 */
|
|
20
|
+
export type Row = { [key: string]: JsonValue }
|
|
21
|
+
|
|
22
|
+
export interface RunResult {
|
|
23
|
+
changes: number
|
|
24
|
+
lastInsertRowid: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PreparedStatement {
|
|
28
|
+
all(...params: unknown[]): Promise<Row[]>
|
|
29
|
+
get(...params: unknown[]): Promise<Row | undefined>
|
|
30
|
+
run(...params: unknown[]): Promise<RunResult>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 本模块需要的配置子集(由 `store.ts` 的 DataConfig 提供)。 */
|
|
34
|
+
export interface DbConfig {
|
|
35
|
+
dbPath: string
|
|
36
|
+
dbUrl: string
|
|
37
|
+
authToken: string
|
|
38
|
+
perWorkspace: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ExecuteInput = Parameters<Client['execute']>[0]
|
|
42
|
+
|
|
43
|
+
const PRAGMAS = ['PRAGMA journal_mode = WAL', 'PRAGMA foreign_keys = ON']
|
|
44
|
+
|
|
45
|
+
/** 近似 bun:sqlite 的兼容层:封装 @libsql/client。 */
|
|
46
|
+
export class Database {
|
|
47
|
+
private initPromise: Promise<void> | null = null
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly client: Client,
|
|
51
|
+
readonly url: string,
|
|
52
|
+
readonly remote: boolean,
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
prepare(sql: string): PreparedStatement {
|
|
56
|
+
return {
|
|
57
|
+
all: async (...params: unknown[]) => (await this.run(sql, params, result => result.rows)).map(toJsonRow),
|
|
58
|
+
get: async (...params: unknown[]) => {
|
|
59
|
+
const row = await this.run(sql, params, result => result.rows[0] as Row | undefined)
|
|
60
|
+
return row === undefined ? undefined : toJsonRow(row)
|
|
61
|
+
},
|
|
62
|
+
run: (...params: unknown[]) => this.run(sql, params, result => ({
|
|
63
|
+
changes: Number(result.rowsAffected ?? 0),
|
|
64
|
+
lastInsertRowid: result.lastInsertRowid != null ? Number(result.lastInsertRowid) : 0,
|
|
65
|
+
})),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 执行多条语句(建表 / 建索引)。 */
|
|
70
|
+
async exec(sql: string): Promise<void> {
|
|
71
|
+
await this.ensureInit()
|
|
72
|
+
await this.client.executeMultiple(sql)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(): void {
|
|
76
|
+
this.client.close()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async run<T>(sql: string, params: unknown[], map: (result: ResultSet) => T): Promise<T> {
|
|
80
|
+
await this.ensureInit()
|
|
81
|
+
const args = params.map(toSqlArg)
|
|
82
|
+
const result = await this.client.execute({ sql, args } as unknown as ExecuteInput)
|
|
83
|
+
return map(result)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** PRAGMA 只做一次;远程库不支持时忽略。 */
|
|
87
|
+
private ensureInit(): Promise<void> {
|
|
88
|
+
if (this.initPromise === null) {
|
|
89
|
+
this.initPromise = (async () => {
|
|
90
|
+
for (const pragma of PRAGMAS) {
|
|
91
|
+
try {
|
|
92
|
+
await this.client.execute(pragma)
|
|
93
|
+
} catch {
|
|
94
|
+
// 远程 libsql:// 不支持本地 PRAGMA,忽略即可。
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
})().catch((error: unknown) => {
|
|
98
|
+
this.initPromise = null
|
|
99
|
+
throw error
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
return this.initPromise
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 返回值规整(无损 JSON 边界) ─────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** 嵌套上限:防御性兜底(数据库返回值不会真的这么深)。 */
|
|
109
|
+
const MAX_JSON_DEPTH = 32
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 把 libSQL 的返回值规整成无损 JSON。
|
|
113
|
+
*
|
|
114
|
+
* libSQL 的行对象同时挂着具名列与**不可枚举**的数字索引 `0..n` 和 `length`
|
|
115
|
+
* (行既能按名也能按位置访问)。dsh 的 `snapshotJsonValue` 把「存在不可枚举的
|
|
116
|
+
* 自有属性」判为有损 —— 工具返回值会被直接拒为 `INVALID_TOOL_OUTPUT`
|
|
117
|
+
* (`value is not lossless JSON`)。这里统一重建为只含可枚举字符串键的纯对象;
|
|
118
|
+
* 顺带把 JSON 无法无损表达的值转成 JSON 安全形式:
|
|
119
|
+
* BigInt(超安全整数范围转字符串)、BLOB(base64)、NaN / ±Infinity(null)、
|
|
120
|
+
* `-0`(0)、Date(ISO 字符串)、function / symbol(null)。
|
|
121
|
+
*/
|
|
122
|
+
export function toJsonValue(value: unknown, depth = 0): JsonValue {
|
|
123
|
+
if (value === null || value === undefined) return null
|
|
124
|
+
const kind = typeof value
|
|
125
|
+
if (kind === 'string' || kind === 'boolean') return value as JsonValue
|
|
126
|
+
if (kind === 'number') {
|
|
127
|
+
const number = value as number
|
|
128
|
+
if (!Number.isFinite(number)) return null
|
|
129
|
+
return Object.is(number, -0) ? 0 : number
|
|
130
|
+
}
|
|
131
|
+
if (kind === 'bigint') {
|
|
132
|
+
const big = value as bigint
|
|
133
|
+
const min = BigInt(Number.MIN_SAFE_INTEGER)
|
|
134
|
+
const max = BigInt(Number.MAX_SAFE_INTEGER)
|
|
135
|
+
return big >= min && big <= max ? Number(big) : big.toString()
|
|
136
|
+
}
|
|
137
|
+
if (depth > MAX_JSON_DEPTH) return null
|
|
138
|
+
if (kind !== 'object') return null
|
|
139
|
+
if (value instanceof Date) return value.toISOString()
|
|
140
|
+
if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
|
|
141
|
+
return Buffer.from(value instanceof ArrayBuffer ? new Uint8Array(value) : value).toString('base64')
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(value)) return value.map(entry => toJsonValue(entry, depth + 1))
|
|
144
|
+
const record = value as Record<string, unknown>
|
|
145
|
+
const out: { [key: string]: JsonValue } = {}
|
|
146
|
+
// 只遍历可枚举的字符串键:libSQL 的不可枚举数字索引与 length 会被自然丢弃。
|
|
147
|
+
for (const key of Object.keys(record)) out[key] = toJsonValue(record[key], depth + 1)
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 一行 → 只含可枚举字符串键的纯对象。 */
|
|
152
|
+
export function toJsonRow(row: unknown): Row {
|
|
153
|
+
const value = toJsonValue(row)
|
|
154
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
155
|
+
? value as Row
|
|
156
|
+
: {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** libSQL 只能绑定 number/string/bigint/Buffer/Date/null,其余序列化为 JSON。 */
|
|
160
|
+
function toSqlArg(value: unknown): unknown {
|
|
161
|
+
if (value === undefined || value === null) return null
|
|
162
|
+
const kind = typeof value
|
|
163
|
+
if (kind === 'string' || kind === 'number' || kind === 'bigint') return value
|
|
164
|
+
if (value instanceof Uint8Array || value instanceof ArrayBuffer || value instanceof Date) return value
|
|
165
|
+
if (kind === 'boolean') return value ? 1 : 0
|
|
166
|
+
return JSON.stringify(value)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── 连接解析 ──────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/** 稳定短哈希:物理表名前缀与 perWorkspace 库文件后缀都用它。 */
|
|
172
|
+
export function shortHash(input: string, length = 8): string {
|
|
173
|
+
return createHash('sha1').update(input).digest('hex').slice(0, length)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 仅用于兜底消毒的标识符(保留中文):移植自参考实现的 `sanitizeId`。 */
|
|
177
|
+
export function sanitizeId(name: string): string {
|
|
178
|
+
return name.replace(/[^a-zA-Z0-9_\u4e00-\u9fa5]/g, '_')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isRemoteUrl(url: string): boolean {
|
|
182
|
+
return /^(libsql:|https?:\/\/)/i.test(url)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeDbUrl(url: string): string {
|
|
186
|
+
return /^(file:|libsql:|https?:\/\/)/i.test(url) ? url : `file:${toPosix(url)}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function toPosix(path: string): string {
|
|
190
|
+
return path.replace(/\\/g, '/')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `file:` 库的本地路径(不猜测 `file://` 的主机/路径语义)。 */
|
|
194
|
+
function localPathOf(url: string): string | undefined {
|
|
195
|
+
if (!url.startsWith('file:')) return undefined
|
|
196
|
+
const rest = url.slice('file:'.length)
|
|
197
|
+
return rest.startsWith('//') ? undefined : rest
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function withScopeSuffix(url: string, hash: string): string {
|
|
201
|
+
return url.endsWith('.db') ? `${url.slice(0, -'.db'.length)}-${hash}.db` : `${url}-${hash}.db`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 默认库目录:`$DSH_HOME/lh-data`。 */
|
|
205
|
+
export function defaultDbDirectory(): string {
|
|
206
|
+
const configured = process.env.DSH_HOME?.trim()
|
|
207
|
+
const home = configured !== undefined && configured.length > 0 ? configured : join(homedir(), '.dsh')
|
|
208
|
+
return join(home, 'lh-data')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 不含 scope 后缀的库 URL —— 连接优先级链的终点:
|
|
213
|
+
* `dbUrl` > `dbPath` > `SQLITE_PATH` > `TURSO_DATABASE_URL` > 默认本地文件。
|
|
214
|
+
*/
|
|
215
|
+
export function baseDbUrl(cfg: DbConfig): string {
|
|
216
|
+
const explicit = cfg.dbUrl.trim() || cfg.dbPath.trim()
|
|
217
|
+
const fromEnv = process.env.SQLITE_PATH || process.env.TURSO_DATABASE_URL || ''
|
|
218
|
+
const raw = explicit || fromEnv
|
|
219
|
+
return raw.length > 0 ? normalizeDbUrl(raw) : `file:${toPosix(join(defaultDbDirectory(), 'data.db'))}`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 连接优先级:`dbUrl` > `dbPath` > `SQLITE_PATH` > `TURSO_DATABASE_URL` > 默认本地文件。
|
|
224
|
+
* `perWorkspace` 且是本地库时,为每个 scope 派生独立库文件。
|
|
225
|
+
*/
|
|
226
|
+
export function resolveDbUrl(cfg: DbConfig, scopeKey: string): string {
|
|
227
|
+
const url = baseDbUrl(cfg)
|
|
228
|
+
if (isRemoteUrl(url)) return url
|
|
229
|
+
return cfg.perWorkspace ? withScopeSuffix(url, shortHash(scopeKey)) : url
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function resolveAuthToken(cfg: DbConfig): string | undefined {
|
|
233
|
+
return cfg.authToken.trim() || process.env.TURSO_AUTH_TOKEN || undefined
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const databases = new Map<string, Database>()
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 目录库:**不做 scope 分片**的固定库,供工作区注册表使用。
|
|
240
|
+
*
|
|
241
|
+
* `perWorkspace=false` 时它与业务库是同一个 URL(同一个 `Database` 实例,
|
|
242
|
+
* 注册表与 datasets 表共存);`perWorkspace=true` 时它是没有后缀的那份
|
|
243
|
+
* `data.db`,与各工作区的 `data-<hash>.db` 并列。
|
|
244
|
+
* 远程库(`libsql://`)不做分片,两种情况都是同一个库。
|
|
245
|
+
*/
|
|
246
|
+
export function resolveCatalogDatabase(cfg: DbConfig): Database {
|
|
247
|
+
return resolveByUrl(cfg, baseDbUrl(cfg))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 惰性建立连接(不在 apply() 里同步建立),按 url 缓存单例。 */
|
|
251
|
+
export function resolveDatabase(cfg: DbConfig, scopeKey: string): Database {
|
|
252
|
+
return resolveByUrl(cfg, resolveDbUrl(cfg, scopeKey))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 按已解析的 url 建立/复用连接(缓存键就是 url)。 */
|
|
256
|
+
function resolveByUrl(cfg: DbConfig, url: string): Database {
|
|
257
|
+
const existing = databases.get(url)
|
|
258
|
+
if (existing !== undefined) return existing
|
|
259
|
+
const localPath = localPathOf(url)
|
|
260
|
+
if (localPath !== undefined && localPath.length > 0) mkdirSync(dirname(localPath), { recursive: true })
|
|
261
|
+
const client = createClient({ url, authToken: resolveAuthToken(cfg) })
|
|
262
|
+
const database = new Database(client, url, isRemoteUrl(url))
|
|
263
|
+
databases.set(url, database)
|
|
264
|
+
return database
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 关闭全部连接:`ctx.effect()` 的 disposer 调用它。 */
|
|
268
|
+
export function closeAllDatabases(): void {
|
|
269
|
+
for (const database of databases.values()) {
|
|
270
|
+
try {
|
|
271
|
+
database.close()
|
|
272
|
+
} catch {
|
|
273
|
+
// 关闭失败不应阻塞卸载流程。
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
databases.clear()
|
|
277
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自建 HTTP 路由的公共层(视图路由与管理路由共用)。
|
|
3
|
+
*
|
|
4
|
+
* 抽出原因:`http.ts` 的视图路由已有 160 行,再加管理路由会失控;两路由共享
|
|
5
|
+
* 同样的「鉴权 + JSON 收发 + queryInt 解析」原语,单独成模块避免复制。
|
|
6
|
+
*
|
|
7
|
+
* 仅主机侧使用;`node:http` 的类型在这里统一引入,视图/管理路由都从这里取。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
11
|
+
import { AdminValidationError } from './admin-validate'
|
|
12
|
+
|
|
13
|
+
/** 路由需要的请求面(duck-typed:只用这几个字段)。 */
|
|
14
|
+
export interface RouteRequest {
|
|
15
|
+
method?: string
|
|
16
|
+
url?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RouteResponse {
|
|
20
|
+
writeHead(status: number, headers?: Record<string, string>): unknown
|
|
21
|
+
end(body?: string): unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const JSON_HEADERS: Record<string, string> = {
|
|
25
|
+
'content-type': 'application/json; charset=utf-8',
|
|
26
|
+
'cache-control': 'no-store',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function sendJson(response: RouteResponse, status: number, payload: unknown): void {
|
|
30
|
+
response.writeHead(status, JSON_HEADERS)
|
|
31
|
+
response.end(`${JSON.stringify(payload)}\n`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sendError(response: RouteResponse, status: number, code: string, message: string): void {
|
|
35
|
+
sendJson(response, status, { error: { code, message } })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function queryInt(raw: string | null): number | undefined {
|
|
39
|
+
if (raw === null || raw.trim().length === 0) return undefined
|
|
40
|
+
const parsed = Number(raw)
|
|
41
|
+
return Number.isFinite(parsed) ? Math.floor(parsed) : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 鉴权:复用平台同样的连接围栏(Host/Origin + 浏览器登录态)。
|
|
46
|
+
* 通过返回 undefined;否则返回应写入的 HTTP 状态码(401 / 403)。
|
|
47
|
+
*/
|
|
48
|
+
export function authorize(
|
|
49
|
+
connection: { requestRejection(request: unknown): 401 | 403 | undefined },
|
|
50
|
+
request: unknown,
|
|
51
|
+
): 401 | 403 | undefined {
|
|
52
|
+
return connection.requestRejection(request)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 读取并 JSON 解析请求体,超 `maxBytes` 或非法 JSON 时抛错(由调用方映射成响应)。 */
|
|
56
|
+
export async function readJsonBody(
|
|
57
|
+
request: IncomingMessage & RouteRequest,
|
|
58
|
+
maxBytes: number,
|
|
59
|
+
): Promise<unknown> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
let size = 0
|
|
62
|
+
const chunks: Buffer[] = []
|
|
63
|
+
let aborted = false
|
|
64
|
+
request.on('data', (chunk: Buffer) => {
|
|
65
|
+
if (aborted) return
|
|
66
|
+
size += chunk.length
|
|
67
|
+
if (size > maxBytes) {
|
|
68
|
+
aborted = true
|
|
69
|
+
request.destroy()
|
|
70
|
+
reject(new AdminValidationError('PAYLOAD_TOO_LARGE', `请求体超过 ${maxBytes} 字节`))
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
chunks.push(chunk)
|
|
74
|
+
})
|
|
75
|
+
request.on('end', () => {
|
|
76
|
+
if (aborted) return
|
|
77
|
+
if (chunks.length === 0) {
|
|
78
|
+
resolve({})
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
83
|
+
} catch {
|
|
84
|
+
reject(new AdminValidationError('BAD_REQUEST', '请求体不是合法 JSON'))
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
request.on('error', (error: Error) => {
|
|
88
|
+
if (!aborted) reject(error)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 前端分页取数的 HTTP 路由 —— 设计文档 §5.5 / §9。
|
|
3
|
+
*
|
|
4
|
+
* 只暴露两个能力:按 `viewId` 取一页、按 `viewId` 释放视图。
|
|
5
|
+
* 接口**不接受任何 SQL**:语句由 `ViewRegistry` 在主机侧持有,因此没有注入面。
|
|
6
|
+
* 每个请求先过 `connection.requestRejection()`(Host/Origin 围栏 + 浏览器鉴权)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
10
|
+
import { ViewError, type ViewDescriptor } from './view'
|
|
11
|
+
import { selectRows } from './table'
|
|
12
|
+
import { debugLog } from './tooling'
|
|
13
|
+
import type { DataServices } from './store'
|
|
14
|
+
import { authorize, queryInt, sendError, sendJson, type RouteRequest, type RouteResponse } from './http-common'
|
|
15
|
+
|
|
16
|
+
/** `/views/<id>` 或 `/views/<id>/rows`。 */
|
|
17
|
+
function parseViewPath(pathname: string, prefix: string): { viewId: string; rows: boolean } | undefined {
|
|
18
|
+
if (!pathname.startsWith(`${prefix}/views/`)) return undefined
|
|
19
|
+
const rest = pathname.slice(`${prefix}/views/`.length).replace(/\/+$/, '')
|
|
20
|
+
const [viewId, tail] = rest.split('/')
|
|
21
|
+
if (viewId === undefined || viewId.length === 0) return undefined
|
|
22
|
+
if (tail === undefined) return { viewId, rows: false }
|
|
23
|
+
if (tail === 'rows') return { viewId, rows: true }
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 注册视图路由。返回 disposer;宿主缺失(`webServer` / `connection` 任一不可用)时返回 undefined。
|
|
29
|
+
* 路由路径重复会抛错(webserver 的契约),由调用方决定如何降级。
|
|
30
|
+
*/
|
|
31
|
+
export function registerViewRoutes(services: DataServices): (() => void) | undefined {
|
|
32
|
+
const { webServer, connection, views, cfg } = services
|
|
33
|
+
if (webServer === undefined || connection === undefined || views === undefined) return undefined
|
|
34
|
+
const prefix = cfg.viewRoutePrefix.replace(/\/+$/, '')
|
|
35
|
+
|
|
36
|
+
const handler = async (rawRequest: unknown, rawResponse: unknown): Promise<void> => {
|
|
37
|
+
const request = rawRequest as RouteRequest & IncomingMessage
|
|
38
|
+
const response = rawResponse as RouteResponse & ServerResponse
|
|
39
|
+
|
|
40
|
+
const rejection = authorize(connection, request)
|
|
41
|
+
if (rejection !== undefined) {
|
|
42
|
+
sendError(response, rejection, rejection === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN', 'unauthorized')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const method = (request.method ?? 'GET').toUpperCase()
|
|
47
|
+
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname
|
|
48
|
+
const parsed = parseViewPath(pathname, prefix)
|
|
49
|
+
if (parsed === undefined) {
|
|
50
|
+
sendError(response, 404, 'NOT_FOUND', 'not found')
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (method !== 'GET' && method !== 'DELETE') {
|
|
54
|
+
sendError(response, 405, 'METHOD_NOT_ALLOWED', 'only GET and DELETE are allowed')
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (method === 'DELETE') {
|
|
59
|
+
const revoked = await views.revoke(parsed.viewId)
|
|
60
|
+
sendJson(response, 200, { viewId: parsed.viewId, revoked })
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const view = views.get(parsed.viewId)
|
|
66
|
+
if (view === undefined) {
|
|
67
|
+
// 未注册对外统一 404,避免探测视图是否存在。
|
|
68
|
+
throw new ViewError('VIEW_NOT_FOUND', 404, '视图不存在或接口不可用(请重新查询)')
|
|
69
|
+
}
|
|
70
|
+
// 归属与就绪状态按视图绑定的 scope 重新断言(defense-in-depth)。
|
|
71
|
+
await services.store.require(view.scopeKey, view.datasetId, { requireReady: true })
|
|
72
|
+
|
|
73
|
+
if (!parsed.rows) {
|
|
74
|
+
const meta: Omit<ViewDescriptor, 'endpoint'> & { endpoint: string } = {
|
|
75
|
+
kind: 'dataset-view',
|
|
76
|
+
viewId: view.viewId,
|
|
77
|
+
endpoint: `${prefix}/views/${view.viewId}/rows`,
|
|
78
|
+
datasetId: view.datasetId,
|
|
79
|
+
name: view.name,
|
|
80
|
+
columns: view.columns,
|
|
81
|
+
totalRows: view.totalRows,
|
|
82
|
+
pageSize: services.cfg.defaultPageSize,
|
|
83
|
+
maxPageSize: services.cfg.maxPageSize,
|
|
84
|
+
stable: view.stable,
|
|
85
|
+
sortable: view.sortable,
|
|
86
|
+
}
|
|
87
|
+
sendJson(response, 200, meta)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const params = new URL(request.url ?? '/', 'http://localhost').searchParams
|
|
92
|
+
const order = params.get('order')
|
|
93
|
+
const statement = views.page(parsed.viewId, {
|
|
94
|
+
page: queryInt(params.get('page')),
|
|
95
|
+
pageSize: queryInt(params.get('pageSize')),
|
|
96
|
+
sort: params.get('sort') ?? undefined,
|
|
97
|
+
order: order === 'desc' || order === 'asc' ? order : undefined,
|
|
98
|
+
})
|
|
99
|
+
const db = await services.store.database(view.scopeKey)
|
|
100
|
+
const { rows, columns } = await selectRows(db, statement.sql, [...statement.params])
|
|
101
|
+
debugLog('http:rows', {
|
|
102
|
+
viewId: view.viewId,
|
|
103
|
+
page: statement.page,
|
|
104
|
+
pageSize: statement.pageSize,
|
|
105
|
+
totalRows: statement.totalRows,
|
|
106
|
+
totalPages: statement.totalPages,
|
|
107
|
+
rows: rows.length,
|
|
108
|
+
})
|
|
109
|
+
sendJson(response, 200, {
|
|
110
|
+
viewId: view.viewId,
|
|
111
|
+
columns,
|
|
112
|
+
rows,
|
|
113
|
+
page: statement.page,
|
|
114
|
+
pageSize: statement.pageSize,
|
|
115
|
+
totalRows: statement.totalRows,
|
|
116
|
+
totalPages: statement.totalPages,
|
|
117
|
+
stable: statement.stable,
|
|
118
|
+
})
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error instanceof ViewError) {
|
|
121
|
+
sendError(response, error.status, error.code, error.message)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
// 错误信息脱敏:不回显 SQL 与物理表名。
|
|
125
|
+
sendError(response, 500, 'QUERY_FAILED', '分页查询失败')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return webServer.register({ kind: 'prefix', path: `${prefix}/views`, handler })
|
|
130
|
+
}
|