@antelopejs/dms-database 0.0.13 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +30 -5
  2. package/dist/db/models/query_history.model.d.ts +1 -0
  3. package/dist/db/models/query_history.model.js +6 -0
  4. package/dist/db/models/query_history.model.js.map +1 -1
  5. package/dist/db/tables/query_history.table.d.ts +3 -0
  6. package/dist/db/tables/query_history.table.js +12 -0
  7. package/dist/db/tables/query_history.table.js.map +1 -1
  8. package/dist/db/tables/saved_queries.table.d.ts +1 -0
  9. package/dist/db/tables/saved_queries.table.js +4 -0
  10. package/dist/db/tables/saved_queries.table.js.map +1 -1
  11. package/dist/i18n/messages.d.ts +39 -0
  12. package/dist/i18n/messages.js +64 -0
  13. package/dist/i18n/messages.js.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -7
  16. package/dist/index.js.map +1 -1
  17. package/dist/module.d.ts +11 -0
  18. package/dist/module.js +55 -0
  19. package/dist/module.js.map +1 -0
  20. package/dist/pages/data.d.ts +1 -1
  21. package/dist/pages/data.js +9 -6
  22. package/dist/pages/data.js.map +1 -1
  23. package/dist/pages/diagram.d.ts +9 -0
  24. package/dist/pages/diagram.js +45 -0
  25. package/dist/pages/diagram.js.map +1 -0
  26. package/dist/pages/index.d.ts +1 -0
  27. package/dist/pages/index.js +1 -0
  28. package/dist/pages/index.js.map +1 -1
  29. package/dist/pages/overview.d.ts +4 -1
  30. package/dist/pages/overview.js +113 -6
  31. package/dist/pages/overview.js.map +1 -1
  32. package/dist/pages/query.d.ts +1 -1
  33. package/dist/pages/query.js +22 -6
  34. package/dist/pages/query.js.map +1 -1
  35. package/dist/pages/schemas.d.ts +2 -1
  36. package/dist/pages/schemas.js +143 -6
  37. package/dist/pages/schemas.js.map +1 -1
  38. package/dist/routes/data.d.ts +8 -0
  39. package/dist/routes/data.js +84 -21
  40. package/dist/routes/data.js.map +1 -1
  41. package/dist/routes/index.d.ts +1 -0
  42. package/dist/routes/index.js +1 -0
  43. package/dist/routes/index.js.map +1 -1
  44. package/dist/routes/overview.d.ts +12 -0
  45. package/dist/routes/overview.js +55 -0
  46. package/dist/routes/overview.js.map +1 -0
  47. package/dist/routes/query.d.ts +5 -1
  48. package/dist/routes/query.js +162 -27
  49. package/dist/routes/query.js.map +1 -1
  50. package/dist/routes/tables.d.ts +15 -12
  51. package/dist/routes/tables.js +95 -55
  52. package/dist/routes/tables.js.map +1 -1
  53. package/dist/service/catalog.d.ts +3 -0
  54. package/dist/service/catalog.js +26 -0
  55. package/dist/service/catalog.js.map +1 -0
  56. package/dist/service/health.d.ts +3 -3
  57. package/dist/service/health.js +17 -14
  58. package/dist/service/health.js.map +1 -1
  59. package/dist/service/introspect.d.ts +3 -1
  60. package/dist/service/introspect.js +131 -43
  61. package/dist/service/introspect.js.map +1 -1
  62. package/dist/service/overview.d.ts +9 -0
  63. package/dist/service/overview.js +113 -0
  64. package/dist/service/overview.js.map +1 -0
  65. package/dist/service/queryInspection.d.ts +15 -0
  66. package/dist/service/queryInspection.js +107 -0
  67. package/dist/service/queryInspection.js.map +1 -0
  68. package/dist/service/references.d.ts +9 -0
  69. package/dist/service/references.js +52 -0
  70. package/dist/service/references.js.map +1 -0
  71. package/dist/service/schemaFilters.d.ts +26 -0
  72. package/dist/service/schemaFilters.js +42 -0
  73. package/dist/service/schemaFilters.js.map +1 -0
  74. package/dist/service/stagedSerialization.d.ts +2 -0
  75. package/dist/service/stagedSerialization.js +38 -10
  76. package/dist/service/stagedSerialization.js.map +1 -1
  77. package/dist/service/types.d.ts +11 -1
  78. package/dist/types/constants.d.ts +2 -0
  79. package/dist/types/constants.js +3 -1
  80. package/dist/types/constants.js.map +1 -1
  81. package/dist/utils/query.d.ts +0 -2
  82. package/dist/utils/query.js +0 -20
  83. package/dist/utils/query.js.map +1 -1
  84. package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
  85. package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
  86. package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
  87. package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
  88. package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
  89. package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
  90. package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
  91. package/frontend-vue/app/build/data/cellValues.ts +132 -0
  92. package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
  93. package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
  94. package/frontend-vue/app/build/data/recentTables.ts +48 -0
  95. package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
  96. package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
  97. package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
  98. package/frontend-vue/app/build/diagram/autosave.ts +72 -0
  99. package/frontend-vue/app/build/diagram/graph.ts +585 -0
  100. package/frontend-vue/app/build/diagram/history.ts +178 -0
  101. package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
  102. package/frontend-vue/app/build/diagram/sync.ts +137 -0
  103. package/frontend-vue/app/build/query/completion.ts +100 -0
  104. package/frontend-vue/app/build/query/csv.ts +32 -0
  105. package/frontend-vue/app/build/query/examples.ts +38 -0
  106. package/frontend-vue/app/build/query/runs.ts +168 -0
  107. package/frontend-vue/app/build/query/tableColumns.ts +35 -0
  108. package/frontend-vue/app/build/query/target.ts +143 -0
  109. package/frontend-vue/app/build/schemas/filterState.ts +128 -0
  110. package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
  111. package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
  112. package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
  113. package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
  114. package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
  115. package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
  116. package/frontend-vue/app/components/DataBrowser.vue +126 -0
  117. package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
  118. package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
  119. package/frontend-vue/app/components/DataFilterBar.vue +163 -0
  120. package/frontend-vue/app/components/DataGrid.vue +737 -499
  121. package/frontend-vue/app/components/DataGridCell.vue +125 -77
  122. package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
  123. package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
  124. package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
  125. package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
  126. package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
  127. package/frontend-vue/app/components/InstancePicker.vue +195 -0
  128. package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
  129. package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
  130. package/frontend-vue/app/components/QueryConsole.vue +726 -0
  131. package/frontend-vue/app/components/QueryLibrary.vue +314 -0
  132. package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
  133. package/frontend-vue/app/components/RowDrawer.vue +520 -0
  134. package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
  135. package/frontend-vue/app/components/SchemaPicker.vue +86 -0
  136. package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
  137. package/frontend-vue/app/components/TableInspector.vue +741 -0
  138. package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
  139. package/frontend-vue/dms.frontend.ts +5 -14
  140. package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
  141. package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
  142. package/frontend-vue/package.json +2 -1
  143. package/frontend-vue/pnpm-lock.yaml +2601 -858
  144. package/frontend-vue/pnpm-workspace.yaml +1 -1
  145. package/frontend-vue/tests/dataBrowser.test.ts +206 -0
  146. package/frontend-vue/tests/diagram.test.ts +516 -0
  147. package/frontend-vue/tests/helpers.test.ts +205 -0
  148. package/frontend-vue/tests/instanceOptions.test.ts +53 -0
  149. package/frontend-vue/tests/locales.test.ts +56 -0
  150. package/frontend-vue/tests/persistence.test.ts +106 -5
  151. package/frontend-vue/tests/pickerMenus.test.ts +254 -0
  152. package/frontend-vue/tests/queryConsole.test.ts +282 -0
  153. package/frontend-vue/tests/queryRuntime.test.ts +2 -2
  154. package/frontend-vue/tests/registration.test.ts +36 -25
  155. package/frontend-vue/tests/schemasFilters.test.ts +115 -0
  156. package/package.json +9 -6
  157. package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
  158. package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
  159. package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
  160. package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
  161. package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
  162. package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
  163. package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
  164. package/frontend-vue/app/composables/useQueryStore.ts +0 -117
  165. package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
  166. package/frontend-vue/app/custom-pages/database/data.vue +0 -41
  167. package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
  168. package/frontend-vue/app/custom-pages/database/query.vue +0 -436
  169. package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
  170. package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
  171. package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
  172. package/frontend-vue/app/utils/dmsTableView.ts +0 -83
  173. package/frontend-vue/app/utils/relativeTime.ts +0 -18
