sqlite-hub 1.0.0 → 1.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 (47) hide show
  1. package/README.md +47 -33
  2. package/bin/sqlite-hub.js +127 -47
  3. package/frontend/js/api.js +6 -0
  4. package/frontend/js/app.js +229 -44
  5. package/frontend/js/components/modal.js +17 -1
  6. package/frontend/js/components/queryChartRenderer.js +28 -3
  7. package/frontend/js/components/queryEditor.js +24 -26
  8. package/frontend/js/components/queryHistoryDetail.js +1 -1
  9. package/frontend/js/components/queryHistoryHeader.js +48 -0
  10. package/frontend/js/components/queryHistoryList.js +132 -0
  11. package/frontend/js/components/queryHistoryPanel.js +72 -136
  12. package/frontend/js/components/structureGraph.js +700 -89
  13. package/frontend/js/components/tableDesignerEditor.js +26 -47
  14. package/frontend/js/components/tableDesignerSidebar.js +7 -31
  15. package/frontend/js/components/tableDesignerSqlPreview.js +2 -1
  16. package/frontend/js/store.js +97 -8
  17. package/frontend/js/utils/exportFilenames.js +3 -1
  18. package/frontend/js/views/charts.js +326 -174
  19. package/frontend/js/views/connections.js +59 -64
  20. package/frontend/js/views/data.js +48 -54
  21. package/frontend/js/views/documents.js +62 -23
  22. package/frontend/js/views/editor.js +0 -2
  23. package/frontend/js/views/mediaTagging.js +6 -5
  24. package/frontend/js/views/settings.js +78 -0
  25. package/frontend/js/views/structure.js +48 -32
  26. package/frontend/js/views/tableDesigner.js +45 -1
  27. package/frontend/styles/components.css +259 -54
  28. package/frontend/styles/structure-graph.css +150 -37
  29. package/frontend/styles/tailwind.generated.css +1 -1
  30. package/frontend/styles/tokens.css +2 -0
  31. package/frontend/styles/views.css +75 -38
  32. package/package.json +3 -2
  33. package/server/routes/export.js +89 -22
  34. package/server/routes/externalApi.js +84 -2
  35. package/server/routes/settings.js +37 -28
  36. package/server/services/appInfoService.js +215 -0
  37. package/server/services/databaseCommandService.js +50 -6
  38. package/server/services/sqlite/dataBrowserService.js +11 -3
  39. package/server/services/sqlite/exportService.js +307 -22
  40. package/tests/api-token-auth.test.js +110 -1
  41. package/tests/cli-args.test.js +16 -3
  42. package/tests/database-command-service.test.js +40 -3
  43. package/tests/export-blob.test.js +54 -1
  44. package/tests/export-filenames.test.js +4 -0
  45. package/tests/settings-api-tokens-route.test.js +22 -1
  46. package/tests/settings-metadata.test.js +99 -1
  47. package/tests/settings-view.test.js +28 -0
@@ -1,5 +1,8 @@
1
+ const { PassThrough } = require("node:stream");
2
+ const parquet = require("parquetjs-lite");
1
3
  const { quoteIdentifier } = require("../../utils/identifier");
2
4
  const { serializeRows } = require("../../utils/sqliteTypes");
5
+ const { ValidationError } = require("../../utils/errors");
3
6
  const {
4
7
  rowsToCsv,
5
8
  rowsToDelimitedText,
@@ -41,6 +44,14 @@ const EXPORT_FORMATS = {
41
44
  extension: "md",
42
45
  mimeType: "text/markdown; charset=utf-8",
43
46
  },
47
+ json: {
48
+ extension: "json",
49
+ mimeType: "application/json; charset=utf-8",
50
+ },
51
+ parquet: {
52
+ extension: "parquet",
53
+ mimeType: "application/vnd.apache.parquet",
54
+ },
44
55
  };
45
56
 
