Visitar URL original
perf(dashboard): the models overview answers in 6ms, not 1.6s · stacksjs/stacks@cebdd5a · GitHub
Skip to content

Commit cebdd5a

Browse files
perf(dashboard): the models overview answers in 6ms, not 1.6s
Resolving a model for the dashboard asked the migration generator which models are the app's own, and the generator rebuilds its staging directory to answer. The models overview resolves every model, so one request made 111 of those calls: 1.6s of blocking file writes on a request that only reads, with nothing else served meanwhile. Global search paid the same on every keystroke. `resolveDashboardModel` now reads directory listings only, and the four callers that resolve many models (the overview, the highlighted counts, global search, the search engine inventory) read the list once and hand it to each resolve. GET /api/dashboard/models: 1.57s, 1.59s, 1.76s before; 6.3ms, 6.2ms, 5.4ms after. The response is identical apart from live row counts. A test fails if a dashboard action or function asks the generator again. Closes #3284
1 parent bb1a1d0 commit cebdd5a

8 files changed

Lines changed: 153 additions & 20 deletions

File tree

‎storage/framework/defaults/ai/skills/stacks-dashboard/SKILL.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,29 @@ or separate routes generated by `useApi`. Configure `security.api.models` or
692692
new boundary does not provide row ownership or permission checks for models
693693
that remain exposed.
694694

695+
### Asking which models exist
696+
697+
Dashboard code asks through `resolveDashboardModel(name, candidate?, sources?)`
698+
and `dashboardModelSources()` in `resources/functions/dashboard/model-access.ts`,
699+
which read directory listings only (`listModelSources` from
700+
`@stacksjs/database/model-sources`). Anything that resolves many models reads
701+
the list once and hands it to each resolve:
702+
703+
```ts
704+
const sources = dashboardModelSources()
705+
for (const source of sources) {
706+
const Model = await resolveDashboardModel(source.name, (await import(source.file)).default, sources)
707+
}
708+
```
709+
710+
Never call `resolveModelSources()` while serving a request. It is the
711+
migration generator's entry point: when framework defaults are merged in it
712+
deletes and rebuilds its staging directory (a symlink per model) on every
713+
call, about 11ms of blocking file writes. The models overview once asked it
714+
per model and took 1.6s; it answers in about 6ms now.
715+
`tests/unit/dashboard-model-sources.test.ts` fails if a dashboard Action or
716+
function uses it.
717+
695718
## What the dashboard may write to a model
696719

697720
Every record has a page to view it (`/models/{slug}/{id}`), and where the

