@antelopejs/dms-database 0.1.1 → 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 (36) hide show
  1. package/README.md +1 -1
  2. package/dist/pages/schemas.js +2 -1
  3. package/dist/pages/schemas.js.map +1 -1
  4. package/dist/routes/tables.d.ts +1 -0
  5. package/dist/routes/tables.js +35 -10
  6. package/dist/routes/tables.js.map +1 -1
  7. package/frontend-vue/app/build/composables/useDataBrowserGrid.ts +4 -38
  8. package/frontend-vue/app/build/composables/useDatabaseHealth.ts +7 -4
  9. package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +1 -2
  10. package/frontend-vue/app/build/composables/useDiagramPersistence.ts +49 -16
  11. package/frontend-vue/app/build/composables/useQueryStore.ts +59 -27
  12. package/frontend-vue/app/build/composables/useTableCounts.ts +50 -0
  13. package/frontend-vue/app/build/query/runs.ts +7 -27
  14. package/frontend-vue/app/build/schemas/useSchemasListing.ts +10 -33
  15. package/frontend-vue/app/components/ConnectionStatus.vue +14 -11
  16. package/frontend-vue/app/components/DataBrowserSidebar.vue +20 -42
  17. package/frontend-vue/app/components/DataBrowserTabBar.vue +1 -1
  18. package/frontend-vue/app/components/DataGrid.vue +43 -89
  19. package/frontend-vue/app/components/InstancePicker.vue +63 -55
  20. package/frontend-vue/app/components/QueryConsole.vue +47 -37
  21. package/frontend-vue/app/components/QueryLibrary.vue +20 -17
  22. package/frontend-vue/app/components/RowDrawer.vue +15 -18
  23. package/frontend-vue/app/components/SchemaDiagram.vue +7 -7
  24. package/frontend-vue/app/components/TableInspector.vue +134 -88
  25. package/frontend-vue/package.json +2 -1
  26. package/frontend-vue/pnpm-lock.yaml +883 -2587
  27. package/frontend-vue/tests/fixtures/inertia.ts +8 -0
  28. package/frontend-vue/tests/fixtures/serverRender.ts +219 -0
  29. package/frontend-vue/tests/fixtures/vueI18n.ts +4 -0
  30. package/frontend-vue/tests/helpers.test.ts +1 -0
  31. package/frontend-vue/tests/locales.test.ts +24 -0
  32. package/frontend-vue/tests/onDemandReads.test.ts +106 -0
  33. package/frontend-vue/tests/registration.test.ts +0 -12
  34. package/frontend-vue/tests/serverRender.test.ts +235 -0
  35. package/frontend-vue/vitest.config.ts +24 -0
  36. package/package.json +6 -5
@@ -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>
@@ -8,7 +8,6 @@ import { useDatabaseSchemas } from '../build/composables/useDatabaseSchemas'
8
8
  import {
9
9
  GRID_PAGE_SIZES,
10
10
  browseListQuery,
11
- browseParamsKey,
12
11
  browseSelectionQuery,
13
12
  useDataBrowserGrid,
14
13
  } from '../build/composables/useDataBrowserGrid'
@@ -63,7 +62,6 @@ const props = defineProps<{
63
62
  }>()
64
63
 
65
64
  const { t, n } = useI18n()
66
- const { $authFetch } = useAuthFetch()
67
65
  const router = useDmsRouter()
68
66
  const grid = useDataBrowserGrid()
69
67
  const staged = useStagedEdits()
@@ -75,12 +73,23 @@ const state = grid.getState(props.tab.id)
75
73
  const readOnly = computed(() => props.tab.instance === CROSS_INSTANCE_VALUE)
76
74
 
77
75
  // --- data loading ---
