@antelopejs/dms-database 0.1.2 → 0.2.1

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 (41) hide show
  1. package/README.md +73 -49
  2. package/dist/db/models/diagram_layouts.model.d.ts +1 -0
  3. package/dist/db/models/diagram_notes.model.d.ts +1 -0
  4. package/dist/db/models/query_history.model.d.ts +1 -0
  5. package/dist/db/models/saved_queries.model.d.ts +1 -0
  6. package/dist/routes/data.d.ts +1 -3
  7. package/dist/routes/data.js +6 -1
  8. package/dist/routes/data.js.map +1 -1
  9. package/frontend-vue/app/build/composables/useDataBrowserGrid.ts +4 -38
  10. package/frontend-vue/app/build/composables/useDatabaseHealth.ts +7 -4
  11. package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +1 -2
  12. package/frontend-vue/app/build/composables/useDiagramPersistence.ts +49 -16
  13. package/frontend-vue/app/build/composables/useQueryStore.ts +59 -27
  14. package/frontend-vue/app/build/composables/useTableCounts.ts +50 -0
  15. package/frontend-vue/app/build/data/stagedWrites.ts +46 -6
  16. package/frontend-vue/app/build/data/useStagedSave.ts +42 -12
  17. package/frontend-vue/app/build/query/runs.ts +7 -27
  18. package/frontend-vue/app/build/schemas/useSchemasListing.ts +10 -33
  19. package/frontend-vue/app/components/ConnectionStatus.vue +14 -11
  20. package/frontend-vue/app/components/DataBrowserSidebar.vue +20 -42
  21. package/frontend-vue/app/components/DataBrowserTabBar.vue +1 -1
  22. package/frontend-vue/app/components/DataGrid.vue +73 -91
  23. package/frontend-vue/app/components/InstancePicker.vue +63 -55
  24. package/frontend-vue/app/components/QueryConsole.vue +47 -37
  25. package/frontend-vue/app/components/QueryLibrary.vue +20 -17
  26. package/frontend-vue/app/components/RowDrawer.vue +15 -18
  27. package/frontend-vue/app/components/SchemaDiagram.vue +7 -7
  28. package/frontend-vue/app/components/TableInspector.vue +116 -84
  29. package/frontend-vue/package.json +4 -3
  30. package/frontend-vue/pnpm-lock.yaml +1388 -2803
  31. package/frontend-vue/tests/dataBrowser.test.ts +59 -1
  32. package/frontend-vue/tests/fixtures/inertia.ts +8 -0
  33. package/frontend-vue/tests/fixtures/serverRender.ts +219 -0
  34. package/frontend-vue/tests/fixtures/vueI18n.ts +4 -0
  35. package/frontend-vue/tests/helpers.test.ts +1 -0
  36. package/frontend-vue/tests/locales.test.ts +24 -0
  37. package/frontend-vue/tests/onDemandReads.test.ts +106 -0
  38. package/frontend-vue/tests/serverRender.test.ts +235 -0
  39. package/frontend-vue/vitest.config.ts +24 -0
  40. package/package.json +31 -14
  41. package/skills/dms-database/SKILL.md +35 -0
@@ -8,14 +8,14 @@ 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'
15
14
  import { onKeyStroke, useEventListener } from '@vueuse/core'
16
15
  import { parseDraft } from '../build/data/cellValues'
17
16
  import { useStagedEdits } from '../build/data/stagedEdits'
18
- import { useStagedSave } from '../build/data/useStagedSave'
17
+ import { mayMoveRows, withWrittenRow } from '../build/data/stagedWrites'
18
+ import { type SavedChanges, useStagedSave } from '../build/data/useStagedSave'
19
19
  import type { TableSummary } from '../build/composables/useDatabaseSchemas'
20
20
  import type { BrowserTab } from '../build/composables/useDataBrowserTabs'
