@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
@@ -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"
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
- dayLabel,
4
3
  groupByDay,
5
4
  HISTORY_KEPT,
6
5
  RUN_DOT_CLASSES,
@@ -38,7 +37,10 @@ const emit = defineEmits<{
38
37
  'clear-history': []
39
38
  }>()
40
39
 
41
- const { t, n, locale } = useI18n()
40
+ const { t, n } = useI18n()
41
+ // Times and days in the user's time zone, which a server render writes in
42
+ // the browser's: the HTML shows what the browser shows.
43
+ const { dateTimeFormat } = useRegionalFormat()
42
44
 
43
45
  const tab = ref<LibraryTab>('history')
44
46
  const search = ref('')
@@ -76,15 +78,18 @@ const historyGroups = computed(() =>
76
78
  matches(entry.source, entry.error, target),
77
79
  ),
78
80
  ({ entry }) => entry.executedAt,
79
- ).map((group) => ({
80
- ...group,
81
- label: dayLabel(group.date, locale.value, {
82
- today: t('dms_database.query.library.today'),
83
- yesterday: t('dms_database.query.library.yesterday'),
84
- }),
85
- })),
81
+ regionalDayKey,
82
+ ).map((group) => ({ ...group, label: dayLabel(group.date) })),
86
83
  )
87
84
 
85
+ /** "Today", "Yesterday" or the date. */
86
+ function dayLabel(date: Date): string {
87
+ const daysAgo = regionalDayNumber(new Date()) - regionalDayNumber(date)
88
+ if (daysAgo === 0) return t('dms_database.query.library.today')
89
+ if (daysAgo === 1) return t('dms_database.query.library.yesterday')
90
+ return dateTimeFormat({ day: 'numeric', month: 'short' }).format(date)
91
+ }
92
+
88
93
  const savedItems = computed(() =>
89
94
  (tab.value === 'team' ? props.team : props.saved).filter((query) =>
90
95
  matches(query.name, query.description, query.source, query.ownerName),
@@ -92,10 +97,9 @@ const savedItems = computed(() =>
92
97
  )
93
98
 
94
99
  function time(iso: string): string {
95
- return new Intl.DateTimeFormat(locale.value, {
96
- hour: '2-digit',
97
- minute: '2-digit',
98
- }).format(new Date(iso))
100
+ return dateTimeFormat({ hour: '2-digit', minute: '2-digit' }).format(
101
+ new Date(iso),
102
+ )
99
103
  }
100
104
 
101
105
  function runMeta(entry: HistoryEntry): string {
@@ -116,10 +120,9 @@ function runMeta(entry: HistoryEntry): string {
116
120
  }
117
121
 
118
122
  function savedMeta(query: SavedQuery): string {
119
- const date = new Intl.DateTimeFormat(locale.value, {
120
- day: 'numeric',
121
- month: 'short',
122
- }).format(new Date(query.updatedAt ?? query.createdAt))
123
+ const date = dateTimeFormat({ day: 'numeric', month: 'short' }).format(
124
+ new Date(query.updatedAt ?? query.createdAt),
125
+ )
123
126
  return query.ownerName ? `${query.ownerName} · ${date}` : date
124
127
  }
125
128
 
@@ -58,7 +58,6 @@ const props = defineProps<{
58
58
  const emit = defineEmits<{ success: [] }>()
59
59
 
60
60
  const { t, n } = useI18n()
61
- const { $authFetch } = useAuthFetch()
62
61
  const staged = useStagedEdits()
63
62
 
64
63
  const active = ref<DrawerTab>('fields')
@@ -169,23 +168,21 @@ function setBoolean(field: string, value: boolean) {
169
168
  }
170
169
 
171
170
  // --- referenced by ---
172
- const references = ref<RowReference[] | null>(null)
173
- const referencesFailed = ref(false)
174
-
175
- async function loadReferences() {
176
- if (!props.rowId || props.readOnly) return
177
- referencesFailed.value = false
178
- try {
179
- const res = await $authFetch<{ items: RowReference[] }>(
180
- '/api/database/browse/references',
181
- {
182
- query: { ...browseSelectionQuery(props.tab), id: props.rowId },
183
- },
184
- )
185
- references.value = res.items
186
- } catch {
187
- referencesFailed.value = true
188
- }
171
+ // Read once the drawer opens, in the browser.
172
+ const referencesRead = useApiData<{ items: RowReference[] }>(
173
+ () =>
174
+ props.rowId && !props.readOnly ? '/api/database/browse/references' : null,
175
+ {
176
+ query: () => ({ ...browseSelectionQuery(props.tab), id: props.rowId }),
177
+ immediate: false,
178
+ server: false,
179
+ },
180
+ )
181
+ const references = computed(() => referencesRead.data.value?.items ?? null)
182
+ const referencesFailed = computed(() => referencesRead.status.value === 'error')
183
+
184
+ function loadReferences() {
185
+ void referencesRead.execute()
189
186
  }
190
187
 
191
188
  onMounted(loadReferences)
@@ -263,13 +263,10 @@ async function load(id: string) {
263
263
  await leaving
264
264
  if (sync !== owner) return
265
265
  try {
266
- const [positions, notes] = await Promise.all([
267
- persistence.fetchLayout(id),
268
- persistence.fetchNotes(id),
269
- ])
266
+ const stored = await persistence.readDiagram(id)
270
267
  if (sync !== owner) return
271
- state.positions = positions
272
- state.notes = notes.map(noteFromStored)
268
+ state.positions = stored.positions
269
+ state.notes = stored.notes.map(noteFromStored)
273
270
  } catch {
274
271
  if (sync !== owner) return
275
272
  state.positions = {}
@@ -288,7 +285,10 @@ async function load(id: string) {
288
285
  loaded.value = true
289
286
  }
290
287
 
291
- watch(schemaId, (id) => id && load(id), { immediate: true })
288
+ // The canvas is drawn in the browser, from a working copy of the layout and
289
+ // notes it reads there: a server render, which would read them without
290
+ // handing them over, never starts it.
291
+ onMounted(() => watch(schemaId, (id) => id && load(id), { immediate: true }))
292
292
 
293
293
  function record(op: DiagramOperation) {
294
294
  history.push(op)