@aiot-nexus/plugin-sdk 2.0.2 → 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 +18 -8
- package/dev-host/host.html +13 -12
- package/dev-host/main.js +134 -9
- package/dev-host/plugin-host.js +1 -427
- package/dist/.generated/plugin-framework-version.d.ts +1 -1
- package/dist/.generated/plugin-framework-version.js +1 -1
- package/dist/.generated/plugin-manifest.schema.json +1 -1
- package/dist/internal/src-electron/main/core/runtime-result.js +42 -0
- package/dist/internal/src-electron/main/plugins/plugin-electron-facade-broker.js +890 -0
- package/dist/internal/src-electron/plugin-host-bootstrap.js +417 -0
- package/dist/internal/src-electron/shared/contracts.js +2 -0
- package/dist/internal/src-electron/shared/plugin-electron-facade-client.js +181 -0
- package/dist/internal/src-electron/shared/plugin-electron-facade-descriptors.js +70 -0
- package/dist/internal/src-electron/shared/plugin-electron-facade.js +243 -0
- package/dist/internal/src-electron/shared/plugin-rpc.js +245 -0
- package/dist/src/electron-facade.d.ts +4 -0
- package/dist/src/electron-facade.js +56 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -1
- package/dist/src/nexus-dev.js +5 -4
- package/dist/src/scaffold.js +2 -2
- package/dist/src/types.d.ts +86 -2
- package/dist/src/web-dev.js +2 -2
- package/package.json +7 -2
- package/templates/minimal/README.md.tmpl +84 -3
- package/templates/minimal/src/main.ts.tmpl +2 -1
- package/templates/sqlite/README.md.tmpl +30 -2
- package/templates/sqlite/src/better-sqlite3.d.ts +12 -1
- package/templates/sqlite/src/main.ts.tmpl +1034 -124
- package/templates/sqlite/src/renderer/app.ts.tmpl +691 -180
- package/templates/sqlite/src/renderer/index.html +40 -22
- package/templates/sqlite/src/renderer/plugin.css +302 -90
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
1
3
|
import Database = require("better-sqlite3");
|
|
2
4
|
import type {
|
|
3
5
|
NexusResult,
|
|
@@ -6,50 +8,178 @@ import type {
|
|
|
6
8
|
PluginRendererOperationRequest
|
|
7
9
|
} from "@aiot-nexus/plugin-sdk";
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
type DatabaseSource = "default" | "external";
|
|
12
|
+
type SqliteAffinity = "integer" | "real" | "text" | "blob" | "numeric";
|
|
13
|
+
type LocatorKind = "primaryKey" | "rowid";
|
|
14
|
+
|
|
15
|
+
interface DatabaseInfo {
|
|
16
|
+
databaseName: string;
|
|
17
|
+
databasePath: string;
|
|
18
|
+
source: DatabaseSource;
|
|
19
|
+
databaseReady: true;
|
|
20
|
+
notice?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TableListRow {
|
|
24
|
+
schema: string;
|
|
25
|
+
name: string;
|
|
26
|
+
type: string;
|
|
27
|
+
ncol: number | bigint;
|
|
28
|
+
wr: number | bigint;
|
|
29
|
+
strict: number | bigint;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface TableXinfoRow {
|
|
33
|
+
cid: number | bigint;
|
|
34
|
+
name: string;
|
|
35
|
+
type: string;
|
|
36
|
+
notnull: number | bigint;
|
|
37
|
+
dflt_value: unknown;
|
|
38
|
+
pk: number | bigint;
|
|
39
|
+
hidden: number | bigint;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface TableIndexListRow {
|
|
43
|
+
origin: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface TableSummary {
|
|
47
|
+
name: string;
|
|
48
|
+
columnCount: number;
|
|
49
|
+
withoutRowid: boolean;
|
|
50
|
+
strict: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ColumnInfo {
|
|
54
|
+
name: string;
|
|
55
|
+
declaredType: string;
|
|
56
|
+
affinity: SqliteAffinity;
|
|
57
|
+
nullable: boolean;
|
|
58
|
+
defaultSql: string | null;
|
|
59
|
+
primaryKeyOrder: number;
|
|
60
|
+
generated: boolean;
|
|
61
|
+
hidden: boolean;
|
|
62
|
+
insertable: boolean;
|
|
63
|
+
requiredOnInsert: boolean;
|
|
64
|
+
autoIntegerPrimaryKey: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TableSchema {
|
|
68
|
+
summary: TableSummary;
|
|
69
|
+
columns: ColumnInfo[];
|
|
70
|
+
visibleColumns: ColumnInfo[];
|
|
71
|
+
writableColumns: ColumnInfo[];
|
|
72
|
+
locator?: LocatorSpec;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface LocatorSpec {
|
|
76
|
+
kind: LocatorKind;
|
|
77
|
+
columns: string[];
|
|
78
|
+
rowidAlias?: "rowid" | "_rowid_" | "oid";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type WireValue =
|
|
82
|
+
| { kind: "null" }
|
|
83
|
+
| { kind: "integer"; value: string }
|
|
84
|
+
| { kind: "real"; value: number }
|
|
85
|
+
| { kind: "text"; value: string; length: number; truncated: boolean }
|
|
86
|
+
| { kind: "blob"; hex: string; byteLength: number; truncated: boolean };
|
|
87
|
+
|
|
88
|
+
interface LocatorValue {
|
|
89
|
+
column: string;
|
|
90
|
+
value: WireValue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface RowLocator {
|
|
94
|
+
kind: LocatorKind;
|
|
95
|
+
values: LocatorValue[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface TableRow {
|
|
99
|
+
cells: Array<{ column: string; value: WireValue }>;
|
|
100
|
+
locator?: RowLocator;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface ActiveDatabase {
|
|
104
|
+
connection: Database;
|
|
105
|
+
databasePath: string;
|
|
106
|
+
source: DatabaseSource;
|
|
107
|
+
created: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface PersistedState {
|
|
111
|
+
schemaVersion: 1;
|
|
112
|
+
databasePath: string;
|
|
14
113
|
}
|
|
15
114
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
115
|
+
interface MutationValue {
|
|
116
|
+
mode: "default" | "unchanged" | "null" | "value";
|
|
117
|
+
value?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface ParsedMutations {
|
|
121
|
+
columns: string[];
|
|
122
|
+
values: unknown[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface ConvertedInputValue {
|
|
126
|
+
value: unknown;
|
|
127
|
+
byteLength: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const defaultDatabaseName = "plugin.db";
|
|
131
|
+
const stateFileName = "sqlite-browser-state.json";
|
|
132
|
+
const defaultTableName = "plugin_records";
|
|
133
|
+
const allowedPageSizes = new Set([25, 50, 100]);
|
|
134
|
+
const maximumTextPreviewBytes = 16 * 1024;
|
|
135
|
+
const maximumBlobPreviewBytes = 32;
|
|
136
|
+
const maximumFieldBytes = 1024 * 1024;
|
|
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;
|
|
142
|
+
const minimumSqliteInteger = -(1n << 63n);
|
|
143
|
+
const maximumSqliteInteger = (1n << 63n) - 1n;
|
|
144
|
+
|
|
145
|
+
let activeDatabase: ActiveDatabase | undefined;
|
|
146
|
+
let activationContext: PluginMainActivationContext | undefined;
|
|
147
|
+
let defaultDatabasePath = "";
|
|
148
|
+
let stateFilePath = "";
|
|
149
|
+
let databaseNotice: string | undefined;
|
|
150
|
+
let databaseSelectionPending = false;
|
|
21
151
|
|
|
22
152
|
export async function activate(context: PluginMainActivationContext): Promise<void> {
|
|
23
|
-
|
|
24
|
-
const path = require("path") as typeof import("path");
|
|
153
|
+
activationContext = context;
|
|
25
154
|
fs.mkdirSync(context.host.pluginDataDirectory, { recursive: true });
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
await
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const logResult = await context.host.log("info", "__PLUGIN_ID__ database " + (databaseCreated ? "created" : "loaded") + ": " + databasePath);
|
|
155
|
+
defaultDatabasePath = path.join(context.host.pluginDataDirectory, defaultDatabaseName);
|
|
156
|
+
stateFilePath = path.join(context.host.pluginDataDirectory, stateFileName);
|
|
157
|
+
activeDatabase = openInitialDatabase();
|
|
158
|
+
|
|
159
|
+
await register(context, "sqlite.getDatabaseInfo", handled(getDatabaseInfo));
|
|
160
|
+
await register(context, "sqlite.chooseDatabase", handledAsync(chooseDatabase));
|
|
161
|
+
await register(context, "sqlite.useDefaultDatabase", handledAsync(useDefaultDatabase));
|
|
162
|
+
await register(context, "sqlite.listTables", handled(listTables));
|
|
163
|
+
await register(context, "sqlite.readTable", handled(readTable));
|
|
164
|
+
await register(context, "sqlite.insertRow", handled(insertRow));
|
|
165
|
+
await register(context, "sqlite.updateRow", handled(updateRow));
|
|
166
|
+
await register(context, "sqlite.deleteRow", handled(deleteRow));
|
|
167
|
+
|
|
168
|
+
const applicationName = await context.host.electron.app.getName();
|
|
169
|
+
const logResult = await context.host.log(
|
|
170
|
+
"info",
|
|
171
|
+
"__PLUGIN_ID__ database " + (activeDatabase.created ? "created" : "loaded") + " in " + applicationName + ": " + activeDatabase.databasePath
|
|
172
|
+
);
|
|
45
173
|
if (!logResult.ok) {
|
|
46
174
|
throw new Error(logResult.error.message);
|
|
47
175
|
}
|
|
48
176
|
}
|
|
49
177
|
|
|
50
178
|
export function deactivate(): void {
|
|
51
|
-
|
|
52
|
-
|
|
179
|
+
activeDatabase?.connection.close();
|
|
180
|
+
activeDatabase = undefined;
|
|
181
|
+
activationContext = undefined;
|
|
182
|
+
databaseSelectionPending = false;
|
|
53
183
|
}
|
|
54
184
|
|
|
55
185
|
async function register(context: PluginMainActivationContext, name: string, handler: PluginRendererOperationHandler): Promise<void> {
|
|
@@ -59,140 +189,920 @@ async function register(context: PluginMainActivationContext, name: string, hand
|
|
|
59
189
|
}
|
|
60
190
|
}
|
|
61
191
|
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
192
|
+
function handled<TResult>(
|
|
193
|
+
action: (request: PluginRendererOperationRequest) => TResult
|
|
194
|
+
): PluginRendererOperationHandler {
|
|
195
|
+
return (request) => {
|
|
196
|
+
try {
|
|
197
|
+
return success(action(request));
|
|
198
|
+
} catch (error: unknown) {
|
|
199
|
+
return sqliteFailure(error);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function handledAsync<TResult>(
|
|
205
|
+
action: (request: PluginRendererOperationRequest) => Promise<TResult>
|
|
206
|
+
): PluginRendererOperationHandler {
|
|
207
|
+
return async (request) => {
|
|
208
|
+
try {
|
|
209
|
+
return success(await action(request));
|
|
210
|
+
} catch (error: unknown) {
|
|
211
|
+
return sqliteFailure(error);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function openInitialDatabase(): ActiveDatabase {
|
|
217
|
+
const persistedPath = readPersistedDatabasePath();
|
|
218
|
+
if (persistedPath) {
|
|
219
|
+
try {
|
|
220
|
+
return openExternalDatabase(persistedPath);
|
|
221
|
+
} catch (error: unknown) {
|
|
222
|
+
databaseNotice = "The recent database could not be reopened. The plugin database is active instead: " + errorMessage(error);
|
|
223
|
+
clearPersistedState();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return openDefaultDatabase();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function openDefaultDatabase(): ActiveDatabase {
|
|
230
|
+
const created = !fs.existsSync(defaultDatabasePath);
|
|
231
|
+
const connection = new Database(defaultDatabasePath);
|
|
232
|
+
try {
|
|
233
|
+
connection.defaultSafeIntegers(true);
|
|
234
|
+
connection.pragma("journal_mode = DELETE");
|
|
235
|
+
connection.prepare(
|
|
236
|
+
"CREATE TABLE IF NOT EXISTS " + quoteIdentifier(defaultTableName) + " ("+
|
|
237
|
+
"id INTEGER PRIMARY KEY AUTOINCREMENT, "+
|
|
238
|
+
"title TEXT NOT NULL, "+
|
|
239
|
+
"note TEXT NOT NULL DEFAULT '', "+
|
|
240
|
+
"created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"+
|
|
241
|
+
")"
|
|
242
|
+
).run();
|
|
243
|
+
validateDatabase(connection);
|
|
244
|
+
return { connection, databasePath: defaultDatabasePath, source: "default", created };
|
|
245
|
+
} catch (error: unknown) {
|
|
246
|
+
connection.close();
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function openExternalDatabase(filePath: string): ActiveDatabase {
|
|
252
|
+
const normalizedPath = path.resolve(filePath);
|
|
253
|
+
const stat = fs.statSync(normalizedPath);
|
|
254
|
+
if (!stat.isFile()) {
|
|
255
|
+
throw new Error("The selected path is not a file.");
|
|
256
|
+
}
|
|
257
|
+
const connection = new Database(normalizedPath, { fileMustExist: true, readonly: false, timeout: 5000 });
|
|
258
|
+
try {
|
|
259
|
+
connection.defaultSafeIntegers(true);
|
|
260
|
+
validateDatabase(connection);
|
|
261
|
+
return { connection, databasePath: normalizedPath, source: "external", created: false };
|
|
262
|
+
} catch (error: unknown) {
|
|
263
|
+
connection.close();
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function validateDatabase(connection: Database): void {
|
|
269
|
+
connection.prepare("SELECT name FROM main.sqlite_schema ORDER BY name LIMIT 1").get();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function getDatabaseInfo(): DatabaseInfo {
|
|
273
|
+
const current = requireDatabase();
|
|
274
|
+
const notice = databaseNotice;
|
|
275
|
+
databaseNotice = undefined;
|
|
66
276
|
return {
|
|
67
|
-
databaseName,
|
|
68
|
-
databasePath,
|
|
69
|
-
|
|
70
|
-
tableName,
|
|
71
|
-
recordCount: typeof countRow.count === "number" ? countRow.count : 0,
|
|
277
|
+
databaseName: path.basename(current.databasePath),
|
|
278
|
+
databasePath: current.databasePath,
|
|
279
|
+
source: current.source,
|
|
72
280
|
databaseReady: true,
|
|
73
|
-
|
|
281
|
+
...(notice ? { notice } : {})
|
|
74
282
|
};
|
|
75
283
|
}
|
|
76
284
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
285
|
+
async function chooseDatabase(): Promise<{ canceled: boolean; database: DatabaseInfo }> {
|
|
286
|
+
if (databaseSelectionPending) {
|
|
287
|
+
throw codedError("Busy", "A database selection dialog is already open.");
|
|
288
|
+
}
|
|
289
|
+
const context = requireActivationContext();
|
|
290
|
+
databaseSelectionPending = true;
|
|
291
|
+
try {
|
|
292
|
+
const current = requireDatabase();
|
|
293
|
+
const result = await context.host.electron.dialog.showOpenDialog({
|
|
294
|
+
title: "Open SQLite database",
|
|
295
|
+
defaultPath: path.dirname(current.databasePath),
|
|
296
|
+
properties: ["openFile"],
|
|
297
|
+
filters: [
|
|
298
|
+
{ name: "SQLite databases", extensions: ["db", "db3", "sqlite", "sqlite3"] },
|
|
299
|
+
{ name: "All files", extensions: ["*"] }
|
|
300
|
+
]
|
|
301
|
+
});
|
|
302
|
+
if (result.canceled || result.filePaths.length === 0) {
|
|
303
|
+
return { canceled: true, database: getDatabaseInfo() };
|
|
304
|
+
}
|
|
305
|
+
const candidate = openExternalDatabase(result.filePaths[0]);
|
|
306
|
+
try {
|
|
307
|
+
persistDatabasePath(candidate.databasePath);
|
|
308
|
+
replaceDatabase(candidate);
|
|
309
|
+
databaseNotice = undefined;
|
|
310
|
+
} catch (error: unknown) {
|
|
311
|
+
candidate.connection.close();
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
await logDatabaseSwitch("external", path.basename(candidate.databasePath));
|
|
315
|
+
return { canceled: false, database: getDatabaseInfo() };
|
|
316
|
+
} finally {
|
|
317
|
+
databaseSelectionPending = false;
|
|
318
|
+
}
|
|
82
319
|
}
|
|
83
320
|
|
|
84
|
-
function
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
321
|
+
async function useDefaultDatabase(): Promise<{ database: DatabaseInfo }> {
|
|
322
|
+
const candidate = openDefaultDatabase();
|
|
323
|
+
try {
|
|
324
|
+
clearPersistedState();
|
|
325
|
+
replaceDatabase(candidate);
|
|
326
|
+
databaseNotice = undefined;
|
|
327
|
+
} catch (error: unknown) {
|
|
328
|
+
candidate.connection.close();
|
|
329
|
+
throw error;
|
|
88
330
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
throw new Error("Inserted SQLite record could not be read.");
|
|
331
|
+
await logDatabaseSwitch("default", defaultDatabaseName);
|
|
332
|
+
return { database: getDatabaseInfo() };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function logDatabaseSwitch(source: DatabaseSource, databaseName: string): Promise<void> {
|
|
336
|
+
const context = activationContext;
|
|
337
|
+
if (!context) {
|
|
338
|
+
return;
|
|
98
339
|
}
|
|
99
|
-
|
|
340
|
+
await context.host.log("info", "__PLUGIN_ID__ switched to " + source + " database: " + databaseName);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function replaceDatabase(candidate: ActiveDatabase): void {
|
|
344
|
+
const previous = activeDatabase;
|
|
345
|
+
activeDatabase = candidate;
|
|
346
|
+
previous?.connection.close();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function listTables(): { tables: TableSummary[] } {
|
|
350
|
+
return { tables: readTableSummaries(requireDatabase().connection) };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function readTable(request: PluginRendererOperationRequest): {
|
|
354
|
+
table: TableSummary;
|
|
355
|
+
columns: ColumnInfo[];
|
|
356
|
+
rows: TableRow[];
|
|
357
|
+
totalRows: string;
|
|
358
|
+
offset: number;
|
|
359
|
+
limit: number;
|
|
360
|
+
hasPrevious: boolean;
|
|
361
|
+
hasNext: boolean;
|
|
362
|
+
writable: boolean;
|
|
363
|
+
locatorKind?: LocatorKind;
|
|
364
|
+
} {
|
|
365
|
+
const input = requireInput(request.input);
|
|
366
|
+
const tableName = readRequiredString(input.table, "table", 512);
|
|
367
|
+
const offset = readOffset(input.offset);
|
|
368
|
+
const limit = readPageSize(input.limit);
|
|
369
|
+
const connection = requireDatabase().connection;
|
|
370
|
+
const schema = readTableSchema(connection, tableName);
|
|
371
|
+
const totalRows = readCount(connection, schema.summary.name);
|
|
372
|
+
const selectList = [
|
|
373
|
+
...locatorProjection(schema),
|
|
374
|
+
...schema.visibleColumns.flatMap(previewProjection)
|
|
375
|
+
].join(", ");
|
|
376
|
+
const orderBy = orderByClause(schema.locator);
|
|
377
|
+
const rawRows = requireArrayRows(
|
|
378
|
+
connection.prepare(
|
|
379
|
+
"SELECT " + selectList + " FROM " + quoteIdentifier(schema.summary.name) + orderBy + " LIMIT ? OFFSET ?"
|
|
380
|
+
).raw(true).all(limit, offset)
|
|
381
|
+
);
|
|
382
|
+
const rows = rawRows.map((rawRow) => readTableRow(schema, rawRow));
|
|
383
|
+
return {
|
|
384
|
+
table: schema.summary,
|
|
385
|
+
columns: schema.columns,
|
|
386
|
+
rows,
|
|
387
|
+
totalRows: totalRows.toString(),
|
|
388
|
+
offset,
|
|
389
|
+
limit,
|
|
390
|
+
hasPrevious: offset > 0,
|
|
391
|
+
hasNext: BigInt(offset + rows.length) < totalRows,
|
|
392
|
+
writable: schema.writableColumns.length > 0,
|
|
393
|
+
...(schema.locator ? { locatorKind: schema.locator.kind } : {})
|
|
394
|
+
};
|
|
100
395
|
}
|
|
101
396
|
|
|
102
|
-
function
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
397
|
+
function insertRow(request: PluginRendererOperationRequest): { changes: number; lastInsertRowid: string | null } {
|
|
398
|
+
const input = requireInput(request.input);
|
|
399
|
+
const tableName = readRequiredString(input.table, "table", 512);
|
|
400
|
+
const schema = readTableSchema(requireDatabase().connection, tableName);
|
|
401
|
+
const parsed = parseMutations(schema, input.values, "insert");
|
|
402
|
+
const sql = parsed.columns.length === 0
|
|
403
|
+
? "INSERT INTO " + quoteIdentifier(schema.summary.name) + " DEFAULT VALUES"
|
|
404
|
+
: "INSERT INTO " + quoteIdentifier(schema.summary.name)
|
|
405
|
+
+ " (" + parsed.columns.map(quoteIdentifier).join(", ") + ")"
|
|
406
|
+
+ " VALUES (" + parsed.columns.map(() => "?").join(", ") + ")";
|
|
407
|
+
const result = requireDatabase().connection.prepare(sql).run(...parsed.values);
|
|
408
|
+
return {
|
|
409
|
+
changes: result.changes,
|
|
410
|
+
lastInsertRowid: result.lastInsertRowid === undefined || result.lastInsertRowid === null
|
|
411
|
+
? null
|
|
412
|
+
: String(result.lastInsertRowid)
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function updateRow(request: PluginRendererOperationRequest): { changes: number } {
|
|
417
|
+
const input = requireInput(request.input);
|
|
418
|
+
const tableName = readRequiredString(input.table, "table", 512);
|
|
419
|
+
const connection = requireDatabase().connection;
|
|
420
|
+
const schema = readTableSchema(connection, tableName);
|
|
421
|
+
const locator = parseLocator(schema, input.locator);
|
|
422
|
+
const parsed = parseMutations(schema, input.values, "update");
|
|
423
|
+
if (parsed.columns.length === 0) {
|
|
424
|
+
throw codedError("InvalidRequest", "Update requires at least one changed column.");
|
|
106
425
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
426
|
+
const where = buildLocatorWhere(schema, locator);
|
|
427
|
+
const sql =
|
|
428
|
+
"UPDATE " + quoteIdentifier(schema.summary.name)
|
|
429
|
+
+ " SET " + parsed.columns.map((column) => quoteIdentifier(column) + " = ?").join(", ")
|
|
430
|
+
+ " WHERE " + where.sql;
|
|
431
|
+
return { changes: runSingleRowMutation(connection, sql, [...parsed.values, ...where.values]) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function deleteRow(request: PluginRendererOperationRequest): { changes: number } {
|
|
435
|
+
const input = requireInput(request.input);
|
|
436
|
+
const tableName = readRequiredString(input.table, "table", 512);
|
|
437
|
+
const connection = requireDatabase().connection;
|
|
438
|
+
const schema = readTableSchema(connection, tableName);
|
|
439
|
+
const locator = parseLocator(schema, input.locator);
|
|
440
|
+
const where = buildLocatorWhere(schema, locator);
|
|
441
|
+
const sql = "DELETE FROM " + quoteIdentifier(schema.summary.name) + " WHERE " + where.sql;
|
|
442
|
+
return { changes: runSingleRowMutation(connection, sql, where.values) };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function runSingleRowMutation(connection: Database, sql: string, values: unknown[]): number {
|
|
446
|
+
return connection.transaction(() => {
|
|
447
|
+
const changes = connection.prepare(sql).run(...values).changes;
|
|
448
|
+
if (changes === 0) {
|
|
449
|
+
throw codedError("NotFound", "The selected row no longer exists.");
|
|
450
|
+
}
|
|
451
|
+
if (changes !== 1) {
|
|
452
|
+
throw codedError("InvalidRequest", "The row locator matched more than one record.");
|
|
453
|
+
}
|
|
454
|
+
return changes;
|
|
455
|
+
})();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function readTableSummaries(connection: Database): TableSummary[] {
|
|
459
|
+
return connection.prepare("PRAGMA main.table_list").all().flatMap((value) => {
|
|
460
|
+
const row = requireRecord(value, "table_list row") as unknown as TableListRow;
|
|
461
|
+
if (typeof row.schema !== "string" || typeof row.name !== "string" || typeof row.type !== "string") {
|
|
462
|
+
throw new Error("SQLite table_list row is malformed.");
|
|
463
|
+
}
|
|
464
|
+
if (row.schema !== "main" || row.type !== "table" || row.name.startsWith("sqlite_")) {
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
return [{
|
|
468
|
+
name: row.name,
|
|
469
|
+
columnCount: integerNumber(row.ncol, "table column count"),
|
|
470
|
+
withoutRowid: integerNumber(row.wr, "without-rowid flag") === 1,
|
|
471
|
+
strict: integerNumber(row.strict, "strict flag") === 1
|
|
472
|
+
}];
|
|
473
|
+
}).sort((left, right) => left.name.localeCompare(right.name, "en-US", { sensitivity: "base" }));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function readTableSchema(connection: Database, tableName: string): TableSchema {
|
|
477
|
+
const summary = readTableSummaries(connection).find((table) => table.name === tableName);
|
|
478
|
+
if (!summary) {
|
|
479
|
+
throw codedError("InvalidRequest", "The requested table is not an editable user table.");
|
|
110
480
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
481
|
+
const rows = connection.prepare("PRAGMA main.table_xinfo(" + quoteIdentifier(tableName) + ")").all();
|
|
482
|
+
const rawColumns = rows.map((value) => requireRecord(value, "table_xinfo row") as unknown as TableXinfoRow);
|
|
483
|
+
const primaryKeyCount = rawColumns.filter((column) => integerNumber(column.pk, "primary-key order") > 0).length;
|
|
484
|
+
const hasPrimaryKeyIndex = connection.prepare("PRAGMA main.index_list(" + quoteIdentifier(tableName) + ")").all().some((value) => {
|
|
485
|
+
const row = requireRecord(value, "index_list row") as unknown as TableIndexListRow;
|
|
486
|
+
return row.origin === "pk";
|
|
487
|
+
});
|
|
488
|
+
const columns = rawColumns.map((row): ColumnInfo => {
|
|
489
|
+
if (typeof row.name !== "string" || row.name.length === 0) {
|
|
490
|
+
throw new Error("SQLite table_xinfo row is malformed.");
|
|
491
|
+
}
|
|
492
|
+
const primaryKeyOrder = integerNumber(row.pk, "primary-key order");
|
|
493
|
+
const hiddenValue = integerNumber(row.hidden, "hidden flag");
|
|
494
|
+
const declaredType = typeof row.type === "string" ? row.type : "";
|
|
495
|
+
const defaultSql = row.dflt_value === null || row.dflt_value === undefined ? null : String(row.dflt_value);
|
|
496
|
+
const autoIntegerPrimaryKey = !summary.withoutRowid
|
|
497
|
+
&& primaryKeyCount === 1
|
|
498
|
+
&& primaryKeyOrder === 1
|
|
499
|
+
&& declaredType.trim().toUpperCase() === "INTEGER"
|
|
500
|
+
&& !hasPrimaryKeyIndex;
|
|
501
|
+
const generated = hiddenValue === 2 || hiddenValue === 3;
|
|
502
|
+
const hidden = hiddenValue === 1;
|
|
503
|
+
const insertable = !generated && !hidden;
|
|
504
|
+
const implicitNotNullPrimaryKey = primaryKeyOrder > 0
|
|
505
|
+
&& (summary.withoutRowid || summary.strict || autoIntegerPrimaryKey);
|
|
506
|
+
const nullable = integerNumber(row.notnull, "not-null flag") === 0 && !implicitNotNullPrimaryKey;
|
|
507
|
+
return {
|
|
508
|
+
name: row.name,
|
|
509
|
+
declaredType,
|
|
510
|
+
affinity: sqliteAffinity(declaredType),
|
|
511
|
+
nullable,
|
|
512
|
+
defaultSql,
|
|
513
|
+
primaryKeyOrder,
|
|
514
|
+
generated,
|
|
515
|
+
hidden,
|
|
516
|
+
insertable,
|
|
517
|
+
requiredOnInsert: insertable && !nullable && defaultSql === null && !autoIntegerPrimaryKey,
|
|
518
|
+
autoIntegerPrimaryKey
|
|
519
|
+
};
|
|
520
|
+
});
|
|
521
|
+
const visibleColumns = columns.filter((column) => !column.hidden);
|
|
522
|
+
const writableColumns = columns.filter((column) => column.insertable);
|
|
523
|
+
return {
|
|
524
|
+
summary,
|
|
525
|
+
columns,
|
|
526
|
+
visibleColumns,
|
|
527
|
+
writableColumns,
|
|
528
|
+
locator: createLocatorSpec(summary, columns)
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function createLocatorSpec(summary: TableSummary, columns: ColumnInfo[]): LocatorSpec | undefined {
|
|
533
|
+
if (!summary.withoutRowid) {
|
|
534
|
+
const names = new Set(columns.map((column) => column.name.toLowerCase()));
|
|
535
|
+
const rowidAlias = (["rowid", "_rowid_", "oid"] as const).find((name) => !names.has(name));
|
|
536
|
+
if (rowidAlias) {
|
|
537
|
+
return { kind: "rowid", columns: [rowidAlias], rowidAlias };
|
|
538
|
+
}
|
|
116
539
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
540
|
+
const primaryKeyColumns = columns
|
|
541
|
+
.filter((column) => column.primaryKeyOrder > 0)
|
|
542
|
+
.sort((left, right) => left.primaryKeyOrder - right.primaryKeyOrder);
|
|
543
|
+
if (primaryKeyColumns.length > 0 && primaryKeyColumns.every((column) => !column.nullable)) {
|
|
544
|
+
return { kind: "primaryKey", columns: primaryKeyColumns.map((column) => column.name) };
|
|
120
545
|
}
|
|
121
|
-
return
|
|
546
|
+
return undefined;
|
|
122
547
|
}
|
|
123
548
|
|
|
124
|
-
function
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
127
|
-
return
|
|
549
|
+
function locatorProjection(schema: TableSchema): string[] {
|
|
550
|
+
const spec = schema.locator;
|
|
551
|
+
if (!spec) {
|
|
552
|
+
return [];
|
|
128
553
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return fail("NotFound", "Record was not found.");
|
|
554
|
+
if (spec.kind === "rowid") {
|
|
555
|
+
return [spec.rowidAlias as string];
|
|
132
556
|
}
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
});
|
|
135
563
|
}
|
|
136
564
|
|
|
137
|
-
function
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
];
|
|
142
577
|
}
|
|
143
578
|
|
|
144
|
-
function
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
579
|
+
function readTableRow(schema: TableSchema, rawRow: unknown[]): TableRow {
|
|
580
|
+
const locatorValueCount = schema.locator?.columns.length || 0;
|
|
581
|
+
const expectedValueCount = locatorValueCount + schema.visibleColumns.length * projectedValuesPerColumn;
|
|
582
|
+
if (rawRow.length !== expectedValueCount) {
|
|
583
|
+
throw new Error("SQLite table row has an unexpected column count.");
|
|
148
584
|
}
|
|
149
|
-
|
|
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));
|
|
598
|
+
return { cells, ...(locator ? { locator } : {}) };
|
|
150
599
|
}
|
|
151
600
|
|
|
152
|
-
function
|
|
153
|
-
|
|
154
|
-
|
|
601
|
+
function createRowLocator(schema: TableSchema, rawLocatorValues: unknown[]): RowLocator | undefined {
|
|
602
|
+
const spec = schema.locator;
|
|
603
|
+
if (!spec) {
|
|
604
|
+
return undefined;
|
|
155
605
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return fail("InvalidRequest", "Title must be 1 to 80 characters.");
|
|
606
|
+
if (spec.kind === "rowid") {
|
|
607
|
+
const value = serializeLocatorValue(rawLocatorValues[0]);
|
|
608
|
+
return value ? { kind: "rowid", values: [{ column: spec.columns[0], value }] } : undefined;
|
|
160
609
|
}
|
|
161
|
-
|
|
162
|
-
|
|
610
|
+
const values: LocatorValue[] = [];
|
|
611
|
+
for (let index = 0; index < spec.columns.length; index += 1) {
|
|
612
|
+
const columnName = spec.columns[index];
|
|
613
|
+
const value = serializeLocatorValue(rawLocatorValues[index]);
|
|
614
|
+
if (!value) {
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
values.push({ column: columnName, value });
|
|
163
618
|
}
|
|
164
|
-
return {
|
|
619
|
+
return { kind: "primaryKey", values };
|
|
165
620
|
}
|
|
166
621
|
|
|
167
|
-
function
|
|
168
|
-
|
|
169
|
-
|
|
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)) {
|
|
629
|
+
return { kind: "null" };
|
|
630
|
+
}
|
|
631
|
+
if (storageType === "integer" && typeof preview === "bigint") {
|
|
632
|
+
return { kind: "integer", value: preview.toString() };
|
|
633
|
+
}
|
|
634
|
+
if (storageType === "integer" && typeof preview === "number" && Number.isSafeInteger(preview)) {
|
|
635
|
+
return { kind: "integer", value: String(preview) };
|
|
636
|
+
}
|
|
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);
|
|
644
|
+
return {
|
|
645
|
+
kind: "text",
|
|
646
|
+
value: boundedPreview,
|
|
647
|
+
length: totalCharacters,
|
|
648
|
+
truncated: Buffer.byteLength(boundedPreview, "utf8") < totalBytes
|
|
649
|
+
};
|
|
170
650
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
:
|
|
176
|
-
|
|
177
|
-
|
|
651
|
+
if (storageType === "blob" && Buffer.isBuffer(preview)) {
|
|
652
|
+
const totalBytes = integerNumber(byteLength, "BLOB byte length");
|
|
653
|
+
return {
|
|
654
|
+
kind: "blob",
|
|
655
|
+
hex: preview.toString("hex"),
|
|
656
|
+
byteLength: totalBytes,
|
|
657
|
+
truncated: preview.length < totalBytes
|
|
658
|
+
};
|
|
178
659
|
}
|
|
179
|
-
|
|
660
|
+
throw new Error("SQLite returned malformed projected cell data.");
|
|
180
661
|
}
|
|
181
662
|
|
|
182
|
-
function
|
|
183
|
-
if (
|
|
184
|
-
|
|
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("");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function serializeLocatorValue(value: unknown): WireValue | undefined {
|
|
681
|
+
if (value === null || value === undefined) {
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
if (typeof value === "string" && (value.length > maximumLocatorBytes
|
|
685
|
+
|| Buffer.byteLength(value, "utf8") > maximumLocatorBytes)) {
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
if (Buffer.isBuffer(value) && value.length > maximumLocatorBytes) {
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
if (typeof value === "string") {
|
|
692
|
+
return { kind: "text", value, length: value.length, truncated: false };
|
|
693
|
+
}
|
|
694
|
+
if (Buffer.isBuffer(value)) {
|
|
695
|
+
return { kind: "blob", hex: value.toString("hex"), byteLength: value.length, truncated: false };
|
|
696
|
+
}
|
|
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;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function parseMutations(schema: TableSchema, value: unknown, mode: "insert" | "update"): ParsedMutations {
|
|
709
|
+
const valuesRecord = requireRecord(value, "column values");
|
|
710
|
+
const knownColumns = new Map(schema.writableColumns.map((column) => [column.name, column]));
|
|
711
|
+
const columns: string[] = [];
|
|
712
|
+
const values: unknown[] = [];
|
|
713
|
+
let requestBytes = 0;
|
|
714
|
+
for (const key of Object.keys(valuesRecord)) {
|
|
715
|
+
const column = knownColumns.get(key);
|
|
716
|
+
if (!column) {
|
|
717
|
+
throw codedError("InvalidRequest", "Column is not writable or does not exist: " + key);
|
|
718
|
+
}
|
|
719
|
+
const mutation = readMutationValue(valuesRecord[key], key);
|
|
720
|
+
if (mutation.mode === "default" || mutation.mode === "unchanged") {
|
|
721
|
+
if (mode === "update" && mutation.mode === "default") {
|
|
722
|
+
throw codedError("InvalidRequest", "Default mode is not supported during update: " + key);
|
|
723
|
+
}
|
|
724
|
+
if (mode === "insert" && mutation.mode === "unchanged") {
|
|
725
|
+
throw codedError("InvalidRequest", "Unchanged mode is not supported during insert: " + key);
|
|
726
|
+
}
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (mutation.mode === "null") {
|
|
730
|
+
if (!column.nullable) {
|
|
731
|
+
throw codedError("InvalidRequest", "Column does not accept NULL: " + key);
|
|
732
|
+
}
|
|
733
|
+
columns.push(key);
|
|
734
|
+
values.push(null);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const converted = convertInputValue(column, mutation.value || "");
|
|
738
|
+
if (converted.byteLength > maximumFieldBytes) {
|
|
739
|
+
throw codedError("InvalidRequest", "Column value exceeds the 1 MiB limit: " + key);
|
|
740
|
+
}
|
|
741
|
+
requestBytes += converted.byteLength;
|
|
742
|
+
if (requestBytes > maximumRequestBytes) {
|
|
743
|
+
throw codedError("InvalidRequest", "Row values exceed the 4 MiB request limit.");
|
|
744
|
+
}
|
|
745
|
+
columns.push(key);
|
|
746
|
+
values.push(converted.value);
|
|
747
|
+
}
|
|
748
|
+
if (mode === "insert") {
|
|
749
|
+
for (const column of schema.writableColumns) {
|
|
750
|
+
if (column.requiredOnInsert && !columns.includes(column.name)) {
|
|
751
|
+
throw codedError("InvalidRequest", "A value is required for column: " + column.name);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return { columns, values };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function readMutationValue(value: unknown, columnName: string): MutationValue {
|
|
759
|
+
const record = requireRecord(value, "mutation for " + columnName);
|
|
760
|
+
if (record.mode !== "default" && record.mode !== "unchanged" && record.mode !== "null" && record.mode !== "value") {
|
|
761
|
+
throw codedError("InvalidRequest", "Column mutation mode is invalid: " + columnName);
|
|
762
|
+
}
|
|
763
|
+
if (record.mode === "value" && typeof record.value !== "string") {
|
|
764
|
+
throw codedError("InvalidRequest", "Column value must be text: " + columnName);
|
|
185
765
|
}
|
|
186
|
-
return
|
|
766
|
+
return { mode: record.mode, ...(typeof record.value === "string" ? { value: record.value } : {}) };
|
|
187
767
|
}
|
|
188
768
|
|
|
189
|
-
function
|
|
769
|
+
function convertInputValue(column: ColumnInfo, source: string): ConvertedInputValue {
|
|
770
|
+
if (column.affinity === "blob") {
|
|
771
|
+
if (source.length > maximumBlobInputCharacters) {
|
|
772
|
+
throw codedError("InvalidRequest", "BLOB value exceeds the accepted encoded input size: " + column.name);
|
|
773
|
+
}
|
|
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 };
|
|
787
|
+
}
|
|
788
|
+
if (column.affinity === "real") {
|
|
789
|
+
const parsed = Number(source);
|
|
790
|
+
if (!source.trim() || !Number.isFinite(parsed)) {
|
|
791
|
+
throw codedError("InvalidRequest", "Column requires a finite number: " + column.name);
|
|
792
|
+
}
|
|
793
|
+
return { value: parsed, byteLength: sourceBytes };
|
|
794
|
+
}
|
|
795
|
+
if (column.affinity === "numeric") {
|
|
796
|
+
if (/^[+-]?\d+$/.test(source)) {
|
|
797
|
+
return { value: parseSqliteInteger(source, "Column " + column.name), byteLength: sourceBytes };
|
|
798
|
+
}
|
|
799
|
+
const parsed = Number(source);
|
|
800
|
+
return {
|
|
801
|
+
value: source.trim() && Number.isFinite(parsed) ? parsed : source,
|
|
802
|
+
byteLength: sourceBytes
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
return { value: source, byteLength: sourceBytes };
|
|
806
|
+
}
|
|
807
|
+
|
|
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
|
+
}
|
|
823
|
+
const value = BigInt(source);
|
|
824
|
+
if (value < minimumSqliteInteger || value > maximumSqliteInteger) {
|
|
825
|
+
throw codedError("InvalidRequest", label + " is outside SQLite's signed 64-bit range.");
|
|
826
|
+
}
|
|
827
|
+
return value;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function parseLocator(schema: TableSchema, value: unknown): RowLocator {
|
|
831
|
+
const spec = schema.locator;
|
|
832
|
+
if (!spec) {
|
|
833
|
+
throw codedError("UnsupportedTable", "This table does not expose a stable row locator.");
|
|
834
|
+
}
|
|
835
|
+
const record = requireRecord(value, "row locator");
|
|
836
|
+
if (record.kind !== spec.kind || !Array.isArray(record.values) || record.values.length !== spec.columns.length) {
|
|
837
|
+
throw codedError("InvalidRequest", "Row locator does not match the current table.");
|
|
838
|
+
}
|
|
839
|
+
const values = record.values.map((entry, index): LocatorValue => {
|
|
840
|
+
const item = requireRecord(entry, "row locator value");
|
|
841
|
+
const expectedColumn = spec.columns[index];
|
|
842
|
+
if (item.column !== expectedColumn) {
|
|
843
|
+
throw codedError("InvalidRequest", "Row locator columns do not match the current table.");
|
|
844
|
+
}
|
|
845
|
+
return { column: expectedColumn, value: readWireValue(item.value) };
|
|
846
|
+
});
|
|
847
|
+
return { kind: spec.kind, values };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function readWireValue(value: unknown): WireValue {
|
|
851
|
+
const record = requireRecord(value, "wire value");
|
|
852
|
+
if (record.kind === "null") {
|
|
853
|
+
return { kind: "null" };
|
|
854
|
+
}
|
|
855
|
+
if (record.kind === "integer" && typeof record.value === "string") {
|
|
856
|
+
return { kind: "integer", value: parseSqliteInteger(record.value, "Row locator").toString() };
|
|
857
|
+
}
|
|
858
|
+
if (record.kind === "real" && typeof record.value === "number" && Number.isFinite(record.value)) {
|
|
859
|
+
return { kind: "real", value: record.value };
|
|
860
|
+
}
|
|
861
|
+
if (record.kind === "text" && typeof record.value === "string" && record.truncated === false
|
|
862
|
+
&& record.value.length <= maximumLocatorBytes
|
|
863
|
+
&& Buffer.byteLength(record.value, "utf8") <= maximumLocatorBytes) {
|
|
864
|
+
return { kind: "text", value: record.value, length: record.value.length, truncated: false };
|
|
865
|
+
}
|
|
866
|
+
if (record.kind === "blob" && typeof record.hex === "string" && record.truncated === false
|
|
867
|
+
&& record.hex.length <= maximumLocatorBytes * 2
|
|
868
|
+
&& record.hex.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(record.hex)) {
|
|
869
|
+
return { kind: "blob", hex: record.hex, byteLength: record.hex.length / 2, truncated: false };
|
|
870
|
+
}
|
|
871
|
+
throw codedError("InvalidRequest", "Row locator contains an invalid value.");
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function buildLocatorWhere(schema: TableSchema, locator: RowLocator): { sql: string; values: unknown[] } {
|
|
875
|
+
const spec = schema.locator;
|
|
876
|
+
if (!spec || locator.kind !== spec.kind) {
|
|
877
|
+
throw codedError("InvalidRequest", "Row locator does not match the current table.");
|
|
878
|
+
}
|
|
879
|
+
const expressions = locator.values.map((entry) => {
|
|
880
|
+
return spec.kind === "rowid"
|
|
881
|
+
? (spec.rowidAlias as string) + " IS ?"
|
|
882
|
+
: quoteIdentifier(entry.column) + " IS ?";
|
|
883
|
+
});
|
|
884
|
+
return { sql: expressions.join(" AND "), values: locator.values.map((entry) => decodeWireValue(entry.value)) };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function decodeWireValue(value: WireValue): unknown {
|
|
888
|
+
if (value.kind === "null") {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
if (value.kind === "integer") {
|
|
892
|
+
return BigInt(value.value);
|
|
893
|
+
}
|
|
894
|
+
if (value.kind === "real") {
|
|
895
|
+
return value.value;
|
|
896
|
+
}
|
|
897
|
+
if (value.kind === "text") {
|
|
898
|
+
return value.value;
|
|
899
|
+
}
|
|
900
|
+
return Buffer.from(value.hex, "hex");
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function readCount(connection: Database, tableName: string): bigint {
|
|
904
|
+
const row = requireRecord(
|
|
905
|
+
connection.prepare("SELECT COUNT(*) AS count FROM " + quoteIdentifier(tableName)).get(),
|
|
906
|
+
"count row"
|
|
907
|
+
);
|
|
908
|
+
if (typeof row.count === "bigint") {
|
|
909
|
+
return row.count;
|
|
910
|
+
}
|
|
911
|
+
if (typeof row.count === "number" && Number.isSafeInteger(row.count) && row.count >= 0) {
|
|
912
|
+
return BigInt(row.count);
|
|
913
|
+
}
|
|
914
|
+
throw new Error("SQLite count row is malformed.");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function orderByClause(locator: LocatorSpec | undefined): string {
|
|
918
|
+
if (!locator) {
|
|
919
|
+
return "";
|
|
920
|
+
}
|
|
921
|
+
if (locator.kind === "rowid") {
|
|
922
|
+
return " ORDER BY " + locator.rowidAlias + " DESC";
|
|
923
|
+
}
|
|
924
|
+
return " ORDER BY " + locator.columns.map((column) => quoteIdentifier(column) + " ASC").join(", ");
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function sqliteAffinity(declaredType: string): SqliteAffinity {
|
|
928
|
+
const normalized = declaredType.toUpperCase();
|
|
929
|
+
if (normalized.includes("INT")) {
|
|
930
|
+
return "integer";
|
|
931
|
+
}
|
|
932
|
+
if (normalized.includes("CHAR") || normalized.includes("CLOB") || normalized.includes("TEXT")) {
|
|
933
|
+
return "text";
|
|
934
|
+
}
|
|
935
|
+
if (!normalized || normalized.includes("BLOB")) {
|
|
936
|
+
return "blob";
|
|
937
|
+
}
|
|
938
|
+
if (normalized.includes("REAL") || normalized.includes("FLOA") || normalized.includes("DOUB")) {
|
|
939
|
+
return "real";
|
|
940
|
+
}
|
|
941
|
+
return "numeric";
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function readPersistedDatabasePath(): string | undefined {
|
|
945
|
+
if (!fs.existsSync(stateFilePath)) {
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
const parsed = JSON.parse(fs.readFileSync(stateFilePath, "utf8")) as Partial<PersistedState>;
|
|
950
|
+
if (parsed.schemaVersion !== 1 || typeof parsed.databasePath !== "string" || !path.isAbsolute(parsed.databasePath)) {
|
|
951
|
+
throw new Error("SQLite browser state is invalid.");
|
|
952
|
+
}
|
|
953
|
+
return path.resolve(parsed.databasePath);
|
|
954
|
+
} catch (error: unknown) {
|
|
955
|
+
const invalidPath = stateFilePath + ".invalid-" + String(Date.now());
|
|
956
|
+
try {
|
|
957
|
+
fs.renameSync(stateFilePath, invalidPath);
|
|
958
|
+
} catch {
|
|
959
|
+
try {
|
|
960
|
+
fs.rmSync(stateFilePath, { force: true });
|
|
961
|
+
} catch {
|
|
962
|
+
// A read-only state file must not prevent the safe default database fallback.
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
databaseNotice = "The saved database selection was invalid and has been reset: " + errorMessage(error);
|
|
966
|
+
return undefined;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function persistDatabasePath(filePath: string): void {
|
|
971
|
+
writeJsonAtomic(stateFilePath, { schemaVersion: 1, databasePath: path.resolve(filePath) } satisfies PersistedState);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function clearPersistedState(): void {
|
|
975
|
+
fs.rmSync(stateFilePath, { force: true });
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function writeJsonAtomic(filePath: string, value: unknown): void {
|
|
979
|
+
const tempPath = filePath + "." + String(process.pid) + "." + String(Date.now()) + ".tmp";
|
|
980
|
+
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
981
|
+
try {
|
|
982
|
+
fs.renameSync(tempPath, filePath);
|
|
983
|
+
} finally {
|
|
984
|
+
fs.rmSync(tempPath, { force: true });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function readPageSize(value: unknown): number {
|
|
989
|
+
const result = value === undefined ? 50 : Number(value);
|
|
990
|
+
if (!Number.isSafeInteger(result) || !allowedPageSizes.has(result)) {
|
|
991
|
+
throw codedError("InvalidRequest", "Page size must be 25, 50, or 100.");
|
|
992
|
+
}
|
|
993
|
+
return result;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function readOffset(value: unknown): number {
|
|
997
|
+
const result = value === undefined ? 0 : Number(value);
|
|
998
|
+
if (!Number.isSafeInteger(result) || result < 0 || result > 1_000_000_000) {
|
|
999
|
+
throw codedError("InvalidRequest", "Offset must be a non-negative integer no greater than 1000000000.");
|
|
1000
|
+
}
|
|
1001
|
+
return result;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function requireInput(value: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
1005
|
+
if (!value) {
|
|
1006
|
+
throw codedError("InvalidRequest", "Operation input must be an object.");
|
|
1007
|
+
}
|
|
1008
|
+
return value;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function readRequiredString(value: unknown, label: string, maximumLength: number): string {
|
|
1012
|
+
if (typeof value !== "string" || value.length < 1 || value.length > maximumLength) {
|
|
1013
|
+
throw codedError("InvalidRequest", label + " must be a non-empty string.");
|
|
1014
|
+
}
|
|
1015
|
+
return value;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
190
1019
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
191
|
-
throw
|
|
1020
|
+
throw codedError("InvalidRequest", "SQLite " + label + " must be an object.");
|
|
192
1021
|
}
|
|
193
1022
|
return value as Record<string, unknown>;
|
|
194
1023
|
}
|
|
195
1024
|
|
|
1025
|
+
function requireArrayRows(value: unknown[]): unknown[][] {
|
|
1026
|
+
return value.map((row) => {
|
|
1027
|
+
if (!Array.isArray(row)) {
|
|
1028
|
+
throw new Error("SQLite raw row is malformed.");
|
|
1029
|
+
}
|
|
1030
|
+
return row;
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function integerNumber(value: unknown, label: string): number {
|
|
1035
|
+
const result = typeof value === "bigint" ? Number(value) : value;
|
|
1036
|
+
if (typeof result !== "number" || !Number.isSafeInteger(result)) {
|
|
1037
|
+
throw new Error("SQLite " + label + " is malformed.");
|
|
1038
|
+
}
|
|
1039
|
+
return result;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function quoteIdentifier(value: string): string {
|
|
1043
|
+
return "\"" + value.replace(/\"/g, "\"\"") + "\"";
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function requireDatabase(): ActiveDatabase {
|
|
1047
|
+
if (!activeDatabase) {
|
|
1048
|
+
throw codedError("DatabaseUnavailable", "SQLite database is not initialized.");
|
|
1049
|
+
}
|
|
1050
|
+
return activeDatabase;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function requireActivationContext(): PluginMainActivationContext {
|
|
1054
|
+
if (!activationContext) {
|
|
1055
|
+
throw codedError("DatabaseUnavailable", "Plugin activation context is unavailable.");
|
|
1056
|
+
}
|
|
1057
|
+
return activationContext;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function success<T>(value: T): NexusResult<T> {
|
|
1061
|
+
return { ok: true, value };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function sqliteFailure(error: unknown): NexusResult<never> {
|
|
1065
|
+
const code = errorCode(error);
|
|
1066
|
+
if (code === "InvalidRequest" || code === "NotFound" || code === "UnsupportedTable"
|
|
1067
|
+
|| code === "Busy" || code === "DatabaseUnavailable") {
|
|
1068
|
+
return fail(code, errorMessage(error));
|
|
1069
|
+
}
|
|
1070
|
+
if (code.startsWith("SQLITE_CONSTRAINT")) {
|
|
1071
|
+
return fail("ConstraintViolation", errorMessage(error));
|
|
1072
|
+
}
|
|
1073
|
+
if (code === "SQLITE_READONLY" || code.startsWith("SQLITE_READONLY_")) {
|
|
1074
|
+
return fail("SqliteReadOnly", errorMessage(error));
|
|
1075
|
+
}
|
|
1076
|
+
if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || code.startsWith("SQLITE_BUSY_") || code.startsWith("SQLITE_LOCKED_")) {
|
|
1077
|
+
return fail("SqliteBusy", errorMessage(error));
|
|
1078
|
+
}
|
|
1079
|
+
if (code === "SQLITE_CORRUPT" || code === "SQLITE_NOTADB" || code.startsWith("SQLITE_CORRUPT_")) {
|
|
1080
|
+
return fail("SqliteInvalidDatabase", errorMessage(error));
|
|
1081
|
+
}
|
|
1082
|
+
if (code === "ENOENT" || code === "EACCES" || code === "EPERM"
|
|
1083
|
+
|| code === "SQLITE_CANTOPEN" || code.startsWith("SQLITE_CANTOPEN_")
|
|
1084
|
+
|| code === "SQLITE_PERM" || code === "SQLITE_AUTH") {
|
|
1085
|
+
return fail("SqliteOpenFailed", errorMessage(error));
|
|
1086
|
+
}
|
|
1087
|
+
return fail("SqliteOperationFailed", errorMessage(error));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function codedError(code: string, message: string): Error {
|
|
1091
|
+
const error = new Error(message) as Error & { code: string };
|
|
1092
|
+
error.code = code;
|
|
1093
|
+
return error;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function errorCode(error: unknown): string {
|
|
1097
|
+
return error && typeof error === "object" && typeof Reflect.get(error, "code") === "string"
|
|
1098
|
+
? String(Reflect.get(error, "code"))
|
|
1099
|
+
: "";
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function errorMessage(error: unknown): string {
|
|
1103
|
+
return error instanceof Error ? error.message : String(error);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
196
1106
|
function fail<T = never>(code: string, message: string): NexusResult<T> {
|
|
197
1107
|
return { ok: false, error: { code, message } };
|
|
198
1108
|
}
|