78
- const data = ref<GridData | null>(grid.getCache(props.tab.id))
79
- const loading = ref(false)
80
- const loadError = ref<string | null>(null)
81
- let requestSeq = 0
82
- // Column metadata only depends on the tab, so it is read once per mount.
83
- const columnsMeta = ref<ColumnsResponse | null>(null)
76
+ const selection = computed(() => browseSelectionQuery(props.tab))
77
+ // The columns sampled from the table: the table alone decides them. A
78
+ // failure leaves the grid on the introspected fields.
79
+ const columnsRead = useApiData<ColumnsResponse>(`${BROWSE_ENDPOINT}/columns`, {
80
+ query: selection,
81
+ })
82
+ // A page of rows, read again as the selection, the page, the sort, the search
83
+ // or the filters change; the previous page stays on screen meanwhile.
84
+ const listRead = useApiData<ListResponse>(`${BROWSE_ENDPOINT}/list`, {
85
+ query: () => ({ ...selection.value, ...browseListQuery(state) }),
86
+ })
87
+ const loading = computed(() => listRead.pending.value)
88
+ const loadError = computed(() => {
89
+ const error = listRead.error.value
90
+ if (!error) return null
91
+ return error instanceof Error ? error.message : String(error)
92
+ })
84
93
 
85
94
  function columnsOf(
86
95
  meta: ColumnsResponse | null,
@@ -96,90 +105,36 @@ function columnsOf(
96
105
  }))
97
106
  }
98
107
 