@@ -0,0 +1,726 @@
1
+ <script setup lang="ts">
2
+ import { useQueryStore } from '../build/composables/useQueryStore'
3
+ import { useDatabaseSchemas } from '../build/composables/useDatabaseSchemas'
4
+ import { useSessionStorage } from '@vueuse/core'
5
+ import { h } from 'vue'
6
+ import { replaceName, type UnknownKind } from '../build/query/runs'
7
+ import {
8
+ describeTarget,
9
+ instanceArgument,
10
+ readQueryTarget,
11
+ targetFromSource,
12
+ withInstance,
13
+ type QueryTarget,
14
+ } from '../build/query/target'
15
+ import {
16
+ prepareQueryPayload,
17
+ QueryInputError,
18
+ } from '../build/composables/useQueryRuntime'
19
+ import type {
20
+ DryRunResult,
21
+ HistoryEntry,
22
+ SavedQuery,
23
+ } from '../build/composables/useQueryStore'
24
+ import { schemaAccess } from '../build/utils/databaseLinks'
25
+ import QueryBuilderPanel from './QueryBuilderPanel.vue'
26
+ import QueryLibrary from './QueryLibrary.vue'
27
+ import QueryResultPanel, {
28
+ type InstanceRows,
29
+ type QueryFailure,
30
+ } from './QueryResultPanel.vue'
31
+
32
+ // The query console: the library rail, the editor and the result. A query
33
+ // that changes data runs only after a dry run told what it touches (D-03):
34
+ // deletes and multi-row writes ask for the table name; a single-row update
35
+ // can be let through without asking until the browser tab is closed.
36
+
37
+ const BROWSE_COUNT = '/api/database/browse/count'
38
+ // The query being written, kept for the browser tab: a reload, or a row link
39
+ // followed by Back, brings it back (Q-9). A link that names a query wins.
40
+ const DRAFT_KEY = 'dms-database:query:draft'
41
+ const SKIP_SINGLE_UPDATES_KEY = 'dms-database:query:skip-single-row-updates'
42
+ const PREFILL_ROWS = 50
43
+ const OPERATION_ICONS: Record<string, string> = {
44
+ insert: 'i-ph-plus-circle',
45
+ update: 'i-ph-pencil-simple',
46
+ replace: 'i-ph-swap',
47
+ delete: 'i-ph-trash',
48
+ }
49
+
50
+ const { t, n } = useI18n()
51
+ const route = useDmsRoute()
52
+ const router = useDmsRouter()
53
+ const { $authFetch } = useAuthFetch()
54
+ const { confirm } = useConfirm()
55
+ const toast = useToast()
56
+ const { user } = useUserSession()
57
+ const store = useQueryStore()
58
+ const { schemas } = useDatabaseSchemas()
59
+
60
+ const source = ref('')
61
+ const executing = ref(false)
62
+ const result = ref<Awaited<ReturnType<typeof store.executeQuery>> | null>(null)
63
+ const failure = ref<QueryFailure | null>(null)
64
+ const target = ref<QueryTarget>({})
65
+ const loadedSaved = ref<SavedQuery | null>(null)
66
+ const activeHistoryId = ref<string | null>(null)
67
+ const libraryLoading = ref(true)
68
+ // Named instances holding rows of a table the last read found empty in the
69
+ // default instance.
70
+ const elsewhere = ref<InstanceRows[]>([])
71
+ const team = ref<SavedQuery[]>([])
72
+ const editor = useTemplateRef<InstanceType<typeof QueryBuilderPanel>>('editor')
73
+
74
+ const edited = computed(
75
+ () =>
76
+ Boolean(loadedSaved.value) && loadedSaved.value?.source !== source.value,
77
+ )
78
+ const ownId = computed(
79
+ () => (user.value as { _id?: string } | null)?._id ?? null,
80
+ )
81
+
82
+ // --- library ---
83
+ async function refreshHistory() {
84
+ await store.fetchHistory().catch(() => undefined)
85
+ }
86
+
87
+ async function refreshSaved() {
88
+ await store.fetchSaved('me').catch(() => undefined)
89
+ try {
90
+ const res = await $authFetch<{ items: SavedQuery[] }>(
91
+ '/api/database/query/saved',
92
+ {
93
+ query: { scope: 'shared' },
94
+ },
95
+ )
96
+ team.value = res.items.filter((query) => query.userId !== ownId.value)
97
+ } catch {
98
+ team.value = []
99
+ }
100
+ }
101
+
102
+ function openSaved(query: SavedQuery) {
103
+ loadedSaved.value = query
104
+ activeHistoryId.value = null
105
+ source.value = query.source
106
+ }
107
+
108
+ function openHistory(entry: HistoryEntry) {
109
+ loadedSaved.value = null
110
+ activeHistoryId.value = entry.id
111
+ source.value = entry.source
112
+ }
113
+
114
+ function newQuery() {
115
+ loadedSaved.value = null
116
+ activeHistoryId.value = null
117
+ source.value = ''
118
+ result.value = null
119
+ failure.value = null
120
+ nextTick(() => editor.value?.focus())
121
+ }
122
+
123
+ const draft = useSessionStorage<{ source: string; savedId: string | null }>(
124
+ DRAFT_KEY,
125
+ { source: '', savedId: null },
126
+ )
127
+ watch([source, () => loadedSaved.value?.id ?? null], ([text, savedId]) => {
128
+ draft.value = { source: text, savedId }
129
+ })
130
+
131
+ function restoreDraft() {
132
+ const { source: text, savedId } = draft.value
133
+ if (!text) return
134
+ const saved = savedId
135
+ ? store.savedQueries.value.find((query) => query.id === savedId)
136
+ : undefined
137
+ loadedSaved.value = saved ?? null
138
+ source.value = text
139
+ }
140
+
141
+ async function deleteSaved(query: SavedQuery) {
142
+ const confirmed = await confirm({
143
+ title: t('dms_database.query.delete.title', { name: query.name }),
144
+ description: query.shared
145
+ ? t('dms_database.query.delete.shared')
146
+ : undefined,
147
+ confirmLabel: t('dms_database.query.delete.confirm'),
148
+ color: 'error',
149
+ icon: 'i-ph-trash',
150
+ })
151
+ if (!confirmed) return
152
+ await store.deleteSaved(query.id).catch(() => undefined)
153
+ if (loadedSaved.value?.id === query.id) loadedSaved.value = null
154
+ await refreshSaved()
155
+ }
156
+
157
+ async function clearHistory() {
158
+ const confirmed = await confirm({
159
+ title: t('dms_database.query.clear.title'),
160
+ description: t('dms_database.query.clear.description'),
161
+ confirmLabel: t('dms_database.query.clear.confirm'),
162
+ color: 'warning',
163
+ })
164
+ if (confirmed) await store.clearHistory().catch(() => undefined)
165
+ }
166
+
167
+ // --- running ---
168
+ function fetchMessage(error: unknown): string {
169
+ const { data, message } = (error ?? {}) as {
170
+ data?: unknown
171
+ message?: unknown
172
+ }
173
+ const inner = (data as { message?: unknown } | null)?.message
174
+ if (typeof inner === 'string' && inner) return inner
175
+ if (typeof data === 'string' && data) return data
176
+ return typeof message === 'string' && message
177
+ ? message
178
+ : t('dms_database.query.result.failed_title')
179
+ }
180
+
181
+ /** How long the server ran a failed query, as its error says. */
182
+ function fetchDuration(error: unknown): number | null {
183
+ const data = (error as { data?: unknown } | null)?.data
184
+ const duration = (data as { durationMs?: unknown } | null)?.durationMs
185
+ return typeof duration === 'number' ? duration : null
186
+ }
187
+
188
+ /**
189
+ * The named instances of a table that hold rows, each with its count: asked
190
+ * only when a read found the table empty in the default instance (decision
191
+ * 11). An instance whose count fails is left out.
192
+ */
193
+ async function instancesWithRows(target: QueryTarget): Promise<InstanceRows[]> {
194
+ const instances =
195
+ schemas.value.find((schema) => schema.id === target.schema)?.instances ?? []
196
+ const counts = await Promise.all(
197
+ instances.map((id) =>
198
+ $authFetch<{ total: number }>(BROWSE_COUNT, {
199
+ query: {
200
+ filter_schema: `is:${target.schema}`,
201
+ filter_table: `is:${target.table}`,
202
+ filter_instance: `is:${id}`,
203
+ },
204
+ }).then(
205
+ (res) => ({ id, count: res.total }),
206
+ () => ({ id, count: 0 }),
207
+ ),
208
+ ),
209
+ )
210
+ return counts.filter((entry) => entry.count > 0)
211
+ }
212
+
213
+ // Each run drops the instances found for the previous one; a slow answer
214
+ // for an older run is ignored.
215
+ let runId = 0
216
+
217
+ async function findElsewhere(
218
+ answer: Awaited<ReturnType<typeof store.executeQuery>>,
219
+ next: QueryTarget,
220
+ ) {
221
+ const id = ++runId
222
+ elsewhere.value = []
223
+ if (
224
+ answer.mutation ||
225
+ answer.rows.length > 0 ||
226
+ next.instance !== '' ||
227
+ !next.schema ||
228
+ !next.table
229
+ )
230
+ return
231
+ const found = await instancesWithRows(next)
232
+ if (id === runId) elsewhere.value = found
233
+ }
234
+
235
+ // The result keeps naming the previous run's target until the new one lands.
236
+ async function execute(query: Record<string, unknown>, text: string) {
237
+ executing.value = true
238
+ const next = readQueryTarget(query)
239
+ try {
240
+ const answer = await store.executeQuery(query, text, 'aql')
241
+ failure.value = null
242
+ result.value = answer
243
+ void findElsewhere(answer, next)
244
+ } catch (error) {
245
+ result.value = null
246
+ failure.value = {
247
+ message: fetchMessage(error),
248
+ durationMs: fetchDuration(error),
249
+ }
250
+ } finally {
251
+ target.value = next
252
+ executing.value = false
253
+ activeHistoryId.value = null
254
+ await refreshHistory()
255
+ }
256
+ }
257
+
258
+ /** A run that failed before the server ran it, worded for the reader. */
259
+ function describeInputError(error: unknown): QueryFailure {
260
+ if (!(error instanceof QueryInputError))
261
+ return {
262
+ message:
263
+ error instanceof Error && error.message
264
+ ? error.message
265
+ : t('dms_database.query.result.failed_title'),
266
+ durationMs: null,
267
+ }
268
+ const { problem } = error
269
+ if (problem.kind === 'unknown_schema')
270
+ return {
271
+ message: t('dms_database.query.errors.unknown_schema', {
272
+ schema: problem.schema,
273
+ }),
274
+ durationMs: null,
275
+ unknown: { kind: 'schema', name: problem.schema },
276
+ }
277
+ const { place, detail } = problem
278
+ return {
279
+ message: place
280
+ ? t('dms_database.query.errors.syntax_at', { ...place })
281
+ : t('dms_database.query.errors.syntax', { detail }),
282
+ durationMs: null,
283
+ }
284
+ }
285
+
286
+ // A run that never reached the server is not kept in History (decision 9).
287
+ function failBeforeRun(text: string, problem: QueryFailure) {
288
+ result.value = null
289
+ elsewhere.value = []
290
+ failure.value = problem
291
+ target.value = targetFromSource(text)
292
+ activeHistoryId.value = null
293
+ }
294
+
295
+ function skipsSingleUpdates(): boolean {
296
+ return (
297
+ typeof window !== 'undefined' &&
298
+ window.sessionStorage.getItem(SKIP_SINGLE_UPDATES_KEY) === 'true'
299
+ )
300
+ }
301
+
302
+ function isSingleRowUpdate(dry: DryRunResult): boolean {
303
+ return (
304
+ (dry.operation === 'update' || dry.operation === 'replace') &&
305
+ dry.rows === 1
306
+ )
307
+ }
308
+
309
+ function guardTexts(dry: DryRunResult) {
310
+ const rows = dry.rows ?? 0
311
+ // The whole target, schema and instance included: `demo.tags @eu`.
312
+ const table = describeTarget(dry) || (dry.table ?? '')
313
+ const unknown = dry.rows === null
314
+ return {
315
+ title: unknown
316
+ ? t('dms_database.query.guard.title_unknown', { table })
317
+ : t(
318
+ `dms_database.query.guard.title_${dry.operation}`,
319
+ { table, count: n(rows) },
320
+ rows,
321
+ ),
322
+ confirmLabel: unknown
323
+ ? t('dms_database.query.guard.confirm_unknown')
324
+ : t(
325
+ `dms_database.query.guard.confirm_${dry.operation}`,
326
+ { count: n(rows) },
327
+ rows,
328
+ ),
329
+ }
330
+ }
331
+
332
+ async function guard(query: Record<string, unknown>, text: string) {
333
+ let dry: DryRunResult
334
+ try {
335
+ dry = await store.dryRun(query)
336
+ } catch (error) {
337
+ return failBeforeRun(text, {
338
+ message: fetchMessage(error),
339
+ durationMs: null,
340
+ })
341
+ }
342
+ if (dry.operation === 'read') return execute(query, text)
343
+ if (isSingleRowUpdate(dry) && skipsSingleUpdates())
344
+ return execute(query, text)
345
+ const skipNextTime = ref(false)
346
+ const typed =
347
+ dry.operation === 'delete' || dry.rows === null || (dry.rows ?? 0) > 1
348
+ const targetLabel = describeTarget(dry)
349
+ const texts = guardTexts(dry)
350
+ await confirm({
351
+ ...texts,
352
+ description: t('dms_database.query.guard.description'),
353
+ color: dry.operation === 'delete' ? 'error' : 'warning',
354
+ icon: OPERATION_ICONS[dry.operation] ?? 'i-ph-warning',
355
+ initialFocus: 'cancel',
356
+ confirmText: typed ? (dry.table ?? dry.schema) : undefined,
357
+ impact: [
358
+ {
359
+ icon: OPERATION_ICONS[dry.operation] ?? 'i-ph-lightning',
360
+ label: t('dms_database.query.guard.operation'),
361
+ count: t(`dms_database.query.guard.op_${dry.operation}`),
362
+ },
363
+ {
364
+ icon: 'i-ph-table',
365
+ label: t('dms_database.query.guard.target'),
366
+ count: targetLabel,
367
+ },
368
+ {
369
+ icon: 'i-ph-rows',
370
+ label: t('dms_database.query.guard.rows'),
371
+ count: dry.rows === null ? '?' : n(dry.rows),
372
+ },
373
+ ],
374
+ body: () =>
375
+ h('div', { class: 'grid gap-3' }, [
376
+ h(
377
+ 'pre',
378
+ {
379
+ class:
380
+ 'border-default bg-elevated/50 max-h-40 overflow-auto rounded-md border p-3 font-mono text-xs whitespace-pre-wrap [overflow-wrap:anywhere]',
381
+ },
382
+ text,
383
+ ),
384
+ isSingleRowUpdate(dry)
385
+ ? h('label', { class: 'flex items-start gap-2 text-sm' }, [
386
+ h('input', {
387
+ type: 'checkbox',
388
+ class: 'mt-1',
389
+ checked: skipNextTime.value,
390
+ onChange: (event: Event) => {
391
+ skipNextTime.value = (
392
+ event.target as HTMLInputElement
393
+ ).checked
394
+ },
395
+ }),
396
+ h('span', [
397
+ h(
398
+ 'span',
399
+ { class: 'text-toned' },
400
+ t('dms_database.query.guard.skip'),
401
+ ),
402
+ h(
403
+ 'span',
404
+ { class: 'text-dimmed block text-xs' },
405
+ t('dms_database.query.guard.skip_detail'),
406
+ ),
407
+ ]),
408
+ ])
409
+ : null,
410
+ ]),
411
+ onConfirm: async () => {
412
+ if (skipNextTime.value)
413
+ window.sessionStorage.setItem(SKIP_SINGLE_UPDATES_KEY, 'true')
414
+ await execute(query, text)
415
+ },
416
+ })
417
+ }
418
+
419
+ async function run() {
420
+ if (!source.value.trim() || executing.value) return
421
+ let prepared: ReturnType<typeof prepareQueryPayload>
422
+ try {
423
+ prepared = prepareQueryPayload(source.value, schemas.value)
424
+ } catch (error) {
425
+ return failBeforeRun(source.value.trim(), describeInputError(error))
426
+ }
427
+ if (prepared.hasMutations) await guard(prepared.query, prepared.source)
428
+ else await execute(prepared.query, prepared.source)
429
+ }
430
+
431
+ function replaceUnknown(kind: UnknownKind, from: string, to: string) {
432
+ source.value = replaceName(source.value, kind, from, to)
433
+ failure.value = null
434
+ }
435
+
436
+ // The empty read again, in a named instance that has rows.
437
+ async function runInInstance(instance: string) {
438
+ if (!target.value.schema) return
439
+ const next = withInstance(source.value, target.value.schema, instance)
440
+ if (next === source.value) return
441
+ source.value = next
442
+ await run()
443
+ }
444
+
445
+ // --- saving ---
446
+ const saveOpen = ref(false)
447
+ const saveName = ref('')
448
+ const saveDescription = ref('')
449
+ const saveShared = ref(false)
450
+ const saveError = ref<string | null>(null)
451
+ // The name says it is missing once left empty, as a DMS form's required field.
452
+ const nameTouched = ref(false)
453
+ const nameError = computed(() =>
454
+ nameTouched.value && !saveName.value.trim()
455
+ ? t('dms_database.query.save.name_required')
456
+ : null,
457
+ )
458
+ // A DMS form's rows in a modal: label column, hairline between rows.
459
+ const SAVE_ROW = { layout: 'form', inset: false, spacing: 'row' } as const
460
+ const saving = ref(false)
461
+ const editing = ref<SavedQuery | null>(null)
462
+ // A past run being saved: the editor takes its text once it is saved.
463
+ const savingRun = ref<HistoryEntry | null>(null)
464
+
465
+ function openSaveModal(from?: SavedQuery | HistoryEntry) {
466
+ const saved = from && 'name' in from ? from : null
467
+ editing.value = saved
468
+ savingRun.value = from && !saved ? (from as HistoryEntry) : null
469
+ saveName.value = saved?.name ?? ''
470
+ saveDescription.value = saved?.description ?? ''
471
+ saveShared.value = saved?.shared ?? false
472
+ saveError.value = null
473
+ nameTouched.value = false
474
+ saveOpen.value = true
475
+ }
476
+
477
+ async function persist(text: string) {
478
+ const payload = prepareQueryPayload(text, schemas.value)
479
+ const input = {
480
+ name: saveName.value.trim(),
481
+ description: saveDescription.value.trim(),
482
+ query: payload.query,
483
+ source: payload.source,
484
+ language: 'aql' as const,
485
+ shared: saveShared.value,
486
+ }
487
+ return editing.value
488
+ ? store.updateSaved(editing.value.id, input)
489
+ : store.saveQuery(input)
490
+ }
491
+
492
+ async function confirmSave() {
493
+ if (!saveName.value.trim()) {
494
+ nameTouched.value = true
495
+ return
496
+ }
497
+ saving.value = true
498
+ saveError.value = null
499
+ try {
500
+ const run = savingRun.value
501
+ const text = run
502
+ ? run.source
503
+ : editing.value && editing.value.id !== loadedSaved.value?.id
504
+ ? editing.value.source
505
+ : source.value
506
+ const saved = await persist(text)
507
+ if (run || !editing.value || editing.value.id === loadedSaved.value?.id) {
508
+ loadedSaved.value = saved
509
+ activeHistoryId.value = null
510
+ source.value = saved.source
511
+ }
512
+ savingRun.value = null
513
+ saveOpen.value = false
514
+ toast.add({
515
+ title: t('dms_database.query.save.saved', { name: saved.name }),
516
+ color: 'success',
517
+ icon: 'i-ph-check',
518
+ })
519
+ await refreshSaved()
520
+ } catch (error) {
521
+ saveError.value =
522
+ error instanceof Error && !('data' in error)
523
+ ? error.message
524
+ : fetchMessage(error)
525
+ } finally {
526
+ saving.value = false
527
+ }
528
+ }
529
+
530
+ // ⌘S on a query of the caller's own saves it in place; anything else asks a name.
531
+ async function save() {
532
+ const own = loadedSaved.value
533
+ if (!own || own.userId !== ownId.value) return openSaveModal()
534
+ editing.value = own
535
+ savingRun.value = null
536
+ saveName.value = own.name
537
+ saveDescription.value = own.description
538
+ saveShared.value = own.shared
539
+ await confirmSave()
540
+ }
541
+
542
+ // --- links into the console ---
543
+ /** Opens what the URL names; false when it names nothing to open. */
544
+ async function openFromRoute(): Promise<boolean> {
545
+ const { q, source: linked, history, schema, table, instance } = route.query
546
+ let opened = false
547
+ if (typeof q === 'string') {
548
+ const match =
549
+ store.savedQueries.value.find((query) => query.id === q) ??
550
+ team.value.find((query) => query.id === q)
551
+ if (match) openSaved(match)
552
+ opened = Boolean(match)
553
+ } else if (typeof history === 'string') {
554
+ const entry = await store.fetchHistoryEntry(history).catch(() => null)
555
+ if (entry) openHistory(entry)
556
+ opened = Boolean(entry)
557
+ } else if (typeof linked === 'string') {
558
+ newQuery()
559
+ source.value = linked
560
+ opened = true
561
+ } else if (typeof schema === 'string' && typeof table === 'string') {
562
+ newQuery()
563
+ // The instance the link names: a named one, the default, or every one.
564
+ source.value = `${schemaAccess(schema)}.instance(${instanceArgument(instance)}).table(${JSON.stringify(table)}).slice(0, ${PREFILL_ROWS})`
565
+ opened = true
566
+ } else {
567
+ return false
568
+ }
569
+ router.replace({ query: {} })
570
+ return opened
571
+ }
572
+
573
+ onMounted(async () => {
574
+ await Promise.all([refreshHistory(), refreshSaved()])
575
+ libraryLoading.value = false
576
+ if (!(await openFromRoute())) restoreDraft()
577
+ })
578
+ </script>
579
+
580
+ <template>
581
+ <!-- Below lg the cards stack at their own height and the page scrolls; a
582
+ card never shrinks under its content. -->
583
+ <div
584
+ class="flex min-h-0 flex-1 flex-col gap-4 max-lg:overflow-y-auto lg:grid lg:grid-cols-[19rem_minmax(0,1fr)]"
585
+ >
586
+ <QueryLibrary
587
+ class="max-lg:max-h-80 max-lg:shrink-0 lg:min-h-0"
588
+ :history="store.historyItems.value"
589
+ :saved="store.savedQueries.value"
590
+ :team="team"
591
+ :active-saved-id="loadedSaved?.id ?? null"
592
+ :active-history-id="activeHistoryId"
593
+ :loading="libraryLoading"
594
+ @open-history="openHistory"
595
+ @save-history="openSaveModal"
596
+ @open-saved="openSaved"
597
+ @edit-saved="openSaveModal"
598
+ @delete-saved="deleteSaved"
599
+ @clear-history="clearHistory"
600
+ />
601
+
602
+ <div
603
+ class="flex min-w-0 flex-col gap-4 max-lg:shrink-0 lg:min-h-0 lg:overflow-y-auto"
604
+ >
605
+ <QueryBuilderPanel
606
+ ref="editor"
607
+ v-model="source"
608
+ class="shrink-0"
609
+ :executing="executing"
610
+ :schemas="schemas"
611
+ :saved-name="loadedSaved?.name"
612
+ :saved-shared="loadedSaved?.shared"
613
+ :edited="edited"
614
+ @execute="run"
615
+ @save="save"
616
+ @new="newQuery"
617
+ />
618
+ <QueryResultPanel
619
+ class="shrink-0"
620
+ :result="result"
621
+ :failure="failure"
622
+ :target="target"
623
+ :schemas="schemas"
624
+ :running="executing"
625
+ :elsewhere="elsewhere"
626
+ @use-source="source = $event"
627
+ @replace-name="replaceUnknown"
628
+ @use-instance="runInInstance"
629
+ />
630
+ </div>
631
+
632
+ <UModal
633
+ v-model:open="saveOpen"
634
+ :title="
635
+ editing
636
+ ? t('dms_database.query.save.edit_title')
637
+ : t('dms_database.query.save.title')
638
+ "
639
+ :description="t('dms_database.query.save.description')"
640
+ >
641
+ <template #body>
642
+ <!-- Drawn as a DMS form in a modal: FieldRow rows, errors under
643
+ their control, the required legend under the rows. -->
644
+ <form
645
+ id="dms-database-save-query"
646
+ class="flex flex-col py-1"
647
+ novalidate
648
+ @submit.prevent="confirmSave"
649
+ >
650
+ <DmsFieldRow
651
+ v-bind="SAVE_ROW"
652
+ :label="t('dms_database.query.save.name')"
653
+ label-for="dms-database-save-query-name"
654
+ required
655
+ >
656
+ <div class="grid min-w-0 gap-1.5">
657
+ <UFormField name="name" :error="nameError ?? false">
658
+ <DmsInputText
659
+ id="dms-database-save-query-name"
660
+ v-model="saveName"
661
+ autofocus
662
+ class="w-full"
663
+ :placeholder="t('dms_database.query.save.name_placeholder')"
664
+ @blur="nameTouched = true"
665
+ />
666
+ <template v-if="nameError" #error="{ error }">
667
+ <DmsFormErrorText :error />
668
+ </template>
669
+ </UFormField>
670
+ </div>
671
+ </DmsFieldRow>
672
+ <DmsFieldRow
673
+ v-bind="SAVE_ROW"
674
+ :label="t('dms_database.query.save.notes')"
675
+ label-for="dms-database-save-query-description"
676
+ >
677
+ <DmsTextarea
678
+ id="dms-database-save-query-description"
679
+ v-model="saveDescription"
680
+ class="w-full"
681
+ :rows="3"
682
+ />
683
+ </DmsFieldRow>
684
+ <DmsFieldRow
685
+ v-bind="SAVE_ROW"
686
+ :label="t('dms_database.query.save.share')"
687
+ :description="t('dms_database.query.save.share_description')"
688
+ label-for="dms-database-save-query-shared"
689
+ >
690
+ <DmsSwitch
691
+ id="dms-database-save-query-shared"
692
+ v-model="saveShared"
693
+ />
694
+ </DmsFieldRow>
695
+ <DmsFormRequiredLegend class="pb-2" />
696
+ <UAlert
697
+ v-if="saveError"
698
+ color="error"
699
+ variant="subtle"
700
+ icon="i-ph-warning"
701
+ :title="t('dms_database.query.save.failed')"
702
+ :description="saveError"
703
+ />
704
+ </form>
705
+ </template>
706
+ <template #footer>
707
+ <div class="flex w-full justify-end gap-2">
708
+ <UButton
709
+ color="neutral"
710
+ variant="outline"
711
+ :disabled="saving"
712
+ :label="t('dms_database.common.cancel')"
713
+ @click="saveOpen = false"
714
+ />
715
+ <UButton
716
+ type="submit"
717
+ form="dms-database-save-query"
718
+ :loading="saving"
719
+ :disabled="!saveName.trim()"
720
+ :label="t('dms_database.query.save.confirm')"
721
+ />
722
+ </div>
723
+ </template>
724
+ </UModal>
725
+ </div>
726
+ </template>