sqlite-hub 2.0.1 → 2.2.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.
Files changed (98) hide show
  1. package/README.md +21 -9
  2. package/docs/MCP.md +8 -0
  3. package/docs/changelog.md +16 -0
  4. package/docs/guidelines/DESIGN.md +64 -0
  5. package/docs/todo.md +2 -0
  6. package/frontend/assets/mockups/backups_1_1920.webp +0 -0
  7. package/frontend/assets/mockups/backups_2_compare_drawer_1920.webp +0 -0
  8. package/frontend/assets/mockups/backups_3_create_backup_modal_1920.webp +0 -0
  9. package/frontend/assets/mockups/backups_4_edit_backup_modal_1920.webp +0 -0
  10. package/frontend/assets/mockups/backups_5_restore_backup_modal_1920.webp +0 -0
  11. package/frontend/assets/mockups/backups_6_delete_backup_modal_1920.webp +0 -0
  12. package/frontend/assets/mockups/charts_1_1920.webp +0 -0
  13. package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
  14. package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
  15. package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
  16. package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
  17. package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
  18. package/frontend/assets/mockups/connections_1_1920.webp +0 -0
  19. package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
  20. package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
  21. package/frontend/assets/mockups/data_1_1920.webp +0 -0
  22. package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
  23. package/frontend/assets/mockups/data_3_generate_data_modal_1920.webp +0 -0
  24. package/frontend/assets/mockups/data_4_data_export_modal_1920.webp +0 -0
  25. package/frontend/assets/mockups/documents_1_1920.webp +0 -0
  26. package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
  27. package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
  28. package/frontend/assets/mockups/logs_1_1920.webp +0 -0
  29. package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
  30. package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
  31. package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
  32. package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
  33. package/frontend/assets/mockups/overview_1_1920.webp +0 -0
  34. package/frontend/assets/mockups/settings_1_1920.webp +0 -0
  35. package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
  36. package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
  37. package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
  38. package/frontend/assets/mockups/structure_1_1920.webp +0 -0
  39. package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
  40. package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
  41. package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
  42. package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
  43. package/frontend/js/api.js +18 -0
  44. package/frontend/js/app.js +567 -15
  45. package/frontend/js/components/badges.js +16 -0
  46. package/frontend/js/components/connectionCard.js +35 -0
  47. package/frontend/js/components/modal.js +268 -247
  48. package/frontend/js/components/queryHistoryDetail.js +25 -45
  49. package/frontend/js/components/rowEditorPanel.js +7 -60
  50. package/frontend/js/components/structureGraph.js +292 -13
  51. package/frontend/js/components/tableDesignerConstraintsDrawer.js +400 -0
  52. package/frontend/js/components/tableDesignerEditor.js +51 -16
  53. package/frontend/js/components/tableDesignerSidebar.js +9 -5
  54. package/frontend/js/store.js +1847 -190
  55. package/frontend/js/utils/connectionRegistry.js +76 -0
  56. package/frontend/js/utils/markdownDocuments.js +21 -4
  57. package/frontend/js/utils/tableDesigner.js +431 -62
  58. package/frontend/js/views/backups.js +2 -5
  59. package/frontend/js/views/charts.js +20 -10
  60. package/frontend/js/views/connections.js +101 -6
  61. package/frontend/js/views/data.js +52 -9
  62. package/frontend/js/views/documents.js +153 -59
  63. package/frontend/js/views/structure.js +28 -5
  64. package/frontend/js/views/tableAdvisor.js +14 -6
  65. package/frontend/js/views/tableDesigner.js +44 -9
  66. package/frontend/styles/base.css +9 -0
  67. package/frontend/styles/components.css +676 -79
  68. package/frontend/styles/structure-graph.css +49 -0
  69. package/frontend/styles/tailwind.generated.css +1 -1
  70. package/frontend/styles/views.css +12 -0
  71. package/package.json +1 -1
  72. package/server/routes/backups.js +37 -1
  73. package/server/routes/charts.js +28 -0
  74. package/server/routes/connections.js +12 -0
  75. package/server/routes/data.js +30 -2
  76. package/server/routes/documents.js +61 -5
  77. package/server/routes/settings.js +32 -0
  78. package/server/routes/tableDesigner.js +35 -2
  79. package/server/server.js +3 -3
  80. package/server/services/mcpToolService.js +30 -0
  81. package/server/services/sqlite/connectionManager.js +25 -2
  82. package/server/services/sqlite/dataBrowserService.js +21 -0
  83. package/server/services/sqlite/introspection.js +39 -1
  84. package/server/services/sqlite/overviewService.js +1 -0
  85. package/server/services/sqlite/structureService.js +66 -11
  86. package/server/services/sqlite/tableAdvisor.js +6 -0
  87. package/server/services/sqlite/tableDesigner/schemaMapping.js +7 -1
  88. package/server/services/sqlite/tableDesigner/sql.js +31 -2
  89. package/server/services/sqlite/tableDesigner/validation.js +69 -9
  90. package/server/services/sqlite/tableDesignerService.js +110 -5
  91. package/server/services/storage/appStateStore.js +590 -10
  92. package/server/utils/fileValidation.js +5 -0
  93. package/server/utils/userActionLog.js +49 -0
  94. package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
  95. package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
  96. package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
  97. package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
  98. package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
