@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,741 @@
1
+ <script lang="ts">
2
+ import type { SchemaSummary } from '../build/composables/useDatabaseSchemas'
3
+
4
+ type InspectorTab = 'columns' | 'indexes' | 'relations' | 'sample'
5
+
6
+ // The drawer mounts the inspector anew on each table J / K step to: the
7
+ // last schemas and tab carry over, so the next table shows at once, on the
8
+ // same tab, while the schemas load again. Written in the browser only.
9
+ let lastSchemas: SchemaSummary[] | undefined
10
+ let lastTab: InspectorTab = 'columns'
11
+ </script>
12
+
13
+ <script setup lang="ts">
14
+ import { useClipboard } from '@vueuse/core'
15
+ import {
16
+ findTableIn,
17
+ inboundRelationsIn,
18
+ useDatabaseSchemas,
19
+ type FieldDescriptor,
20
+ } from '../build/composables/useDatabaseSchemas'
21
+ import {
22
+ COLUMN_ROLE_CLASSES,
23
+ type ColumnRole,
24
+ describeField,
25
+ describeModifier,
26
+ isPrimaryKey,
27
+ } from '../build/utils/fieldTypes'
28
+ import { CROSS_INSTANCE_VALUE } from '../build/composables/useDataBrowserTabs'
29
+ import { ALL_INSTANCES_PARAM, tableLink } from '../build/utils/databaseLinks'
30
+
31
+ // The Schemas page's row drawer (D-07), also opened from the diagram: the
32
+ // facts of one table, its columns, indexes and relations both ways, and a
33
+ // sample row. The table view hands it the row and the means to step to the
34
+ // next one (J / K); a relation opens the table it names in its place.
35
+
36
+ interface TableRow {
37
+ schema: string
38
+ name: string
39
+ }
40
+
41
+ interface RowNavigation {
42
+ index: number
43
+ total: number
44
+ hasPrev: boolean
45
+ hasNext: boolean
46
+ prev: () => void
47
+ next: () => void
48
+ }
49
+
50
+ const props = defineProps<{
51
+ rowData?: TableRow
52
+ navigation?: RowNavigation
53
+ }>()
54
+
55
+ // A link out closes the drawer.
56
+ const emit = defineEmits<{ success: [] }>()
57
+
58
+ // Descriptor kinds that admit an empty value on their own.
59
+ const NULLISH_KINDS = new Set<FieldDescriptor['kind']>([
60
+ 'null',
61
+ 'undefined',
62
+ 'any',
63
+ 'unknown',
64
+ ])
65
+ const BROWSE_LIST = '/api/database/browse/list'
66
+ const BROWSE_COUNT = '/api/database/browse/count'
67
+
68
+ const { t, n } = useI18n()
69
+ const { $authFetch } = useAuthFetch()
70
+ const { copy } = useClipboard()
71
+ const toast = useToast()
72
+
73
+ const isBrowser = typeof window !== 'undefined'
74
+
75
+ const tab = ref<InspectorTab>(lastTab)
76
+ watch(tab, (value) => {
77
+ if (isBrowser) lastTab = value
78
+ })
79
+
80
+ const {
81
+ schemas: loadedSchemas,
82
+ isSettled: schemasSettled,
83
+ error: schemasError,
84
+ refresh: refreshSchemas,
85
+ } = useDatabaseSchemas()
86
+ watch(schemasSettled, (settled) => {
87
+ if (settled && !schemasError.value && isBrowser)
88
+ lastSchemas = loadedSchemas.value
89
+ })
90
+ // Every schema: the last ones until these load, and should these fail.
91
+ const schemas = computed(() =>
92
+ schemasSettled.value && !schemasError.value
93
+ ? loadedSchemas.value
94
+ : lastSchemas,
95
+ )
96
+ const loading = computed(
97
+ () => !schemasSettled.value && schemas.value === undefined,
98
+ )
99
+ const failed = computed(
100
+ () => schemasSettled.value && schemas.value === undefined,
101
+ )
102
+
103
+ // The table a relation was followed to, in place of the row's.
104
+ const followed = ref<TableRow | null>(null)
105
+ const shown = computed(() => followed.value ?? props.rowData)
106
+ // J / K step from the row the table view opened: its position is not the
107
+ // followed table's.
108
+ const rowNavigation = computed(() =>
109
+ followed.value ? undefined : props.navigation,
110
+ )
111
+
112
+ const schemaId = computed(() => shown.value?.schema ?? '')
113
+ const tableName = computed(() => shown.value?.name ?? '')
114
+ const tableKey = computed(() => `${schemaId.value}::${tableName.value}`)
115
+ const table = computed(() =>
116
+ findTableIn(schemas.value ?? [], schemaId.value, tableName.value),
117
+ )
118
+ // Renamed, dropped or mistyped: once the schemas are in, nothing names it.
119
+ const missing = computed(
120
+ () => !loading.value && !failed.value && table.value === undefined,
121
+ )
122
+ const schema = computed(() =>
123
+ schemas.value?.find((s) => s.id === schemaId.value),
124
+ )
125
+ const instanceCount = computed(() => schema.value?.stats.instanceCount ?? 1)
126
+ const address = computed(() => ({
127
+ schema: schemaId.value,
128
+ table: tableName.value,
129
+ }))
130
+
131
+ function isNullable(descriptor: FieldDescriptor): boolean {
132
+ if (NULLISH_KINDS.has(descriptor.kind)) return true
133
+ return (
134
+ descriptor.kind === 'union' &&
135
+ descriptor.members.some((m) => m.kind === 'null' || m.kind === 'undefined')
136
+ )
137
+ }
138
+
139
+ const relationByField = computed(
140
+ () => new Map((table.value?.relations ?? []).map((r) => [r.fromField, r])),
141
+ )
142
+
143
+ const columns = computed(() => {
144
+ const current = table.value
145
+ if (!current) return []
146
+ return Object.entries(current.fields).map(([name, descriptor]) => {
147
+ const relation = relationByField.value.get(name)
148
+ const key = isPrimaryKey(name, current.indexes ?? {})
149
+ const role: ColumnRole = key ? 'key' : relation ? 'relation' : 'plain'
150
+ const type = describeField(descriptor, name)
151
+ return {
152
+ name,
153
+ role,
154
+ icon: key ? 'i-ph-key' : relation ? 'i-ph-arrow-right' : type.icon,
155
+ label: key
156
+ ? t('dms_database.inspector.primary_key')
157
+ : relation
158
+ ? relation.toTable
159
+ : type.label,
160
+ nullable: !key && isNullable(descriptor),
161
+ modifiers: (current.modifiers?.[name] ?? []).map(describeModifier),
162
+ }
163
+ })
164
+ })
165
+
166
+ const indexes = computed(() =>
167
+ Object.entries(table.value?.indexes ?? {}).map(([name, index]) => ({
168
+ name,
169
+ fields: index.fields ?? [],
170
+ compound: (index.fields?.length ?? 0) > 1,
171
+ multi: Boolean(index.multi),
172
+ })),
173
+ )
174
+
175
+ // A table outside the inspected one's schema is named with its schema.
176
+ function qualified(schemaOf: string, name: string): string {
177
+ return schemaOf === schemaId.value ? name : `${schemaOf}.${name}`
178
+ }
179
+
180
+ const outgoing = computed(() =>
181
+ (table.value?.relations ?? []).map((r) => ({
182
+ key: `out-${r.fromField}`,
183
+ from: `${tableName.value}.${r.fromField}`,
184
+ to: `${qualified(r.toSchema, r.toTable)}.${r.toField}`,
185
+ cardinality: r.many ? 'N : N' : 'N : 1',
186
+ target: { schema: r.toSchema, name: r.toTable },
187
+ })),
188
+ )
189
+
190
+ const incoming = computed(() =>
191
+ inboundRelationsIn(schemas.value ?? [], schemaId.value, tableName.value).map(
192
+ (r) => ({
193
+ key: `in-${r.fromSchema}-${r.fromTable}-${r.fromField}`,
194
+ from: `${qualified(r.fromSchema, r.fromTable)}.${r.fromField}`,
195
+ to: `${tableName.value}.${r.toField}`,
196
+ cardinality: r.many ? 'N : N' : 'N : 1',
197
+ target: { schema: r.fromSchema, name: r.fromTable },
198
+ }),
199
+ ),
200
+ )
201
+
202
+ // The row's own table again closes the detour.
203
+ function follow(target: TableRow) {
204
+ const own =
205
+ target.schema === props.rowData?.schema &&
206
+ target.name === props.rowData?.name
207
+ followed.value = own ? null : target
208
+ }
209
+
210
+ // --- rows: in every instance at once; the data browser shows each one's ---
211
+ const total = ref<number | null>(null)
212
+
213
+ async function loadTotal(key: string) {
214
+ total.value = null
215
+ if (!schemaId.value || !tableName.value) return
216
+ try {
217
+ const result = await $authFetch<{ total: number }>(BROWSE_COUNT, {
218
+ query: {
219
+ filter_schema: `is:${schemaId.value}`,
220
+ filter_table: `is:${tableName.value}`,
221
+ filter_instance: `is:${CROSS_INSTANCE_VALUE}`,
222
+ },
223
+ })
224
+ if (key === tableKey.value) total.value = result.total
225
+ } catch {
226
+ // The total the list gave, if any, stays.
227
+ }
228
+ }
229
+
230
+ watch(tableKey, loadTotal, { immediate: true })
231
+
232
+ const rowsFact = computed(() => {
233
+ const rows = total.value
234
+ return rows === null
235
+ ? ''
236
+ : t('dms_database.inspector.facts.rows', { count: n(rows) }, rows)
237
+ })
238
+
239
+ const structureFacts = computed(() =>
240
+ [
241
+ t('dms_database.inspector.facts.columns', columns.value.length),
242
+ t('dms_database.inspector.facts.indexes', indexes.value.length),
243
+ t(
244
+ 'dms_database.inspector.facts.relations',
245
+ outgoing.value.length + incoming.value.length,
246
+ ),
247
+ ].join(' · '),
248
+ )
249
+
250
+ const tabs = computed(() => [
251
+ {
252
+ value: 'columns',
253
+ label: t('dms_database.inspector.tabs.columns'),
254
+ badge: loading.value ? undefined : columns.value.length,
255
+ },
256
+ {
257
+ value: 'indexes',
258
+ label: t('dms_database.inspector.tabs.indexes'),
259
+ badge: loading.value ? undefined : indexes.value.length,
260
+ },
261
+ {
262
+ value: 'relations',
263
+ label: t('dms_database.inspector.tabs.relations'),
264
+ badge: loading.value
265
+ ? undefined
266
+ : outgoing.value.length + incoming.value.length,
267
+ },
268
+ { value: 'sample', label: t('dms_database.inspector.tabs.sample') },
269
+ ])
270
+
271
+ // --- sample row: the latest row, or the next one on demand ---
272
+ const sample = ref<Record<string, unknown> | null>(null)
273
+ const sampleOffset = ref(0)
274
+ const sampleTotal = ref(0)
275
+ const sampleLoading = ref(false)
276
+ const sampleFailed = ref(false)
277
+
278
+ interface SamplePage {
279
+ results: Record<string, unknown>[]
280
+ total: number
281
+ }
282
+
283
+ // The instance the sample is read from: the default one, or the first named
284
+ // instance holding rows when the default one holds none.
285
+ const sampleInstance = ref<string | null>(null)
286
+
287
+ const sampleInstanceLabel = computed(() =>
288
+ sampleInstance.value === null
289
+ ? t('dms_database.inspector.sample.default_instance')
290
+ : t('dms_database.inspector.sample.named_instance', {
291
+ name: sampleInstance.value,
292
+ }),
293
+ )
294
+
295
+ function fetchSample(instance: string | null) {
296
+ const query: Record<string, string | number> = {
297
+ filter_schema: `is:${schemaId.value}`,
298
+ filter_table: `is:${tableName.value}`,
299
+ offset: sampleOffset.value,
300
+ limit: 1,
301
+ }
302
+ if (instance) query.filter_instance = `is:${instance}`
303
+ return $authFetch<SamplePage>(BROWSE_LIST, { query })
304
+ }
305
+
306
+ async function firstFilledInstance(): Promise<SamplePage> {
307
+ const page = await fetchSample(null)
308
+ if (page.total > 0) return page
309
+ for (const instance of schema.value?.instances ?? []) {
310
+ const named = await fetchSample(instance)
311
+ if (named.total > 0) {
312
+ sampleInstance.value = instance
313
+ return named
314
+ }
315
+ }
316
+ return page
317
+ }
318
+
319
+ async function loadSample() {
320
+ if (!schemaId.value || !tableName.value) return
321
+ sampleLoading.value = true
322
+ sampleFailed.value = false
323
+ try {
324
+ const res =
325
+ sampleInstance.value === null && sampleOffset.value === 0
326
+ ? await firstFilledInstance()
327
+ : await fetchSample(sampleInstance.value)
328
+ sample.value = res.results?.[0] ?? null
329
+ sampleTotal.value = res.total ?? 0
330
+ } catch {
331
+ sampleFailed.value = true
332
+ sample.value = null
333
+ } finally {
334
+ sampleLoading.value = false
335
+ }
336
+ }
337
+
338
+ function nextSample() {
339
+ sampleOffset.value =
340
+ sampleTotal.value > 0 ? (sampleOffset.value + 1) % sampleTotal.value : 0
341
+ loadSample()
342
+ }
343
+
344
+ // Another table (a relation followed) starts over from its latest row.
345
+ watch(tableKey, () => {
346
+ sample.value = null
347
+ sampleOffset.value = 0
348
+ sampleTotal.value = 0
349
+ sampleInstance.value = null
350
+ sampleFailed.value = false
351
+ if (tab.value === 'sample' && !loading.value) loadSample()
352
+ })
353
+
354
+ // The sample waits for the schemas: they name the instances to look in.
355
+ watch(
356
+ () => tab.value === 'sample' && !loading.value,
357
+ (ready) => {
358
+ if (ready && !sample.value && !sampleLoading.value) loadSample()
359
+ },
360
+ { immediate: true },
361
+ )
362
+
363
+ // The `_instance` tag the store adds to rows of named instances is not a
364
+ // column of the table.
365
+ const sampleJson = computed(() => {
366
+ if (!sample.value) return ''
367
+ const { _instance: _tag, ...row } = sample.value
368
+ return JSON.stringify(row, null, 2)
369
+ })
370
+
371
+ async function copyName() {
372
+ await copy(tableName.value)
373
+ toast.add({
374
+ title: t('dms_database.inspector.copied', { name: tableName.value }),
375
+ color: 'success',
376
+ icon: 'i-ph-check',
377
+ })
378
+ }
379
+ </script>
380
+
381
+ <template>
382
+ <!-- As wide as the side drawer, whatever the open tab holds. -->
383
+ <div class="flex h-full w-full min-w-0 flex-col gap-4">
384
+ <header class="grid grid-cols-[minmax(0,1fr)] gap-3">
385
+ <div class="flex min-w-0 items-center gap-2">
386
+ <DmsEyebrow
387
+ class="min-w-0 truncate"
388
+ :label="t('dms_database.inspector.eyebrow', { schema: schemaId })"
389
+ />
390
+ <span class="flex-1" />
391
+ <template v-if="rowNavigation">
392
+ <span class="text-dimmed shrink-0 font-mono text-[11px] tabular-nums">
393
+ {{ rowNavigation.index + 1 }} / {{ rowNavigation.total }}
394
+ </span>
395
+ <UButton
396
+ icon="i-ph-caret-up"
397
+ color="neutral"
398
+ variant="ghost"
399
+ size="xs"
400
+ :disabled="!rowNavigation.hasPrev"
401
+ :aria-label="t('dms_database.inspector.previous')"
402
+ :title="t('dms_database.inspector.previous')"
403
+ @click="rowNavigation.prev()"
404
+ />
405
+ <UButton
406
+ icon="i-ph-caret-down"
407
+ color="neutral"
408
+ variant="ghost"
409
+ size="xs"
410
+ :disabled="!rowNavigation.hasNext"
411
+ :aria-label="t('dms_database.inspector.next')"
412
+ :title="t('dms_database.inspector.next')"
413
+ @click="rowNavigation.next()"
414
+ />
415
+ </template>
416
+ </div>
417
+ <div class="flex min-w-0 flex-wrap items-center gap-2">
418
+ <h3
419
+ class="text-highlighted min-w-0 break-all font-mono text-lg font-semibold"
420
+ >
421
+ {{ tableName }}
422
+ </h3>
423
+ <UBadge
424
+ v-if="!loading"
425
+ color="neutral"
426
+ variant="outline"
427
+ size="sm"
428
+ class="font-mono"
429
+ >
430
+ {{ t('dms_database.inspector.instances', instanceCount) }}
431
+ </UBadge>
432
+ <UButton
433
+ icon="i-ph-copy"
434
+ color="neutral"
435
+ variant="ghost"
436
+ size="xs"
437
+ :aria-label="t('dms_database.inspector.copy_name')"
438
+ :title="t('dms_database.inspector.copy_name')"
439
+ @click="copyName"
440
+ />
441
+ </div>
442
+ <div v-if="loading" class="grid gap-2" aria-busy="true">
443
+ <USkeleton class="h-4 w-40" />
444
+ <USkeleton class="h-4 w-64 max-w-full" />
445
+ </div>
446
+ <div v-else class="text-muted grid gap-0.5 text-sm tabular-nums">
447
+ <p v-if="rowsFact">
448
+ {{ rowsFact }}
449
+ <template v-if="instanceCount > 1">
450
+ ·
451
+ <DmsAutoLink
452
+ @click="emit('success')"
453
+ :to="
454
+ tableLink('data', { ...address, instance: ALL_INSTANCES_PARAM })
455
+ "
456
+ class="text-primary hover:underline"
457
+ >
458
+ {{ t('dms_database.inspector.facts.by_instance') }}
459
+ </DmsAutoLink>
460
+ </template>
461
+ </p>
462
+ <p>{{ structureFacts }}</p>
463
+ </div>
464
+ <div class="flex flex-wrap gap-2">
465
+ <UButton
466
+ :to="tableLink('data', address)"
467
+ @click="emit('success')"
468
+ icon="i-ph-rows"
469
+ size="sm"
470
+ :label="t('dms_database.inspector.actions.browse')"
471
+ />
472
+ <UButton
473
+ :to="tableLink('query', address)"
474
+ @click="emit('success')"
475
+ icon="i-ph-code"
476
+ size="sm"
477
+ color="neutral"
478
+ variant="outline"
479
+ :label="t('dms_database.inspector.actions.query')"
480
+ />
481
+ <UButton
482
+ :to="tableLink('diagram', address)"
483
+ @click="emit('success')"
484
+ icon="i-ph-graph"
485
+ size="sm"
486
+ color="neutral"
487
+ variant="ghost"
488
+ :label="t('dms_database.inspector.actions.diagram')"
489
+ />
490
+ </div>
491
+ <!-- The tab list scrolls sideways on its own: held to the header's
492
+ width, the four tabs scroll on a phone rather than overflow. -->
493
+ <UTabs
494
+ v-model="tab"
495
+ :items="tabs"
496
+ variant="link"
497
+ size="sm"
498
+ :content="false"
499
+ class="min-w-0"
500
+ />
501
+ </header>
502
+
503
+ <div class="min-h-0 min-w-0 flex-1 overflow-auto">
504
+ <div v-if="loading" class="grid gap-3" aria-busy="true">
505
+ <USkeleton v-for="line in 6" :key="line" class="h-7 w-full" />
506
+ </div>
507
+
508
+ <DmsEmptyState
509
+ v-else-if="failed"
510
+ size="sm"
511
+ variant="error"
512
+ :title="t('dms_database.inspector.load_error')"
513
+ :actions="[
514
+ {
515
+ label: t('dms_database.common.retry'),
516
+ icon: 'i-ph-arrows-clockwise',
517
+ onClick: refreshSchemas,
518
+ },
519
+ ]"
520
+ />
521
+
522
+ <DmsEmptyState
523
+ v-else-if="missing"
524
+ size="sm"
525
+ variant="no-result"
526
+ icon="i-ph-magnifying-glass"
527
+ :title="t('dms_database.schemas.inspector.not_found')"
528
+ :description="`${schemaId}.${tableName}`"
529
+ />
530
+
531
+ <table v-else-if="tab === 'columns'" class="w-full text-sm">
532
+ <thead>
533
+ <tr class="border-default border-b text-left">
534
+ <th class="text-dimmed py-2 pr-3 text-xs font-semibold">
535
+ {{ t('dms_database.inspector.cols.column') }}
536
+ </th>
537
+ <th class="text-dimmed px-3 py-2 text-xs font-semibold">
538
+ {{ t('dms_database.inspector.cols.type') }}
539
+ </th>
540
+ <th class="text-dimmed px-3 py-2 text-xs font-semibold">
541
+ {{ t('dms_database.inspector.cols.null') }}
542
+ </th>
543
+ <th class="text-dimmed py-2 pl-3 text-xs font-semibold">
544
+ {{ t('dms_database.inspector.cols.notes') }}
545
+ </th>
546
+ </tr>
547
+ </thead>
548
+ <tbody>
549
+ <tr
550
+ v-for="column in columns"
551
+ :key="column.name"
552
+ class="border-default/60 border-b last:border-0"
553
+ >
554
+ <td
555
+ class="text-highlighted break-all py-2 pr-3 font-mono text-[12.5px]"
556
+ >
557
+ {{ column.name }}
558
+ </td>
559
+ <td class="px-3 py-2">
560
+ <span
561
+ class="inline-flex items-center gap-1.5 font-mono text-[11.5px]"
562
+ :class="COLUMN_ROLE_CLASSES[column.role]"
563
+ >
564
+ <UIcon :name="column.icon" class="size-3.5 shrink-0" />
565
+ {{ column.label }}
566
+ </span>
567
+ </td>
568
+ <td class="px-3 py-2">
569
+ <span
570
+ v-if="column.nullable"
571
+ class="text-dimmed rounded border border-dashed border-current px-1 font-mono text-[10.5px]"
572
+ >
573
+ null
574
+ </span>
575
+ <span v-else class="text-dimmed">—</span>
576
+ </td>
577
+ <td class="text-muted py-2 pl-3 text-xs">
578
+ <span
579
+ v-for="modifier in column.modifiers"
580
+ :key="modifier.id"
581
+ class="mr-2 inline-flex items-center gap-1"
582
+ >
583
+ <UIcon :name="modifier.icon" class="size-3.5" />
584
+ {{ modifier.labelKey ? t(modifier.labelKey) : modifier.id }}
585
+ </span>
586
+ </td>
587
+ </tr>
588
+ </tbody>
589
+ </table>
590
+
591
+ <div v-else-if="tab === 'indexes'" class="grid gap-2">
592
+ <div
593
+ v-for="index in indexes"
594
+ :key="index.name"
595
+ class="border-default flex min-w-0 items-center gap-3 rounded-md border px-3 py-2.5"
596
+ >
597
+ <UBadge
598
+ :color="index.compound ? 'primary' : 'neutral'"
599
+ variant="subtle"
600
+ size="sm"
601
+ class="font-mono"
602
+ >
603
+ {{
604
+ index.compound
605
+ ? t('dms_database.inspector.index.compound')
606
+ : t('dms_database.inspector.index.single')
607
+ }}
608
+ </UBadge>
609
+ <span class="text-toned min-w-0 truncate font-mono text-[12.5px]">
610
+ {{ index.name }}
611
+ </span>
612
+ <span class="ml-auto flex flex-wrap justify-end gap-1">
613
+ <span
614
+ v-for="field in index.fields"
615
+ :key="field"
616
+ class="bg-elevated text-muted rounded px-1.5 py-0.5 font-mono text-[11px]"
617
+ >
618
+ {{ field }}
619
+ </span>
620
+ </span>
621
+ </div>
622
+ <DmsEmptyState
623
+ v-if="indexes.length === 0"
624
+ size="sm"
625
+ icon="i-ph-key"
626
+ :title="t('dms_database.inspector.index.empty')"
627
+ />
628
+ </div>
629
+
630
+ <div v-else-if="tab === 'relations'" class="grid gap-5">
631
+ <section class="grid gap-2">
632
+ <DmsEyebrow
633
+ :label="
634
+ t('dms_database.inspector.relations.out', {
635
+ count: outgoing.length,
636
+ })
637
+ "
638
+ />
639
+ <button
640
+ v-for="relation in outgoing"
641
+ :key="relation.key"
642
+ type="button"
643
+ class="border-default hover:bg-elevated/50 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto] items-center gap-2 rounded-md border px-3 py-2 text-left font-mono text-[12px]"
644
+ @click="follow(relation.target)"
645
+ >
646
+ <span class="text-toned truncate">{{ relation.from }}</span>
647
+ <UIcon name="i-ph-arrow-right" class="text-info size-3.5" />
648
+ <span class="text-info truncate">{{ relation.to }}</span>
649
+ <span class="text-dimmed text-[10.5px]">
650
+ {{ relation.cardinality }}
651
+ </span>
652
+ </button>
653
+ <p v-if="outgoing.length === 0" class="text-dimmed text-sm">
654
+ {{ t('dms_database.inspector.relations.none_out') }}
655
+ </p>
656
+ </section>
657
+ <section class="grid gap-2">
658
+ <DmsEyebrow
659
+ :label="
660
+ t('dms_database.inspector.relations.in', {
661
+ count: incoming.length,
662
+ })
663
+ "
664
+ />
665
+ <button
666
+ v-for="relation in incoming"
667
+ :key="relation.key"
668
+ type="button"
669
+ class="border-default hover:bg-elevated/50 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto] items-center gap-2 rounded-md border px-3 py-2 text-left font-mono text-[12px]"
670
+ @click="follow(relation.target)"
671
+ >
672
+ <span class="text-info truncate">{{ relation.from }}</span>
673
+ <UIcon name="i-ph-arrow-right" class="text-info size-3.5" />
674
+ <span class="text-toned truncate">{{ relation.to }}</span>
675
+ <span class="text-dimmed text-[10.5px]">
676
+ {{ relation.cardinality }}
677
+ </span>
678
+ </button>
679
+ <p v-if="incoming.length === 0" class="text-dimmed text-sm">
680
+ {{ t('dms_database.inspector.relations.none_in') }}
681
+ </p>
682
+ </section>
683
+ </div>
684
+
685
+ <div v-else class="grid gap-3">
686
+ <!-- Nothing to page through in an empty table. -->
687
+ <div
688
+ v-if="sampleTotal > 0"
689
+ class="flex min-w-0 items-center justify-between gap-2"
690
+ >
691
+ <DmsEyebrow
692
+ class="min-w-0 truncate"
693
+ :label="
694
+ t('dms_database.inspector.sample.position', {
695
+ position: sampleOffset + 1,
696
+ total: sampleTotal,
697
+ instance: sampleInstanceLabel,
698
+ })
699
+ "
700
+ />
701
+ <UButton
702
+ icon="i-ph-arrows-clockwise"
703
+ color="neutral"
704
+ variant="ghost"
705
+ size="xs"
706
+ :disabled="sampleTotal < 2"
707
+ :loading="sampleLoading"
708
+ :label="t('dms_database.inspector.sample.another')"
709
+ @click="nextSample"
710
+ />
711
+ </div>
712
+ <DmsEmptyState
713
+ v-if="sampleFailed"
714
+ size="sm"
715
+ variant="error"
716
+ :title="t('dms_database.inspector.sample.error')"
717
+ :actions="[
718
+ {
719
+ label: t('dms_database.common.retry'),
720
+ icon: 'i-ph-arrows-clockwise',
721
+ onClick: loadSample,
722
+ },
723
+ ]"
724
+ />
725
+ <div
726
+ v-else-if="sampleLoading && !sample"
727
+ class="grid gap-2"
728
+ aria-busy="true"
729
+ >
730
+ <USkeleton v-for="line in 5" :key="line" class="h-4 w-full" />
731
+ </div>
732
+ <DmsEmptyState
733
+ v-else-if="!sample"
734
+ size="sm"
735
+ :title="t('dms_database.inspector.sample.empty')"
736
+ />
737
+ <DmsCodeSnippet v-else :code="sampleJson" language="json" />
738
+ </div>
739
+ </div>
740
+ </div>
741
+ </template>