@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @aiot-nexus/plugin-sdk
2
2
 
3
- > 版本适用性:当前包版本为 `2.1.0`,面向 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 运行模型兼容层。
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.0 -> 2.2.0
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.0.tgz` 指定本地包;CLI 会把本地包一并放入生成工程。
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 的表仍可浏览和新增,但禁用编辑、删除;更新和删除在事务内要求恰好命中一行。表格中的 TEXT 预览最多 16 KiB,BLOB 只显示字节数和前 32 字节。
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,不属于插件页面。切换后调试宿主向同一 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 完成集成验证。
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
 
@@ -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: 100%;
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 auto;
123
+ flex: 1 1 0;
121
124
  min-width: 0;
122
- padding: 12px;
123
- overflow: auto;
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 auto;
132
+ flex: 1 1 0;
129
133
  width: 100%;
130
- min-height: 360px;
131
- border: 1px solid var(--border);
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: {
@@ -1,5 +1,5 @@
1
1
  import pluginManifestSchema from "../.generated/plugin-manifest.schema.json";
2
- export declare const PLUGIN_SDK_VERSION = "2.1.0";
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.0";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiot-nexus/plugin-sdk",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Types, PluginFrame client, and CLI for AIoT Nexus independent plugins.",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -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
- export async function chooseAndReadText(context: PluginMainActivationContext): Promise<string | undefined> {
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
- return result.canceled || !result.filePaths[0]
34
- ? undefined
35
- : fs.readFile(result.filePaths[0], "utf8");
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 maximumTextPreviewLength = 16 * 1024;
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
- ...(hasRowidLocator ? [schema.locator?.rowidAlias as string] : []),
372
- ...selectColumns
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 rowidOffset = schema.locator?.kind === "rowid" ? 1 : 0;
549
- if (rawRow.length !== schema.visibleColumns.length + rowidOffset) {
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
- column: column.name,
554
- value: serializePreviewValue(rawRow[index + rowidOffset])
555
- }));
556
- const locator = createRowLocator(schema, rawRow, rowidOffset);
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, rawRow: unknown[], rowidOffset: number): RowLocator | undefined {
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(rawRow[0]);
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 (const columnName of spec.columns) {
571
- const index = schema.visibleColumns.findIndex((column) => column.name === columnName);
572
- const value = index < 0 ? undefined : serializeLocatorValue(rawRow[index + rowidOffset]);
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 serializePreviewValue(value: unknown): WireValue {
582
- if (value === null || value === undefined) {
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 value === "bigint") {
586
- return { kind: "integer", value: value.toString() };
631
+ if (storageType === "integer" && typeof preview === "bigint") {
632
+ return { kind: "integer", value: preview.toString() };
587
633
  }
588
- if (typeof value === "number") {
589
- return Number.isInteger(value)
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 value === "string") {
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: value.slice(0, maximumTextPreviewLength),
597
- length: value.length,
598
- truncated: value.length > maximumTextPreviewLength
646
+ value: boundedPreview,
647
+ length: totalCharacters,
648
+ truncated: Buffer.byteLength(boundedPreview, "utf8") < totalBytes
599
649
  };
600
650
  }
601
- if (Buffer.isBuffer(value)) {
651
+ if (storageType === "blob" && Buffer.isBuffer(preview)) {
652
+ const totalBytes = integerNumber(byteLength, "BLOB byte length");
602
653
  return {
603
654
  kind: "blob",
604
- hex: value.subarray(0, maximumBlobPreviewBytes).toString("hex"),
605
- byteLength: value.length,
606
- truncated: value.length > maximumBlobPreviewBytes
655
+ hex: preview.toString("hex"),
656
+ byteLength: totalBytes,
657
+ truncated: preview.length < totalBytes
607
658
  };
608
659
  }
609
- throw new Error("SQLite returned an unsupported cell value.");
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 (typeof value === "string" && Buffer.byteLength(value, "utf8") > maximumFieldBytes) {
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 > maximumFieldBytes) {
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
- return serializePreviewValue(value);
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
- const sourceBytes = Buffer.byteLength(source, "utf8");
691
- if (column.affinity === "integer") {
692
- if (!/^[+-]?\d+$/.test(source)) {
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
- return { value: parseSqliteInteger(source, column.name), byteLength: sourceBytes };
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 parseSqliteInteger(source: string, columnName: string): bigint {
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", "Column integer is outside SQLite's signed 64-bit range: " + columnName);
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" && /^[+-]?\d+$/.test(record.value)) {
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
- && Buffer.byteLength(record.value, "utf8") <= maximumFieldBytes) {
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 % 2 === 0 && /^[0-9a-fA-F]*$/.test(record.hex)
773
- && record.hex.length / 2 <= maximumFieldBytes) {
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<void> {
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
- writeStatusMessage("status.databaseOpened", { name: result.database.databaseName });
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
- writeStatusMessage("status.defaultOpened");
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 {