@antelopejs/dms-database 0.1.2 → 0.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 (30) hide show
  1. package/README.md +1 -1
  2. package/frontend-vue/app/build/composables/useDataBrowserGrid.ts +4 -38
  3. package/frontend-vue/app/build/composables/useDatabaseHealth.ts +7 -4
  4. package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +1 -2
  5. package/frontend-vue/app/build/composables/useDiagramPersistence.ts +49 -16
  6. package/frontend-vue/app/build/composables/useQueryStore.ts +59 -27
  7. package/frontend-vue/app/build/composables/useTableCounts.ts +50 -0
  8. package/frontend-vue/app/build/query/runs.ts +7 -27
  9. package/frontend-vue/app/build/schemas/useSchemasListing.ts +10 -33
  10. package/frontend-vue/app/components/ConnectionStatus.vue +14 -11
  11. package/frontend-vue/app/components/DataBrowserSidebar.vue +20 -42
  12. package/frontend-vue/app/components/DataBrowserTabBar.vue +1 -1
  13. package/frontend-vue/app/components/DataGrid.vue +43 -89
  14. package/frontend-vue/app/components/InstancePicker.vue +63 -55
  15. package/frontend-vue/app/components/QueryConsole.vue +47 -37
  16. package/frontend-vue/app/components/QueryLibrary.vue +20 -17
  17. package/frontend-vue/app/components/RowDrawer.vue +15 -18
  18. package/frontend-vue/app/components/SchemaDiagram.vue +7 -7
  19. package/frontend-vue/app/components/TableInspector.vue +116 -84
  20. package/frontend-vue/package.json +2 -1
  21. package/frontend-vue/pnpm-lock.yaml +883 -2587
  22. package/frontend-vue/tests/fixtures/inertia.ts +8 -0
  23. package/frontend-vue/tests/fixtures/serverRender.ts +219 -0
  24. package/frontend-vue/tests/fixtures/vueI18n.ts +4 -0
  25. package/frontend-vue/tests/helpers.test.ts +1 -0
  26. package/frontend-vue/tests/locales.test.ts +24 -0
  27. package/frontend-vue/tests/onDemandReads.test.ts +106 -0
  28. package/frontend-vue/tests/serverRender.test.ts +235 -0
  29. package/frontend-vue/vitest.config.ts +24 -0
  30. package/package.json +4 -4
package/README.md CHANGED
@@ -25,7 +25,7 @@ instance, `all` for every instance, otherwise the instance's name.
25
25
  - **Query console**: AQL with completion of the workspace's schemas, a library of past, saved and
26
26
  shared queries, and a dry run before any query that changes data.
27
27
 
28
- The module requires `@antelopejs/dms` 0.7.2 or later and `@antelopejs/dms-frontend` 0.5.2.
28
+ The module requires `@antelopejs/dms` 0.9 (`@antelopejs/interface-dms` 0.7) and `@antelopejs/dms-frontend` 0.6.
29
29
 
30
30
  ## Installation
31
31
 
@@ -1,7 +1,7 @@
1
1
  import { type BrowserTab, DEFAULT_INSTANCE_VALUE } from './useDataBrowserTabs'
2
- // Per-tab grid state (paging, sort, filters, column widths) and a data cache
3
- // so switching tabs restores instantly without a refetch. Keyed by
4
- // BrowserTab.id.
2
+ // Per-tab grid state (paging, sort, filters, column widths), keyed by
3
+ // BrowserTab.id. The rows themselves are `useApiData`'s: a tab switched back
4
+ // to shows its last page at once and reads it again.
5
5
 
