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