@aiot-nexus/plugin-sdk 2.1.0 → 2.1.1
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 +5 -5
- package/dev-host/host.html +13 -12
- package/dev-host/main.js +0 -2
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/package.json +1 -1
- package/templates/minimal/README.md.tmpl +55 -7
- package/templates/sqlite/README.md.tmpl +2 -2
- package/templates/sqlite/src/main.ts.tmpl +156 -61
- package/templates/sqlite/src/renderer/app.ts.tmpl +9 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @aiot-nexus/plugin-sdk
|
|
2
2
|
|
|
3
|
-
> 版本适用性:当前包版本为 `2.1.
|
|
3
|
+
> 版本适用性:当前包版本为 `2.1.1`,面向 Framework API `2.1.0` 的 v2 worker/RPC 模型,并兼容仍声明 `2.0.x` 的插件。1.x SDK 和同步 Host API 已停止适用,但其历史语义保留在本文末尾和 [v1 到 v2 迁移说明](../docs/plugin-framework-v1-v2-migration.md);2.x 不提供 1.x 运行模型兼容层。
|
|
4
4
|
|
|
5
5
|
AIoT Nexus 独立插件开发包,包含公开 TypeScript 契约、PluginFrame 浏览器客户端和 `nexus-plugin` 命令行工具。
|
|
6
6
|
|
|
@@ -39,7 +39,7 @@ SDK 提供两层非阻塞版本检查:
|
|
|
39
39
|
- `nexus-plugin` 启动时先读取缓存。缓存新鲜期为 24 小时;缓存缺失或过期时在后台检查,不等待网络,不改变命令退出码。检测到普通更新时会显示:
|
|
40
40
|
|
|
41
41
|
```text
|
|
42
|
-
npm notice Update available for @aiot-nexus/plugin-sdk: 2.1.
|
|
42
|
+
npm notice Update available for @aiot-nexus/plugin-sdk: 2.1.1 -> 2.2.0
|
|
43
43
|
npm notice Run npm install --save-dev @aiot-nexus/plugin-sdk@latest
|
|
44
44
|
```
|
|
45
45
|
|
|
@@ -68,7 +68,7 @@ nexus-plugin create my-frame-plugin \
|
|
|
68
68
|
- 目标目录非空时命令以返回码 `2` 拒绝覆盖。生成或安装失败返回 `1`。
|
|
69
69
|
- 两套模板均读取宿主 `PluginRendererContext.language` 和 `themeMode`,通过 `onContextChanged` 在同一 iframe 内响应运行时语言与主题切换,并内置 `zh-CN` / `en-US` renderer 文案;manifest 使用相同文化资源本地化宿主入口。模板页面自身不提供语言或主题选择控件。
|
|
70
70
|
|
|
71
|
-
默认来源从 npm registry 安装。离线开发时先执行 `npm run pack:sdk`,再通过 `--sdk-source /path/to/aiot-nexus-plugin-sdk-2.1.
|
|
71
|
+
默认来源从 npm registry 安装。离线开发时先执行 `npm run pack:sdk`,再通过 `--sdk-source /path/to/aiot-nexus-plugin-sdk-2.1.1.tgz` 指定本地包;CLI 会把本地包一并放入生成工程。
|
|
72
72
|
|
|
73
73
|
### SQLite 数据库浏览器模板
|
|
74
74
|
|
|
@@ -76,7 +76,7 @@ nexus-plugin create my-frame-plugin \
|
|
|
76
76
|
|
|
77
77
|
最近选择的外部路径会原子写入插件数据目录的 `sqlite-browser-state.json`。下次启动会先尝试恢复;文件缺失、损坏或不再是 SQLite 时清除记录、回退 `plugin.db`,并在页面显示恢复提示。外部数据库不会执行示例表建表或 journal pragma。
|
|
78
78
|
|
|
79
|
-
模板按 `table_list` / `table_xinfo` 动态显示普通用户表、列类型、SQLite affinity、主键、NULL、默认值和生成列。新增字段可选“使用默认值 / NULL / 输入值”,编辑字段可选“不修改 / NULL / 输入值”;INTEGER 使用精确十进制整数,REAL 必须有限,NUMERIC 可输入整数、浮点或文本,BLOB 输入偶数长度十六进制并按解码后的实际字节数计入 1 MiB/4 MiB 限制。普通 rowid 表优先用内建 rowid 定位;`WITHOUT ROWID` 表或 rowid 别名全部被遮蔽的表只回退到全部非空的单/复合主键。没有唯一 locator
|
|
79
|
+
模板按 `table_list` / `table_xinfo` 动态显示普通用户表、列类型、SQLite affinity、主键、NULL、默认值和生成列。新增字段可选“使用默认值 / NULL / 输入值”,编辑字段可选“不修改 / NULL / 输入值”;INTEGER 使用精确十进制整数,REAL 必须有限,NUMERIC 可输入整数、浮点或文本,BLOB 输入偶数长度十六进制并按解码后的实际字节数计入 1 MiB/4 MiB 限制。普通 rowid 表优先用内建 rowid 定位;`WITHOUT ROWID` 表或 rowid 别名全部被遮蔽的表只回退到全部非空的单/复合主键。没有唯一 locator 的表仍可浏览和新增,超过 16 KiB 的主键 locator 也按只读行处理;更新和删除在事务内要求恰好命中一行。表格中的 TEXT 预览最多读取 16 KiB UTF-8 数据,BLOB 只读取总字节数和前 32 字节,避免把完整大字段复制进插件 worker 和 iframe。
|
|
80
80
|
|
|
81
81
|
该模板刻意不依赖 Quasar,以保持独立插件依赖最小化,因此数据库网格使用原生 `<table>`,这是宿主默认 `QTable` 规则之外的 SDK 模板例外。页面仍包含分页、横向表格滚动、中英文、亮暗主题、busy/empty/error 状态和窄窗口布局。
|
|
82
82
|
|
|
@@ -113,7 +113,7 @@ nexus-plugin dev [plugin-project-directory]
|
|
|
113
113
|
[--no-devtools]
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
调试宿主工具栏中的语言和主题选择器用于模拟 Nexus 主框架 context
|
|
116
|
+
调试宿主工具栏中的语言和主题选择器用于模拟 Nexus 主框架 context,不属于插件页面。除该工具栏外,插件 iframe 无边距、无边框地填满窗口剩余区域;宿主不根据插件上报的内容尺寸限制 iframe 宽高,Electron 调试窗口也不设置最小宽高。切换 context 后调试宿主向同一 iframe 重发框架 `2.1.0` 的 `init` 消息;SDK 将后续 `init` 作为 context 更新并原地刷新语言和主题,同时仍接受可选的 `context-changed` 消息。调试宿主还复用正式的 `nexus-plugin://` 资源协议、iframe sandbox、CSP、消息结构、operation 校验、Electron Facade 和当前 Host API 权限语义,但不包含 Nexus 的完整页面路由、Pinia 状态和应用级功能。`mainEntry` 的激活、deactivate、Host API 请求和 renderer operation 调用都通过异步 RPC 连接 worker;交付前仍需把 `dist/<plugin-id>/` 放入正式 Nexus 完成集成验证。
|
|
117
117
|
|
|
118
118
|
浏览器 UI 命令支持 `--no-build`、`--no-open`、`--port <port>`,以及与 Electron 调试宿主相同的 `--language`、`--theme`、`--surface` 和 `--contribution-id` 参数。它使用普通 `http://127.0.0.1` iframe,不模拟 `nexus-plugin://` 协议或 Electron sandbox,因此只作为 UI 开发入口。Renderer-only 输出不会覆盖正式插件制品。
|
|
119
119
|
|
package/dev-host/host.html
CHANGED
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
html,
|
|
35
35
|
body {
|
|
36
36
|
width: 100%;
|
|
37
|
+
height: 100%;
|
|
37
38
|
min-width: 0;
|
|
38
|
-
min-height:
|
|
39
|
+
min-height: 0;
|
|
39
40
|
margin: 0;
|
|
41
|
+
overflow: hidden;
|
|
40
42
|
background: var(--page);
|
|
41
43
|
color: var(--text);
|
|
42
44
|
}
|
|
@@ -51,6 +53,7 @@
|
|
|
51
53
|
top: 0;
|
|
52
54
|
z-index: 1;
|
|
53
55
|
display: flex;
|
|
56
|
+
flex: 0 0 auto;
|
|
54
57
|
align-items: center;
|
|
55
58
|
min-height: 48px;
|
|
56
59
|
gap: 12px;
|
|
@@ -117,18 +120,21 @@
|
|
|
117
120
|
.frame-shell {
|
|
118
121
|
display: flex;
|
|
119
122
|
flex-direction: column;
|
|
120
|
-
flex: 1 1
|
|
123
|
+
flex: 1 1 0;
|
|
121
124
|
min-width: 0;
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
min-height: 0;
|
|
126
|
+
padding: 0;
|
|
127
|
+
overflow: hidden;
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
iframe {
|
|
127
131
|
display: block;
|
|
128
|
-
flex: 1 1
|
|
132
|
+
flex: 1 1 0;
|
|
129
133
|
width: 100%;
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
height: 100%;
|
|
135
|
+
min-width: 0;
|
|
136
|
+
min-height: 0;
|
|
137
|
+
border: 0;
|
|
132
138
|
background: var(--surface);
|
|
133
139
|
}
|
|
134
140
|
|
|
@@ -146,10 +152,6 @@
|
|
|
146
152
|
.status {
|
|
147
153
|
margin-left: auto;
|
|
148
154
|
}
|
|
149
|
-
|
|
150
|
-
.frame-shell {
|
|
151
|
-
padding: 8px;
|
|
152
|
-
}
|
|
153
155
|
}
|
|
154
156
|
</style>
|
|
155
157
|
</head>
|
|
@@ -255,7 +257,6 @@
|
|
|
255
257
|
return;
|
|
256
258
|
}
|
|
257
259
|
if (message.type === "nexus-plugin:resize" && typeof message.height === "number") {
|
|
258
|
-
iframe.style.height = Math.min(6000, Math.max(160, Math.ceil(message.height))) + "px";
|
|
259
260
|
resizeSeen = true;
|
|
260
261
|
scheduleSmokeReport();
|
|
261
262
|
return;
|
package/dev-host/main.js
CHANGED
|
@@ -832,8 +832,6 @@ function createWindow() {
|
|
|
832
832
|
mainWindow = new BrowserWindow({
|
|
833
833
|
width: 1200,
|
|
834
834
|
height: 800,
|
|
835
|
-
minWidth: 640,
|
|
836
|
-
minHeight: 480,
|
|
837
835
|
show: !configuration.smoke,
|
|
838
836
|
backgroundColor: configuration.themeMode === "dark" ? "#181a1d" : "#f4f5f7",
|
|
839
837
|
webPreferences: {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pluginManifestSchema from "../.generated/plugin-manifest.schema.json";
|
|
2
|
-
export declare const PLUGIN_SDK_VERSION = "2.1.
|
|
2
|
+
export declare const PLUGIN_SDK_VERSION = "2.1.1";
|
|
3
3
|
export { pluginManifestSchema };
|
|
4
4
|
export * from "../.generated/plugin-framework-version";
|
|
5
5
|
export * from "./types";
|
package/dist/src/index.js
CHANGED
|
@@ -20,7 +20,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
20
20
|
exports.pluginManifestSchema = exports.PLUGIN_SDK_VERSION = void 0;
|
|
21
21
|
const plugin_manifest_schema_json_1 = __importDefault(require("../.generated/plugin-manifest.schema.json"));
|
|
22
22
|
exports.pluginManifestSchema = plugin_manifest_schema_json_1.default;
|
|
23
|
-
exports.PLUGIN_SDK_VERSION = "2.1.
|
|
23
|
+
exports.PLUGIN_SDK_VERSION = "2.1.1";
|
|
24
24
|
__exportStar(require("../.generated/plugin-framework-version"), exports);
|
|
25
25
|
__exportStar(require("./types"), exports);
|
|
26
26
|
__exportStar(require("./electron-facade"), exports);
|
package/package.json
CHANGED
|
@@ -23,16 +23,64 @@ The deployable plugin package is `dist/__PLUGIN_ID__/`. For a manual installatio
|
|
|
23
23
|
|
|
24
24
|
```ts
|
|
25
25
|
import * as fs from "fs/promises";
|
|
26
|
-
import type { PluginMainActivationContext } from "@aiot-nexus/plugin-sdk";
|
|
26
|
+
import type { NexusResult, PluginMainActivationContext } from "@aiot-nexus/plugin-sdk";
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const maximumTextFileBytes = 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
export async function chooseAndReadText(
|
|
31
|
+
context: PluginMainActivationContext
|
|
32
|
+
): Promise<NexusResult<string | undefined>> {
|
|
29
33
|
const result = await context.host.electron.dialog.showOpenDialog({
|
|
30
34
|
properties: ["openFile"],
|
|
31
35
|
filters: [{ name: "Text", extensions: ["txt", "md", "json"] }]
|
|
32
36
|
});
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
if (result.canceled || !result.filePaths[0]) {
|
|
38
|
+
return { ok: true, value: undefined };
|
|
39
|
+
}
|
|
40
|
+
return readTextFileBounded(result.filePaths[0]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readTextFileBounded(filePath: string): Promise<NexusResult<string>> {
|
|
44
|
+
try {
|
|
45
|
+
const file = await fs.open(filePath, "r");
|
|
46
|
+
try {
|
|
47
|
+
const stat = await file.stat();
|
|
48
|
+
if (!stat.isFile()) {
|
|
49
|
+
return failure("FileNotReadable", "The selected path is not a regular file.");
|
|
50
|
+
}
|
|
51
|
+
if (stat.size > maximumTextFileBytes) {
|
|
52
|
+
return failure("FileTooLarge", "The selected text file exceeds the 1 MiB limit.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The extra byte detects a file that grows after stat without an unbounded allocation.
|
|
56
|
+
const buffer = Buffer.alloc(maximumTextFileBytes + 1);
|
|
57
|
+
let totalBytes = 0;
|
|
58
|
+
while (totalBytes < buffer.length) {
|
|
59
|
+
const { bytesRead } = await file.read(
|
|
60
|
+
buffer,
|
|
61
|
+
totalBytes,
|
|
62
|
+
buffer.length - totalBytes,
|
|
63
|
+
totalBytes
|
|
64
|
+
);
|
|
65
|
+
if (bytesRead === 0) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
totalBytes += bytesRead;
|
|
69
|
+
}
|
|
70
|
+
if (totalBytes > maximumTextFileBytes) {
|
|
71
|
+
return failure("FileTooLarge", "The selected text file exceeds the 1 MiB limit.");
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, value: buffer.subarray(0, totalBytes).toString("utf8") };
|
|
74
|
+
} finally {
|
|
75
|
+
await file.close();
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
return failure("FileReadFailed", "The selected text file could not be read.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function failure(code: string, message: string): NexusResult<never> {
|
|
83
|
+
return { ok: false, error: { code, message } };
|
|
36
84
|
}
|
|
37
85
|
|
|
38
86
|
export async function chooseFolder(context: PluginMainActivationContext): Promise<string | undefined> {
|
|
@@ -48,11 +96,11 @@ export async function chooseSavePath(context: PluginMainActivationContext): Prom
|
|
|
48
96
|
}
|
|
49
97
|
```
|
|
50
98
|
|
|
51
|
-
Call these helpers from an operation registered with `context.host.registerRendererMethod`, then let the renderer invoke that narrow operation through `client.invokeMain()`. Do not expose the Facade or unrestricted file paths to the iframe. `npm run dev:web` is renderer-only and deliberately has no `mainEntry`, Node filesystem, or Electron Facade; use `npm run dev` or a formal Nexus host to test these dialogs.
|
|
99
|
+
Call these helpers from an operation registered with `context.host.registerRendererMethod`, then let the renderer invoke that narrow operation through `client.invokeMain()`. Return the `NexusResult` from `chooseAndReadText` directly so the renderer receives the stable `FileNotReadable`, `FileTooLarge`, or `FileReadFailed` code. Do not expose the Facade or unrestricted file paths to the iframe. For larger files, stream or paginate in `mainEntry` and return only bounded chunks or summaries instead of the whole file. `npm run dev:web` is renderer-only and deliberately has no `mainEntry`, Node filesystem, or Electron Facade; use `npm run dev` or a formal Nexus host to test these dialogs.
|
|
52
100
|
|
|
53
101
|
The renderer calls the plugin-worker `demo.echo` operation through `@aiot-nexus/plugin-sdk`. The worker also calls `context.host.electron.app.getName()` during activation, so `npm run dev` verifies the real asynchronous Electron Facade path. Run `npm run validate` after changing the manifest or output layout.
|
|
54
102
|
|
|
55
|
-
The renderer follows `context.language` and `context.themeMode` from Nexus, subscribes to in-place context updates without reloading the iframe, and includes `en-US` and `zh-CN` message catalogs. The plugin page has no language or theme selector; the controls shown by `npm run dev` belong to the debug host and simulate Nexus context changes. Add or edit renderer messages in `src/renderer/app.ts`; edit host-shell titles and descriptions in `src/manifest.json` resources.
|
|
103
|
+
The renderer follows `context.language` and `context.themeMode` from Nexus, subscribes to in-place context updates without reloading the iframe, and includes `en-US` and `zh-CN` message catalogs. The plugin page has no language or theme selector; the controls shown by `npm run dev` belong to the debug host and simulate Nexus context changes. Below that toolbar, the iframe has no host padding, border, minimum size, or content-driven height and fills the remaining host viewport. Add or edit renderer messages in `src/renderer/app.ts`; edit host-shell titles and descriptions in `src/manifest.json` resources.
|
|
56
104
|
|
|
57
105
|
The current SDK has no generic business-event subscription API. For a long-running operation, register a fast `job.start` operation that returns a `taskId`, then expose `job.getStatus` and optional `job.cancel` for renderer polling. See the Nexus repository's plugin IPC and event notification example for the full contract and code.
|
|
58
106
|
|
|
@@ -45,9 +45,9 @@ For related workflows, use `properties: ["openDirectory", "createDirectory"]` to
|
|
|
45
45
|
|
|
46
46
|
External files are opened read/write only after a separate candidate connection can query `sqlite_schema`. Canceling the dialog or selecting a corrupt, locked, or inaccessible file keeps the current connection. The latest external path is atomically stored as `sqlite-browser-state.json` in the plugin data directory. Startup restores it when valid; otherwise the template clears the saved selection, falls back to `plugin.db`, and returns a visible notice. External databases do not receive the sample schema or journal pragma.
|
|
47
47
|
|
|
48
|
-
The table selector lists ordinary user tables from the `main` schema. Dynamic headers show declared type, SQLite affinity, primary-key order, NULL, default, and generated-column metadata. The editor supports default/NULL/value modes for inserts and unchanged/NULL/value modes for updates. INTEGER input uses exact decimal integers, REAL must be finite, NUMERIC accepts integer, real, or text values, and BLOB input uses even-length hexadecimal text. The 1 MiB field and 4 MiB row limits count decoded BLOB bytes rather than hexadecimal characters. Ordinary rowid tables prefer an unshadowed built-in rowid; `WITHOUT ROWID` tables, or tables that shadow every rowid alias, fall back only to primary-key columns that are all non-NULL. A table without a unique locator remains browsable and insertable, but update and delete are unavailable. Update and delete run in a transaction and must affect exactly one row. TEXT previews are capped at 16 KiB; BLOB previews show the byte length and first 32 bytes.
|
|
48
|
+
The table selector lists ordinary user tables from the `main` schema. Dynamic headers show declared type, SQLite affinity, primary-key order, NULL, default, and generated-column metadata. The editor supports default/NULL/value modes for inserts and unchanged/NULL/value modes for updates. INTEGER input uses exact decimal integers, REAL must be finite, NUMERIC accepts integer, real, or text values, and BLOB input uses even-length hexadecimal text. The 1 MiB field and 4 MiB row limits count decoded BLOB bytes rather than hexadecimal characters. Ordinary rowid tables prefer an unshadowed built-in rowid; `WITHOUT ROWID` tables, or tables that shadow every rowid alias, fall back only to primary-key columns that are all non-NULL. A table without a unique locator remains browsable and insertable, but update and delete are unavailable. Primary-key locators larger than 16 KiB are also treated as browse-only so a page cannot copy unbounded keys into the plugin worker and iframe. Update and delete run in a transaction and must affect exactly one row. TEXT previews are capped at 16 KiB of UTF-8 data; BLOB previews show the byte length and first 32 bytes.
|
|
49
49
|
|
|
50
|
-
The renderer follows `context.language` and `context.themeMode` from Nexus and preserves unfinished editor values during in-place context updates. The plugin page has no language or theme selector; the controls shown by `npm run dev` belong to the debug host. This SDK template deliberately has no Quasar dependency, so its data grid uses a native `<table>` as a dependency-minimized template exception. It still provides pagination, contained horizontal scrolling, light/dark themes, `en-US` / `zh-CN`, and busy/empty/error states. Edit renderer messages in `src/renderer/app.ts`; edit host-shell titles and descriptions in `src/manifest.json` resources.
|
|
50
|
+
The renderer follows `context.language` and `context.themeMode` from Nexus and preserves unfinished editor values during in-place context updates. The plugin page has no language or theme selector; the controls shown by `npm run dev` belong to the debug host. Below that toolbar, the iframe has no host padding, border, minimum size, or content-driven height and fills the remaining host viewport. This SDK template deliberately has no Quasar dependency, so its data grid uses a native `<table>` as a dependency-minimized template exception. It still provides pagination, contained horizontal scrolling, light/dark themes, `en-US` / `zh-CN`, and busy/empty/error states. Edit renderer messages in `src/renderer/app.ts`; edit host-shell titles and descriptions in `src/manifest.json` resources.
|
|
51
51
|
|
|
52
52
|
The current SDK has no generic business-event subscription API. For a long-running operation, register a fast `job.start` operation that returns a `taskId`, then expose `job.getStatus` and optional `job.cancel` for renderer polling. See the Nexus repository's plugin IPC and event notification example for the full contract and code.
|
|
53
53
|
|
|
@@ -131,10 +131,14 @@ const defaultDatabaseName = "plugin.db";
|
|
|
131
131
|
const stateFileName = "sqlite-browser-state.json";
|
|
132
132
|
const defaultTableName = "plugin_records";
|
|
133
133
|
const allowedPageSizes = new Set([25, 50, 100]);
|
|
134
|
-
const
|
|
134
|
+
const maximumTextPreviewBytes = 16 * 1024;
|
|
135
135
|
const maximumBlobPreviewBytes = 32;
|
|
136
136
|
const maximumFieldBytes = 1024 * 1024;
|
|
137
137
|
const maximumRequestBytes = 4 * 1024 * 1024;
|
|
138
|
+
const maximumBlobInputCharacters = maximumFieldBytes * 2 + 4096;
|
|
139
|
+
const maximumLocatorBytes = 16 * 1024;
|
|
140
|
+
const maximumSqliteIntegerCharacters = 20;
|
|
141
|
+
const projectedValuesPerColumn = 4;
|
|
138
142
|
const minimumSqliteInteger = -(1n << 63n);
|
|
139
143
|
const maximumSqliteInteger = (1n << 63n) - 1n;
|
|
140
144
|
|
|
@@ -365,11 +369,9 @@ function readTable(request: PluginRendererOperationRequest): {
|
|
|
365
369
|
const connection = requireDatabase().connection;
|
|
366
370
|
const schema = readTableSchema(connection, tableName);
|
|
367
371
|
const totalRows = readCount(connection, schema.summary.name);
|
|
368
|
-
const selectColumns = schema.visibleColumns.map((column) => quoteIdentifier(column.name));
|
|
369
|
-
const hasRowidLocator = schema.locator?.kind === "rowid" && schema.locator.rowidAlias;
|
|
370
372
|
const selectList = [
|
|
371
|
-
...(
|
|
372
|
-
...
|
|
373
|
+
...locatorProjection(schema),
|
|
374
|
+
...schema.visibleColumns.flatMap(previewProjection)
|
|
373
375
|
].join(", ");
|
|
374
376
|
const orderBy = orderByClause(schema.locator);
|
|
375
377
|
const rawRows = requireArrayRows(
|
|
@@ -544,32 +546,71 @@ function createLocatorSpec(summary: TableSummary, columns: ColumnInfo[]): Locato
|
|
|
544
546
|
return undefined;
|
|
545
547
|
}
|
|
546
548
|
|
|
549
|
+
function locatorProjection(schema: TableSchema): string[] {
|
|
550
|
+
const spec = schema.locator;
|
|
551
|
+
if (!spec) {
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
if (spec.kind === "rowid") {
|
|
555
|
+
return [spec.rowidAlias as string];
|
|
556
|
+
}
|
|
557
|
+
return spec.columns.map((column) => {
|
|
558
|
+
const identifier = quoteIdentifier(column);
|
|
559
|
+
return "CASE WHEN typeof(" + identifier + ") IN ('text', 'blob')"
|
|
560
|
+
+ " AND length(CAST(" + identifier + " AS BLOB)) > " + String(maximumLocatorBytes)
|
|
561
|
+
+ " THEN NULL ELSE " + identifier + " END";
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function previewProjection(column: ColumnInfo): string[] {
|
|
566
|
+
const identifier = quoteIdentifier(column.name);
|
|
567
|
+
const storageType = "typeof(" + identifier + ")";
|
|
568
|
+
return [
|
|
569
|
+
storageType,
|
|
570
|
+
"CASE WHEN " + storageType + " = 'text' THEN length(" + identifier + ") ELSE NULL END",
|
|
571
|
+
"CASE WHEN " + storageType + " IN ('text', 'blob') THEN length(CAST(" + identifier + " AS BLOB)) ELSE NULL END",
|
|
572
|
+
"CASE " + storageType
|
|
573
|
+
+ " WHEN 'text' THEN substr(" + identifier + ", 1, " + String(maximumTextPreviewBytes) + ")"
|
|
574
|
+
+ " WHEN 'blob' THEN substr(" + identifier + ", 1, " + String(maximumBlobPreviewBytes) + ")"
|
|
575
|
+
+ " ELSE " + identifier + " END"
|
|
576
|
+
];
|
|
577
|
+
}
|
|
578
|
+
|
|
547
579
|
function readTableRow(schema: TableSchema, rawRow: unknown[]): TableRow {
|
|
548
|
-
const
|
|
549
|
-
|
|
580
|
+
const locatorValueCount = schema.locator?.columns.length || 0;
|
|
581
|
+
const expectedValueCount = locatorValueCount + schema.visibleColumns.length * projectedValuesPerColumn;
|
|
582
|
+
if (rawRow.length !== expectedValueCount) {
|
|
550
583
|
throw new Error("SQLite table row has an unexpected column count.");
|
|
551
584
|
}
|
|
552
|
-
const cells = schema.visibleColumns.map((column, index) =>
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
585
|
+
const cells = schema.visibleColumns.map((column, index) => {
|
|
586
|
+
const valueOffset = locatorValueCount + index * projectedValuesPerColumn;
|
|
587
|
+
return {
|
|
588
|
+
column: column.name,
|
|
589
|
+
value: serializeProjectedValue(
|
|
590
|
+
rawRow[valueOffset],
|
|
591
|
+
rawRow[valueOffset + 1],
|
|
592
|
+
rawRow[valueOffset + 2],
|
|
593
|
+
rawRow[valueOffset + 3]
|
|
594
|
+
)
|
|
595
|
+
};
|
|
596
|
+
});
|
|
597
|
+
const locator = createRowLocator(schema, rawRow.slice(0, locatorValueCount));
|
|
557
598
|
return { cells, ...(locator ? { locator } : {}) };
|
|
558
599
|
}
|
|
559
600
|
|
|
560
|
-
function createRowLocator(schema: TableSchema,
|
|
601
|
+
function createRowLocator(schema: TableSchema, rawLocatorValues: unknown[]): RowLocator | undefined {
|
|
561
602
|
const spec = schema.locator;
|
|
562
603
|
if (!spec) {
|
|
563
604
|
return undefined;
|
|
564
605
|
}
|
|
565
606
|
if (spec.kind === "rowid") {
|
|
566
|
-
const value = serializeLocatorValue(
|
|
607
|
+
const value = serializeLocatorValue(rawLocatorValues[0]);
|
|
567
608
|
return value ? { kind: "rowid", values: [{ column: spec.columns[0], value }] } : undefined;
|
|
568
609
|
}
|
|
569
610
|
const values: LocatorValue[] = [];
|
|
570
|
-
for (
|
|
571
|
-
const
|
|
572
|
-
const value =
|
|
611
|
+
for (let index = 0; index < spec.columns.length; index += 1) {
|
|
612
|
+
const columnName = spec.columns[index];
|
|
613
|
+
const value = serializeLocatorValue(rawLocatorValues[index]);
|
|
573
614
|
if (!value) {
|
|
574
615
|
return undefined;
|
|
575
616
|
}
|
|
@@ -578,42 +619,73 @@ function createRowLocator(schema: TableSchema, rawRow: unknown[], rowidOffset: n
|
|
|
578
619
|
return { kind: "primaryKey", values };
|
|
579
620
|
}
|
|
580
621
|
|
|
581
|
-
function
|
|
582
|
-
|
|
622
|
+
function serializeProjectedValue(
|
|
623
|
+
storageType: unknown,
|
|
624
|
+
characterLength: unknown,
|
|
625
|
+
byteLength: unknown,
|
|
626
|
+
preview: unknown
|
|
627
|
+
): WireValue {
|
|
628
|
+
if (storageType === "null" && (preview === null || preview === undefined)) {
|
|
583
629
|
return { kind: "null" };
|
|
584
630
|
}
|
|
585
|
-
if (typeof
|
|
586
|
-
return { kind: "integer", value:
|
|
631
|
+
if (storageType === "integer" && typeof preview === "bigint") {
|
|
632
|
+
return { kind: "integer", value: preview.toString() };
|
|
587
633
|
}
|
|
588
|
-
if (typeof
|
|
589
|
-
return
|
|
590
|
-
? { kind: "integer", value: String(value) }
|
|
591
|
-
: { kind: "real", value };
|
|
634
|
+
if (storageType === "integer" && typeof preview === "number" && Number.isSafeInteger(preview)) {
|
|
635
|
+
return { kind: "integer", value: String(preview) };
|
|
592
636
|
}
|
|
593
|
-
if (typeof
|
|
637
|
+
if (storageType === "real" && typeof preview === "number" && Number.isFinite(preview)) {
|
|
638
|
+
return { kind: "real", value: preview };
|
|
639
|
+
}
|
|
640
|
+
if (storageType === "text" && typeof preview === "string") {
|
|
641
|
+
const totalCharacters = integerNumber(characterLength, "text character length");
|
|
642
|
+
const totalBytes = integerNumber(byteLength, "text byte length");
|
|
643
|
+
const boundedPreview = truncateUtf8(preview, maximumTextPreviewBytes);
|
|
594
644
|
return {
|
|
595
645
|
kind: "text",
|
|
596
|
-
value:
|
|
597
|
-
length:
|
|
598
|
-
truncated:
|
|
646
|
+
value: boundedPreview,
|
|
647
|
+
length: totalCharacters,
|
|
648
|
+
truncated: Buffer.byteLength(boundedPreview, "utf8") < totalBytes
|
|
599
649
|
};
|
|
600
650
|
}
|
|
601
|
-
if (Buffer.isBuffer(
|
|
651
|
+
if (storageType === "blob" && Buffer.isBuffer(preview)) {
|
|
652
|
+
const totalBytes = integerNumber(byteLength, "BLOB byte length");
|
|
602
653
|
return {
|
|
603
654
|
kind: "blob",
|
|
604
|
-
hex:
|
|
605
|
-
byteLength:
|
|
606
|
-
truncated:
|
|
655
|
+
hex: preview.toString("hex"),
|
|
656
|
+
byteLength: totalBytes,
|
|
657
|
+
truncated: preview.length < totalBytes
|
|
607
658
|
};
|
|
608
659
|
}
|
|
609
|
-
throw new Error("SQLite returned
|
|
660
|
+
throw new Error("SQLite returned malformed projected cell data.");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function truncateUtf8(value: string, maximumBytes: number): string {
|
|
664
|
+
if (Buffer.byteLength(value, "utf8") <= maximumBytes) {
|
|
665
|
+
return value;
|
|
666
|
+
}
|
|
667
|
+
const characters: string[] = [];
|
|
668
|
+
let byteLength = 0;
|
|
669
|
+
for (const character of value) {
|
|
670
|
+
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
671
|
+
if (byteLength + characterBytes > maximumBytes) {
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
characters.push(character);
|
|
675
|
+
byteLength += characterBytes;
|
|
676
|
+
}
|
|
677
|
+
return characters.join("");
|
|
610
678
|
}
|
|
611
679
|
|
|
612
680
|
function serializeLocatorValue(value: unknown): WireValue | undefined {
|
|
613
|
-
if (
|
|
681
|
+
if (value === null || value === undefined) {
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
if (typeof value === "string" && (value.length > maximumLocatorBytes
|
|
685
|
+
|| Buffer.byteLength(value, "utf8") > maximumLocatorBytes)) {
|
|
614
686
|
return undefined;
|
|
615
687
|
}
|
|
616
|
-
if (Buffer.isBuffer(value) && value.length >
|
|
688
|
+
if (Buffer.isBuffer(value) && value.length > maximumLocatorBytes) {
|
|
617
689
|
return undefined;
|
|
618
690
|
}
|
|
619
691
|
if (typeof value === "string") {
|
|
@@ -622,7 +694,15 @@ function serializeLocatorValue(value: unknown): WireValue | undefined {
|
|
|
622
694
|
if (Buffer.isBuffer(value)) {
|
|
623
695
|
return { kind: "blob", hex: value.toString("hex"), byteLength: value.length, truncated: false };
|
|
624
696
|
}
|
|
625
|
-
|
|
697
|
+
if (typeof value === "bigint") {
|
|
698
|
+
return { kind: "integer", value: value.toString() };
|
|
699
|
+
}
|
|
700
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
701
|
+
return Number.isInteger(value)
|
|
702
|
+
? { kind: "integer", value: String(value) }
|
|
703
|
+
: { kind: "real", value };
|
|
704
|
+
}
|
|
705
|
+
return undefined;
|
|
626
706
|
}
|
|
627
707
|
|
|
628
708
|
function parseMutations(schema: TableSchema, value: unknown, mode: "insert" | "update"): ParsedMutations {
|
|
@@ -687,12 +767,23 @@ function readMutationValue(value: unknown, columnName: string): MutationValue {
|
|
|
687
767
|
}
|
|
688
768
|
|
|
689
769
|
function convertInputValue(column: ColumnInfo, source: string): ConvertedInputValue {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
throw codedError("InvalidRequest", "Column requires an integer: " + column.name);
|
|
770
|
+
if (column.affinity === "blob") {
|
|
771
|
+
if (source.length > maximumBlobInputCharacters) {
|
|
772
|
+
throw codedError("InvalidRequest", "BLOB value exceeds the accepted encoded input size: " + column.name);
|
|
694
773
|
}
|
|
695
|
-
|
|
774
|
+
const normalized = source.replace(/\s+/g, "");
|
|
775
|
+
if (normalized.length > maximumFieldBytes * 2) {
|
|
776
|
+
throw codedError("InvalidRequest", "BLOB value exceeds the 1 MiB limit: " + column.name);
|
|
777
|
+
}
|
|
778
|
+
if (normalized.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(normalized)) {
|
|
779
|
+
throw codedError("InvalidRequest", "BLOB columns require an even-length hexadecimal value: " + column.name);
|
|
780
|
+
}
|
|
781
|
+
const buffer = Buffer.from(normalized, "hex");
|
|
782
|
+
return { value: buffer, byteLength: buffer.length };
|
|
783
|
+
}
|
|
784
|
+
const sourceBytes = boundedUtf8ByteLength(source, maximumFieldBytes, "Column value exceeds the 1 MiB limit: " + column.name);
|
|
785
|
+
if (column.affinity === "integer") {
|
|
786
|
+
return { value: parseSqliteInteger(source, "Column " + column.name), byteLength: sourceBytes };
|
|
696
787
|
}
|
|
697
788
|
if (column.affinity === "real") {
|
|
698
789
|
const parsed = Number(source);
|
|
@@ -703,7 +794,7 @@ function convertInputValue(column: ColumnInfo, source: string): ConvertedInputVa
|
|
|
703
794
|
}
|
|
704
795
|
if (column.affinity === "numeric") {
|
|
705
796
|
if (/^[+-]?\d+$/.test(source)) {
|
|
706
|
-
return { value: parseSqliteInteger(source, column.name), byteLength: sourceBytes };
|
|
797
|
+
return { value: parseSqliteInteger(source, "Column " + column.name), byteLength: sourceBytes };
|
|
707
798
|
}
|
|
708
799
|
const parsed = Number(source);
|
|
709
800
|
return {
|
|
@@ -711,24 +802,27 @@ function convertInputValue(column: ColumnInfo, source: string): ConvertedInputVa
|
|
|
711
802
|
byteLength: sourceBytes
|
|
712
803
|
};
|
|
713
804
|
}
|
|
714
|
-
if (column.affinity === "blob") {
|
|
715
|
-
const normalized = source.replace(/\s+/g, "");
|
|
716
|
-
if (normalized.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(normalized)) {
|
|
717
|
-
throw codedError("InvalidRequest", "BLOB columns require an even-length hexadecimal value: " + column.name);
|
|
718
|
-
}
|
|
719
|
-
if (normalized.length > maximumFieldBytes * 2) {
|
|
720
|
-
throw codedError("InvalidRequest", "BLOB value exceeds the 1 MiB limit: " + column.name);
|
|
721
|
-
}
|
|
722
|
-
const buffer = Buffer.from(normalized, "hex");
|
|
723
|
-
return { value: buffer, byteLength: buffer.length };
|
|
724
|
-
}
|
|
725
805
|
return { value: source, byteLength: sourceBytes };
|
|
726
806
|
}
|
|
727
807
|
|
|
728
|
-
function
|
|
808
|
+
function boundedUtf8ByteLength(source: string, maximumBytes: number, message: string): number {
|
|
809
|
+
if (source.length > maximumBytes) {
|
|
810
|
+
throw codedError("InvalidRequest", message);
|
|
811
|
+
}
|
|
812
|
+
const byteLength = Buffer.byteLength(source, "utf8");
|
|
813
|
+
if (byteLength > maximumBytes) {
|
|
814
|
+
throw codedError("InvalidRequest", message);
|
|
815
|
+
}
|
|
816
|
+
return byteLength;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function parseSqliteInteger(source: string, label: string): bigint {
|
|
820
|
+
if (source.length > maximumSqliteIntegerCharacters || !/^[+-]?\d+$/.test(source)) {
|
|
821
|
+
throw codedError("InvalidRequest", label + " requires a signed 64-bit integer.");
|
|
822
|
+
}
|
|
729
823
|
const value = BigInt(source);
|
|
730
824
|
if (value < minimumSqliteInteger || value > maximumSqliteInteger) {
|
|
731
|
-
throw codedError("InvalidRequest", "
|
|
825
|
+
throw codedError("InvalidRequest", label + " is outside SQLite's signed 64-bit range.");
|
|
732
826
|
}
|
|
733
827
|
return value;
|
|
734
828
|
}
|
|
@@ -758,19 +852,20 @@ function readWireValue(value: unknown): WireValue {
|
|
|
758
852
|
if (record.kind === "null") {
|
|
759
853
|
return { kind: "null" };
|
|
760
854
|
}
|
|
761
|
-
if (record.kind === "integer" && typeof record.value === "string"
|
|
762
|
-
return { kind: "integer", value: record.value };
|
|
855
|
+
if (record.kind === "integer" && typeof record.value === "string") {
|
|
856
|
+
return { kind: "integer", value: parseSqliteInteger(record.value, "Row locator").toString() };
|
|
763
857
|
}
|
|
764
858
|
if (record.kind === "real" && typeof record.value === "number" && Number.isFinite(record.value)) {
|
|
765
859
|
return { kind: "real", value: record.value };
|
|
766
860
|
}
|
|
767
861
|
if (record.kind === "text" && typeof record.value === "string" && record.truncated === false
|
|
768
|
-
&&
|
|
862
|
+
&& record.value.length <= maximumLocatorBytes
|
|
863
|
+
&& Buffer.byteLength(record.value, "utf8") <= maximumLocatorBytes) {
|
|
769
864
|
return { kind: "text", value: record.value, length: record.value.length, truncated: false };
|
|
770
865
|
}
|
|
771
866
|
if (record.kind === "blob" && typeof record.hex === "string" && record.truncated === false
|
|
772
|
-
&& record.hex.length
|
|
773
|
-
&& record.hex.length
|
|
867
|
+
&& record.hex.length <= maximumLocatorBytes * 2
|
|
868
|
+
&& record.hex.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(record.hex)) {
|
|
774
869
|
return { kind: "blob", hex: record.hex, byteLength: record.hex.length / 2, truncated: false };
|
|
775
870
|
}
|
|
776
871
|
throw codedError("InvalidRequest", "Row locator contains an invalid value.");
|
|
@@ -327,7 +327,7 @@ void client.waitForContext().then((context) => {
|
|
|
327
327
|
writeStatusMessage("error.initializationFailed", { message: errorMessage(error) });
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
-
async function refreshDatabase(preserveSelection: boolean): Promise<
|
|
330
|
+
async function refreshDatabase(preserveSelection: boolean): Promise<boolean> {
|
|
331
331
|
setBusy(true, "status.loading");
|
|
332
332
|
try {
|
|
333
333
|
const [info, tableList] = await Promise.all([
|
|
@@ -351,10 +351,12 @@ async function refreshDatabase(preserveSelection: boolean): Promise<void> {
|
|
|
351
351
|
} else {
|
|
352
352
|
writeStatusMessage("status.ready");
|
|
353
353
|
}
|
|
354
|
+
return true;
|
|
354
355
|
} catch (error: unknown) {
|
|
355
356
|
tableData = undefined;
|
|
356
357
|
renderTableData();
|
|
357
358
|
writeStatusMessage("error.refreshFailed", { message: errorMessage(error) });
|
|
359
|
+
return false;
|
|
358
360
|
} finally {
|
|
359
361
|
setBusy(false, "status.idle");
|
|
360
362
|
}
|
|
@@ -371,8 +373,9 @@ async function openDatabase(): Promise<void> {
|
|
|
371
373
|
closeEditor();
|
|
372
374
|
selectedTable = "";
|
|
373
375
|
offset = 0;
|
|
374
|
-
await refreshDatabase(false)
|
|
375
|
-
|
|
376
|
+
if (await refreshDatabase(false)) {
|
|
377
|
+
writeStatusMessage("status.databaseOpened", { name: result.database.databaseName });
|
|
378
|
+
}
|
|
376
379
|
} catch (error: unknown) {
|
|
377
380
|
writeStatusMessage("error.operationFailed", { message: errorMessage(error) });
|
|
378
381
|
} finally {
|
|
@@ -387,8 +390,9 @@ async function useDefaultDatabase(): Promise<void> {
|
|
|
387
390
|
closeEditor();
|
|
388
391
|
selectedTable = "plugin_records";
|
|
389
392
|
offset = 0;
|
|
390
|
-
await refreshDatabase(true)
|
|
391
|
-
|
|
393
|
+
if (await refreshDatabase(true)) {
|
|
394
|
+
writeStatusMessage("status.defaultOpened");
|
|
395
|
+
}
|
|
392
396
|
} catch (error: unknown) {
|
|
393
397
|
writeStatusMessage("error.operationFailed", { message: errorMessage(error) });
|
|
394
398
|
} finally {
|