99
- async function fetchPage(force: boolean) {
100
- const selection = browseSelectionQuery(props.tab)
101
- const needColumns = force || columnsMeta.value === null
102
- return Promise.all([
103
- needColumns
104
- ? // A columns failure must not discard the rows: the grid falls back
105
- // to the introspected fields.
106
- $authFetch<ColumnsResponse>(`${BROWSE_ENDPOINT}/columns`, {
107
- query: selection,
108
- }).catch(() => null)
109
- : Promise.resolve(columnsMeta.value),
110
- $authFetch<ListResponse>(`${BROWSE_ENDPOINT}/list`, {
111
- query: { ...selection, ...browseListQuery(state) },
112
- }),
113
- ])
114
- }
115
-
116
- async function load(force = false) {
117
- const key = browseParamsKey(props.tab, state)
118
- const cached = grid.getCache(props.tab.id)
119
- if (!force && cached && cached.paramsKey === key) {
120
- // Retire a request in flight: its late answer would overwrite the cache
121
- // with rows for parameters the user has left.
122
- requestSeq += 1
123
- data.value = cached
124
- loadError.value = null
125
- loading.value = false
126
- return
127
- }
128
- const seq = ++requestSeq
129
- loading.value = true
130
- loadError.value = null
131
- try {
132
- const [columnsRes, listRes] = await fetchPage(force)
133
- if (seq !== requestSeq) return
134
- if (columnsRes) columnsMeta.value = columnsRes
135
- const primaryKey =
136
- listRes.primaryKey ?? columnsMeta.value?.primaryKey ?? null
137
- const next: GridData = {
138
- columns: columnsOf(columnsMeta.value, primaryKey),
139
- rows: listRes.results ?? [],
140
- total: listRes.total ?? 0,
141
- primaryKey,
142
- paramsKey: key,
143
- }
144
- grid.setCache(props.tab.id, next)
145
- data.value = next
146
- if (
147
- editing.value &&
148
- rowIdOf(next.rows[editing.value.r] ?? {}) !== editing.value.rowId
149
- ) {
150
- cancelEdit()
151
- }
152
- // Rows deleted elsewhere can leave the page past the end.
153
- if (next.total > 0 && state.page * state.pageSize >= next.total) {
154
- state.page = Math.max(0, Math.ceil(next.total / state.pageSize) - 1)
155
- }
156
- } catch (error) {
157
- if (seq !== requestSeq) return
158
- loadError.value = error instanceof Error ? error.message : String(error)
159
- } finally {
160
- if (seq === requestSeq) loading.value = false
108
+ const data = computed<GridData | null>(() => {
109
+ const list = listRead.data.value
110
+ if (!list) return null
111
+ const meta = columnsRead.data.value
112
+ const primaryKey = list.primaryKey ?? meta?.primaryKey ?? null
113
+ return {
114
+ columns: columnsOf(meta, primaryKey),
115
+ rows: list.results ?? [],
116
+ total: list.total ?? 0,
117
+ primaryKey,
161
118
  }
162
- }
163
-
164
- watch(
165
- () => browseParamsKey(props.tab, state),
166
- () => load(),
167
- { immediate: true },
168
- )
119
+ })
169
120
 
170
- // A table cached empty while the schemas were still loading recovers once
171
- // its introspected fields arrive.
172
- watch(
173
- () => props.table,
174
- (table) => {
175
- if (table && (data.value?.columns.length ?? 0) === 0 && !loading.value)
176
- load(true)
177
- },
178
- )
121
+ watch(data, (next) => {
122
+ if (!next) return
123
+ if (
124
+ editing.value &&
125
+ rowIdOf(next.rows[editing.value.r] ?? {}) !== editing.value.rowId
126
+ ) {
127
+ cancelEdit()
128
+ }
129
+ // Rows deleted elsewhere can leave the page past the end.
130
+ if (next.total > 0 && state.page * state.pageSize >= next.total) {
131
+ state.page = Math.max(0, Math.ceil(next.total / state.pageSize) - 1)
132
+ }
133
+ })
179
134
 
180
135
  function reload() {
181
- grid.invalidate(props.tab.id)
182
- load(true)
136
+ void columnsRead.refresh()
137
+ void listRead.refresh()
183
138
  }
184
139
 
185
140
  const columns = computed(() => data.value?.columns ?? [])
@@ -237,7 +192,6 @@ watch(searchDraft, (value) => {
237
192
  })
238
193
  onBeforeUnmount(() => {
239
194
  clearTimeout(searchTimer)
240
- requestSeq += 1
241
195
  const value = searchDraft.value.trim()
242
196
  if (value !== state.search) {
243
197
  state.search = value
@@ -40,76 +40,84 @@ const emit = defineEmits<{ 'update:modelValue': [choice: InstanceChoice] }>()
40
40
  const INSTANCES_ENDPOINT = '/api/database/tables/instances'
41
41
  const SEARCH_DEBOUNCE_MS = 250
42
42
 
43
+ /** One read of a schema's named instances, with what it searched. */
44
+ interface InstancesPage {
45
+ schema: string
46
+ term: string
47
+ instances: string[]
48
+ total: number
49
+ }
50
+
43
51
  const { t, n } = useI18n()
44
- const { $authFetch } = useAuthFetch()
45
52
 
46
53
  const search = ref('')
54
+ // What the server searches: the term once typing settles.
55
+ const term = ref('')
47
56
  const open = ref(false)
48
- const names = ref<string[]>([])
49
- const matched = ref(0)
50
- // Named instances of the schema, before any search; null until known.
51
- const total = ref<number | null>(null)
52
- const status = ref<InstancesStatus>('idle')
53
- let request = 0
54
- let loadedKey: string | null = null
55
-
56
- async function load() {
57
- const schema = props.schema
58
- if (!schema) return
59
- const term = search.value.trim()
60
- const key = `${schema}\u0000${term}`
61
- if (key === loadedKey) return
62
- const current = ++request
63
- status.value = 'pending'
64
- try {
65
- const res = await $authFetch<{ instances: string[]; total: number }>(
57
+
58
+ watchDebounced(search, (value) => (term.value = value.trim()), {
59
+ debounce: SEARCH_DEBOUNCE_MS,
60
+ })
61
+
62
+ // Read when the menu opens, in the browser only, never on the page's
63
+ // refreshes: a closed menu shows nothing of it.
64
+ const instances = useApiData<InstancesPage>(
65
+ () =>
66
+ props.schema
67
+ ? `dms-database:instances:${JSON.stringify([props.schema, term.value])}`
68
+ : null,
69
+ async ({ $authFetch, signal }) => {
70
+ const schema = props.schema as string
71
+ const searched = term.value
72
+ const page = await $authFetch<{ instances: string[]; total: number }>(
66
73
  INSTANCES_ENDPOINT,
67
74
  {
68
- query: {
69
- schema,
70
- search: term || undefined,
71
- limit: PICKER_LIMIT,
72
- },
75
+ query: { schema, search: searched || undefined, limit: PICKER_LIMIT },
76
+ signal,
73
77
  },
74
78
  )
75
- if (current !== request) return
76
- names.value = res.instances
77
- matched.value = res.total
78
- if (!term) total.value = res.total
79
- status.value = 'success'
80
- loadedKey = key
81
- } catch {
82
- if (current === request) status.value = 'error'
83
- }
84
- }
85
-
86
- // Another schema, other instances: they load when the menu opens.
87
- watch(
88
- () => props.schema,
89
- () => {
90
- request++
91
- loadedKey = null
92
- names.value = []
93
- matched.value = 0
94
- total.value = null
95
- status.value = 'idle'
96
- if (open.value) void load()
79
+ return { schema, term: searched, ...page }
80
+ },
81
+ {
82
+ immediate: false,
83
+ server: false,
84
+ watch: false,
85
+ refreshOnPageRefresh: false,
97
86
  },
98
87
  )
99
88
 
89
+ // Another schema shows none of the previous one's instances while its own
90
+ // load; another search keeps the previous matches on screen meanwhile.
91
+ const page = computed(() => {
92
+ const value = instances.data.value
93
+ return value?.schema === props.schema ? value : null
94
+ })
95
+ const names = computed(() => page.value?.instances ?? [])
96
+ const matched = computed(() => page.value?.total ?? 0)
97
+ const status = computed(() => instances.status.value as InstancesStatus)
98
+
99
+ // Named instances of the schema, before any search; null until known.
100
+ const unsearched = shallowRef<InstancesPage | null>(null)
101
+ watch(page, (value) => {
102
+ if (value && !value.term) unsearched.value = value
103
+ })
104
+ const total = computed(() =>
105
+ unsearched.value?.schema === props.schema ? unsearched.value.total : null,
106
+ )
107
+
108
+ function load() {
109
+ if (!open.value || !props.schema) return
110
+ if (instances.pending.value || instances.status.value === 'success') return
111
+ void instances.execute()
112
+ }
113
+
114
+ watch([() => props.schema, term], load)
115
+
100
116
  function onOpen(value: boolean) {
101
117
  open.value = value
102
- if (value) void load()
118
+ load()
103
119
  }
104
120
 
105
- watchDebounced(
106
- search,
107
- () => {
108
- if (open.value) void load()
109
- },
110
- { debounce: SEARCH_DEBOUNCE_MS },
111
- )
112
-
113
121
  const items = computed(() =>
114
122
  instanceMenu({
115
123
  search: search.value,
@@ -50,10 +50,9 @@ const OPERATION_ICONS: Record<string, string> = {
50
50
  const { t, n } = useI18n()
51
51
  const route = useDmsRoute()
52
52
  const router = useDmsRouter()
53
- const { $authFetch } = useAuthFetch()
54
53
  const { confirm } = useConfirm()
55
54
  const toast = useToast()
56
- const { user } = useUserSession()
55
+ const { user } = useDmsUserSession()
57
56
  const store = useQueryStore()
58
57
  const { schemas } = useDatabaseSchemas()
59
58
 
@@ -64,11 +63,9 @@ const failure = ref<QueryFailure | null>(null)
64
63
  const target = ref<QueryTarget>({})
65
64
  const loadedSaved = ref<SavedQuery | null>(null)
66
65
  const activeHistoryId = ref<string | null>(null)
67
- const libraryLoading = ref(true)
68
66
  // Named instances holding rows of a table the last read found empty in the
69
67
  // default instance.
70
68
  const elsewhere = ref<InstanceRows[]>([])
71
- const team = ref<SavedQuery[]>([])
72
69
  const editor = useTemplateRef<InstanceType<typeof QueryBuilderPanel>>('editor')
73
70
 
74
71
  const edited = computed(
@@ -80,24 +77,10 @@ const ownId = computed(
80
77
  )
81
78
 
82
79
  // --- 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
- }
80
+ // The team's queries: the shared ones the caller did not write.
81
+ const team = computed(() =>
82
+ store.sharedQueries.value.filter((query) => query.userId !== ownId.value),
83
+ )
101
84
 
102
85
  function openSaved(query: SavedQuery) {
103
86
  loadedSaved.value = query
@@ -151,7 +134,7 @@ async function deleteSaved(query: SavedQuery) {
151
134
  if (!confirmed) return
152
135
  await store.deleteSaved(query.id).catch(() => undefined)
153
136
  if (loadedSaved.value?.id === query.id) loadedSaved.value = null
154
- await refreshSaved()
137
+ await store.refreshSaved()
155
138
  }
156
139
 
157
140
  async function clearHistory() {
@@ -185,22 +168,25 @@ function fetchDuration(error: unknown): number | null {
185
168
  return typeof duration === 'number' ? duration : null
186
169
  }
187
170
 
171
+ type CountRows = (query: Record<string, string>) => Promise<{ total: number }>
172
+
188
173
  /**
189
174
  * The named instances of a table that hold rows, each with its count: asked
190
175
  * only when a read found the table empty in the default instance (decision
191
176
  * 11). An instance whose count fails is left out.
192
177
  */
193
- async function instancesWithRows(target: QueryTarget): Promise<InstanceRows[]> {
178
+ async function instancesWithRows(
179
+ target: QueryTarget,
180
+ count: CountRows,
181
+ ): Promise<InstanceRows[]> {
194
182
  const instances =
195
183
  schemas.value.find((schema) => schema.id === target.schema)?.instances ?? []
196
184
  const counts = await Promise.all(
197
185
  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
- },
186
+ count({
187
+ filter_schema: `is:${target.schema}`,
188
+ filter_table: `is:${target.table}`,
189
+ filter_instance: `is:${id}`,
204
190
  }).then(
205
191
  (res) => ({ id, count: res.total }),
206
192
  () => ({ id, count: 0 }),
@@ -210,6 +196,29 @@ async function instancesWithRows(target: QueryTarget): Promise<InstanceRows[]> {
210
196
  return counts.filter((entry) => entry.count > 0)
211
197
  }
212
198
 
199
+ // The table the last read found empty, whose instances are counted: in the
200
+ // browser, after a run.
201
+ const emptyTarget = shallowRef<QueryTarget | null>(null)
202
+ const elsewhereRead = useApiData<InstanceRows[]>(
203
+ () =>
204
+ emptyTarget.value
205
+ ? `dms-database:elsewhere:${JSON.stringify([
206
+ emptyTarget.value.schema,
207
+ emptyTarget.value.table,
208
+ ])}`
209
+ : null,
210
+ ({ $authFetch, signal }) =>
211
+ instancesWithRows(emptyTarget.value as QueryTarget, (query) =>
212
+ $authFetch<{ total: number }>(BROWSE_COUNT, { query, signal }),
213
+ ),
214
+ {
215
+ immediate: false,
216
+ server: false,
217
+ watch: false,
218
+ refreshOnPageRefresh: false,
219
+ },
220
+ )
221
+
213
222
  // Each run drops the instances found for the previous one; a slow answer
214
223
  // for an older run is ignored.
215
224
  let runId = 0
@@ -228,8 +237,9 @@ async function findElsewhere(
228
237
  !next.table
229
238
  )
230
239
  return
231
- const found = await instancesWithRows(next)
232
- if (id === runId) elsewhere.value = found
240
+ emptyTarget.value = next
241
+ await elsewhereRead.execute()
242
+ if (id === runId) elsewhere.value = elsewhereRead.data.value ?? []
233
243
  }
234
244
 
235
245
  // The result keeps naming the previous run's target until the new one lands.
@@ -251,7 +261,7 @@ async function execute(query: Record<string, unknown>, text: string) {
251
261
  target.value = next
252
262
  executing.value = false
253
263
  activeHistoryId.value = null
254
- await refreshHistory()
264
+ await store.refreshHistory()
255
265
  }
256
266
  }
257
267
 
@@ -516,7 +526,7 @@ async function confirmSave() {
516
526
  color: 'success',
517
527
  icon: 'i-ph-check',
518
528
  })
519
- await refreshSaved()
529
+ await store.refreshSaved()
520
530
  } catch (error) {
521
531
  saveError.value =
522
532
  error instanceof Error && !('data' in error)
@@ -570,9 +580,9 @@ async function openFromRoute(): Promise<boolean> {
570
580
  return opened
571
581
  }
572
582
 
583
+ // The link and the draft name queries of the library: once it is read.
573
584
  onMounted(async () => {
574
- await Promise.all([refreshHistory(), refreshSaved()])
575
- libraryLoading.value = false
585
+ await store.libraryRead
576
586
  if (!(await openFromRoute())) restoreDraft()
577
587
  })
578
588
  </script>
@@ -590,7 +600,7 @@ onMounted(async () => {
590
600
  :team="team"
591
601
  :active-saved-id="loadedSaved?.id ?? null"
592
602
  :active-history-id="activeHistoryId"
593
- :loading="libraryLoading"
603
+ :loading="store.libraryLoading.value"
594
604
  @open-history="openHistory"
595
605
  @save-history="openSaveModal"
596
606
  @open-saved="openSaved"