@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.
Files changed (32) hide show
  1. package/README.md +18 -8
  2. package/dev-host/host.html +13 -12
  3. package/dev-host/main.js +134 -9
  4. package/dev-host/plugin-host.js +1 -427
  5. package/dist/.generated/plugin-framework-version.d.ts +1 -1
  6. package/dist/.generated/plugin-framework-version.js +1 -1
  7. package/dist/.generated/plugin-manifest.schema.json +1 -1
  8. package/dist/internal/src-electron/main/core/runtime-result.js +42 -0
  9. package/dist/internal/src-electron/main/plugins/plugin-electron-facade-broker.js +890 -0
  10. package/dist/internal/src-electron/plugin-host-bootstrap.js +417 -0
  11. package/dist/internal/src-electron/shared/contracts.js +2 -0
  12. package/dist/internal/src-electron/shared/plugin-electron-facade-client.js +181 -0
  13. package/dist/internal/src-electron/shared/plugin-electron-facade-descriptors.js +70 -0
  14. package/dist/internal/src-electron/shared/plugin-electron-facade.js +243 -0
  15. package/dist/internal/src-electron/shared/plugin-rpc.js +245 -0
  16. package/dist/src/electron-facade.d.ts +4 -0
  17. package/dist/src/electron-facade.js +56 -0
  18. package/dist/src/index.d.ts +2 -1
  19. package/dist/src/index.js +2 -1
  20. package/dist/src/nexus-dev.js +5 -4
  21. package/dist/src/scaffold.js +2 -2
  22. package/dist/src/types.d.ts +86 -2
  23. package/dist/src/web-dev.js +2 -2
  24. package/package.json +7 -2
  25. package/templates/minimal/README.md.tmpl +84 -3
  26. package/templates/minimal/src/main.ts.tmpl +2 -1
  27. package/templates/sqlite/README.md.tmpl +30 -2
  28. package/templates/sqlite/src/better-sqlite3.d.ts +12 -1
  29. package/templates/sqlite/src/main.ts.tmpl +1034 -124
  30. package/templates/sqlite/src/renderer/app.ts.tmpl +691 -180
  31. package/templates/sqlite/src/renderer/index.html +40 -22
  32. 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
- interface SqliteRecord {
10
- id: number;
11
- title: string;
12
- note: string;
13
- createdAt: string;
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
- const databaseName = "plugin.db";
17
- const tableName = "plugin_records";
18
- let database: Database | undefined;
19
- let databasePath = "";
20
- let databaseCreated = false;
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
- const fs = require("fs") as typeof import("fs");
24
- const path = require("path") as typeof import("path");
153
+ activationContext = context;
25
154
  fs.mkdirSync(context.host.pluginDataDirectory, { recursive: true });
26
- databasePath = path.join(context.host.pluginDataDirectory, databaseName);
27
- databaseCreated = !fs.existsSync(databasePath);
28
- database = new Database(databasePath);
29
- database.pragma("journal_mode = DELETE");
30
- database.prepare(
31
- "CREATE TABLE IF NOT EXISTS " + tableName + " ("+
32
- "id INTEGER PRIMARY KEY AUTOINCREMENT, "+
33
- "title TEXT NOT NULL, "+
34
- "note TEXT NOT NULL DEFAULT '', "+
35
- "created_at TEXT NOT NULL"+
36
- ")"
37
- ).run();
38
-
39
- await register(context, "sqlite.getInfo", getInfo);
40
- await register(context, "sqlite.listRecords", listRecords);
41
- await register(context, "sqlite.insertRecord", insertRecord);
42
- await register(context, "sqlite.updateRecord", updateRecord);
43
- await register(context, "sqlite.deleteRecord", deleteRecord);
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
- database?.close();
52
- database = undefined;
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 getInfo(): Record<string, unknown> {
63
- const activeDatabase = requireDatabase();
64
- const countRow = requireObject(activeDatabase.prepare("SELECT COUNT(*) AS count FROM " + tableName).get(), "count row");
65
- const latestRow = requireObject(activeDatabase.prepare("SELECT MAX(created_at) AS latest FROM " + tableName).get(), "latest row");
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
- databaseCreated,
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
- latestCreatedAt: typeof latestRow.latest === "string" ? latestRow.latest : null
281
+ ...(notice ? { notice } : {})
74
282
  };
75
283
  }
76
284
 