@@ -1,6 +1,7 @@
1
1
  const express = require("express");
2
2
  const path = require("node:path");
3
3
  const { DatabaseRequiredError, route, successResponse } = require("../utils/errors");
4
+ const { recordUserAction } = require("../utils/userActionLog");
4
5
 
5
6
  function getActiveDatabaseKey(connectionManager) {
6
7
  return connectionManager.getActiveConnection()?.id ?? null;
@@ -27,20 +28,23 @@ function resolveActiveDatabaseDocumentName(connection) {
27
28
  return basename || "Database";
28
29
  }
29
30
 
30
- function buildInitialDocumentPayload(connection) {
31
+ function buildInitialDocumentPayload(connection, options = {}) {
31
32
  const databaseName = resolveActiveDatabaseDocumentName(connection);
33
+ const timestamp = new Date().toISOString();
34
+ const createdAt = String(options.createdAt ?? connection?.createdAt ?? "").trim() || timestamp;
35
+ const lastModifiedAt = String(options.lastModifiedAt ?? "").trim() || timestamp;
32
36
 
33
37
  return {
34
38
  title: databaseName,
35
39
  filename: databaseName,
36
- content: `# ${databaseName}\n`,
40
+ content: `# ${databaseName}\n\n- created at: ${createdAt}\n- last modified: ${lastModifiedAt}\n\n## Purpose\n`,
37
41
  };
38
42
  }
39
43
 
40
44
  function pickDocumentPatch(body = {}) {
41
45
  const patch = {};
42
46
 
43
- for (const field of ["title", "filename", "content"]) {
47
+ for (const field of ["title", "filename", "content", "folderId"]) {
44
48
  if (Object.prototype.hasOwnProperty.call(body, field)) {
45
49
  patch[field] = body[field];
46
50
  }
@@ -58,7 +62,9 @@ function ensureDatabaseDocuments({ appStateStore, connectionManager, databaseKey
58
62
 
59
63
  appStateStore.createDatabaseDocument(
60
64
  databaseKey,
61
- buildInitialDocumentPayload(connectionManager.getActiveConnection())
65
+ buildInitialDocumentPayload(connectionManager.getActiveConnection(), {
66
+ lastModifiedAt: appStateStore.getLatestActivityLogTimestamp?.(databaseKey),
67
+ })
62
68
  );
63
69
 
64
70
  return appStateStore.listDatabaseDocuments(databaseKey);
@@ -76,6 +82,7 @@ function createDocumentsRouter({ appStateStore, connectionManager }) {
76
82
  successResponse({
77
83
  data: {
78
84
  items: ensureDatabaseDocuments({ appStateStore, connectionManager, databaseKey }),
85
+ folders: appStateStore.listDatabaseDocumentFolders(databaseKey),
79
86
  },
80
87
  metadata: { databaseKey },
81
88
  })
@@ -83,6 +90,27 @@ function createDocumentsRouter({ appStateStore, connectionManager }) {
83
90
  })
84
91
  );
85
92
 
93
+ router.post(
94
+ "/folders",
95
+ route((req, res) => {
96
+ const databaseKey = requireActiveDatabaseKey(connectionManager);
97
+ const folder = appStateStore.createDatabaseDocumentFolder(databaseKey, {
98
+ name: req.body?.name,
99
+ });
100
+
101
+ res.status(201).json(
102
+ successResponse({
103
+ message: "Document folder created.",
104
+ data: folder,
105
+ metadata: {
106
+ databaseKey,
107
+ folders: appStateStore.listDatabaseDocumentFolders(databaseKey),
108
+ },
109
+ })
110
+ );
111
+ })
112
+ );
113
+
86
114
  router.post(
87
115
  "/",
88
116
  route((req, res) => {
@@ -91,6 +119,19 @@ function createDocumentsRouter({ appStateStore, connectionManager }) {
91
119
  title: req.body?.title,
92
120
  filename: req.body?.filename,
93
121
  content: req.body?.content,
122
+ folderId: req.body?.folderId,
123
+ });
124
+ recordUserAction({
125
+ appStateStore,
126
+ connectionManager,
127
+ action: "document.create",
128
+ databaseKey,
129
+ targetType: "document",
130
+ targetName: document.filename ?? document.id,
131
+ metadata: {
132
+ documentId: document.id,
133
+ title: document.title ?? null,
134
+ },
94
135
  });
95
136
 
96
137
  res.status(201).json(
@@ -140,11 +181,26 @@ function createDocumentsRouter({ appStateStore, connectionManager }) {
140
181
  "/:documentId",
141
182
  route((req, res) => {
142
183
  const databaseKey = requireActiveDatabaseKey(connectionManager);
184
+ const document = appStateStore.getDatabaseDocument(databaseKey, req.params.documentId);
185
+ const deleted = appStateStore.deleteDatabaseDocument(databaseKey, req.params.documentId);
186
+
187
+ recordUserAction({
188
+ appStateStore,
189
+ connectionManager,
190
+ action: "document.delete",
191
+ databaseKey,
192
+ targetType: "document",
193
+ targetName: document.filename ?? req.params.documentId,
194
+ metadata: {
195
+ documentId: req.params.documentId,
196
+ title: document.title ?? null,
197
+ },
198
+ });
143
199
 
144
200
  res.json(
145
201
  successResponse({
146
202
  message: "Document deleted.",
147
- data: appStateStore.deleteDatabaseDocument(databaseKey, req.params.documentId),
203
+ data: deleted,
148
204
  metadata: { databaseKey },
149
205
  })
150
206
  );
@@ -10,6 +10,7 @@ const {
10
10
  } = require("../services/appInfoService");
11
11
  const { McpStatusService } = require("../services/mcpStatusService");
12
12
  const { MCP_TOOL_DEFINITIONS } = require("../services/mcpToolService");
13
+ const { recordUserAction } = require("../utils/userActionLog");
13
14
 
14
15
  function getActiveTokenContext({ connectionManager, tokenService }) {
15
16
  const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
@@ -161,6 +162,20 @@ function createSettingsRouter({ appStateStore, connectionManager, tokenService,
161
162
  const activeDatabase = requireActiveDatabase(connectionManager);
162
163
  const token = tokenService.createToken(activeDatabase.id, req.body?.name);
163
164
 
165
+ recordUserAction({
166
+ appStateStore,
167
+ connectionManager,
168
+ action: "settings.api-token.create",
169
+ targetType: "api-token",
170
+ targetName: token.name ?? token.id,
171
+ databaseKey: activeDatabase.id,
172
+ metadata: {
173
+ apiTokenId: token.id,
174
+ apiTokenName: token.name ?? null,
175
+ tokenPrefix: token.tokenPrefix ?? null,
176
+ },
177
+ });
178
+
164
179
  res.status(201).json(
165
180
  successResponse({
166
181
  message: "API token created. It will only be shown once.",
@@ -175,8 +190,25 @@ function createSettingsRouter({ appStateStore, connectionManager, tokenService,
175
190
  "/api-tokens/:tokenId",
176
191
  route((req, res) => {
177
192
  const activeDatabase = requireActiveDatabase(connectionManager);
193
+ const token = tokenService
194
+ .listTokens(activeDatabase.id)
195
+ .find((candidate) => candidate.id === req.params.tokenId);
178
196
  const result = tokenService.deleteToken(activeDatabase.id, req.params.tokenId);
179
197
 
198
+ recordUserAction({
199
+ appStateStore,
200
+ connectionManager,
201
+ action: "settings.api-token.delete",
202
+ targetType: "api-token",
203
+ targetName: token?.name ?? req.params.tokenId,
204
+ databaseKey: activeDatabase.id,
205
+ metadata: {
206
+ apiTokenId: req.params.tokenId,
207
+ apiTokenName: token?.name ?? null,
208
+ tokenPrefix: token?.tokenPrefix ?? null,
209
+ },
210
+ });
211
+
180
212
  res.json(
181
213
  successResponse({
182
214
  message: "API token deleted.",
@@ -1,7 +1,8 @@
1
1
  const express = require("express");
2
2
  const { route, successResponse } = require("../utils/errors");
3
+ const { recordUserAction } = require("../utils/userActionLog");
3
4
 
4
- function createTableDesignerRouter({ tableDesignerService }) {
5
+ function createTableDesignerRouter({ tableDesignerService, appStateStore = null, connectionManager = null }) {
5
6
  const router = express.Router();
6
7
 
7
8
  router.get(
@@ -18,6 +19,20 @@ function createTableDesignerRouter({ tableDesignerService }) {
18
19
  })
19
20
  );
20
21
 
22
+ router.post(
23
+ "/validate-check",
24
+ route((req, res) => {
25
+ const data = tableDesignerService.validateCheckExpression(req.body ?? {});
26
+
27
+ res.json(
28
+ successResponse({
29
+ data,
30
+ message: "CHECK expression validated.",
31
+ })
32
+ );
33
+ })
34
+ );
35
+
21
36
  router.get(
22
37
  "/:tableName",
23
38
  route((req, res) => {
@@ -38,12 +53,30 @@ function createTableDesignerRouter({ tableDesignerService }) {
38
53
  const data = tableDesignerService.saveDraft(req.body ?? {});
39
54
  const isCreate = String(req.body?.draft?.mode ?? req.body?.mode ?? "").trim() !== "edit";
40
55
  const fillsImportedRows = Boolean(req.body?.draft?.fillImportedRows ?? req.body?.fillImportedRows);
56
+ const importedRows = req.body?.draft?.importRows ?? req.body?.draft?.importedCsvRows ?? [];
57
+ const executedSqlCount = Array.isArray(data.executedSql) ? data.executedSql.length : 0;
58
+
59
+ if (isCreate || executedSqlCount > 0) {
60
+ recordUserAction({
61
+ appStateStore,
62
+ connectionManager,
63
+ action: isCreate ? "table-designer.table.create" : "table-designer.table.update",
64
+ targetType: "table",
65
+ targetName: data.savedTableName ?? req.body?.draft?.tableName ?? req.body?.tableName,
66
+ metadata: {
67
+ mode: isCreate ? "create" : "edit",
68
+ executedSqlCount,
69
+ fillsImportedRows,
70
+ importedRowCount: fillsImportedRows && Array.isArray(importedRows) ? importedRows.length : 0,
71
+ },
72
+ });
73
+ }
41
74
 
42
75
  res.json(
43
76
  successResponse({
44
77
  message: isCreate
45
78
  ? fillsImportedRows
46
- ? "Table created and filled from CSV."
79
+ ? "Table created and data imported."
47
80
  : "Table created."
48
81
  : "Table schema updated.",
49
82
  data,
package/server/server.js CHANGED
@@ -129,13 +129,13 @@ app.use(
129
129
  nativeFileDialogService,
130
130
  })
131
131
  );
132
- app.use("/api/backups", createBackupsRouter({ backupService }));
132
+ app.use("/api/backups", createBackupsRouter({ backupService, appStateStore, connectionManager }));
133
133
  app.use("/api/db", createOverviewRouter({ overviewService }));
134
134
  app.use("/api/sql", createSqlRouter({ appStateStore, connectionManager, sqlExecutor }));
135
135
  app.use("/api/charts", createChartsRouter({ appStateStore, connectionManager, sqlExecutor }));
136
136
  app.use("/api/structure", createStructureRouter({ structureService }));
137
- app.use("/api/data", createDataRouter({ dataBrowserService }));
138
- app.use("/api/table-designer", createTableDesignerRouter({ tableDesignerService }));
137
+ app.use("/api/data", createDataRouter({ dataBrowserService, appStateStore, connectionManager }));
138
+ app.use("/api/table-designer", createTableDesignerRouter({ tableDesignerService, appStateStore, connectionManager }));
139
139
  app.use("/api/media-tagging", createMediaTaggingRouter({ mediaTaggingService }));
140
140
  app.use(
141
141
  "/api/settings",
@@ -76,6 +76,28 @@ const MCP_TOOL_DEFINITIONS = [
76
76
  ["databaseId", "sql"]
77
77
  ),
78
78
  },
79
+ {
80
+ name: "get_saved_queries",
81
+ description: "List saved SQL Editor queries for a database. This is the MCP equivalent of `sqlite-hub --database:name --queries`.",
82
+ inputSchema: objectSchema({
83
+ databaseId: databaseIdProperty(),
84
+ limit: { type: "integer", minimum: 1, maximum: 500, default: 100 },
85
+ }, ["databaseId"]),
86
+ },
87
+ {
88
+ name: "execute_stored_query",
89
+ description: "Execute a saved SQL Editor query by id, title, display title, or SQL fragment. This is the MCP equivalent of `sqlite-hub --database:name --execute:\"query\"`.",
90
+ inputSchema: objectSchema(
91
+ {
92
+ databaseId: databaseIdProperty(),
93
+ queryName: {
94
+ type: "string",
95
+ description: "Saved query id, title, display title, or SQL fragment.",
96
+ },
97
+ },
98
+ ["databaseId", "queryName"]
99
+ ),
100
+ },
79
101
  {
80
102
  name: "explain_query_plan",
81
103
  description: "Run SQLite EXPLAIN QUERY PLAN for a read-only query and return structured plan rows.",
@@ -142,6 +164,7 @@ function redactConnection(connection = {}) {
142
164
  label: connection.label,
143
165
  readOnly: Boolean(connection.readOnly),
144
166
  sizeBytes: connection.sizeBytes ?? null,
167
+ createdAt: connection.createdAt ?? null,
145
168
  lastOpenedAt: connection.lastOpenedAt ?? null,
146
169
  lastModifiedAt: connection.lastModifiedAt ?? null,
147
170
  };
@@ -154,6 +177,7 @@ function redactOverview(overview = {}) {
154
177
  file: {
155
178
  filename: overview.file?.filename ?? overview.connection?.label ?? null,
156
179
  sizeBytes: overview.file?.sizeBytes ?? null,
180
+ createdAt: overview.file?.createdAt ?? null,
157
181
  lastModifiedAt: overview.file?.lastModifiedAt ?? null,
158
182
  },
159
183
  };
@@ -227,6 +251,12 @@ class McpToolService {
227
251
  executedBy: "mcp",
228
252
  maxRows: args.maxRows,
229
253
  });
254
+ case "get_saved_queries":
255
+ return this.databaseService.listSavedQueries(args.databaseId, args.limit);
256
+ case "execute_stored_query":
257
+ return this.databaseService.executeSavedQuery(args.databaseId, args.queryName, {
258
+ executedBy: "mcp",
259
+ });
230
260
  case "explain_query_plan":
231
261
  return this.databaseService.explainQueryPlan(args.databaseId, args.sql);
232
262
  case "read_documents":
@@ -70,6 +70,7 @@ class ConnectionManager {
70
70
  label: options.label?.trim() || path.basename(filePath),
71
71
  path: filePath,
72
72
  lastOpenedAt: new Date().toISOString(),
73
+ createdAt: metadata.createdAt,
73
74
  lastModifiedAt: metadata.lastModifiedAt,
74
75
  sizeBytes: metadata.sizeBytes,
75
76
  readOnly: options.readOnly ?? !isWritable(filePath),
@@ -216,7 +217,7 @@ class ConnectionManager {
216
217
  return recentConnections;
217
218
  }
218
219
 
219
- updateRecentConnection(id, { filePath, label, readOnly = false, logoUpload = null, clearLogo = false }) {
220
+ updateRecentConnection(id, { filePath, label, readOnly = false, logoUpload = null, clearLogo = false, tags = null }) {
220
221
  const recentConnections = this.appStateStore.getRecentConnections();
221
222
  const existing = recentConnections.find((connection) => connection.id === id);
222
223
 
@@ -245,7 +246,7 @@ class ConnectionManager {
245
246
 
246
247
  try {
247
248
  if (this.current?.id === id) {
248
- return this.openConnection({
249
+ const connection = this.openConnection({
249
250
  filePath: resolvedPath,
250
251
  label: normalizedLabel,
251
252
  id,
@@ -253,6 +254,15 @@ class ConnectionManager {
253
254
  readOnly: normalizedReadOnly,
254
255
  logoPath: nextLogoPath,
255
256
  });
257
+
258
+ if (Array.isArray(tags)) {
259
+ this.appStateStore.setConnectionTags(id, tags);
260
+ }
261
+
262
+ return {
263
+ ...connection,
264
+ tags: this.appStateStore.getConnectionTags(id),
265
+ };
256
266
  }
257
267
 
258
268
  const db = this.openRawDatabase(resolvedPath, {
@@ -267,6 +277,7 @@ class ConnectionManager {
267
277
  ...existing,
268
278
  label: normalizedLabel,
269
279
  path: resolvedPath,
280
+ createdAt: metadata.createdAt,
270
281
  lastModifiedAt: metadata.lastModifiedAt,
271
282
  sizeBytes: metadata.sizeBytes,
272
283
  readOnly: normalizedReadOnly,
@@ -275,9 +286,14 @@ class ConnectionManager {
275
286
 
276
287
  this.appStateStore.updateRecentConnection(id, () => nextConnection);
277
288
 
289
+ if (Array.isArray(tags)) {
290
+ this.appStateStore.setConnectionTags(id, tags);
291
+ }
292
+
278
293
  return {
279
294
  ...nextConnection,
280
295
  logoUrl: this.appStateStore.getConnectionLogoUrl(nextLogoPath),
296
+ tags: this.appStateStore.getConnectionTags(id),
281
297
  isActive: false,
282
298
  };
283
299
  } catch (error) {
@@ -295,8 +311,11 @@ class ConnectionManager {
295
311
  }
296
312
 
297
313
  const { db, ...connection } = this.current;
314
+ const storedConnection = this.appStateStore.getRecentConnection(connection.id);
315
+
298
316
  return {
299
317
  ...connection,
318
+ tags: storedConnection?.tags ?? [],
300
319
  isActive: true,
301
320
  };
302
321
  }
@@ -334,6 +353,10 @@ class ConnectionManager {
334
353
  }));
335
354
  }
336
355
 
356
+ listConnectionTags() {
357
+ return this.appStateStore.listConnectionTags();
358
+ }
359
+
337
360
  getStatus() {
338
361
  const active = this.getActiveConnection();
339
362
 
@@ -83,6 +83,14 @@ function isUnchangedSubmittedValue(currentValue, submittedValue) {
83
83
  return formatPreviewValue(currentValue) === formatPreviewValue(submittedValue);
84
84
  }
85
85
 
86
+ function assertDataTableWritable(tableDetail) {
87
+ if (tableDetail.isShadow) {
88
+ throw new ValidationError(
89
+ `Shadow table ${tableDetail.name} is read-only in Data.`
90
+ );
91
+ }
92
+ }
93
+
86
94
  class DataBrowserService {
87
95
  constructor({ connectionManager }) {
88
96
  this.connectionManager = connectionManager;
@@ -102,6 +110,9 @@ class DataBrowserService {
102
110
  return {
103
111
  name: entry.name,
104
112
  columnCount,
113
+ tableKind: entry.tableKind ?? "table",
114
+ isVirtual: Boolean(entry.isVirtual),
115
+ isShadow: Boolean(entry.isShadow),
105
116
  };
106
117
  });
107
118
  }
@@ -161,6 +172,10 @@ class DataBrowserService {
161
172
  return {
162
173
  name: tableDetail.name,
163
174
  type: tableDetail.type,
175
+ tableKind: tableDetail.tableKind,
176
+ isVirtual: Boolean(tableDetail.isVirtual),
177
+ isShadow: Boolean(tableDetail.isShadow),
178
+ readOnly: Boolean(tableDetail.isShadow),
164
179
  rowCount,
165
180
  limit,
166
181
  offset,
@@ -253,6 +268,8 @@ class DataBrowserService {
253
268
  const tableDetail = getTableDetail(db, tableName, { includeRowCount: false });
254
269
  const identity = payload.identity ?? null;
255
270
 
271
+ assertDataTableWritable(tableDetail);
272
+
256
273
  if (tableDetail.notSafelyUpdatable) {
257
274
  throw new ValidationError(
258
275
  `Table ${tableName} cannot be safely updated because it has no stable row identity.`
@@ -299,6 +316,8 @@ class DataBrowserService {
299
316
  const db = this.connectionManager.getActiveDatabase();
300
317
  const tableDetail = getTableDetail(db, tableName, { includeRowCount: false });
301
318
 
319
+ assertDataTableWritable(tableDetail);
320
+
302
321
  return insertSyntheticRows(db, tableDetail, payload);
303
322
  }
304
323
 
@@ -351,6 +370,8 @@ class DataBrowserService {
351
370
  const values = payload.values ?? {};
352
371
  const identity = payload.identity ?? null;
353
372
 
373
+ assertDataTableWritable(tableDetail);
374
+
354
375
  if (tableDetail.notSafelyUpdatable) {
355
376
  throw new ValidationError(
356
377
  `Table ${tableName} cannot be safely updated because it has no stable row identity.`
@@ -19,6 +19,37 @@ function getTableListMap(db) {
19
19
  }
20
20
  }
21
21
 
22
+ function normalizeTableKind(entry = {}, tableListEntry = null) {
23
+ const pragmaType = String(tableListEntry?.type ?? "")
24
+ .trim()
25
+ .toLowerCase();
26
+
27
+ if (pragmaType) {
28
+ return pragmaType;
29
+ }
30
+
31
+ if (entry.type === "table" && /^\s*CREATE\s+VIRTUAL\s+TABLE\b/i.test(entry.sql || "")) {
32
+ return "virtual";
33
+ }
34
+
35
+ return entry.type;
36
+ }
37
+
38
+ function decorateStructureEntry(entry, tableListMap = null) {
39
+ if (entry.type !== "table") {
40
+ return entry;
41
+ }
42
+
43
+ const tableKind = normalizeTableKind(entry, tableListMap?.get(entry.name));
44
+
45
+ return {
46
+ ...entry,
47
+ tableKind,
48
+ isVirtual: tableKind === "virtual",
49
+ isShadow: tableKind === "shadow",
50
+ };
51
+ }
52
+
22
53
  function getMasterEntry(db, type, name) {
23
54
  const entry = db
24
55
  .prepare(
@@ -34,6 +65,8 @@ function getMasterEntry(db, type, name) {
34
65
  }
35
66
 
36
67
  function getRawStructureEntries(db) {
68
+ const tableListMap = getTableListMap(db);
69
+
37
70
  return db
38
71
  .prepare(
39
72
  [
@@ -43,7 +76,8 @@ function getRawStructureEntries(db) {
43
76
  "ORDER BY type ASC, name ASC",
44
77
  ].join(" ")
45
78
  )
46
- .all();
79
+ .all()
80
+ .map((entry) => decorateStructureEntry(entry, tableListMap));
47
81
  }
48
82
 
49
83
  function normalizeColumn(column, visibleSet) {
@@ -607,11 +641,15 @@ function getTableDetail(db, tableName, options = {}) {
607
641
  typeof tableListEntry?.wr === "number"
608
642
  ? Boolean(tableListEntry.wr)
609
643
  : /WITHOUT\s+ROWID/i.test(entry.sql || "");
644
+ const tableKind = normalizeTableKind(entry, tableListEntry);
610
645
 
611
646
  const tableDetail = {
612
647
  type: entry.type,
613
648
  name: entry.name,
614
649
  ddl: entry.sql,
650
+ tableKind,
651
+ isVirtual: tableKind === "virtual",
652
+ isShadow: tableKind === "shadow",
615
653
  withoutRowId,
616
654
  strict: Boolean(tableListEntry?.strict),
617
655
  columns,
@@ -123,6 +123,7 @@ class OverviewService {
123
123
  filename: connection.label,
124
124
  path: connection.path,
125
125
  sizeBytes: connection.sizeBytes,
126
+ createdAt: connection.createdAt,
126
127
  lastModifiedAt: connection.lastModifiedAt,
127
128
  },
128
129
  sqlite: {
@@ -3,6 +3,65 @@ const { quoteIdentifier } = require("../../utils/identifier");
3
3
  const { serializeRows } = require("../../utils/sqliteTypes");
4
4
  const { TypeGenerationService } = require("../typeGenerationService");
5
5
 
6
+ function resolveShadowOwnerTableName(shadowTable, tables) {
7
+ if (!shadowTable?.isShadow) {
8
+ return null;
9
+ }
10
+
11
+ return (
12
+ tables
13
+ .filter((table) => table.isVirtual && shadowTable.name.startsWith(`${table.name}_`))
14
+ .sort((left, right) => right.name.length - left.name.length)[0]?.name ?? null
15
+ );
16
+ }
17
+
18
+ function unquoteSqlIdentifier(identifier) {
19
+ const value = String(identifier ?? "").trim();
20
+
21
+ if (value.length >= 2 && value[0] === '"' && value.at(-1) === '"') {
22
+ return value.slice(1, -1).replace(/""/g, '"');
23
+ }
24
+
25
+ if (value.length >= 2 && value[0] === "`" && value.at(-1) === "`") {
26
+ return value.slice(1, -1).replace(/``/g, "`");
27
+ }
28
+
29
+ if (value.length >= 2 && value[0] === "[" && value.at(-1) === "]") {
30
+ return value.slice(1, -1);
31
+ }
32
+
33
+ return value;
34
+ }
35
+
36
+ function extractVirtualTableModule(ddl = "") {
37
+ const match = String(ddl ?? "").match(
38
+ /\bUSING\s+("[^"]+(?:""[^"]*)*"|`[^`]+(?:``[^`]*)*`|\[[^\]]+\]|[A-Za-z_][A-Za-z0-9_]*)/i
39
+ );
40
+
41
+ return match ? unquoteSqlIdentifier(match[1]) : null;
42
+ }
43
+
44
+ function serializeGraphTable(table, shadowOwnerTableName = null) {
45
+ const isVirtual = Boolean(table.isVirtual);
46
+
47
+ return {
48
+ type: table.type,
49
+ name: table.name,
50
+ ddl: table.ddl,
51
+ tableKind: table.tableKind,
52
+ isVirtual,
53
+ isShadow: Boolean(table.isShadow),
54
+ virtualModule: isVirtual ? extractVirtualTableModule(table.ddl) : null,
55
+ shadowOwnerTable: shadowOwnerTableName,
56
+ withoutRowId: table.withoutRowId,
57
+ strict: table.strict,
58
+ columns: table.columns,
59
+ foreignKeys: table.foreignKeys,
60
+ identityStrategy: table.identityStrategy,
61
+ notSafelyUpdatable: table.notSafelyUpdatable,
62
+ };
63
+ }
64
+
6
65
  class StructureService {
7
66
  constructor({ connectionManager, appStateStore }) {
8
67
  this.connectionManager = connectionManager;
@@ -35,17 +94,9 @@ class StructureService {
35
94
  triggers: entries.filter((entry) => entry.type === "trigger"),
36
95
  },
37
96
  graph: {
38
- tables: tables.map((table) => ({
39
- type: table.type,
40
- name: table.name,
41
- ddl: table.ddl,
42
- withoutRowId: table.withoutRowId,
43
- strict: table.strict,
44
- columns: table.columns,
45
- foreignKeys: table.foreignKeys,
46
- identityStrategy: table.identityStrategy,
47
- notSafelyUpdatable: table.notSafelyUpdatable,
48
- })),
97
+ tables: tables.map((table) =>
98
+ serializeGraphTable(table, resolveShadowOwnerTableName(table, tables))
99
+ ),
49
100
  relationshipCount,
50
101
  },
51
102
  };
@@ -65,6 +116,10 @@ class StructureService {
65
116
  type: table.type,
66
117
  name: table.name,
67
118
  ddl: table.ddl,
119
+ tableKind: table.tableKind,
120
+ isVirtual: Boolean(table.isVirtual),
121
+ isShadow: Boolean(table.isShadow),
122
+ virtualModule: table.isVirtual ? extractVirtualTableModule(table.ddl) : null,
68
123
  withoutRowId: table.withoutRowId,
69
124
  strict: table.strict,
70
125
  columns: table.columns,
@@ -763,12 +763,18 @@ function analyzeTable(db, tableName) {
763
763
  score: calculateScore(issues),
764
764
  issueCount: issues.length,
765
765
  rowCount: Number(tableDetail.rowCount ?? 0),
766
+ tableKind: tableDetail.tableKind,
767
+ isVirtual: Boolean(tableDetail.isVirtual),
768
+ isShadow: Boolean(tableDetail.isShadow),
766
769
  issues,
767
770
  columnProfiles,
768
771
  table: {
769
772
  columnCount: (tableDetail.columns ?? []).filter((column) => column.visible && !column.generated).length,
770
773
  indexCount: (tableDetail.indexes ?? []).length,
771
774
  foreignKeyCount: (tableDetail.foreignKeys ?? []).length,
775
+ tableKind: tableDetail.tableKind,
776
+ isVirtual: Boolean(tableDetail.isVirtual),
777
+ isShadow: Boolean(tableDetail.isShadow),
772
778
  },
773
779
  analyzedAt: new Date().toISOString(),
774
780
  };