‎storage/framework/defaults/app/Actions/Dashboard/Models/GetModels.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Glob } from 'bun'
33
import { Action } from '@stacksjs/actions/runtime'
44
import { path } from '@stacksjs/path'
55
import { countRows } from '../../../../resources/functions/dashboard/data'
6-
import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
6+
import { dashboardModelSources, resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
77
import { dashboardOperationalError } from '../dashboard-response'
88

99
export default new Action({
@@ -16,10 +16,11 @@ export default new Action({
1616
const glob = new Glob('**/*.ts')
1717
const scanOptions = { cwd: path.userModelsPath(), onlyFiles: true }
1818
const displayModels: Model[] = []
19+
const sources = dashboardModelSources()
1920

2021
for await (const file of glob.scan(scanOptions)) {
2122
const candidate = (await import(path.userModelsPath(file))).default as Model
22-
const model = await resolveDashboardModel(candidate.name || '', candidate)
23+
const model = await resolveDashboardModel(candidate.name || '', candidate, sources)
2324
if (!model) continue
2425
if (model.dashboard?.highlight !== undefined && model.dashboard?.highlight !== false)
2526
displayModels.push(model)
@@ -29,7 +30,7 @@ export default new Action({
2930
// If no models should be highlighted, return up to 8 accessible models.
3031
for await (const file of glob.scan(scanOptions)) {
3132
const candidate = (await import(path.userModelsPath(file))).default as Model
32-
const model = await resolveDashboardModel(candidate.name || '', candidate)
33+
const model = await resolveDashboardModel(candidate.name || '', candidate, sources)
3334
if (!model) continue
3435
displayModels.push(model)
3536
}

‎storage/framework/defaults/app/Actions/Dashboard/Models/ModelsIndexAction.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { Action } from '@stacksjs/actions/runtime'
22
import { overridesReady } from '@stacksjs/config'
3-
import { resolveModelSources } from '@stacksjs/database/model-sources'
43
import { countRows } from '../../../../resources/functions/dashboard/data'
54
import { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
6-
import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
5+
import { dashboardModelSources, resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
76
import { resolveDashboardIcon } from '../../../../resources/functions/dashboard/sidebar'
87
import { isUnmigratedTable } from './model-migration'
98
import { modelApiConfiguration } from './model-api'
@@ -110,8 +109,11 @@ export default new Action({
110109
async handle() {
111110
await overridesReady
112111
let merged: DiscoveredModel[]
112+
// Listed once, and handed to every resolve below.
113+
let sources: ReturnType<typeof dashboardModelSources>
113114
try {
114-
merged = (resolveModelSources()?.models ?? []).map(model => ({
115+
sources = dashboardModelSources()
116+
merged = sources.map(model => ({
115117
name: model.name,
116118
file: model.file,
117119
source: model.origin === 'framework' ? 'framework' : 'userland',
@@ -125,7 +127,7 @@ export default new Action({
125127
let Model: any
126128
try {
127129
const module = await import(m.file)
128-
Model = await resolveDashboardModel(m.name, module.default ?? module)
130+
Model = await resolveDashboardModel(m.name, module.default ?? module, sources)
129131
if (!Model) return null
130132
}
131133
catch (error) {

‎storage/framework/defaults/app/Actions/Dashboard/Search/GlobalSearchAction.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import type { RequestInstance } from '@stacksjs/types'
22
import { Action } from '@stacksjs/actions/runtime'
33
import { getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
44
import { overridesReady } from '@stacksjs/config'
5-
import { resolveModelSources } from '@stacksjs/database/model-sources'
65
import { hiddenModelColumns } from '../../../../resources/functions/dashboard/model-columns'
7-
import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
6+
import { dashboardModelSources, resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
87
import { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
98

109
interface SearchableModel {
@@ -87,13 +86,13 @@ function iconForModel(name: string): string {
8786

8887
async function loadSearchableModels(): Promise<SearchableModel[]> {
8988
await overridesReady
90-
const sources = resolveModelSources()?.models ?? []
89+
const sources = dashboardModelSources()
9190

9291
const models: SearchableModel[] = []
9392
for (const { name: fallbackName, file } of sources) {
9493
try {
9594
const module = await import(file)
96-
const definition = await resolveDashboardModel(fallbackName, module.default ?? module)
95+
const definition = await resolveDashboardModel(fallbackName, module.default ?? module, sources)
9796
if (!definition) continue
9897
const name = String(definition.name || fallbackName)
9998
const search = definition.traits?.useSearch

‎storage/framework/defaults/app/Actions/Dashboard/Search/SearchEngineIndexAction.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { Action } from '@stacksjs/actions/runtime'
22
import { overridesReady, searchEngine } from '@stacksjs/config'
3-
import { resolveModelSources } from '@stacksjs/database/model-sources'
43
import { searchIndexName, settingsFromSearchTrait, useSearchEngine } from '@stacksjs/search-engine'
5-
import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
4+
import { dashboardModelSources, resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
65
import { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
76

87
export default new Action({
@@ -13,9 +12,10 @@ export default new Action({
1312
try {
1413
await overridesReady
1514
const models = []
16-
for (const source of resolveModelSources()?.models ?? []) {
15+
const sources = dashboardModelSources()
16+
for (const source of sources) {
1717
const module = await import(source.file)
18-
const Model = await resolveDashboardModel(source.name, module.default ?? module)
18+
const Model = await resolveDashboardModel(source.name, module.default ?? module, sources)
1919
const trait = Model?.traits?.useSearch
2020
if (!trait) continue
2121
const settings = settingsFromSearchTrait(typeof trait === 'object' ? trait : {})

‎storage/framework/defaults/resources/functions/dashboard/model-access.ts‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
/** Server-side exposure boundary for the generic dashboard model surfaces. */
22
import type { DashboardModelOptions } from '@stacksjs/types'
3-
import { resolveModelSources } from '@stacksjs/database/model-sources'
3+
import type { ModelSource } from '@stacksjs/database/model-sources'
4+
import { listModelSources } from '@stacksjs/database/model-sources'
45
import { loadModelIfExists } from './data'
56

67
/** Omission preserves the existing dashboard exposure. `enabled` only controls navigation. */
78
export function hasDashboardModelAccess(Model: { dashboard?: DashboardModelOptions } | null | undefined): boolean {
89
return Model?.dashboard?.access !== false
910
}
1011

11-
/** Resolve the native model, then exclude it before callers inspect metadata or query rows. */
12-
export async function resolveDashboardModel(name: string, candidate?: any): Promise<any | null> {
12+
/**
13+
* The models in the app's schema, for {@link resolveDashboardModel}. Read
14+
* once by a caller that resolves many models and handed to each call, so a
15+
* page of 110 models lists the model directories once and not 110 times.
16+
*/
17+
export function dashboardModelSources(): ModelSource[] {
18+
return listModelSources() ?? []
19+
}
20+
21+
/**
22+
* Resolve the native model, then exclude it before callers inspect metadata or query rows.
23+
*
24+
* `sources` is {@link dashboardModelSources}, from a caller that already has
25+
* it. Without it the directories are listed here: reads only. This used to
26+
* ask the migration generator's `resolveModelSources`, which rebuilds its
27+
* staging directory on every call, about 11ms of blocking file writes per
28+
* model resolved.
29+
*/
30+
export async function resolveDashboardModel(name: string, candidate?: any, sources: readonly ModelSource[] = dashboardModelSources()): Promise<any | null> {
1331
// App-owned definitions retain their native ORM and dashboard access even
1432
// when a same-named framework feature exports a disabled proxy.
15-
const source = resolveModelSources()?.models.find(model => model.name === name)
33+
const source = sources.find(model => model.name === name)
1634
if (source && source.origin !== 'framework') {
1735
const Model = candidate ?? await loadModelIfExists(name)
1836
return Model && hasDashboardModelAccess(Model) ? Model : null

‎tests/unit/dashboard-global-search.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ describe('dashboard global search', () => {
1212

1313
expect(action).toContain('definition.traits?.useSearch')
1414
expect(action).toContain('Array.isArray(search.searchable)')
15-
expect(action).toContain('resolveModelSources()')
15+
// The app's schema, listed once and without the generator's staging (dashboard-model-sources.test.ts).
16+
expect(action).toContain('dashboardModelSources()')
1617
expect(action).toContain('searchModelRows(model.model')
1718
expect(action).not.toContain("import('bun:sqlite')")
1819
expect(action).toContain('hiddenModelColumns(definition)')
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { readdirSync, readFileSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { dashboardModelSources, resolveDashboardModel } from '../../storage/framework/defaults/resources/functions/dashboard/model-access'
5+
6+
/**
7+
* The dashboard asks which models exist without writing anything.
8+
*
9+
* It used to ask `resolveModelSources`, the migration generator's entry
10+
* point, once for every model it resolved. On the merge path that call
11+
* deletes and rebuilds the staging directory (a symlink per model): about
12+
* 11ms of blocking file writes each. The models overview resolves every
13+
* model, so it took 1.6s and held the server for all of it; global search
14+
* paid the same on every keystroke.
15+
*
16+
* `listModelSources` reads directory listings only, and a caller that
17+
* resolves many models reads the list once and hands it to each resolve.
18+
* The overview now answers in about 6ms with the same response.
19+
*/
20+
const defaults = 'storage/framework/defaults'
21+
22+
/** Source with its comments removed, so a sentence about a function is not read as a call to it. */
23+
function code(path: string): string {
24+
return new Bun.Transpiler({ loader: 'ts' }).transformSync(readFileSync(path, 'utf8'))
25+
}
26+
27+
function sourcesUnder(dir: string): string[] {
28+
return readdirSync(dir, { withFileTypes: true, recursive: true })
29+
.filter(entry => entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts'))
30+
.map(entry => join(entry.parentPath, entry.name))
31+
}
32+
33+
describe('which models exist, asked while serving a request', () => {
34+
const served = [
35+
...sourcesUnder(`${defaults}/app/Actions/Dashboard`),
36+
...sourcesUnder(`${defaults}/resources/functions/dashboard`),
37+
]
38+
39+
it('is never asked of the migration generator, which rebuilds its staging directory to answer', () => {
40+
expect(served.length).toBeGreaterThan(300)
41+
const asking = served.filter(file => /\bresolveModelSources\b/.test(code(file)))
42+
expect(asking).toEqual([])
43+
// The control for the scan: it does see the name where it is used.
44+
expect(/\bresolveModelSources\b/.test(code('storage/framework/core/database/src/migrations.ts'))).toBe(true)
45+
expect(code(`${defaults}/resources/functions/dashboard/model-access.ts`)).toContain('listModelSources()')
46+
})
47+
48+
it('is asked once by anything that resolves many models', () => {
49+
const loops: Array<[string, RegExp]> = [
50+
['app/Actions/Dashboard/Models/ModelsIndexAction.ts', /resolveDashboardModel\(m\.name, module\.default \?\? module, sources\)/],
51+
['app/Actions/Dashboard/Models/GetModels.ts', /resolveDashboardModel\(candidate\.name \|\| "", candidate, sources\)/],
52+
['app/Actions/Dashboard/Search/GlobalSearchAction.ts', /resolveDashboardModel\(fallbackName, module\.default \?\? module, sources\)/],
53+
['app/Actions/Dashboard/Search/SearchEngineIndexAction.ts', /resolveDashboardModel\(source\.name, module\.default \?\? module, sources\)/],
54+
]
55+
for (const [file, call] of loops) {
56+
const source = code(`${defaults}/${file}`)
57+
expect(source, file).toMatch(call)
58+
expect((source.match(/dashboardModelSources\(\)/g) || []).length, file).toBe(1)
59+
// No resolve in the file is left to list the directories for itself.
60+
const resolves = source.match(/resolveDashboardModel\([^\n]*\)/g) || []
61+
expect(resolves.length, file).toBeGreaterThan(0)
62+
expect(resolves.filter(resolve => !resolve.includes(', sources)')), file).toEqual([])
63+
}
64+
})
65+
66+
it('a resolve handed the list uses it, and does not look for itself', async () => {
67+
const App = { name: 'OnlyInThisTest', dashboard: {} }
68+
const Hidden = { name: 'OnlyInThisTestHidden', dashboard: { access: false } }
69+
const sources = [
70+
{ name: 'OnlyInThisTest', file: '/nowhere/OnlyInThisTest.ts', origin: 'user' as const },
71+
{ name: 'OnlyInThisTestHidden', file: '/nowhere/OnlyInThisTestHidden.ts', origin: 'user' as const },
72+
]
73+
// An app's own model is the one it is handed, and one it keeps out stays out.
74+
expect(await resolveDashboardModel('OnlyInThisTest', App, sources)).toBe(App)
75+
expect(await resolveDashboardModel('OnlyInThisTestHidden', Hidden, sources)).toBeNull()
76+
// The same call without the list reads the real directories, where no such model is.
77+
expect(dashboardModelSources().some(source => source.name === 'OnlyInThisTest')).toBe(false)
78+
})
79+
80+
it('the list is the app\'s schema: names, files and where each comes from', () => {
81+
const sources = dashboardModelSources()
82+
expect(sources.length).toBeGreaterThan(40)
83+
for (const source of sources.slice(0, 5)) {
84+
expect(source.name).toMatch(/^[A-Z]\w+$/)
85+
expect(source.file.endsWith(`${source.name}.ts`)).toBe(true)
86+
expect(['user', 'framework']).toContain(source.origin)
87+
}
88+
})
89+
})

0 commit comments

Comments
 (0)