6
6
  export interface ColumnFilterState {
7
7
  mode: 'is' | 'contains'
@@ -31,9 +31,6 @@ export interface GridData {
31
31
  rows: Record<string, unknown>[]
32
32
  total: number
33
33
  primaryKey: string | null
34
- // Serialised query parameters that produced this payload; lets the grid
35
- // skip refetching when nothing changed.
36
- paramsKey: string
37
34
  }
38
35
 
39
36
  export const GRID_PAGE_SIZES = [25, 50, 100, 200]
@@ -55,32 +52,15 @@ export function useDataBrowserGrid() {
55
52
  'dms-database-grid-states',
56
53
  () => ({}),
57
54
  )
58
- const cache = useDmsState<Record<string, GridData>>(
59
- 'dms-database-grid-cache',
60
- () => ({}),
61
- )
62
55
 
63
56
  function getState(tabId: string): GridState {
64
57
  if (!states.value[tabId]) states.value[tabId] = defaultGridState()
65
58
  return states.value[tabId]
66
59
  }
67
60
 
68
- function getCache(tabId: string): GridData | null {
69
- return cache.value[tabId] ?? null
70
- }
71
-
72
- function setCache(tabId: string, data: GridData) {
73
- cache.value[tabId] = data
74
- }
75
-
76
- function invalidate(tabId: string) {
77
- Reflect.deleteProperty(cache.value, tabId)
78
- }
79
-
80
61
  // Forget everything about a closed tab.
81
62
  function drop(tabId: string) {
82
63
  Reflect.deleteProperty(states.value, tabId)
83
- Reflect.deleteProperty(cache.value, tabId)
84
64
  }
85
65
 
86
66
  // A tab switched to another instance keeps what the user narrowed the
@@ -94,10 +74,9 @@ export function useDataBrowserGrid() {
94
74
  columnWidths: { ...from.columnWidths },
95
75
  page: 0,
96
76
  }
97
- Reflect.deleteProperty(cache.value, toId)
98
77
  }
99
78
 
100
- return { getState, getCache, setCache, invalidate, drop, carry }
79
+ return { getState, drop, carry }
101
80
  }
102
81
 
103
82
  // --- query helpers shared by the grid ---
@@ -162,16 +141,3 @@ export function browseListQuery(
162
141
  }
163
142
  return query
164
143
  }
165
-
166
- // Everything that changes the server payload (column widths deliberately not).
167
- export function browseParamsKey(tab: BrowserTab, state: GridState): string {
168
- return JSON.stringify([
169
- tab.id,
170
- state.page,
171
- state.pageSize,
172
- state.sortKey,
173
- state.sortDirection,
174
- state.search,
175
- activeFilterEntries(state),
176
- ])
177
- }
@@ -34,11 +34,14 @@ const EMPTY_HEALTH: OverviewHealth = {
34
34
  storage: [],
35
35
  }
36
36
 
