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
@@ -40,10 +40,11 @@ const CONNECTION_LOGO_DIRECTORY = "db_logos";
40
40
  const LEGACY_STATE_FILENAME = "app-state.json";
41
41
  const STATE_DATABASE_FILENAME = "sqlite-hub-state.db";
42
42
  const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
43
+ const MAX_CONNECTION_TAG_NAME_LENGTH = 40;
43
44
  const MAX_DOCUMENT_CONTENT_BYTES = 5 * 1024 * 1024;
44
45
  const MAX_DOCUMENT_FILENAME_LENGTH = 160;
45
46
  const QUERY_EXECUTION_SOURCES = new Set(["api", "cli", "user", "mcp"]);
46
- const ACCESS_LOG_SOURCES = new Set(["api", "cli"]);
47
+ const ACCESS_LOG_SOURCES = new Set(["api", "cli", "user"]);
47
48
  const ACCESS_LOG_STATUSES = new Set(["success", "error"]);
48
49
  const MAX_ACCESS_LOG_TEXT_LENGTH = 500;
49
50
  const MAX_ACCESS_LOG_METADATA_BYTES = 16 * 1024;
@@ -77,6 +78,32 @@ function normalizeDocumentId(documentId) {
77
78
  return normalizedDocumentId;
78
79
  }
79
80
 
81
+ function normalizeDocumentFolderId(folderId) {
82
+ const normalizedFolderId = String(folderId ?? "").trim();
83
+
84
+ return normalizedFolderId || null;
85
+ }
86
+
87
+ function normalizeDocumentFolderName(name) {
88
+ const normalizedName = String(name ?? "")
89
+ .trim()
90
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
91
+ .replace(/[\\/]+/g, " ")
92
+ .replace(/\s+/g, " ")
93
+ .replace(/^\.+/, "")
94
+ .trim();
95
+
96
+ if (!normalizedName) {
97
+ throw new ValidationError("Folder name is required.");
98
+ }
99
+
100
+ if (normalizedName.length > 80) {
101
+ throw new ValidationError("Folder name must not exceed 80 characters.");
102
+ }
103
+
104
+ return normalizedName;
105
+ }
106
+
80
107
  function splitMarkdownFilename(filename) {
81
108
  const normalizedFilename = String(filename ?? "");
82
109
  const extensionMatch = normalizedFilename.match(/\.md$/i);
@@ -251,6 +278,44 @@ const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
251
278
  ".png": "png",
252
279
  ".webp": "webp",
253
280
  };
