@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.
- package/README.md +73 -49
- package/dist/db/models/diagram_layouts.model.d.ts +1 -0
- package/dist/db/models/diagram_notes.model.d.ts +1 -0
- package/dist/db/models/query_history.model.d.ts +1 -0
- package/dist/db/models/saved_queries.model.d.ts +1 -0
- package/dist/routes/data.d.ts +1 -3
- package/dist/routes/data.js +6 -1
- package/dist/routes/data.js.map +1 -1
- package/frontend-vue/app/build/composables/useDataBrowserGrid.ts +4 -38
- package/frontend-vue/app/build/composables/useDatabaseHealth.ts +7 -4
- package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +1 -2
- package/frontend-vue/app/build/composables/useDiagramPersistence.ts +49 -16
- package/frontend-vue/app/build/composables/useQueryStore.ts +59 -27
- package/frontend-vue/app/build/composables/useTableCounts.ts +50 -0
- package/frontend-vue/app/build/data/stagedWrites.ts +46 -6
- package/frontend-vue/app/build/data/useStagedSave.ts +42 -12
- package/frontend-vue/app/build/query/runs.ts +7 -27
- package/frontend-vue/app/build/schemas/useSchemasListing.ts +10 -33
- package/frontend-vue/app/components/ConnectionStatus.vue +14 -11
- package/frontend-vue/app/components/DataBrowserSidebar.vue +20 -42
- package/frontend-vue/app/components/DataBrowserTabBar.vue +1 -1
- package/frontend-vue/app/components/DataGrid.vue +73 -91
- package/frontend-vue/app/components/InstancePicker.vue +63 -55
- package/frontend-vue/app/components/QueryConsole.vue +47 -37
- package/frontend-vue/app/components/QueryLibrary.vue +20 -17
- package/frontend-vue/app/components/RowDrawer.vue +15 -18
- package/frontend-vue/app/components/SchemaDiagram.vue +7 -7
- package/frontend-vue/app/components/TableInspector.vue +116 -84
- package/frontend-vue/package.json +4 -3
- package/frontend-vue/pnpm-lock.yaml +1388 -2803
- package/frontend-vue/tests/dataBrowser.test.ts +59 -1
- package/frontend-vue/tests/fixtures/inertia.ts +8 -0
- package/frontend-vue/tests/fixtures/serverRender.ts +219 -0
- package/frontend-vue/tests/fixtures/vueI18n.ts +4 -0
- package/frontend-vue/tests/helpers.test.ts +1 -0
- package/frontend-vue/tests/locales.test.ts +24 -0
- package/frontend-vue/tests/onDemandReads.test.ts +106 -0
- package/frontend-vue/tests/serverRender.test.ts +235 -0
- package/frontend-vue/vitest.config.ts +24 -0
- package/package.json +31 -14
- package/skills/dms-database/SKILL.md +35 -0
|
@@ -7,7 +7,12 @@ import {
|
|
|
7
7
|
tabInstanceOf,
|
|
8
8
|
} from '../app/build/composables/useDataBrowserTabs'
|
|
9
9
|
import { cellLabel, draftText, parseDraft } from '../app/build/data/cellValues'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
changesOf,
|
|
12
|
+
mayMoveRows,
|
|
13
|
+
withWrittenRow,
|
|
14
|
+
writeRows,
|
|
15
|
+
} from '../app/build/data/stagedWrites'
|
|
11
16
|
import {
|
|
12
17
|
instanceFromUrl,
|
|
13
18
|
instanceToUrl,
|
|
@@ -148,6 +153,24 @@ describe('staged writes', () => {
|
|
|
148
153
|
expect(settled).toEqual(['a'])
|
|
149
154
|
})
|
|
150
155
|
|
|
156
|
+
it('hands each row written its answer, and a row gone none', async () => {
|
|
157
|
+
const settled: [string, unknown][] = []
|
|
158
|
+
await writeRows(
|
|
159
|
+
rows,
|
|
160
|
+
async (id, body) => {
|
|
161
|
+
if (id === 'b')
|
|
162
|
+
throw Object.assign(new Error('gone'), { statusCode: 404 })
|
|
163
|
+
return { _id: id, ...body, stored: true }
|
|
164
|
+
},
|
|
165
|
+
(id, answer) => settled.push([id, answer]),
|
|
166
|
+
)
|
|
167
|
+
expect(settled).toEqual([
|
|
168
|
+
['a', { _id: 'a', name: 'A', stored: true }],
|
|
169
|
+
['b', undefined],
|
|
170
|
+
['c', { _id: 'c', name: 'C', stored: true }],
|
|
171
|
+
])
|
|
172
|
+
})
|
|
173
|
+
|
|
151
174
|
it('picks the changes of the rows written', () => {
|
|
152
175
|
const changes = [
|
|
153
176
|
{ rowId: 'a', field: 'x' },
|
|
@@ -161,6 +184,41 @@ describe('staged writes', () => {
|
|
|
161
184
|
})
|
|
162
185
|
})
|
|
163
186
|
|
|
187
|
+
describe('rows written in place', () => {
|
|
188
|
+
const idOf = (row: Record<string, unknown>) =>
|
|
189
|
+
row._id === undefined ? null : String(row._id)
|
|
190
|
+
const page = [
|
|
191
|
+
{ _id: 1, name: 'Ada', _instance: 'eu' },
|
|
192
|
+
{ _id: 2, name: 'Bob', _instance: 'eu' },
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
it('shows the row the server answered in place of the one read', () => {
|
|
196
|
+
expect(
|
|
197
|
+
withWrittenRow(page, { _id: 2, name: 'Bea', updated: true }, idOf),
|
|
198
|
+
).toEqual([
|
|
199
|
+
{ _id: 1, name: 'Ada', _instance: 'eu' },
|
|
200
|
+
{ _id: 2, name: 'Bea', updated: true, _instance: 'eu' },
|
|
201
|
+
])
|
|
202
|
+
expect(withWrittenRow(page, { _id: 3, name: 'Cy' }, idOf)).toEqual(page)
|
|
203
|
+
expect(withWrittenRow(page, { name: 'no id' }, idOf)).toBe(page)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('reads the page again only when a write may move rows in or out of it', () => {
|
|
207
|
+
const plain = { sortKey: null, search: '', filtered: [] }
|
|
208
|
+
expect(mayMoveRows(['name'], plain)).toBe(false)
|
|
209
|
+
expect(mayMoveRows(['name'], { ...plain, sortKey: 'total' })).toBe(false)
|
|
210
|
+
expect(mayMoveRows(['name'], { ...plain, sortKey: 'name' })).toBe(true)
|
|
211
|
+
expect(mayMoveRows(['name'], { ...plain, filtered: ['status'] })).toBe(
|
|
212
|
+
false,
|
|
213
|
+
)
|
|
214
|
+
expect(
|
|
215
|
+
mayMoveRows(['name', 'status'], { ...plain, filtered: ['status'] }),
|
|
216
|
+
).toBe(true)
|
|
217
|
+
expect(mayMoveRows(['name'], { ...plain, search: 'ad' })).toBe(true)
|
|
218
|
+
expect(mayMoveRows([], plain)).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
164
222
|
describe('instance URL parameter', () => {
|
|
165
223
|
it('reads none as the default instance, all as every instance', () => {
|
|
166
224
|
expect(instanceFromUrl(undefined)).toEqual({ kind: 'default' })
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// What the renderer imports of Inertia, which a server render of a component
|
|
2
|
+
// does not reach: the browser's page, links and visits.
|
|
3
|
+
export const Link = {}
|
|
4
|
+
export const router = {
|
|
5
|
+
visit: () => undefined,
|
|
6
|
+
on: () => () => undefined,
|
|
7
|
+
}
|
|
8
|
+
export const usePage = () => ({ url: undefined })
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server renders of the module's components on the renderer's real data
|
|
3
|
+
* layer (`#dms/frontend-module`) and the DMS's real `useApiData`, as the
|
|
4
|
+
* generated application runs them: what each render reads from the backend,
|
|
5
|
+
* and what it hands the browser with the page.
|
|
6
|
+
*/
|
|
7
|
+
import * as vue from 'vue'
|
|
8
|
+
import { createSSRApp, h, type Component } from 'vue'
|
|
9
|
+
import { renderToString } from 'vue/server-renderer'
|
|
10
|
+
import { vi } from 'vitest'
|
|
11
|
+
import type { DmsPayload } from '#dms/frontend-module'
|
|
12
|
+
|
|
13
|
+
const engine = await import('#dms/frontend-module')
|
|
14
|
+
const regional = await import('#dms-core/app/utils/regional')
|
|
15
|
+
|
|
16
|
+
/** A backend request a render made. */
|
|
17
|
+
export interface BackendRead {
|
|
18
|
+
url: string
|
|
19
|
+
method: string
|
|
20
|
+
query: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Backend = (read: BackendRead) => unknown
|
|
24
|
+
|
|
25
|
+
interface FetchOptions {
|
|
26
|
+
method?: string
|
|
27
|
+
query?: Record<string, unknown>
|
|
28
|
+
signal?: AbortSignal
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let backend: Backend = () => ({})
|
|
32
|
+
const reads: BackendRead[] = []
|
|
33
|
+
/** The options each read was made with, as `useDmsFetch` passed them. */
|
|
34
|
+
const readOptions = new WeakMap<BackendRead, FetchOptions>()
|
|
35
|
+
|
|
36
|
+
const $authFetch = async (url: string, options: FetchOptions = {}) => {
|
|
37
|
+
const read = {
|
|
38
|
+
url,
|
|
39
|
+
method: (options.method ?? 'GET').toUpperCase(),
|
|
40
|
+
query: options.query ?? {},
|
|
41
|
+
}
|
|
42
|
+
reads.push(read)
|
|
43
|
+
readOptions.set(read, options)
|
|
44
|
+
return backend(read)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const noop = () => undefined
|
|
48
|
+
|
|
49
|
+
// The application's auto-imports: Vue's API, the renderer's, the DMS
|
|
50
|
+
// layers' the module calls, and the module's own composables.
|
|
51
|
+
for (const [name, value] of Object.entries(vue))
|
|
52
|
+
if (/^[a-z]/.test(name)) vi.stubGlobal(name, value)
|
|
53
|
+
vi.stubGlobal('useDmsFetch', engine.useDmsFetch)
|
|
54
|
+
vi.stubGlobal('useDmsAsyncData', engine.useDmsAsyncData)
|
|
55
|
+
vi.stubGlobal('useDmsState', engine.useDmsState)
|
|
56
|
+
vi.stubGlobal('useDmsRoute', engine.useDmsRoute)
|
|
57
|
+
vi.stubGlobal('useDmsRouter', engine.useDmsRouter)
|
|
58
|
+
const { useApiData } = await import('#dms-core/app/composables/auth/useApiData')
|
|
59
|
+
vi.stubGlobal('useApiData', useApiData)
|
|
60
|
+
// `useFetchAuth`: the former name of `useApiData`.
|
|
61
|
+
vi.stubGlobal('useAuthFetch', () => ({ $authFetch, useFetchAuth: useApiData }))
|
|
62
|
+
vi.stubGlobal('useI18n', () => ({
|
|
63
|
+
t: (key: string, params?: Record<string, unknown>) =>
|
|
64
|
+
params ? `${key} ${Object.values(params).join(' ')}` : key,
|
|
65
|
+
n: (value: number) => String(value),
|
|
66
|
+
locale: vue.ref('en'),
|
|
67
|
+
}))
|
|
68
|
+
vi.stubGlobal('useRegionalFormat', () => ({
|
|
69
|
+
locale: vue.ref('en'),
|
|
70
|
+
dateTimeFormat: (options: Intl.DateTimeFormatOptions) =>
|
|
71
|
+
regional.regionalDateTimeFormat('en', options),
|
|
72
|
+
}))
|
|
73
|
+
vi.stubGlobal('regionalDayKey', regional.regionalDayKey)
|
|
74
|
+
vi.stubGlobal('regionalDayNumber', regional.regionalDayNumber)
|
|
75
|
+
vi.stubGlobal('useToast', () => ({ add: noop }))
|
|
76
|
+
vi.stubGlobal('useConfirm', () => ({ confirm: async () => false }))
|
|
77
|
+
vi.stubGlobal('useDrawer', () => ({ open: noop }))
|
|
78
|
+
vi.stubGlobal('useUnsavedChanges', noop)
|
|
79
|
+
vi.stubGlobal('useDmsUserSession', () => ({
|
|
80
|
+
user: vue.ref({ _id: 'me' }),
|
|
81
|
+
loggedIn: vue.ref(true),
|
|
82
|
+
}))
|
|
83
|
+
for (const [name, module] of Object.entries({
|
|
84
|
+
useDataBrowserGrid:
|
|
85
|
+
await import('../../app/build/composables/useDataBrowserGrid'),
|
|
86
|
+
useDataBrowserTabs:
|
|
87
|
+
await import('../../app/build/composables/useDataBrowserTabs'),
|
|
88
|
+
useStagedEdits: await import('../../app/build/data/stagedEdits'),
|
|
89
|
+
useDatabaseSchemas:
|
|
90
|
+
await import('../../app/build/composables/useDatabaseSchemas'),
|
|
91
|
+
}))
|
|
92
|
+
vi.stubGlobal(name, (module as Record<string, unknown>)[name])
|
|
93
|
+
|
|
94
|
+
// The DMS's and Nuxt UI's components the module's templates use: each
|
|
95
|
+
// renders its default slot, its props as attributes.
|
|
96
|
+
const UI_COMPONENTS = [
|
|
97
|
+
'DmsAutoLink',
|
|
98
|
+
'DmsCard',
|
|
99
|
+
'DmsChart',
|
|
100
|
+
'DmsCodeSnippet',
|
|
101
|
+
'DmsEmptyState',
|
|
102
|
+
'DmsEyebrow',
|
|
103
|
+
'DmsFieldRow',
|
|
104
|
+
'DmsFlowCanvas',
|
|
105
|
+
'DmsFormErrorText',
|
|
106
|
+
'DmsFormRequiredLegend',
|
|
107
|
+
'DmsInputCode',
|
|
108
|
+
'DmsInputText',
|
|
109
|
+
'DmsSaveBar',
|
|
110
|
+
'DmsSegmented',
|
|
111
|
+
'DmsStatusSummary',
|
|
112
|
+
'DmsSwitch',
|
|
113
|
+
'DmsTextarea',
|
|
114
|
+
'I18nT',
|
|
115
|
+
'UAlert',
|
|
116
|
+
'UAvatar',
|
|
117
|
+
'UBadge',
|
|
118
|
+
'UButton',
|
|
119
|
+
'UFormField',
|
|
120
|
+
'UIcon',
|
|
121
|
+
'UInput',
|
|
122
|
+
'UKbd',
|
|
123
|
+
'UModal',
|
|
124
|
+
'UPopover',
|
|
125
|
+
'UProgress',
|
|
126
|
+
'USelect',
|
|
127
|
+
'USelectMenu',
|
|
128
|
+
'USkeleton',
|
|
129
|
+
'USlideover',
|
|
130
|
+
'UTabs',
|
|
131
|
+
'UTooltip',
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
const uiStub = (name: string): Component =>
|
|
135
|
+
vue.defineComponent({
|
|
136
|
+
name,
|
|
137
|
+
setup:
|
|
138
|
+
(_, { slots }) =>
|
|
139
|
+
() =>
|
|
140
|
+
h('div', { 'data-stub': name }, slots.default?.()),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
export interface ServerRender {
|
|
144
|
+
html: string
|
|
145
|
+
payload: DmsPayload
|
|
146
|
+
/** Every backend request the render made. */
|
|
147
|
+
reads: BackendRead[]
|
|
148
|
+
/** The requests made whose response the payload does not carry. */
|
|
149
|
+
untransferred: BackendRead[]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The key a `useApiData(url)` read is transferred under, as the renderer
|
|
154
|
+
* gives it (`dmsRequestKey`): a digest of the request, which the payload
|
|
155
|
+
* shows instead of the URL.
|
|
156
|
+
*/
|
|
157
|
+
function transferKey(read: BackendRead): string | null {
|
|
158
|
+
const { method, query } = readOptions.get(read) ?? {}
|
|
159
|
+
return engine.dmsRequestKey(read.url, { method, query }) ?? null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Renders `component` with `props` as a server renders the page `url`,
|
|
164
|
+
* answering each backend request with `respond`.
|
|
165
|
+
*/
|
|
166
|
+
export async function serverRender(
|
|
167
|
+
component: Component,
|
|
168
|
+
props: Record<string, unknown> = {},
|
|
169
|
+
options: { url?: string; respond?: Backend } = {},
|
|
170
|
+
): Promise<ServerRender> {
|
|
171
|
+
backend = options.respond ?? (() => ({}))
|
|
172
|
+
reads.length = 0
|
|
173
|
+
const runtime = engine.createDmsFrontendRuntime(undefined, {}, true)
|
|
174
|
+
runtime.route.fullPath = options.url ?? '/'
|
|
175
|
+
const app = createSSRApp({ render: () => h(component, props) })
|
|
176
|
+
engine.provideDmsFrontendRuntime(app, runtime)
|
|
177
|
+
app.component('DmsClientOnly', engine.DmsClientOnly)
|
|
178
|
+
for (const name of UI_COMPONENTS) app.component(name, uiStub(name))
|
|
179
|
+
app.config.warnHandler = noop
|
|
180
|
+
const html = await renderToString(app)
|
|
181
|
+
const payload = engine.serializeDmsPayload(runtime)
|
|
182
|
+
const transferred = new Set(
|
|
183
|
+
Object.keys({ ...payload.data, ...payload.errors }),
|
|
184
|
+
)
|
|
185
|
+
const made = [...reads]
|
|
186
|
+
return {
|
|
187
|
+
html,
|
|
188
|
+
payload,
|
|
189
|
+
reads: made,
|
|
190
|
+
untransferred: made.filter(
|
|
191
|
+
(read) => !transferred.has(transferKey(read) ?? ''),
|
|
192
|
+
),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What a browser's app context gives a composable, and what it read. */
|
|
197
|
+
export interface BrowserContext<T> {
|
|
198
|
+
value: T
|
|
199
|
+
/** Every backend request made since, live. */
|
|
200
|
+
reads: BackendRead[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Runs `setup` in a browser's app context (no server render to hydrate),
|
|
205
|
+
* answering each backend request with `respond`.
|
|
206
|
+
*/
|
|
207
|
+
export function inBrowser<T>(
|
|
208
|
+
setup: () => T,
|
|
209
|
+
respond: Backend,
|
|
210
|
+
): BrowserContext<T> {
|
|
211
|
+
backend = respond
|
|
212
|
+
reads.length = 0
|
|
213
|
+
const runtime = engine.createDmsFrontendRuntime(undefined, undefined, false)
|
|
214
|
+
const app = createSSRApp({ render: () => null })
|
|
215
|
+
engine.provideDmsFrontendRuntime(app, runtime)
|
|
216
|
+
const scope = vue.effectScope()
|
|
217
|
+
const value = app.runWithContext(() => scope.run(setup) as T)
|
|
218
|
+
return { value, reads }
|
|
219
|
+
}
|
|
@@ -151,6 +151,7 @@ describe('query console helpers', () => {
|
|
|
151
151
|
entry({ id: 'b', executedAt: '2026-10-06T10:00:00' }),
|
|
152
152
|
],
|
|
153
153
|
(run) => run.executedAt,
|
|
154
|
+
(date) => date.toISOString().slice(0, 10),
|
|
154
155
|
)
|
|
155
156
|
expect(groups.map((group) => group.items.map((run) => run.id))).toEqual([
|
|
156
157
|
['a'],
|
|
@@ -54,3 +54,27 @@ describe('locale messages', () => {
|
|
|
54
54
|
)
|
|
55
55
|
})
|
|
56
56
|
})
|
|
57
|
+
|
|
58
|
+
// `<i18n-t>` looks for its component's own messages first; the module's
|
|
59
|
+
// components have none, and vue-i18n warns on each page that renders one
|
|
60
|
+
// ("Not found parent scope"). The module's messages are global: say so.
|
|
61
|
+
const COMPONENTS_DIR = join(import.meta.dirname, '../app')
|
|
62
|
+
|
|
63
|
+
function vueFiles(dir: string): string[] {
|
|
64
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
65
|
+
const path = join(dir, entry.name)
|
|
66
|
+
if (entry.isDirectory()) return vueFiles(path)
|
|
67
|
+
return entry.name.endsWith('.vue') ? [path] : []
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('translated templates', () => {
|
|
72
|
+
it('reads every <i18n-t> from the global messages', () => {
|
|
73
|
+
const unscoped = vueFiles(COMPONENTS_DIR).flatMap((file) =>
|
|
74
|
+
(readFileSync(file, 'utf8').match(/<i18n-t\b[^>]*>/g) ?? [])
|
|
75
|
+
.filter((tag) => !/\sscope="global"/.test(tag))
|
|
76
|
+
.map((tag) => `${file.slice(COMPONENTS_DIR.length)}: ${tag}`),
|
|
77
|
+
)
|
|
78
|
+
expect(unscoped).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
inBrowser,
|
|
4
|
+
serverRender,
|
|
5
|
+
type BackendRead,
|
|
6
|
+
} from './fixtures/serverRender'
|
|
7
|
+
import { useDiagramPersistence } from '../app/build/composables/useDiagramPersistence'
|
|
8
|
+
import { useQueryStore } from '../app/build/composables/useQueryStore'
|
|
9
|
+
import InstancePicker from '../app/components/InstancePicker.vue'
|
|
10
|
+
import RowDrawer from '../app/components/RowDrawer.vue'
|
|
11
|
+
|
|
12
|
+
// The reads the module makes on demand go through `useApiData` too
|
|
13
|
+
// (`immediate: false` + `execute()`): nothing on the server, nothing before
|
|
14
|
+
// they are asked for, and a fresh read each time they are.
|
|
15
|
+
|
|
16
|
+
const NOTE = {
|
|
17
|
+
id: 'n1',
|
|
18
|
+
schemaName: 'shop',
|
|
19
|
+
text: 'Orders',
|
|
20
|
+
x: 0,
|
|
21
|
+
y: 0,
|
|
22
|
+
width: 200,
|
|
23
|
+
height: 100,
|
|
24
|
+
color: null,
|
|
25
|
+
createdAt: '2026-10-01T10:00:00.000Z',
|
|
26
|
+
updatedAt: '2026-10-01T10:00:00.000Z',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let layoutVersion = 0
|
|
30
|
+
|
|
31
|
+
function backend({ url }: BackendRead): unknown {
|
|
32
|
+
if (url === '/api/database/diagram/layout/shop')
|
|
33
|
+
return { positions: { orders: { x: layoutVersion++, y: 0 } } }
|
|
34
|
+
if (url === '/api/database/diagram/notes') return { items: [NOTE] }
|
|
35
|
+
if (url === '/api/database/diagram/layout/broken')
|
|
36
|
+
throw Object.assign(new Error('Unavailable'), { statusCode: 503 })
|
|
37
|
+
if (url === '/api/database/query/history/run-1')
|
|
38
|
+
return { id: 'run-1', source: 'db.schema("shop")' }
|
|
39
|
+
return { items: [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('reads on demand', () => {
|
|
43
|
+
it("the diagram reads a schema's layout and notes when the canvas asks, each time", async () => {
|
|
44
|
+
layoutVersion = 0
|
|
45
|
+
const browser = inBrowser(useDiagramPersistence, backend)
|
|
46
|
+
expect(browser.reads).toEqual([])
|
|
47
|
+
const first = await browser.value.readDiagram('shop')
|
|
48
|
+
expect(first).toEqual({
|
|
49
|
+
schema: 'shop',
|
|
50
|
+
positions: { orders: { x: 0, y: 0 } },
|
|
51
|
+
notes: [NOTE],
|
|
52
|
+
})
|
|
53
|
+
expect(browser.reads.map((read) => read.url).sort()).toEqual([
|
|
54
|
+
'/api/database/diagram/layout/shop',
|
|
55
|
+
'/api/database/diagram/notes',
|
|
56
|
+
])
|
|
57
|
+
expect(browser.reads[1]!.query).toEqual({ schema: 'shop' })
|
|
58
|
+
// The canvas edits a working copy: a new load reads what is stored now.
|
|
59
|
+
const second = await browser.value.readDiagram('shop')
|
|
60
|
+
expect(second.positions).toEqual({ orders: { x: 1, y: 0 } })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('the diagram says so when its layout cannot be read', async () => {
|
|
64
|
+
const browser = inBrowser(useDiagramPersistence, backend)
|
|
65
|
+
await expect(browser.value.readDiagram('broken')).rejects.toMatchObject({
|
|
66
|
+
statusCode: 503,
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('the console reads a linked run when it opens it, not before', async () => {
|
|
71
|
+
const browser = inBrowser(useQueryStore, backend)
|
|
72
|
+
await browser.value.libraryRead
|
|
73
|
+
const before = browser.reads.length
|
|
74
|
+
const entry = await browser.value.fetchHistoryEntry('run-1')
|
|
75
|
+
expect(entry).toMatchObject({ id: 'run-1' })
|
|
76
|
+
expect(browser.reads.slice(before).map((read) => read.url)).toEqual([
|
|
77
|
+
'/api/database/query/history/run-1',
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('server render: what only the browser reads', () => {
|
|
83
|
+
it("a row's drawer leaves its references to the browser", async () => {
|
|
84
|
+
const page = await serverRender(
|
|
85
|
+
RowDrawer,
|
|
86
|
+
{
|
|
87
|
+
tab: { id: 't', schema: 'shop', table: 'orders', instance: '' },
|
|
88
|
+
rowId: 'r1',
|
|
89
|
+
row: { _id: 'r1' },
|
|
90
|
+
columns: [],
|
|
91
|
+
onStage: () => undefined,
|
|
92
|
+
},
|
|
93
|
+
{ respond: backend },
|
|
94
|
+
)
|
|
95
|
+
expect(page.reads).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('the instance picker reads nothing until its menu opens', async () => {
|
|
99
|
+
const page = await serverRender(
|
|
100
|
+
InstancePicker,
|
|
101
|
+
{ modelValue: { kind: 'default' }, schema: 'shop' },
|
|
102
|
+
{ respond: backend },
|
|
103
|
+
)
|
|
104
|
+
expect(page.reads).toEqual([])
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
|
+
import { serverRender, type BackendRead } from './fixtures/serverRender'
|
|
4
|
+
import ConnectionStatus from '../app/components/ConnectionStatus.vue'
|
|
5
|
+
import DataBrowser from '../app/components/DataBrowser.vue'
|
|
6
|
+
import QueryConsole from '../app/components/QueryConsole.vue'
|
|
7
|
+
import SchemaDiagram from '../app/components/SchemaDiagram.vue'
|
|
8
|
+
import SchemasFilterBar from '../app/components/SchemasFilterBar.vue'
|
|
9
|
+
import TableInspector from '../app/components/TableInspector.vue'
|
|
10
|
+
|
|
11
|
+
// What a server render of each page reads, and that the browser is handed
|
|
12
|
+
// every response it read: a read the page does not hand over is made again
|
|
13
|
+
// by the browser, after a server request for nothing.
|
|
14
|
+
|
|
15
|
+
const table = (name: string) => ({
|
|
16
|
+
name,
|
|
17
|
+
fields: { _id: { kind: 'string' } },
|
|
18
|
+
indexes: {},
|
|
19
|
+
relations: [],
|
|
20
|
+
})
|
|
21
|
+
const schema = (id: string, instances: string[] = []) => ({
|
|
22
|
+
id,
|
|
23
|
+
options: {},
|
|
24
|
+
tables: [table('orders'), table('customers')],
|
|
25
|
+
stats: { tableCount: 2, elementCount: 3, instanceCount: instances.length },
|
|
26
|
+
instances,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function backend({ url, query }: BackendRead): unknown {
|
|
30
|
+
switch (url) {
|
|
31
|
+
case '/api/database/schemas':
|
|
32
|
+
return { schemas: [schema('audit'), schema('shop', ['eu'])] }
|
|
33
|
+
case '/api/database/browse/tables':
|
|
34
|
+
return {
|
|
35
|
+
items: [
|
|
36
|
+
{ name: 'orders', elementCount: query.filter_instance ? 7 : 4242 },
|
|
37
|
+
{ name: 'customers', elementCount: 31 },
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
case '/api/database/health':
|
|
41
|
+
return { status: 'ok', latencyMs: 3, driver: 'MongoDB', storage: [] }
|
|
42
|
+
case '/api/database/query/saved':
|
|
43
|
+
return {
|
|
44
|
+
items:
|
|
45
|
+
query.scope === 'shared'
|
|
46
|
+
? [query_('team-query', 'Teammate query', 'other')]
|
|
47
|
+
: [query_('own-query', 'My saved query', 'me')],
|
|
48
|
+
}
|
|
49
|
+
case '/api/database/query/history':
|
|
50
|
+
return {
|
|
51
|
+
items: [
|
|
52
|
+
{
|
|
53
|
+
id: 'run-1',
|
|
54
|
+
userId: 'me',
|
|
55
|
+
query: {},
|
|
56
|
+
source: 'db.schema("shop").table("orders")',
|
|
57
|
+
language: 'aql',
|
|
58
|
+
executedAt: '2026-10-14T08:00:00.000Z',
|
|
59
|
+
durationMs: 12,
|
|
60
|
+
rowCount: 3,
|
|
61
|
+
status: 'ok',
|
|
62
|
+
mutation: false,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
case '/api/database/browse/count':
|
|
67
|
+
return { total: 9876 }
|
|
68
|
+
case '/api/database/tables/source':
|
|
69
|
+
return { total: 12, all: 40, results: [{ _id: 'shop::orders' }] }
|
|
70
|
+
default:
|
|
71
|
+
return {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function query_(id: string, name: string, userId: string) {
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
userId,
|
|
79
|
+
name,
|
|
80
|
+
description: '',
|
|
81
|
+
query: {},
|
|
82
|
+
source: `db.schema("shop") // ${name}`,
|
|
83
|
+
language: 'aql',
|
|
84
|
+
shared: userId !== 'me',
|
|
85
|
+
createdAt: '2026-10-01T10:00:00.000Z',
|
|
86
|
+
updatedAt: '2026-10-01T10:00:00.000Z',
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const render = (
|
|
91
|
+
component: Parameters<typeof serverRender>[0],
|
|
92
|
+
props: Record<string, unknown> = {},
|
|
93
|
+
url = '/',
|
|
94
|
+
) => serverRender(component, props, { url, respond: backend })
|
|
95
|
+
|
|
96
|
+
describe('server render: every read is handed to the browser', () => {
|
|
97
|
+
it('data browser: the table counts of the first schema', async () => {
|
|
98
|
+
const page = await render(DataBrowser, {}, '/modules/database/data')
|
|
99
|
+
expect(page.reads.map((read) => read.url)).toEqual([
|
|
100
|
+
'/api/database/schemas',
|
|
101
|
+
'/api/database/browse/tables',
|
|
102
|
+
])
|
|
103
|
+
expect(page.reads[1]!.query).toEqual({ filter_schema: 'is:audit' })
|
|
104
|
+
expect(page.untransferred).toEqual([])
|
|
105
|
+
expect(page.html).toContain('4242')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('data browser: a table link reads its schema and instance', async () => {
|
|
109
|
+
const page = await render(
|
|
110
|
+
DataBrowser,
|
|
111
|
+
{},
|
|
112
|
+
'/modules/database/data?schema=shop&table=orders&instance=eu',
|
|
113
|
+
)
|
|
114
|
+
expect(page.reads[1]!.query).toEqual({
|
|
115
|
+
filter_schema: 'is:shop',
|
|
116
|
+
filter_instance: 'is:eu',
|
|
117
|
+
})
|
|
118
|
+
expect(page.untransferred).toEqual([])
|
|
119
|
+
expect(page.html).toContain('>7<')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('query console: the library, saved, shared and run', async () => {
|
|
123
|
+
const page = await render(QueryConsole, {}, '/modules/database/query')
|
|
124
|
+
expect(page.reads.map((read) => read.url).sort()).toEqual([
|
|
125
|
+
'/api/database/query/history',
|
|
126
|
+
'/api/database/query/saved',
|
|
127
|
+
'/api/database/query/saved',
|
|
128
|
+
'/api/database/schemas',
|
|
129
|
+
])
|
|
130
|
+
expect(page.untransferred).toEqual([])
|
|
131
|
+
expect(page.html).toContain(
|
|
132
|
+
'db.schema("shop").table("orders")',
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('diagram: no layout or notes, which only the browser draws', async () => {
|
|
137
|
+
const page = await render(SchemaDiagram, {}, '/modules/database/diagram')
|
|
138
|
+
expect(page.reads.map((read) => read.url)).toEqual([
|
|
139
|
+
'/api/database/schemas',
|
|
140
|
+
])
|
|
141
|
+
expect(page.untransferred).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('diagram: no layout or notes either once the schemas are in', async () => {
|
|
145
|
+
// A block of the page read the schemas before the diagram's setup ran.
|
|
146
|
+
const SchemasFirst = defineComponent({
|
|
147
|
+
async setup() {
|
|
148
|
+
await useApiData('/api/database/schemas')
|
|
149
|
+
return () => h(SchemaDiagram)
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
const page = await render(SchemasFirst, {}, '/modules/database/diagram')
|
|
153
|
+
expect(page.reads.map((read) => read.url)).toEqual([
|
|
154
|
+
'/api/database/schemas',
|
|
155
|
+
])
|
|
156
|
+
expect(page.untransferred).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('table inspector: the rows of the table in every instance', async () => {
|
|
160
|
+
const page = await render(
|
|
161
|
+
TableInspector,
|
|
162
|
+
{ rowData: { schema: 'shop', name: 'orders' } },
|
|
163
|
+
'/modules/database/schemas',
|
|
164
|
+
)
|
|
165
|
+
expect(page.reads.map((read) => read.url).sort()).toEqual([
|
|
166
|
+
'/api/database/browse/count',
|
|
167
|
+
'/api/database/schemas',
|
|
168
|
+
])
|
|
169
|
+
expect(page.untransferred).toEqual([])
|
|
170
|
+
expect(page.html).toContain('9876')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('schemas filter bar: the tables listed out of all', async () => {
|
|
174
|
+
const page = await render(
|
|
175
|
+
SchemasFilterBar,
|
|
176
|
+
{},
|
|
177
|
+
'/modules/database/schemas?scope=shop',
|
|
178
|
+
)
|
|
179
|
+
expect(page.reads).toContainEqual({
|
|
180
|
+
url: '/api/database/tables/source',
|
|
181
|
+
method: 'GET',
|
|
182
|
+
query: { filter_scope: 'is:shop' },
|
|
183
|
+
})
|
|
184
|
+
expect(page.untransferred).toEqual([])
|
|
185
|
+
expect(page.html).toMatch(/12[^<]*40/)
|
|
186
|
+
// The counts only, not the rows the route lists.
|
|
187
|
+
expect(Object.values(page.payload.data)).toContainEqual({
|
|
188
|
+
total: 12,
|
|
189
|
+
all: 40,
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('overview: the connection health', async () => {
|
|
194
|
+
const page = await render(
|
|
195
|
+
ConnectionStatus,
|
|
196
|
+
{},
|
|
197
|
+
'/modules/database/overview',
|
|
198
|
+
)
|
|
199
|
+
expect(page.reads.map((read) => read.url)).toEqual(['/api/database/health'])
|
|
200
|
+
expect(page.untransferred).toEqual([])
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('server render: what the browser hydrates the same', () => {
|
|
205
|
+
afterEach(() => {
|
|
206
|
+
vi.useRealTimers()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('overview: the probe age, whenever the page renders', async () => {
|
|
210
|
+
// The browser hydrates a few seconds after the server rendered: the age
|
|
211
|
+
// of the probe must not follow the clock before the page is mounted.
|
|
212
|
+
const checkedAt = '2026-10-15T20:00:00.000Z'
|
|
213
|
+
const health = (read: BackendRead) =>
|
|
214
|
+
read.url === '/api/database/health'
|
|
215
|
+
? { ...(backend(read) as object), checkedAt }
|
|
216
|
+
: backend(read)
|
|
217
|
+
const renderAt = async (time: string) => {
|
|
218
|
+
vi.useFakeTimers({ toFake: ['Date'] })
|
|
219
|
+
vi.setSystemTime(new Date(time))
|
|
220
|
+
const page = await serverRender(
|
|
221
|
+
ConnectionStatus,
|
|
222
|
+
{},
|
|
223
|
+
{
|
|
224
|
+
url: '/modules/database/overview',
|
|
225
|
+
respond: health,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
vi.useRealTimers()
|
|
229
|
+
return page.html
|
|
230
|
+
}
|
|
231
|
+
const rendered = await renderAt('2026-10-15T20:00:00.400Z')
|
|
232
|
+
expect(rendered).toContain('checked_seconds 0')
|
|
233
|
+
expect(await renderAt('2026-10-15T20:00:07.000Z')).toBe(rendered)
|
|
234
|
+
})
|
|
235
|
+
})
|