37
- export function useDatabaseHealth() {
38
- const { useFetchAuth } = useAuthFetch()
39
- const { data, pending, error, refresh } = useFetchAuth<OverviewHealth>(
37
+ /** `refreshInterval` probes again on that interval while the page shows it. */
38
+ export function useDatabaseHealth(options: { refreshInterval?: number } = {}) {
39
+ const { data, pending, error, refresh } = useApiData<OverviewHealth>(
40
40
  HEALTH_ENDPOINT,
41
- { default: () => ({ ...EMPTY_HEALTH }) },
41
+ {
42
+ default: () => ({ ...EMPTY_HEALTH }),
43
+ refreshInterval: options.refreshInterval,
44
+ },
42
45
  )
43
46
 
44
47
  return {
@@ -110,8 +110,7 @@ export function inboundRelationsIn(
110
110
  }
111
111
 
112
112
  export function useDatabaseSchemas() {
113
- const { useFetchAuth } = useAuthFetch()
114
- const { data, pending, error, status, refresh } = useFetchAuth<{
113
+ const { data, pending, error, status, refresh } = useApiData<{
115
114
  schemas: SchemaSummary[]
116
115
  }>(SCHEMAS_ENDPOINT, { default: () => EMPTY_SCHEMAS })
117
116
 
@@ -32,6 +32,13 @@ export type DiagramNoteUpdate = Partial<
32
32
  Pick<DiagramNote, 'text' | 'x' | 'y' | 'width' | 'height' | 'color'>
33
33
  >
34
34
 
35
+ /** A schema's diagram as stored: its layout and its notes. */
36
+ export interface StoredDiagram {
37
+ schema: string
38
+ positions: DiagramPositions
39
+ notes: DiagramNote[]
40
+ }
41
+
35
42
  const BASE = '/api/database/diagram'
36
43
  // A request that hangs fails after this (ofetch's own `timeout`), so the
37
44
  // canvas shows the error and its "Try again" instead of "Saving…" forever.
@@ -40,12 +47,47 @@ const REQUEST_TIMEOUT_MS = 15_000
40
47
  export function useDiagramPersistence() {
41
48
  const { $authFetch } = useAuthFetch()
42
49
 
43
- async function fetchLayout(schema: string): Promise<DiagramPositions> {
44
- const res = await $authFetch<{ positions: DiagramPositions }>(
45
- `${BASE}/layout/${encodeURIComponent(schema)}`,
46
- { timeout: REQUEST_TIMEOUT_MS },
47
- )
48
- return res.positions ?? {}
50
+ // The schema whose diagram is read: in the browser only, when the canvas
51
+ // asks, since it draws and edits a working copy of it.
52
+ const readSchema = shallowRef<string | null>(null)
53
+ const stored = useApiData<StoredDiagram>(
54
+ () =>
55
+ readSchema.value ? `dms-database:diagram:${readSchema.value}` : null,
56
+ async ({ $authFetch: read, signal }) => {
57
+ const schema = readSchema.value as string
58
+ const [layout, notes] = await Promise.all([
59
+ read<{ positions: DiagramPositions }>(
60
+ `${BASE}/layout/${encodeURIComponent(schema)}`,
61
+ { timeout: REQUEST_TIMEOUT_MS, signal },
62
+ ),
63
+ read<{ items: DiagramNote[] }>(`${BASE}/notes`, {
64
+ query: { schema },
65
+ timeout: REQUEST_TIMEOUT_MS,
66
+ signal,
67
+ }),
68
+ ])
69
+ return {
70
+ schema,
71
+ positions: layout.positions ?? {},
72
+ notes: notes.items ?? [],
73
+ }
74
+ },
75
+ {
76
+ immediate: false,
77
+ server: false,
78
+ watch: false,
79
+ refreshOnPageRefresh: false,
80
+ },
81
+ )
82
+
83
+ /** Reads a schema's layout and notes; throws when they cannot be read. */
84
+ async function readDiagram(schema: string): Promise<StoredDiagram> {
85
+ readSchema.value = schema
86
+ await stored.execute()
87
+ const diagram = stored.data.value
88
+ if (stored.error.value || diagram?.schema !== schema)
89
+ throw stored.error.value ?? new Error('Diagram not read')
90
+ return diagram
49
91
  }
50
92
 
51
93
  async function saveLayout(
@@ -62,14 +104,6 @@ export function useDiagramPersistence() {
62
104
  )
63
105
  }
64
106
 
65
- async function fetchNotes(schema: string): Promise<DiagramNote[]> {
66
- const res = await $authFetch<{ items: DiagramNote[] }>(`${BASE}/notes`, {
67
- query: { schema },
68
- timeout: REQUEST_TIMEOUT_MS,
69
- })
70
- return res.items ?? []
71
- }
72
-
73
107
  async function createNote(payload: DiagramNoteCreate): Promise<DiagramNote> {
74
108
  return $authFetch<DiagramNote>(`${BASE}/notes`, {
75
109
  method: 'POST',
@@ -97,9 +131,8 @@ export function useDiagramPersistence() {
97
131
  }
98
132
 
99
133
  return {
100
- fetchLayout,
134
+ readDiagram,
101
135
  saveLayout,
102
- fetchNotes,
103
136
  createNote,
104
137
  updateNote,
105
138
  deleteNote,
@@ -1,5 +1,4 @@
1
1
  export type QueryLanguage = 'aql'
2
- export type QueryScope = 'me' | 'shared'
3
2
 
4
3
  export interface SavedQuery {
5
4
  id: string
@@ -68,31 +67,61 @@ import { HISTORY_KEPT } from '../query/runs'
68
67
 
69
68
  const QUERY_BASE = '/api/database/query'
70
69
 
70
+ /**
71
+ * The query console's library and requests. The library (the caller's saved
72
+ * queries, the team's shared ones, the runs the server keeps) is read as the
73
+ * console sets up: a server render reads it and hands it over with the page.
74
+ * Every write is a request of its own, after which the console refreshes
75
+ * the part of the library it changed.
76
+ */
71
77
  export function useQueryStore() {
72
78
  const { $authFetch } = useAuthFetch()
73
79
 
74
- const savedQueries = ref<SavedQuery[]>([])
75
- const historyItems = ref<HistoryEntry[]>([])
76
-
77
- async function fetchSaved(scope: QueryScope): Promise<void> {
78
- const response = await $authFetch<{ items: SavedQuery[] }>(
79
- `${QUERY_BASE}/saved`,
80
- { query: { scope } },
81
- )
82
- savedQueries.value = response.items ?? []
80
+ const saved = useApiData<{ items: SavedQuery[] }>(`${QUERY_BASE}/saved`, {
81
+ query: { scope: 'me' },
82
+ })
83
+ const shared = useApiData<{ items: SavedQuery[] }>(`${QUERY_BASE}/saved`, {
84
+ query: { scope: 'shared' },
85
+ })
86
+ const history = useApiData<{ items: HistoryEntry[] }>(
87
+ `${QUERY_BASE}/history`,
88
+ { query: { limit: HISTORY_KEPT } },
89
+ )
90
+ const reads = [saved, shared, history]
91
+
92
+ /** The saved queries and the shared ones, read again. */
93
+ async function refreshSaved(): Promise<void> {
94
+ await Promise.all([saved.refresh(), shared.refresh()])
83
95
  }
84
96
 
97
+ // A run a link names (`?history=`), read when the console opens it.
98
+ const entryId = shallowRef<string | null>(null)
99
+ const entry = useApiData<HistoryEntry>(
100
+ () =>
101
+ entryId.value
102
+ ? `${QUERY_BASE}/history/${encodeURIComponent(entryId.value)}`
103
+ : null,
104
+ {
105
+ immediate: false,
106
+ server: false,
107
+ watch: false,
108
+ refreshOnPageRefresh: false,
109
+ },
110
+ )
111
+
85
112
  async function fetchHistoryEntry(id: string): Promise<HistoryEntry> {
86
- return $authFetch<HistoryEntry>(
87
- `${QUERY_BASE}/history/${encodeURIComponent(id)}`,
88
- )
113
+ entryId.value = id
114
+ await entry.execute()
115
+ if (entry.error.value || !entry.data.value)
116
+ throw entry.error.value ?? new Error('History entry not read')
117
+ return entry.data.value
89
118
  }
90
119
 
91
120
  async function clearHistory(): Promise<void> {
92
121
  await $authFetch<{ success: boolean }>(`${QUERY_BASE}/history`, {
93
122
  method: 'DELETE',
94
123
  })
95
- historyItems.value = []
124
+ history.data.value = { items: [] }
96
125
  }
97
126
 
98
127
  async function dryRun(query: Record<string, unknown>): Promise<DryRunResult> {
@@ -102,15 +131,6 @@ export function useQueryStore() {
102
131
  })
103
132
  }
104
133
 
105
- /** Every run the server keeps, at once. */
106
- async function fetchHistory(): Promise<void> {
107
- const response = await $authFetch<{ items: HistoryEntry[] }>(
108
- `${QUERY_BASE}/history`,
109
- { query: { limit: HISTORY_KEPT } },
110
- )
111
- historyItems.value = response.items ?? []
112
- }
113
-
114
134
  async function executeQuery(
115
135
  query: Record<string, unknown>,
116
136
  source: string,
@@ -147,10 +167,22 @@ export function useQueryStore() {
147
167
  }
148
168
 
149
169
  return {
150
- savedQueries,
151
- historyItems,
152
- fetchSaved,
153
- fetchHistory,
170
+ /** The caller's saved queries. A failed refresh keeps the last list. */
171
+ savedQueries: computed(() => saved.data.value?.items ?? []),
172
+ /** The queries shared with the team, the caller's own included. */
173
+ sharedQueries: computed(() => shared.data.value?.items ?? []),
174
+ /** Every run the server keeps, newest first. */
175
+ historyItems: computed(() => history.data.value?.items ?? []),
176
+ /** The library's first read is still running. */
177
+ libraryLoading: computed(() =>
178
+ reads.some(
179
+ (read) => read.data.value === null && read.status.value !== 'error',
180
+ ),
181
+ ),
182
+ /** Settles once the library's first read did, failed or not. */
183
+ libraryRead: Promise.all(reads).then(() => undefined),
184
+ refreshSaved,
185
+ refreshHistory: () => history.refresh(),
154
186
  fetchHistoryEntry,
155
187
  clearHistory,
156
188
  dryRun,
@@ -0,0 +1,50 @@
1
+ import type { Ref } from 'vue'
2
+ import { isFilterToken } from './useDataBrowserGrid'
3
+ import { DEFAULT_INSTANCE_VALUE } from './useDataBrowserTabs'
4
+
5
+ const TABLES_ENDPOINT = '/api/database/browse/tables'
6
+
7
+ interface TableEntry {
8
+ name: string
9
+ elementCount: number
10
+ }
11
+
12
+ /**
13
+ * The rows of each table of a schema in one instance, for the data browser's
14
+ * table list. A server render reads them and hands them over with the page;
15
+ * another schema or instance is read once picked. No count shows while they
16
+ * load, nor once they failed: `failed` says so.
17
+ */
18
+ export function useTableCounts(
19
+ schema: Ref<string | null>,
20
+ instance: Ref<string>,
21
+ ) {
22
+ const { data, status } = useApiData<{ items: TableEntry[] }>(
23
+ () => (schema.value ? TABLES_ENDPOINT : null),
24
+ {
25
+ query: () => {
26
+ const query: Record<string, string> = {
27
+ filter_schema: isFilterToken(schema.value ?? ''),
28
+ }
29
+ if (instance.value !== DEFAULT_INSTANCE_VALUE)
30
+ query.filter_instance = isFilterToken(instance.value)
31
+ return query
32
+ },
33
+ },
34
+ )
35
+
36
+ return {
37
+ /** Rows by table name. */
38
+ counts: computed<Record<string, number>>(() =>
39
+ status.value === 'success'
40
+ ? Object.fromEntries(
41
+ (data.value?.items ?? []).map((item) => [
42
+ item.name,
43
+ item.elementCount,
44
+ ]),
45
+ )
46
+ : {},
47
+ ),
48
+ failed: computed(() => status.value === 'error'),
49
+ }
50
+ }
@@ -33,19 +33,20 @@ export interface DayGroup<T> {
33
33
  items: T[]
34
34
  }
35
35
 
36
- function dayKey(date: Date): string {
37
- return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
38
- }
39
-
40
- /** Entries by local calendar day, newest day first, in their given order. */
36
+ /**
37
+ * Entries by calendar day, newest day first, in their given order. `dayOf`
38
+ * names a date's day: the reader's (`regionalDayKey`), so that a server
39
+ * render groups as the browser does.
40
+ */
41
41
  export function groupByDay<T>(
42
42
  entries: T[],
43
43
  dateOf: (entry: T) => string,
44
+ dayOf: (date: Date) => string,
44
45
  ): DayGroup<T>[] {
45
46
  const groups = new Map<string, DayGroup<T>>()
46
47
  for (const entry of entries) {
47
48
  const date = new Date(dateOf(entry))
48
- const key = dayKey(date)
49
+ const key = dayOf(date)
49
50
  const group = groups.get(key) ?? { key, date, items: [] }
50
51
  group.items.push(entry)
51
52
  groups.set(key, group)
@@ -53,27 +54,6 @@ export function groupByDay<T>(
53
54
  return [...groups.values()]
54
55
  }
55
56
 
56
- const DAY_MS = 86_400_000
57
-
58
- /** "Today", "Yesterday" or the date, in the interface language. */
59
- export function dayLabel(
60
- date: Date,
61
- locale: string,
62
- words: { today: string; yesterday: string },
63
- ): string {
64
- const today = new Date()
65
- today.setHours(0, 0, 0, 0)
66
- const day = new Date(date)
67
- day.setHours(0, 0, 0, 0)
68
- const days = Math.round((today.getTime() - day.getTime()) / DAY_MS)
69
- if (days === 0) return words.today
70
- if (days === 1) return words.yesterday
71
- return new Intl.DateTimeFormat(locale, {
72
- day: 'numeric',
73
- month: 'short',
74
- }).format(date)
75
- }
76
-
77
57
  export type UnknownKind = 'schema' | 'instance' | 'table'
78
58
 
79
59
  /** A name a query used that its schema does not know. */
@@ -1,4 +1,4 @@
1
- import { computed, ref, watch } from 'vue'
1
+ import { computed } from 'vue'
2
2
  import {
3
3
  readFilterState,
4
4
  sourceQuery,
@@ -16,47 +16,24 @@ interface SourceResponse {
16
16
  all?: number
17
17
  }
18
18
 
19
- type ListingStatus = 'idle' | 'pending' | 'success' | 'error'
20
-
21
19
  export function useSchemasListing() {
22
20
  const route = useDmsRoute()
23
- const { $authFetch } = useAuthFetch()
24
21
  const state = computed<SchemasFilterState>(() => readFilterState(route.query))
25
- const query = computed(() => sourceQuery(state.value))
26
-
27
- // The counts of the last answer stay while the next one loads.
28
- const total = ref(0)
29
- const all = ref<number | null>(null)
30
- const status = ref<ListingStatus>('idle')
31
- let latest = 0
32
22
 
33
- async function load() {
34
- const request = ++latest
35
- status.value = 'pending'
36
- try {
37
- const response = await $authFetch<SourceResponse>(TABLES_SOURCE, {
38
- query: query.value,
39
- })
40
- if (request !== latest) return
41
- total.value = response.total
42
- all.value = response.all ?? null
43
- status.value = 'success'
44
- } catch {
45
- if (request === latest) status.value = 'error'
46
- }
47
- }
48
-
49
- // In the browser only: the server renders no count.
50
- if (typeof window !== 'undefined') {
51
- watch(() => JSON.stringify(query.value), load, { immediate: true })
52
- }
23
+ // Rendered on the server and handed over, the counts only (not the rows
24
+ // the route lists); the counts of the last answer stay while the next one
25
+ // loads.
26
+ const { data, status } = useApiData<SourceResponse>(TABLES_SOURCE, {
27
+ query: () => sourceQuery(state.value),
28
+ pick: ['total', 'all'],
29
+ })
53
30
 
54
31
  return {
55
32
  state,
56
33
  /** Tables the bar's filters list. */
57
- total,
34
+ total: computed(() => data.value?.total ?? 0),
58
35
  /** Registered tables before any filter; null until known. */
59
- all,
36
+ all: computed(() => data.value?.all ?? null),
60
37
  status,
61
38
  }
62
39
  }
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { useDatabaseHealth } from '../build/composables/useDatabaseHealth'
3
- import { useIntervalFn, useNow } from '@vueuse/core'
3
+ import { useNow } from '@vueuse/core'
4
4
 
5
5
  // The overview's health hero: whether the database answers, how fast, and
6
6
  // what it holds. The probe runs again every PROBE_INTERVAL_MS while the page
@@ -10,10 +10,16 @@ const SECOND_MS = 1000
10
10
  const MINUTE_S = 60
11
11
 
12
12
  const { t, n, locale } = useI18n()
13
- const { health, isLoading, error, refresh } = useDatabaseHealth()
14
- const now = useNow({ interval: SECOND_MS })
15
-
16
- useIntervalFn(() => refresh(), PROBE_INTERVAL_MS)
13
+ const { health, isLoading, error, refresh } = useDatabaseHealth({
14
+ refreshInterval: PROBE_INTERVAL_MS,
15
+ })
16
+ const clock = useNow({ interval: SECOND_MS })
17
+ // The probe's own time until the page is mounted: the server renders the age
18
+ // a few seconds before the browser hydrates it, and both must write the same.
19
+ const mounted = ref(false)
20
+ onMounted(() => {
21
+ mounted.value = true
22
+ })
17
23
 
18
24
  // A failed health request is a lost connection too: the route answers
19
25
  // whenever the backend is up.
@@ -24,12 +30,9 @@ const isDown = computed(
24
30
  const checkedAgo = computed(() => {
25
31
  const checkedAt = health.value.checkedAt
26
32
  if (!checkedAt) return ''
27
- const seconds = Math.max(
28
- 0,
29
- Math.round(
30
- (now.value.getTime() - new Date(checkedAt).getTime()) / SECOND_MS,
31
- ),
32
- )
33
+ const checked = new Date(checkedAt).getTime()
34
+ const now = mounted.value ? clock.value.getTime() : checked
35
+ const seconds = Math.max(0, Math.round((now - checked) / SECOND_MS))
33
36
  return seconds < MINUTE_S
34
37
  ? t('dms_database.overview.connection.checked_seconds', { count: seconds })
35
38
  : t('dms_database.overview.connection.checked_minutes', {
@@ -6,10 +6,11 @@ import {
6
6
  tabInstanceOf,
7
7
  useDataBrowserTabs,
8
8
  } from '../build/composables/useDataBrowserTabs'
9
- import { isFilterToken } from '../build/composables/useDataBrowserGrid'
9
+ import { useTableCounts } from '../build/composables/useTableCounts'
10
10
  import type { InstanceChoice } from '../build/data/instanceOptions'
11
11
  import { onKeyStroke } from '@vueuse/core'
12
12
  import type { SchemaSummary } from '../build/composables/useDatabaseSchemas'
13
+ import { instanceFromUrl } from '../build/utils/databaseLinks'
13
14
  import InstancePicker from './InstancePicker.vue'
14
15
  import SchemaPicker from './SchemaPicker.vue'
15
16
 
@@ -20,13 +21,6 @@ import SchemaPicker from './SchemaPicker.vue'
20
21
  // table open in a tab carries a hollow ring, the active one included: the
21
22
  // fill of the row says which is shown.
22
23
 
23
- const TABLES_ENDPOINT = '/api/database/browse/tables'
24
-
25
- interface TableEntry {
26
- name: string
27
- elementCount: number
28
- }
29
-
30
24
  const props = defineProps<{
31
25
  schemas: SchemaSummary[]
32
26
  /** The schema list is being read. */
@@ -38,7 +32,6 @@ const props = defineProps<{
38
32
  const emit = defineEmits<{ retry: [] }>()
39
33
 
40
34
  const { t, n } = useI18n()
41
- const { $authFetch } = useAuthFetch()
42
35
  const { tabs, activeId, activeTab, activations, openTab } = useDataBrowserTabs()
43
36
  const route = useDmsRoute()
44
37
 
@@ -49,13 +42,17 @@ const filterInput = useTemplateRef<{ inputRef?: HTMLInputElement }>(
49
42
  'filterInput',
50
43
  )
51
44
 
52
- // A schema-only link (?schema=X with no table) picks the schema; otherwise
53
- // the active tab leads, then the first schema. A schema or instance picked
54
- // here holds until the user goes to a tab (another one, or the same again).
45
+ // The schema a link names (?schema=X) leads, else the first one; once a tab
46
+ // is active, it leads. A table link (?schema=X&table=Y) opens its tab once
47
+ // mounted, in the instance it names: that scope is shown from the start, so
48
+ // the server renders the counts the browser keeps. A schema or instance
49
+ // picked here holds until the user goes to a tab (another one, or the same
50
+ // again).
55
51
  const linkedSchema =
56
- typeof route.query.schema === 'string' && !route.query.table
57
- ? route.query.schema
58
- : null
52
+ typeof route.query.schema === 'string' ? route.query.schema : null
53
+ const linkedInstance = route.query.table
54
+ ? tabInstanceOf(instanceFromUrl(route.query.instance))
55
+ : DEFAULT_INSTANCE_VALUE
59
56
 
60
57
  watch(
61
58
  [activeId, activations],
@@ -74,6 +71,7 @@ watch(
74
71
  if (schemaId.value || list.length === 0) return
75
72
  const linked = list.find((schema) => schema.id === linkedSchema)
76
73
  schemaId.value = linked?.id ?? list[0]?.id ?? null
74
+ if (linked) instance.value = linkedInstance
77
75
  },
78
76
  { immediate: true },
79
77
  )
@@ -111,32 +109,7 @@ const instanceName = computed(() => {
111
109
  })
112
110
 
113
111
  // --- tables and their counts in the picked instance ---
114
- const counts = ref<Record<string, number>>({})
115
- const countsFailed = ref(false)
116
-
117
- watch(
118
- [schemaId, instance],
119
- async ([id, picked]) => {
120
- counts.value = {}
121
- countsFailed.value = false
122
- if (!id) return
123
- const query: Record<string, string> = { filter_schema: isFilterToken(id) }
124
- if (picked !== DEFAULT_INSTANCE_VALUE)
125
- query.filter_instance = isFilterToken(picked)
126
- try {
127
- const res = await $authFetch<{ items: TableEntry[] }>(TABLES_ENDPOINT, {
128
- query,
129
- })
130
- if (schemaId.value !== id || instance.value !== picked) return
131
- counts.value = Object.fromEntries(
132
- res.items.map((item) => [item.name, item.elementCount]),
133
- )
134
- } catch {
135
- if (schemaId.value === id) countsFailed.value = true
136
- }
137
- },
138
- { immediate: true },
139
- )
112
+ const { counts, failed: countsFailed } = useTableCounts(schemaId, instance)
140
113
 
141
114
  const openIds = computed(() => new Set(tabs.value.map((tab) => tab.id)))
142
115
 
@@ -327,7 +300,12 @@ onKeyStroke('t', (event) => {
327
300
  <span v-if="countsFailed">
328
301
  {{ t('dms_database.data.scope.counts_failed') }}
329
302
  </span>
330
- <i18n-t v-else keypath="dms_database.data.scope.counts_for" tag="span">
303
+ <i18n-t
304
+ v-else
305
+ keypath="dms_database.data.scope.counts_for"
306
+ tag="span"
307
+ scope="global"
308
+ >
331
309
  <template #instance>
332
310
  <span class="text-primary font-mono">{{ instanceName }}</span>
333
311
  </template>
@@ -248,7 +248,7 @@ async function close(tab: BrowserTab) {
248
248
  v-if="hasPreview"
249
249
  class="text-dimmed hidden shrink-0 self-center whitespace-nowrap px-3 pb-1 pt-1.5 text-[11px] lg:inline"
250
250
  >
251
- <i18n-t keypath="dms_database.data.tabs.hint" tag="span">
251
+ <i18n-t keypath="dms_database.data.tabs.hint" tag="span" scope="global">
252
252
  <template #italic>
253
253
  <em>{{ t('dms_database.data.tabs.italic') }}</em>
254
254
  </template>