46
57
  function normalizeExportFormat(format) {
@@ -54,6 +65,20 @@ function normalizeExportFormat(format) {
54
65
  }
55
66
 
56
67
  function renderExportContent({ columns, rows, format, csvDelimiter }) {
68
+ if (format === "parquet") {
69
+ throw new ValidationError("Parquet exports are binary and must use a download endpoint.");
70
+ }
71
+
72
+ if (format === "json") {
73
+ return JSON.stringify(
74
+ rows.map((row) =>
75
+ Object.fromEntries(columns.map((column) => [column, row[column]]))
76
+ ),
77
+ null,
78
+ 2
79
+ );
80
+ }
81
+
57
82
  if (format === "tsv") {
58
83
  return rowsToDelimitedText({ columns, rows, delimiter: "\t" });
59
84
  }
@@ -65,6 +90,206 @@ function renderExportContent({ columns, rows, format, csvDelimiter }) {
65
90
  return rowsToCsv({ columns, rows, delimiter: csvDelimiter });
66
91
  }
67
92
 
93
+ function isSerializedBlob(value) {
94
+ return (
95
+ value &&
96
+ typeof value === "object" &&
97
+ value.__type === "blob" &&
98
+ typeof value.data === "string"
99
+ );
100
+ }
101
+
102
+ function getParquetValueKind(value) {
103
+ if (value === null || value === undefined) {
104
+ return null;
105
+ }
106
+
107
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array || isSerializedBlob(value)) {
108
+ return "blob";
109
+ }
110
+
111
+ if (typeof value === "boolean") {
112
+ return "boolean";
113
+ }
114
+
115
+ if (typeof value === "number" && Number.isFinite(value)) {
116
+ return Number.isInteger(value) ? "integer" : "number";
117
+ }
118
+
119
+ return "string";
120
+ }
121
+
122
+ function inferParquetColumnType(rows, column) {
123
+ const kinds = new Set();
124
+
125
+ for (const row of rows) {
126
+ const kind = getParquetValueKind(row?.[column]);
127
+
128
+ if (kind) {
129
+ kinds.add(kind);
130
+ }
131
+ }
132
+
133
+ if (kinds.size === 0) {
134
+ return "UTF8";
135
+ }
136
+
137
+ if (kinds.size === 1 && kinds.has("blob")) {
138
+ return "BYTE_ARRAY";
139
+ }
140
+
141
+ if (kinds.size === 1 && kinds.has("boolean")) {
142
+ return "BOOLEAN";
143
+ }
144
+
145
+ if (kinds.size === 1 && kinds.has("integer")) {
146
+ return "INT64";
147
+ }
148
+
149
+ if (
150
+ (kinds.size === 1 && kinds.has("number")) ||
151
+ (kinds.size === 2 && kinds.has("integer") && kinds.has("number"))
152
+ ) {
153
+ return "DOUBLE";
154
+ }
155
+
156
+ return "UTF8";
157
+ }
158
+
159
+ function stringifyParquetValue(value) {
160
+ if (value === null || value === undefined) {
161
+ return undefined;
162
+ }
163
+
164
+ if (typeof value === "string") {
165
+ return value;
166
+ }
167
+
168
+ if (Buffer.isBuffer(value)) {
169
+ return value.toString("base64");
170
+ }
171
+
172
+ if (value instanceof Uint8Array) {
173
+ return Buffer.from(value).toString("base64");
174
+ }
175
+
176
+ if (typeof value === "object") {
177
+ return JSON.stringify(value);
178
+ }
179
+
180
+ return String(value);
181
+ }
182
+
183
+ function normalizeParquetBlob(value) {
184
+ if (value === null || value === undefined) {
185
+ return undefined;
186
+ }
187
+
188
+ if (Buffer.isBuffer(value)) {
189
+ return value;
190
+ }
191
+
192
+ if (value instanceof Uint8Array) {
193
+ return Buffer.from(value);
194
+ }
195
+
196
+ if (isSerializedBlob(value)) {
197
+ return Buffer.from(value.data, value.encoding === "hex" ? "hex" : "base64");
198
+ }
199
+
200
+ return Buffer.from(stringifyParquetValue(value) ?? "", "utf8");
201
+ }
202
+
203
+ function normalizeParquetValue(value, type) {
204
+ if (value === null || value === undefined) {
205
+ return undefined;
206
+ }
207
+
208
+ if (type === "BYTE_ARRAY") {
209
+ return normalizeParquetBlob(value);
210
+ }
211
+
212
+ if (type === "BOOLEAN") {
213
+ return Boolean(value);
214
+ }
215
+
216
+ if (type === "INT64" || type === "DOUBLE") {
217
+ return Number(value);
218
+ }
219
+
220
+ return stringifyParquetValue(value);
221
+ }
222
+
223
+ function createParquetSchema(columns, rows) {
224
+ return new parquet.ParquetSchema(
225
+ Object.fromEntries(
226
+ columns.map((column) => [
227
+ column,
228
+ {
229
+ type: inferParquetColumnType(rows, column),
230
+ optional: true,
231
+ },
232
+ ])
233
+ )
234
+ );
235
+ }
236
+
237
+ async function rowsToParquetBuffer({ columns, rows }) {
238
+ const schema = createParquetSchema(columns, rows);
239
+ const output = new PassThrough();
240
+ const chunks = [];
241
+ const finished = new Promise((resolve, reject) => {
242
+ output.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
243
+ output.on("end", resolve);
244
+ output.on("error", reject);
245
+ });
246
+ const writer = await parquet.ParquetWriter.openStream(schema, output, {
247
+ useDataPageV2: false,
248
+ });
249
+ const columnTypes = Object.fromEntries(
250
+ columns.map((column) => [column, schema.schema[column].type])
251
+ );
252
+
253
+ for (const row of rows) {
254
+ const parquetRow = {};
255
+
256
+ for (const column of columns) {
257
+ const value = normalizeParquetValue(row?.[column], columnTypes[column]);
258
+
259
+ if (value !== undefined) {
260
+ parquetRow[column] = value;
261
+ }
262
+ }
263
+
264
+ await writer.appendRow(parquetRow);
265
+ }
266
+
267
+ await writer.close();
268
+ await finished;
269
+
270
+ return Buffer.concat(chunks);
271
+ }
272
+
273
+ async function renderDownloadContent({ columns, rows, format, csvDelimiter }) {
274
+ if (format === "parquet") {
275
+ return rowsToParquetBuffer({ columns, rows });
276
+ }
277
+
278
+ return renderExportContent({ columns, rows, format, csvDelimiter });
279
+ }
280
+
281
+ function buildExportResult({ filenameBase, formatConfig, content, format, columns, rowCount }) {
282
+ return {
283
+ filename: `${filenameBase}.${formatConfig.extension}`,
284
+ content,
285
+ csv: format === "csv" ? content : undefined,
286
+ format,
287
+ mimeType: formatConfig.mimeType,
288
+ columns,
289
+ rowCount,
290
+ };
291
+ }
292
+
68
293
  class ExportService {
69
294
  constructor({ appStateStore, connectionManager, sqlExecutor }) {
70
295
  this.appStateStore = appStateStore;
@@ -76,7 +301,7 @@ class ExportService {
76
301
  return this.appStateStore.getSettings().csvDelimiter || ",";
77
302
  }
78
303
 
79
- exportQuery(sql, options = {}) {
304
+ buildQueryExportData(sql, options = {}) {
80
305
  const format = normalizeExportFormat(options.format);
81
306
  const formatConfig = EXPORT_FORMATS[format];
82
307
  const activeConnection = this.connectionManager.getActiveConnection();
@@ -96,25 +321,55 @@ class ExportService {
96
321
  persistHistory: false,
97
322
  requireReader: true,
98
323
  });
99
- const content = renderExportContent({
324
+
325
+ return {
326
+ filenameBase,
327
+ format,
328
+ formatConfig,
100
329
  columns: result.columns,
101
330
  rows: result.rows,
102
- format,
331
+ };
332
+ }
333
+
334
+ exportQuery(sql, options = {}) {
335
+ const exportData = this.buildQueryExportData(sql, options);
336
+ const content = renderExportContent({
337
+ columns: exportData.columns,
338
+ rows: exportData.rows,
339
+ format: exportData.format,
103
340
  csvDelimiter: this.getDelimiter(),
104
341
  });
105
342
 
106
- return {
107
- filename: `${filenameBase}.${formatConfig.extension}`,
343
+ return buildExportResult({
344
+ filenameBase: exportData.filenameBase,
345
+ formatConfig: exportData.formatConfig,
108
346
  content,
109
- csv: format === "csv" ? content : undefined,
110
- format,
111
- mimeType: formatConfig.mimeType,
112
- columns: result.columns,
113
- rowCount: result.rows.length,
114
- };
347
+ format: exportData.format,
348
+ columns: exportData.columns,
349
+ rowCount: exportData.rows.length,
350
+ });
115
351
  }
116
352
 
117
- exportTable(tableName, options = {}) {
353
+ async exportQueryDownload(sql, options = {}) {
354
+ const exportData = this.buildQueryExportData(sql, options);
355
+ const content = await renderDownloadContent({
356
+ columns: exportData.columns,
357
+ rows: exportData.rows,
358
+ format: exportData.format,
359
+ csvDelimiter: this.getDelimiter(),
360
+ });
361
+
362
+ return buildExportResult({
363
+ filenameBase: exportData.filenameBase,
364
+ formatConfig: exportData.formatConfig,
365
+ content,
366
+ format: exportData.format,
367
+ columns: exportData.columns,
368
+ rowCount: exportData.rows.length,
369
+ });
370
+ }
371
+
372
+ buildTableExportData(tableName, options = {}) {
118
373
  const format = normalizeExportFormat(options.format);
119
374
  const formatConfig = EXPORT_FORMATS[format];
120
375
  const db = this.connectionManager.getActiveDatabase();
@@ -138,22 +393,52 @@ class ExportService {
138
393
  blobMode: "full",
139
394
  });
140
395
  const columns = statement.columns().map((column) => column.name);
141
- const content = renderExportContent({
396
+
397
+ return {
398
+ filenameBase: tableName,
399
+ format,
400
+ formatConfig,
142
401
  columns,
143
402
  rows,
144
- format,
403
+ };
404
+ }
405
+
406
+ exportTable(tableName, options = {}) {
407
+ const exportData = this.buildTableExportData(tableName, options);
408
+ const content = renderExportContent({
409
+ columns: exportData.columns,
410
+ rows: exportData.rows,
411
+ format: exportData.format,
145
412
  csvDelimiter: this.getDelimiter(),
146
413
  });
147
414
 
148
- return {
149
- filename: `${tableName}.${formatConfig.extension}`,
415
+ return buildExportResult({
416
+ filenameBase: exportData.filenameBase,
417
+ formatConfig: exportData.formatConfig,
150
418
  content,
151
- csv: format === "csv" ? content : undefined,
152
- format,
153
- mimeType: formatConfig.mimeType,
154
- columns,
155
- rowCount: rows.length,
156
- };
419
+ format: exportData.format,
420
+ columns: exportData.columns,
421
+ rowCount: exportData.rows.length,
422
+ });
423
+ }
424
+
425
+ async exportTableDownload(tableName, options = {}) {
426
+ const exportData = this.buildTableExportData(tableName, options);
427
+ const content = await renderDownloadContent({
428
+ columns: exportData.columns,
429
+ rows: exportData.rows,
430
+ format: exportData.format,
431
+ csvDelimiter: this.getDelimiter(),
432
+ });
433
+
434
+ return buildExportResult({
435
+ filenameBase: exportData.filenameBase,
436
+ formatConfig: exportData.formatConfig,
437
+ content,
438
+ format: exportData.format,
439
+ columns: exportData.columns,
440
+ rowCount: exportData.rows.length,
441
+ });
157
442
  }
158
443
  }
159
444
 
@@ -8,6 +8,7 @@ const test = require("node:test");
8
8
  const { createExternalApiRouter } = require("../server/routes/externalApi");
9
9
  const { ApiTokenService } = require("../server/services/apiTokenService");
10
10
  const { AppStateStore } = require("../server/services/storage/appStateStore");
11
+ const { ReadOnlyError } = require("../server/utils/errors");
11
12
  const { errorMiddleware } = require("../server/utils/errors");
12
13
 
13
14
  function createConnection(id, label, databasePath) {
@@ -39,13 +40,59 @@ async function startApi(t) {
39
40
  serviceCalls.push(databaseId);
40
41
  return [{ name: "companies" }];
41
42
  },
43
+ executeRawQuery(databaseId, sql, options = {}) {
44
+ serviceCalls.push(`${databaseId}:query:${sql}:${options.storeName ?? ""}`);
45
+ if (sql === "READONLY") {
46
+ throw new ReadOnlyError("Cannot execute raw SQL against a read-only database.");
47
+ }
48
+ return {
49
+ result: {
50
+ sql,
51
+ statementCount: 1,
52
+ statements: [],
53
+ rows: [],
54
+ columns: [],
55
+ affectedRowCount: 0,
56
+ resultKind: "unknown",
57
+ timingMs: 2,
58
+ historyId: 42,
59
+ storedQuery: options.storeName
60
+ ? {
61
+ id: 42,
62
+ title: options.storeName,
63
+ isSaved: true,
64
+ }
65
+ : null,
66
+ },
67
+ };
68
+ },
42
69
  };
43
70
  const app = express();
44
71
 
45
72
  app.use(express.json());
46
73
  app.use(
47
74
  "/api/v1",
48
- createExternalApiRouter({ databaseService, tokenService })
75
+ createExternalApiRouter({
76
+ databaseService,
77
+ tokenService,
78
+ appInfoService: async ({ port, url }) => ({
79
+ packageName: "sqlite-hub",
80
+ appVersion: "1.0.1",
81
+ sqliteVersion: "3.50.0",
82
+ port,
83
+ url,
84
+ versionCheck: {
85
+ packageName: "sqlite-hub",
86
+ currentVersion: "1.0.1",
87
+ latestVersion: "1.0.1",
88
+ updateAvailable: false,
89
+ checkedAt: "2026-06-20T10:00:00.000Z",
90
+ source: "npm",
91
+ releaseUrl: "https://www.npmjs.com/package/sqlite-hub/v/1.0.1",
92
+ status: "current",
93
+ },
94
+ }),
95
+ })
49
96
  );
50
97
  app.use(errorMiddleware);
51
98
 
@@ -125,3 +172,65 @@ test("tokens are hashed, deletable, and isolated per database", async (t) => {
125
172
  /invalid for this database/
126
173
  );
127
174
  });
175
+
176
+ test("public API info returns app and version status without a token", async (t) => {
177
+ const fixture = await startApi(t);
178
+ const response = await fetch(`${fixture.baseUrl}/info`);
179
+ const payload = await response.json();
180
+
181
+ assert.equal(response.status, 200);
182
+ assert.equal(payload.data.packageName, "sqlite-hub");
183
+ assert.equal(payload.data.appVersion, "1.0.1");
184
+ assert.equal(payload.data.sqliteVersion, "3.50.0");
185
+ assert.equal(payload.data.versionCheck.status, "current");
186
+ assert.equal(payload.data.versionCheck.updateAvailable, false);
187
+ assert.match(payload.data.url, /^http:\/\/127\.0\.0\.1:\d+$/);
188
+ });
189
+
190
+ test("query API executes raw SQL with a database token", async (t) => {
191
+ const fixture = await startApi(t);
192
+ const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
193
+ const response = await fetch(`${fixture.baseUrl}/query`, {
194
+ method: "POST",
195
+ headers: {
196
+ Authorization: `Bearer ${created.token}`,
197
+ "Content-Type": "application/json",
198
+ },
199
+ body: JSON.stringify({
200
+ databaseId: fixture.databaseA.id,
201
+ sql: "SELECT 1",
202
+ store: "Stored API Query",
203
+ }),
204
+ });
205
+ const payload = await response.json();
206
+
207
+ assert.equal(response.status, 200);
208
+ assert.equal(payload.data.sql, "SELECT 1");
209
+ assert.equal(payload.data.historyId, 42);
210
+ assert.equal(payload.data.storedQuery.title, "Stored API Query");
211
+ assert.equal(payload.metadata.stored, true);
212
+ assert.equal(payload.metadata.databaseId, fixture.databaseA.id);
213
+ assert.deepEqual(fixture.serviceCalls, [
214
+ `${fixture.databaseA.id}:query:SELECT 1:Stored API Query`,
215
+ ]);
216
+ });
217
+
218
+ test("query API rejects read-only raw SQL execution", async (t) => {
219
+ const fixture = await startApi(t);
220
+ const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
221
+ const response = await fetch(`${fixture.baseUrl}/query`, {
222
+ method: "POST",
223
+ headers: {
224
+ Authorization: `Bearer ${created.token}`,
225
+ "Content-Type": "application/json",
226
+ },
227
+ body: JSON.stringify({
228
+ databaseId: fixture.databaseA.id,
229
+ sql: "READONLY",
230
+ }),
231
+ });
232
+ const payload = await response.json();
233
+
234
+ assert.equal(response.status, 403);
235
+ assert.equal(payload.error.code, "SQLITE_READONLY");
236
+ });
@@ -31,6 +31,11 @@ test("parses database info commands with the new schema", () => {
31
31
  assert.equal(parseCliArguments(["--database:db", "--lastopened"]).lastOpenedInfo, true);
32
32
  });
33
33
 
34
+ test("parses app info command and keeps config as a legacy alias", () => {
35
+ assert.equal(parseCliArguments(["--info"]).info, true);
36
+ assert.equal(parseCliArguments(["--config"]).info, true);
37
+ });
38
+
34
39
  test("keeps old sqleditor aliases working", () => {
35
40
  const listOptions = parseCliArguments(["--database:Unit-00", "--sqleditor"]);
36
41
  const executeOptions = parseCliArguments(["--database:Unit-00", "--sqleditor:Saved Query"]);
@@ -50,8 +55,13 @@ test("keeps old database detail aliases working", () => {
50
55
  assert.equal(tableOptions.tables, true);
51
56
  });
52
57
 
53
- test("parses query display and export commands", () => {
54
- const showOptions = parseCliArguments(["--database:db", "--query:Stock Winners"]);
58
+ test("parses raw query, store name, saved query display, and export commands", () => {
59
+ const rawOptions = parseCliArguments([
60
+ "--database:db",
61
+ "--query:SELECT 1",
62
+ "--store:Stored Select",
63
+ ]);
64
+ const showOptions = parseCliArguments(["--database:db", "--saved-query:Stock Winners"]);
55
65
  const notesOptions = parseCliArguments(["--database:db", "--notes:Stock Winners"]);
56
66
  const exportOptions = parseCliArguments([
57
67
  "--database:db",
@@ -59,6 +69,8 @@ test("parses query display and export commands", () => {
59
69
  "--format:md",
60
70
  ]);
61
71
 
72
+ assert.equal(rawOptions.rawQuery, "SELECT 1");
73
+ assert.equal(rawOptions.storeName, "Stored Select");
62
74
  assert.equal(showOptions.showQuery, "Stock Winners");
63
75
  assert.equal(notesOptions.showNotes, "Stock Winners");
64
76
  assert.equal(exportOptions.exportTarget, "Stock Winners");
@@ -96,5 +108,6 @@ test("parses document commands", () => {
96
108
  test("validates export formats", () => {
97
109
  assert.equal(normalizeExportFormat("csv"), "csv");
98
110
  assert.equal(normalizeExportFormat("TSV"), "tsv");
99
- assert.throws(() => normalizeExportFormat("json"), /Unsupported export format/);
111
+ assert.equal(normalizeExportFormat("json"), "json");
112
+ assert.throws(() => normalizeExportFormat("xlsx"), /Unsupported export format/);
100
113
  });
@@ -8,7 +8,7 @@ const Database = require("better-sqlite3");
8
8
  const { DatabaseCommandService } = require("../server/services/databaseCommandService");
9
9
  const { AppStateStore } = require("../server/services/storage/appStateStore");
10
10
 
11
- function createFixture(t) {
11
+ function createFixture(t, options = {}) {
12
12
  const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-command-service-"));
13
13
  const databasePath = path.join(directory, "sample.db");
14
14
  const targetDb = new Database(databasePath);
@@ -27,7 +27,7 @@ function createFixture(t) {
27
27
  lastOpenedAt: new Date().toISOString(),
28
28
  lastModifiedAt: new Date().toISOString(),
29
29
  sizeBytes: fs.statSync(databasePath).size,
30
- readOnly: false,
30
+ readOnly: Boolean(options.readOnly),
31
31
  logoPath: null,
32
32
  };
33
33
 
@@ -72,6 +72,7 @@ function createFixture(t) {
72
72
  return {
73
73
  connection,
74
74
  service: new DatabaseCommandService({ appStateStore: store }),
75
+ store,
75
76
  };
76
77
  }
77
78
 
@@ -79,7 +80,7 @@ test("database command service provides shared CLI and API operations", (t) => {
79
80
  const { connection, service } = createFixture(t);
80
81
 
81
82
  assert.equal(service.getDatabase("sample").id, connection.id);
82
- assert.deepEqual(service.listTables(connection.id), [{ name: "companies" }]);
83
+ assert.deepEqual(service.listTables(connection.id), [{ name: "companies", columnCount: 2 }]);
83
84
  assert.equal(service.getTable(connection.id, "companies").rowCount, 2);
84
85
 
85
86
  const row = service.getTableRow(connection.id, "companies", "1");
@@ -100,3 +101,39 @@ test("database command service provides shared CLI and API operations", (t) => {
100
101
  assert.equal(service.listDocuments(connection.id).length, 1);
101
102
  assert.equal(service.getDocument(connection.id, "Readme").content, "# Sample\n");
102
103
  });
104
+
105
+ test("raw query execution writes SQL Editor query history", (t) => {
106
+ const { connection, service, store } = createFixture(t);
107
+ const beforeCount = Number(
108
+ store.db.prepare("SELECT COUNT(*) AS count FROM query_history").get().count
109
+ );
110
+ const { result, storedQuery } = service.executeRawQuery(
111
+ connection.id,
112
+ "INSERT INTO companies (name) VALUES ('Initech')",
113
+ { storeName: "Add Initech" }
114
+ );
115
+ const afterCount = Number(
116
+ store.db.prepare("SELECT COUNT(*) AS count FROM query_history").get().count
117
+ );
118
+ const historyRow = store.db
119
+ .prepare("SELECT raw_sql, query_type, title, is_saved FROM query_history WHERE id = ?")
120
+ .get(result.historyId);
121
+
122
+ assert.equal(result.affectedRowCount, 1);
123
+ assert.equal(afterCount, beforeCount + 1);
124
+ assert.equal(historyRow.raw_sql, "INSERT INTO companies (name) VALUES ('Initech')");
125
+ assert.equal(historyRow.query_type, "insert");
126
+ assert.equal(historyRow.title, "Add Initech");
127
+ assert.equal(historyRow.is_saved, 1);
128
+ assert.equal(storedQuery.title, "Add Initech");
129
+ assert.equal(storedQuery.isSaved, true);
130
+ });
131
+
132
+ test("raw query execution is blocked for read-only connections", (t) => {
133
+ const { connection, service } = createFixture(t, { readOnly: true });
134
+
135
+ assert.throws(
136
+ () => service.executeRawQuery(connection.id, "SELECT 1"),
137
+ /read-only database/
138
+ );
139
+ });