77
- function listRecords(): { records: SqliteRecord[] } {
78
- const rows = requireDatabase().prepare(
79
- "SELECT id, title, note, created_at AS createdAt FROM " + tableName + " ORDER BY id DESC LIMIT 100"
80
- ).all();
81
- return { records: rows.map(readRecordRow) };
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 insertRecord(request: PluginRendererOperationRequest): NexusResult<never> | { record: SqliteRecord } {
85
- const inputResult = readRecordInput(request.input, "Insert");
86
- if (!inputResult.ok) {
87
- return inputResult;
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
- const createdAt = new Date().toISOString();
90
- const activeDatabase = requireDatabase();
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.");
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
- return { record };
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 updateRecord(request: PluginRendererOperationRequest): NexusResult<never> | { record: SqliteRecord } {
103
- const idResult = readRecordId(request.input, "Update");
104
- if (!idResult.ok) {
105
- return idResult;
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 inputResult = readRecordInput(request.input, "Update");
108
- if (!inputResult.ok) {
109
- return inputResult;
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 updateResult = requireDatabase().prepare(
112
- "UPDATE " + tableName + " SET title = ?, note = ? WHERE id = ?"
113
- ).run(inputResult.value.title, inputResult.value.note, idResult.value);
114
- if (updateResult.changes === 0) {
115
- return fail("NotFound", "Record was not found.");
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 record = readRecord(idResult.value);
118
- if (!record) {
119
- throw new Error("Updated SQLite record could not be read.");
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 { record };
546
+ return undefined;
122
547
  }
123
548
 
124
- function deleteRecord(request: PluginRendererOperationRequest): NexusResult<never> | { deletedId: number; record: SqliteRecord } {
125
- const idResult = readRecordId(request.input, "Delete");
126
- if (!idResult.ok) {
127
- return idResult;
549
+ function locatorProjection(schema: TableSchema): string[] {
550
+ const spec = schema.locator;
551
+ if (!spec) {
552
+ return [];
128
553
  }
129
- const record = readRecord(idResult.value);
130
- if (!record) {
131
- return fail("NotFound", "Record was not found.");
554
+ if (spec.kind === "rowid") {
555
+ return [spec.rowidAlias as string];
132
556
  }
133
- requireDatabase().prepare("DELETE FROM " + tableName + " WHERE id = ?").run(idResult.value);
134
- return { deletedId: idResult.value, record };
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 readRecord(id: number): SqliteRecord | undefined {
138
- const row = requireDatabase().prepare(
139
- "SELECT id, title, note, created_at AS createdAt FROM " + tableName + " WHERE id = ?"
140
- ).get(id);
141
- return row === undefined ? undefined : readRecordRow(row);
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 readRecordRow(value: unknown): SqliteRecord {
145
- const row = requireObject(value, "record row");
146
- if (!Number.isSafeInteger(row.id) || typeof row.title !== "string" || typeof row.note !== "string" || typeof row.createdAt !== "string") {
147
- throw new Error("SQLite record row is malformed.");
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
- return { id: row.id as number, title: row.title, note: row.note, createdAt: row.createdAt };
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 readRecordInput(input: Record<string, unknown> | undefined, action: string): NexusResult<{ title: string; note: string }> {
153
- if (!input) {
154
- return fail("InvalidRequest", action + " input must be an object.");
601
+ function createRowLocator(schema: TableSchema, rawLocatorValues: unknown[]): RowLocator | undefined {
602
+ const spec = schema.locator;
603
+ if (!spec) {
604
+ return undefined;
155
605
  }
156
- const title = typeof input.title === "string" ? input.title.trim() : "";
157
- const note = typeof input.note === "string" ? input.note.trim() : "";
158
- if (title.length < 1 || title.length > 80) {
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
- if (note.length > 500) {
162
- return fail("InvalidRequest", "Note must be 500 characters or fewer.");
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 { ok: true, value: { title, note } };
619
+ return { kind: "primaryKey", values };
165
620
  }
166
621
 
167
- function readRecordId(input: Record<string, unknown> | undefined, action: string): NexusResult<number> {
168
- if (!input) {
169
- return fail("InvalidRequest", action + " input must be an object.");
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
- const id = typeof input.id === "number"
172
- ? input.id
173
- : typeof input.id === "string"
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.");
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
- return { ok: true, value: id };
660
+ throw new Error("SQLite returned malformed projected cell data.");
180
661
  }
181
662
 
182
- function requireDatabase(): Database {
183
- if (!database) {
184
- throw new Error("SQLite database is not initialized.");
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 database;
766
+ return { mode: record.mode, ...(typeof record.value === "string" ? { value: record.value } : {}) };
187
767
  }
188
768
 
189
- function requireObject(value: unknown, label: string): Record<string, unknown> {
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 new Error("SQLite " + label + " is malformed.");
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
  }