21
21
  import type {
@@ -34,6 +34,8 @@ import RowDrawer from './RowDrawer.vue'
34
34
  // The grid of one open table. Rows are read a page at a time; a committed
35
35
  // cell is staged, not written (D-02): the save bar offers a review of every
36
36
  // change before it is written, then the save and its undo for a few seconds.
37
+ // A row written shows as the server answered it, in place: the page is read
38
+ // again only when the write may move rows in or out of it.
37
39
 
38
40
  const BROWSE_ENDPOINT = '/api/database/browse'
39
41
  // Browse-only columns: the source instance of a cross-instance read.
@@ -63,7 +65,6 @@ const props = defineProps<{
63
65
  }>()
64
66
 
65
67
  const { t, n } = useI18n()
66
- const { $authFetch } = useAuthFetch()
67
68
  const router = useDmsRouter()
68
69
  const grid = useDataBrowserGrid()
69
70
  const staged = useStagedEdits()
@@ -75,12 +76,23 @@ const state = grid.getState(props.tab.id)
75
76
  const readOnly = computed(() => props.tab.instance === CROSS_INSTANCE_VALUE)
76
77
 
77
78
  // --- 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)
79
+ const selection = computed(() => browseSelectionQuery(props.tab))
80
+ // The columns sampled from the table: the table alone decides them. A
81
+ // failure leaves the grid on the introspected fields.
82
+ const columnsRead = useApiData<ColumnsResponse>(`${BROWSE_ENDPOINT}/columns`, {
83
+ query: selection,
84
+ })
85
+ // A page of rows, read again as the selection, the page, the sort, the search
86
+ // or the filters change; the previous page stays on screen meanwhile.
87
+ const listRead = useApiData<ListResponse>(`${BROWSE_ENDPOINT}/list`, {
88
+ query: () => ({ ...selection.value, ...browseListQuery(state) }),
89
+ })
90
+ const loading = computed(() => listRead.pending.value)
91
+ const loadError = computed(() => {
92
+ const error = listRead.error.value
93
+ if (!error) return null
94
+ return error instanceof Error ? error.message : String(error)
95
+ })
84
96
 
85
97
  function columnsOf(
86
98
  meta: ColumnsResponse | null,
@@ -96,90 +108,36 @@ function columnsOf(
96
108
  }))
97
109
  }
98
110
 
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
111
+ const data = computed<GridData | null>(() => {
112
+ const list = listRead.data.value
113
+ if (!list) return null
114
+ const meta = columnsRead.data.value
115
+ const primaryKey = list.primaryKey ?? meta?.primaryKey ?? null
116
+ return {
117
+ columns: columnsOf(meta, primaryKey),
118
+ rows: list.results ?? [],
119
+ total: list.total ?? 0,
120
+ primaryKey,
161
121
  }
162
- }
163
-
164
- watch(
165
- () => browseParamsKey(props.tab, state),
166
- () => load(),
167
- { immediate: true },
168
- )
122
+ })
169
123
 
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
- )
124
+ watch(data, (next) => {
125
+ if (!next) return
126
+ if (
127
+ editing.value &&
128
+ rowIdOf(next.rows[editing.value.r] ?? {}) !== editing.value.rowId
129
+ ) {
130
+ cancelEdit()
131
+ }
132
+ // Rows deleted elsewhere can leave the page past the end.
133
+ if (next.total > 0 && state.page * state.pageSize >= next.total) {
134
+ state.page = Math.max(0, Math.ceil(next.total / state.pageSize) - 1)
135
+ }
136
+ })
179
137
 
180
138
  function reload() {
181
- grid.invalidate(props.tab.id)
182
- load(true)
139
+ void columnsRead.refresh()
140
+ void listRead.refresh()
183
141
  }
184
142
 
185
143
  const columns = computed(() => data.value?.columns ?? [])
@@ -237,7 +195,6 @@ watch(searchDraft, (value) => {
237
195
  })
238
196
  onBeforeUnmount(() => {
239
197
  clearTimeout(searchTimer)
240
- requestSeq += 1
241
198
  const value = searchDraft.value.trim()
242
199
  if (value !== state.search) {
243
200
  state.search = value
@@ -492,6 +449,30 @@ const pendingCount = computed(() => staged.count(props.tab.id))
492
449
  const changedFields = computed(() => [
493
450
  ...new Set(staged.list(props.tab.id).map((change) => change.field)),
494
451
  ])
452
+ // The page on screen, with a row written in place of the one read: the read's
453
+ // data is replaced (it is shallow), so the cell shows the stored value at once.
454
+ function showWritten(tab: BrowserTab, row: Record<string, unknown>) {
455
+ const list = listRead.data.value
456
+ if (tab.id !== props.tab.id || !list) return
457
+ listRead.data.value = {
458
+ ...list,
459
+ results: withWrittenRow(list.results ?? [], row, rowIdOf),
460
+ }
461
+ }
462
+
463
+ // A save may change which rows the page holds, or their order, only through
464
+ // the fields it sorts, filters or searches by; a row gone meanwhile leaves.
465
+ function afterSave({ fields, gone }: SavedChanges) {
466
+ const filtered = Object.entries(state.filters)
467
+ .filter(([, filter]) => filter.value)
468
+ .map(([field]) => field)
469
+ if (
470
+ gone > 0 ||
471
+ mayMoveRows(fields, { sortKey: state.sortKey, search: state.search, filtered })
472
+ )
473
+ reload()
474
+ }
475
+
495
476
  const {
496
477
  saving,
497
478
  review,
@@ -499,7 +480,8 @@ const {
499
480
  undo,
500
481
  } = useStagedSave({
501
482
  tab: () => props.tab,
502
- onSaved: reload,
483
+ onWritten: showWritten,
484
+ onSaved: afterSave,
503
485
  })
504
486
 
505
487
  onKeyStroke('s', (event) => {
@@ -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)