281
+
282
+ function normalizeConnectionTagName(value) {
283
+ const normalizedName = String(value ?? "")
284
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
285
+ .trim();
286
+
287
+ if (!normalizedName) {
288
+ throw new ValidationError("Tag name is required.");
289
+ }
290
+
291
+ if (normalizedName.length > MAX_CONNECTION_TAG_NAME_LENGTH) {
292
+ throw new ValidationError(
293
+ `Tag name must be ${MAX_CONNECTION_TAG_NAME_LENGTH} characters or fewer.`
294
+ );
295
+ }
296
+
297
+ return normalizedName;
298
+ }
299
+
300
+ function normalizeConnectionTagNames(values = []) {
301
+ if (!Array.isArray(values)) {
302
+ return [];
303
+ }
304
+
305
+ const namesByKey = new Map();
306
+
307
+ for (const value of values) {
308
+ const name = normalizeConnectionTagName(value);
309
+ const key = name.toLowerCase();
310
+
311
+ if (!namesByKey.has(key)) {
312
+ namesByKey.set(key, name);
313
+ }
314
+ }
315
+
316
+ return [...namesByKey.values()];
317
+ }
318
+
254
319
  const MEDIA_TAGGING_CONFIG_FIELDS = [
255
320
  {
256
321
  column: "tag_table",
@@ -464,6 +529,29 @@ class AppStateStore {
464
529
  CREATE INDEX IF NOT EXISTS idx_recent_connections_last_opened
465
530
  ON recent_connections(lastOpenedAt DESC, id ASC);
466
531
 
532
+ CREATE TABLE IF NOT EXISTS tags (
533
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
534
+ name TEXT NOT NULL COLLATE NOCASE UNIQUE,
535
+ createdAt TEXT NOT NULL,
536
+ updatedAt TEXT NOT NULL
537
+ );
538
+
539
+ CREATE TABLE IF NOT EXISTS connection_tags (
540
+ connectionId TEXT NOT NULL,
541
+ tagId INTEGER NOT NULL,
542
+ PRIMARY KEY (connectionId, tagId),
543
+ FOREIGN KEY (connectionId)
544
+ REFERENCES recent_connections(id)
545
+ ON UPDATE CASCADE
546
+ ON DELETE CASCADE,
547
+ FOREIGN KEY (tagId)
548
+ REFERENCES tags(id)
549
+ ON DELETE CASCADE
550
+ );
551
+
552
+ CREATE INDEX IF NOT EXISTS idx_connection_tags_tag
553
+ ON connection_tags(tagId, connectionId);
554
+
467
555
  CREATE TABLE IF NOT EXISTS query_history (
468
556
  id INTEGER PRIMARY KEY AUTOINCREMENT,
469
557
  database_key TEXT NOT NULL,
@@ -559,6 +647,7 @@ class AppStateStore {
559
647
  CREATE TABLE IF NOT EXISTS database_documents (
560
648
  id TEXT PRIMARY KEY,
561
649
  database_key TEXT NOT NULL,
650
+ folder_id TEXT,
562
651
  title TEXT NOT NULL,
563
652
  filename TEXT NOT NULL,
564
653
  content TEXT NOT NULL DEFAULT '',
@@ -570,6 +659,18 @@ class AppStateStore {
570
659
  CREATE INDEX IF NOT EXISTS idx_database_documents_database_updated
571
660
  ON database_documents(database_key, updated_at DESC, id ASC);
572
661
 
662
+ CREATE TABLE IF NOT EXISTS database_document_folders (
663
+ id TEXT PRIMARY KEY,
664
+ database_key TEXT NOT NULL,
665
+ name TEXT NOT NULL,
666
+ created_at TEXT NOT NULL,
667
+ updated_at TEXT NOT NULL,
668
+ UNIQUE(database_key, name)
669
+ );
670
+
671
+ CREATE INDEX IF NOT EXISTS idx_database_document_folders_database_name
672
+ ON database_document_folders(database_key, name COLLATE NOCASE, id ASC);
673
+
573
674
  CREATE TABLE IF NOT EXISTS api_tokens (
574
675
  id TEXT PRIMARY KEY,
575
676
  database_key TEXT NOT NULL,
@@ -585,7 +686,7 @@ class AppStateStore {
585
686
 
586
687
  CREATE TABLE IF NOT EXISTS access_log (
587
688
  id INTEGER PRIMARY KEY AUTOINCREMENT,
588
- source TEXT NOT NULL CHECK(source IN ('api', 'cli')),
689
+ source TEXT NOT NULL CHECK(source IN ('api', 'cli', 'user')),
589
690
  action TEXT NOT NULL,
590
691
  database_key TEXT,
591
692
  target_type TEXT,
@@ -665,9 +766,113 @@ class AppStateStore {
665
766
  }
666
767
 
667
768
  this.ensureQueryRunsSchema();
769
+ this.ensureAccessLogSchema();
770
+ this.ensureDatabaseDocumentsSchema();
668
771
  this.ensureMediaTaggingConfigSchema();
669
772
  }
670
773
 
774
+ ensureDatabaseDocumentsSchema() {
775
+ const columns = new Set(
776
+ this.db
777
+ .prepare("PRAGMA table_info(database_documents)")
778
+ .all()
779
+ .map((column) => column.name)
780
+ );
781
+
782
+ if (!columns.has("folder_id")) {
783
+ this.db.exec("ALTER TABLE database_documents ADD COLUMN folder_id TEXT");
784
+ }
785
+
786
+ this.db.exec(`
787
+ CREATE INDEX IF NOT EXISTS idx_database_documents_folder_updated
788
+ ON database_documents(database_key, folder_id, updated_at DESC, id ASC);
789
+
790
+ CREATE TABLE IF NOT EXISTS database_document_folders (
791
+ id TEXT PRIMARY KEY,
792
+ database_key TEXT NOT NULL,
793
+ name TEXT NOT NULL,
794
+ created_at TEXT NOT NULL,
795
+ updated_at TEXT NOT NULL,
796
+ UNIQUE(database_key, name)
797
+ );
798
+
799
+ CREATE INDEX IF NOT EXISTS idx_database_document_folders_database_name
800
+ ON database_document_folders(database_key, name COLLATE NOCASE, id ASC);
801
+ `);
802
+ }
803
+
804
+ ensureAccessLogSchema() {
805
+ const row = this.db
806
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_log'")
807
+ .get();
808
+ const tableSql = String(row?.sql ?? "");
809
+
810
+ if (tableSql.includes("'user'")) {
811
+ return;
812
+ }
813
+
814
+ this.db.exec(`
815
+ DROP TABLE IF EXISTS access_log_source_migration;
816
+ DROP INDEX IF EXISTS idx_access_log_started;
817
+ DROP INDEX IF EXISTS idx_access_log_source_started;
818
+ DROP INDEX IF EXISTS idx_access_log_database_started;
819
+
820
+ ALTER TABLE access_log RENAME TO access_log_source_migration;
821
+
822
+ CREATE TABLE access_log (
823
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
824
+ source TEXT NOT NULL CHECK(source IN ('api', 'cli', 'user')),
825
+ action TEXT NOT NULL,
826
+ database_key TEXT,
827
+ target_type TEXT,
828
+ target_name TEXT,
829
+ status TEXT NOT NULL CHECK(status IN ('success', 'error')),
830
+ started_at TEXT NOT NULL,
831
+ duration_ms INTEGER,
832
+ error_message TEXT,
833
+ metadata_json TEXT NOT NULL DEFAULT '{}'
834
+ );
835
+
836
+ INSERT INTO access_log (
837
+ id,
838
+ source,
839
+ action,
840
+ database_key,
841
+ target_type,
842
+ target_name,
843
+ status,
844
+ started_at,
845
+ duration_ms,
846
+ error_message,
847
+ metadata_json
848
+ )
849
+ SELECT
850
+ id,
851
+ source,
852
+ action,
853
+ database_key,
854
+ target_type,
855
+ target_name,
856
+ status,
857
+ started_at,
858
+ duration_ms,
859
+ error_message,
860
+ metadata_json
861
+ FROM access_log_source_migration;
862
+
863
+ DROP TABLE access_log_source_migration;
864
+
865
+ CREATE INDEX IF NOT EXISTS idx_access_log_started
866
+ ON access_log(started_at DESC, id DESC);
867
+
868
+ CREATE INDEX IF NOT EXISTS idx_access_log_source_started
869
+ ON access_log(source, started_at DESC, id DESC);
870
+
871
+ CREATE INDEX IF NOT EXISTS idx_access_log_database_started
872
+ ON access_log(database_key, started_at DESC, id DESC);
873
+ `);
874
+ }
875
+
671
876
  ensureQueryRunsSchema() {
672
877
  const queryRunColumns = new Set(
673
878
  this.db
@@ -2182,7 +2387,7 @@ class AppStateStore {
2182
2387
  }
2183
2388
 
2184
2389
  if (actor) {
2185
- if (actor !== "api" && actor !== "cli") {
2390
+ if (actor !== "api" && actor !== "cli" && actor !== "user") {
2186
2391
  return {
2187
2392
  whereSql: "WHERE 1 = 0",
2188
2393
  params: [],
@@ -2347,6 +2552,16 @@ class AppStateStore {
2347
2552
  };
2348
2553
  }
2349
2554
 
2555
+ getLatestActivityLogTimestamp(databaseKey) {
2556
+ const result = this.listActivityLogs({
2557
+ databaseKey,
2558
+ limit: 1,
2559
+ offset: 0,
2560
+ });
2561
+
2562
+ return result.items[0]?.occurredAt ?? null;
2563
+ }
2564
+
2350
2565
  updateQueryHistoryField(historyId, fieldName, value, databaseKey) {
2351
2566
  const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
2352
2567
 
@@ -2917,8 +3132,142 @@ class AppStateStore {
2917
3132
  });
2918
3133
  }
2919
3134
 
2920
- getRecentConnections() {
3135
+ decorateConnectionTag(row = {}) {
3136
+ return {
3137
+ id: Number(row.id),
3138
+ name: String(row.name ?? ""),
3139
+ createdAt: row.createdAt ?? null,
3140
+ updatedAt: row.updatedAt ?? null,
3141
+ ...(row.connectionCount === undefined
3142
+ ? {}
3143
+ : { connectionCount: Number(row.connectionCount ?? 0) }),
3144
+ };
3145
+ }
3146
+
3147
+ getConnectionTagsByConnectionIds(connectionIds = []) {
3148
+ const ids = connectionIds
3149
+ .map((id) => String(id ?? "").trim())
3150
+ .filter(Boolean)
3151
+ .filter((id, index, values) => values.indexOf(id) === index);
3152
+
3153
+ if (!ids.length) {
3154
+ return new Map();
3155
+ }
3156
+
3157
+ const placeholders = ids.map(() => "?").join(", ");
3158
+ const rows = this.db
3159
+ .prepare(
3160
+ `
3161
+ SELECT
3162
+ ct.connectionId,
3163
+ t.id,
3164
+ t.name,
3165
+ t.createdAt,
3166
+ t.updatedAt
3167
+ FROM connection_tags ct
3168
+ INNER JOIN tags t
3169
+ ON t.id = ct.tagId
3170
+ WHERE ct.connectionId IN (${placeholders})
3171
+ ORDER BY t.name COLLATE NOCASE ASC, t.id ASC
3172
+ `
3173
+ )
3174
+ .all(...ids);
3175
+ const tagsByConnectionId = new Map(ids.map((id) => [id, []]));
3176
+
3177
+ for (const row of rows) {
3178
+ tagsByConnectionId
3179
+ .get(row.connectionId)
3180
+ ?.push(this.decorateConnectionTag(row));
3181
+ }
3182
+
3183
+ return tagsByConnectionId;
3184
+ }
3185
+
3186
+ getConnectionTags(connectionId) {
3187
+ const normalizedConnectionId = String(connectionId ?? "").trim();
3188
+
3189
+ if (!normalizedConnectionId) {
3190
+ return [];
3191
+ }
3192
+
3193
+ return this.getConnectionTagsByConnectionIds([normalizedConnectionId]).get(
3194
+ normalizedConnectionId
3195
+ ) ?? [];
3196
+ }
3197
+
3198
+ listConnectionTags() {
2921
3199
  return this.db
3200
+ .prepare(
3201
+ `
3202
+ SELECT
3203
+ t.id,
3204
+ t.name,
3205
+ t.createdAt,
3206
+ t.updatedAt,
3207
+ COUNT(ct.connectionId) AS connectionCount
3208
+ FROM tags t
3209
+ LEFT JOIN connection_tags ct
3210
+ ON ct.tagId = t.id
3211
+ GROUP BY t.id
3212
+ ORDER BY t.name COLLATE NOCASE ASC, t.id ASC
3213
+ `
3214
+ )
3215
+ .all()
3216
+ .map((row) => this.decorateConnectionTag(row));
3217
+ }
3218
+
3219
+ getConnectionTagById(tagId) {
3220
+ const row = this.db
3221
+ .prepare(
3222
+ `
3223
+ SELECT id, name, createdAt, updatedAt
3224
+ FROM tags
3225
+ WHERE id = ?
3226
+ `
3227
+ )
3228
+ .get(Number(tagId));
3229
+
3230
+ return row ? this.decorateConnectionTag(row) : null;
3231
+ }
3232
+
3233
+ getConnectionTagByName(name) {
3234
+ const normalizedName = normalizeConnectionTagName(name);
3235
+ const row = this.db
3236
+ .prepare(
3237
+ `
3238
+ SELECT id, name, createdAt, updatedAt
3239
+ FROM tags
3240
+ WHERE name = ? COLLATE NOCASE
3241
+ `
3242
+ )
3243
+ .get(normalizedName);
3244
+
3245
+ return row ? this.decorateConnectionTag(row) : null;
3246
+ }
3247
+
3248
+ getOrCreateConnectionTag(name) {
3249
+ const normalizedName = normalizeConnectionTagName(name);
3250
+ const existingTag = this.getConnectionTagByName(normalizedName);
3251
+
3252
+ if (existingTag) {
3253
+ return existingTag;
3254
+ }
3255
+
3256
+ const timestamp = new Date().toISOString();
3257
+ const result = this.db
3258
+ .prepare(
3259
+ `
3260
+ INSERT INTO tags (name, createdAt, updatedAt)
3261
+ VALUES (?, ?, ?)
3262
+ `
3263
+ )
3264
+ .run(normalizedName, timestamp, timestamp);
3265
+
3266
+ return this.getConnectionTagById(result.lastInsertRowid);
3267
+ }
3268
+
3269
+ getRecentConnections() {
3270
+ const rows = this.db
2922
3271
  .prepare(`
2923
3272
  SELECT
2924
3273
  id,
@@ -2932,8 +3281,108 @@ class AppStateStore {
2932
3281
  FROM recent_connections
2933
3282
  ORDER BY lastOpenedAt DESC, id ASC
2934
3283
  `)
2935
- .all()
2936
- .map((connection) => this.decorateConnection(connection));
3284
+ .all();
3285
+ const tagsByConnectionId = this.getConnectionTagsByConnectionIds(
3286
+ rows.map((connection) => connection.id)
3287
+ );
3288
+
3289
+ return rows.map((connection) =>
3290
+ this.decorateConnection(
3291
+ connection,
3292
+ tagsByConnectionId.get(connection.id) ?? []
3293
+ )
3294
+ );
3295
+ }
3296
+
3297
+ getRecentConnection(id) {
3298
+ return (
3299
+ this.getRecentConnections().find(
3300
+ (connection) => connection.id === String(id ?? "").trim()
3301
+ ) ?? null
3302
+ );
3303
+ }
3304
+
3305
+ assertRecentConnectionExists(id) {
3306
+ const normalizedConnectionId = String(id ?? "").trim();
3307
+
3308
+ if (!normalizedConnectionId) {
3309
+ throw new ValidationError("Connection id is required.");
3310
+ }
3311
+
3312
+ const row = this.db
3313
+ .prepare("SELECT id FROM recent_connections WHERE id = ?")
3314
+ .get(normalizedConnectionId);
3315
+
3316
+ if (!row) {
3317
+ throw new NotFoundError(`Recent connection not found: ${normalizedConnectionId}`);
3318
+ }
3319
+
3320
+ return normalizedConnectionId;
3321
+ }
3322
+
3323
+ setConnectionTags(connectionId, tagNames = []) {
3324
+ const normalizedConnectionId = this.assertRecentConnectionExists(connectionId);
3325
+ const normalizedTagNames = normalizeConnectionTagNames(tagNames);
3326
+
3327
+ this.db.transaction(() => {
3328
+ this.db
3329
+ .prepare("DELETE FROM connection_tags WHERE connectionId = ?")
3330
+ .run(normalizedConnectionId);
3331
+
3332
+ for (const tagName of normalizedTagNames) {
3333
+ const tag = this.getOrCreateConnectionTag(tagName);
3334
+
3335
+ this.db
3336
+ .prepare(
3337
+ `
3338
+ INSERT OR IGNORE INTO connection_tags (connectionId, tagId)
3339
+ VALUES (?, ?)
3340
+ `
3341
+ )
3342
+ .run(normalizedConnectionId, tag.id);
3343
+ }
3344
+ })();
3345
+
3346
+ return this.getConnectionTags(normalizedConnectionId);
3347
+ }
3348
+
3349
+ addConnectionTag(connectionId, tagName) {
3350
+ const normalizedConnectionId = this.assertRecentConnectionExists(connectionId);
3351
+ const tag = this.getOrCreateConnectionTag(tagName);
3352
+
3353
+ this.db
3354
+ .prepare(
3355
+ `
3356
+ INSERT OR IGNORE INTO connection_tags (connectionId, tagId)
3357
+ VALUES (?, ?)
3358
+ `
3359
+ )
3360
+ .run(normalizedConnectionId, tag.id);
3361
+
3362
+ return this.getConnectionTags(normalizedConnectionId);
3363
+ }
3364
+
3365
+ removeConnectionTag(connectionId, tagId) {
3366
+ const normalizedConnectionId = this.assertRecentConnectionExists(connectionId);
3367
+
3368
+ this.db
3369
+ .prepare(
3370
+ `
3371
+ DELETE FROM connection_tags
3372
+ WHERE connectionId = ? AND tagId = ?
3373
+ `
3374
+ )
3375
+ .run(normalizedConnectionId, Number(tagId));
3376
+
3377
+ return this.getConnectionTags(normalizedConnectionId);
3378
+ }
3379
+
3380
+ deleteConnectionTag(tagId) {
3381
+ const result = this.db
3382
+ .prepare("DELETE FROM tags WHERE id = ?")
3383
+ .run(Number(tagId));
3384
+
3385
+ return result.changes > 0;
2937
3386
  }
2938
3387
 
2939
3388
  upsertRecentConnection(connection, options = {}) {
@@ -3121,7 +3570,7 @@ class AppStateStore {
3121
3570
  );
3122
3571
  }
3123
3572
 
3124
- decorateConnection(connection = {}) {
3573
+ decorateConnection(connection = {}, tags = []) {
3125
3574
  const logoPath = this.normalizeLogoPath(connection.logoPath);
3126
3575
 
3127
3576
  return {
@@ -3129,6 +3578,7 @@ class AppStateStore {
3129
3578
  readOnly: Boolean(connection.readOnly),
3130
3579
  logoPath,
3131
3580
  logoUrl: this.getConnectionLogoUrl(logoPath),
3581
+ tags: Array.isArray(tags) ? tags : [],
3132
3582
  };
3133
3583
  }
3134
3584
 
@@ -3395,6 +3845,7 @@ class AppStateStore {
3395
3845
  return {
3396
3846
  id: String(row.id ?? ""),
3397
3847
  databaseKey: row.database_key ?? row.databaseKey ?? "",
3848
+ folderId: row.folder_id ?? row.folderId ?? null,
3398
3849
  title: String(row.title ?? ""),
3399
3850
  filename: String(row.filename ?? ""),
3400
3851
  content: row.content === undefined ? undefined : String(row.content ?? ""),
@@ -3404,6 +3855,115 @@ class AppStateStore {
3404
3855
  };
3405
3856
  }
3406
3857
 
3858
+ decorateDatabaseDocumentFolderRow(row = {}) {
3859
+ return {
3860
+ id: String(row.id ?? ""),
3861
+ databaseKey: row.database_key ?? row.databaseKey ?? "",
3862
+ name: String(row.name ?? ""),
3863
+ createdAt: row.created_at ?? row.createdAt ?? null,
3864
+ updatedAt: row.updated_at ?? row.updatedAt ?? null,
3865
+ };
3866
+ }
3867
+
3868
+ listDatabaseDocumentFolders(databaseKey) {
3869
+ const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
3870
+
3871
+ return this.db
3872
+ .prepare(
3873
+ `
3874
+ SELECT
3875
+ id,
3876
+ database_key,
3877
+ name,
3878
+ created_at,
3879
+ updated_at
3880
+ FROM database_document_folders
3881
+ WHERE database_key = ?
3882
+ ORDER BY name COLLATE NOCASE ASC, id ASC
3883
+ `
3884
+ )
3885
+ .all(normalizedDatabaseKey)
3886
+ .map((row) => this.decorateDatabaseDocumentFolderRow(row));
3887
+ }
3888
+
3889
+ getDatabaseDocumentFolder(databaseKey, folderId) {
3890
+ const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
3891
+ const normalizedFolderId = normalizeDocumentFolderId(folderId);
3892
+
3893
+ if (!normalizedFolderId) {
3894
+ return null;
3895
+ }
3896
+
3897
+ const row = this.db
3898
+ .prepare(
3899
+ `
3900
+ SELECT
3901
+ id,
3902
+ database_key,
3903
+ name,
3904
+ created_at,
3905
+ updated_at
3906
+ FROM database_document_folders
3907
+ WHERE database_key = ?
3908
+ AND id = ?
3909
+ `
3910
+ )
3911
+ .get(normalizedDatabaseKey, normalizedFolderId);
3912
+
3913
+ if (!row) {
3914
+ throw new NotFoundError("Document folder was not found.");
3915
+ }
3916
+
3917
+ return this.decorateDatabaseDocumentFolderRow(row);
3918
+ }
3919
+
3920
+ createDatabaseDocumentFolder(databaseKey, folder = {}) {
3921
+ const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
3922
+ const name = normalizeDocumentFolderName(folder.name);
3923
+ const now = new Date().toISOString();
3924
+ const id = crypto.randomUUID();
3925
+ const existingFolder = this.db
3926
+ .prepare(
3927
+ `
3928
+ SELECT id
3929
+ FROM database_document_folders
3930
+ WHERE database_key = ?
3931
+ AND name = ? COLLATE NOCASE
3932
+ LIMIT 1
3933
+ `
3934
+ )
3935
+ .get(normalizedDatabaseKey, name);
3936
+
3937
+ if (existingFolder) {
3938
+ throw new ConflictError("A folder with this name already exists.");
3939
+ }
3940
+
3941
+ try {
3942
+ this.db
3943
+ .prepare(
3944
+ `
3945
+ INSERT INTO database_document_folders (
3946
+ id,
3947
+ database_key,
3948
+ name,
3949
+ created_at,
3950
+ updated_at
3951
+ )
3952
+ VALUES (?, ?, ?, ?, ?)
3953
+ `
3954
+ )
3955
+ .run(id, normalizedDatabaseKey, name, now, now);
3956
+ } catch (error) {
3957
+ if (error?.code === "SQLITE_CONSTRAINT_UNIQUE") {
3958
+ throw new ConflictError("A folder with this name already exists.");
3959
+ }
3960
+
3961
+ throw error;
3962
+ }
3963
+
3964
+ return this.getDatabaseDocumentFolder(normalizedDatabaseKey, id);
3965
+ }
3966
+
3407
3967
  documentFilenameExists(databaseKey, filename, ignoredDocumentId = null) {
3408
3968
  const row = this.db
3409
3969
  .prepare(
@@ -3452,6 +4012,7 @@ class AppStateStore {
3452
4012
  SELECT
3453
4013
  id,
3454
4014
  database_key,
4015
+ folder_id,
3455
4016
  title,
3456
4017
  filename,
3457
4018
  LENGTH(content) AS content_length,
@@ -3475,6 +4036,7 @@ class AppStateStore {
3475
4036
  SELECT
3476
4037
  id,
3477
4038
  database_key,
4039
+ folder_id,
3478
4040
  title,
3479
4041
  filename,
3480
4042
  content,
@@ -3501,6 +4063,11 @@ class AppStateStore {
3501
4063
  normalizedDatabaseKey,
3502
4064
  normalizeDocumentFilename(document.filename)
3503
4065
  );
4066
+ const folderId = normalizeDocumentFolderId(document.folderId);
4067
+ if (folderId) {
4068
+ this.getDatabaseDocumentFolder(normalizedDatabaseKey, folderId);
4069
+ }
4070
+
3504
4071
  const title = normalizeDocumentTitle(document.title, filename);
3505
4072
  const content = normalizeDocumentContent(document.content);
3506
4073
  const now = new Date().toISOString();
@@ -3512,16 +4079,17 @@ class AppStateStore {
3512
4079
  INSERT INTO database_documents (
3513
4080
  id,
3514
4081
  database_key,
4082
+ folder_id,
3515
4083
  title,
3516
4084
  filename,
3517
4085
  content,
3518
4086
  created_at,
3519
4087
  updated_at
3520
4088
  )
3521
- VALUES (?, ?, ?, ?, ?, ?, ?)
4089
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3522
4090
  `
3523
4091
  )
3524
- .run(id, normalizedDatabaseKey, title, filename, content, now, now);
4092
+ .run(id, normalizedDatabaseKey, folderId, title, filename, content, now, now);
3525
4093
 
3526
4094
  return this.getDatabaseDocument(normalizedDatabaseKey, id);
3527
4095
  }
@@ -3532,6 +4100,15 @@ class AppStateStore {
3532
4100
  const hasFilename = Object.prototype.hasOwnProperty.call(patch, "filename");
3533
4101
  const hasTitle = Object.prototype.hasOwnProperty.call(patch, "title");
3534
4102
  const hasContent = Object.prototype.hasOwnProperty.call(patch, "content");
4103
+ const hasFolderId = Object.prototype.hasOwnProperty.call(patch, "folderId");
4104
+ const folderId = hasFolderId
4105
+ ? normalizeDocumentFolderId(patch.folderId)
4106
+ : existingDocument.folderId;
4107
+
4108
+ if (folderId) {
4109
+ this.getDatabaseDocumentFolder(normalizedDatabaseKey, folderId);
4110
+ }
4111
+
3535
4112
  const filename = hasFilename
3536
4113
  ? this.resolveUniqueDocumentFilename(
3537
4114
  normalizedDatabaseKey,
@@ -3554,6 +4131,7 @@ class AppStateStore {
3554
4131
  `
3555
4132
  UPDATE database_documents
3556
4133
  SET
4134
+ folder_id = ?,
3557
4135
  title = ?,
3558
4136
  filename = ?,
3559
4137
  content = ?,
@@ -3562,7 +4140,7 @@ class AppStateStore {
3562
4140
  AND id = ?
3563
4141
  `
3564
4142
  )
3565
- .run(title, filename, content, updatedAt, normalizedDatabaseKey, existingDocument.id);
4143
+ .run(folderId, title, filename, content, updatedAt, normalizedDatabaseKey, existingDocument.id);
3566
4144
 
3567
4145
  return this.getDatabaseDocument(normalizedDatabaseKey, existingDocument.id);
3568
4146
  }
@@ -3696,4 +4274,6 @@ class AppStateStore {
3696
4274
  module.exports = {
3697
4275
  AppStateStore,
3698
4276
  DEFAULT_STATE,
4277
+ MAX_CONNECTION_TAG_NAME_LENGTH,
4278
+ normalizeConnectionTagName,
3699
4279
  };