Skip to content

Commit 2dededf

Browse files
committed
feat(cli): add command for visualizing schema bloat
This adds a command `sanity schema validate --debug-metafile-path <path>` which writes a file with information about the size of the serialized schema which follows ESBuild's metafile format. This can then be analyzed through https://esbuild.github.io/analyze/
1 parent eb8af47 commit 2dededf

File tree

5 files changed

+224
-8
lines changed

5 files changed

+224
-8
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type {SeralizedSchemaDebug, SerializedTypeDebug} from '../../threads/validateSchema'
2+
3+
// This implements the metafile format of ESBuild.
4+
type Metafile = {
5+
inputs: Record<string, MetafileInput>
6+
outputs: Record<string, MetafileOutput>
7+
}
8+
9+
type MetafileOutput = {
10+
imports: []
11+
exports: []
12+
inputs: Record<string, {bytesInOutput: number}>
13+
bytes: number
14+
}
15+
16+
type MetafileInput = {
17+
bytes: number
18+
imports: []
19+
format: 'esm' | 'csj'
20+
}
21+
22+
/** Converts the */
23+
export function generateMetafile(schema: SeralizedSchemaDebug): Metafile {
24+
const output: MetafileOutput = {
25+
imports: [],
26+
exports: [],
27+
inputs: {},
28+
bytes: 0,
29+
}
30+
31+
// Generate a esbuild metafile
32+
const inputs: Record<string, MetafileInput> = {}
33+
inputs['schema'] = {
34+
bytes: 0,
35+
imports: [],
36+
format: 'esm',
37+
}
38+
39+
function processType(path: string, entry: SerializedTypeDebug) {
40+
let childSize = 0
41+
42+
if (entry.fields) {
43+
for (const [name, fieldEntry] of Object.entries(entry.fields)) {
44+
processType(`${path}/${name}`, fieldEntry)
45+
childSize += fieldEntry.size
46+
}
47+
}
48+
49+
if (entry.of) {
50+
for (const [name, fieldEntry] of Object.entries(entry.of)) {
51+
processType(`${path}/${name}`, fieldEntry)
52+
childSize += fieldEntry.size
53+
}
54+
}
55+
56+
const selfSize = entry.size - childSize
57+
58+
inputs[path] = {
59+
bytes: selfSize,
60+
imports: [],
61+
format: 'esm',
62+
}
63+
64+
output.inputs[path] = {
65+
bytesInOutput: selfSize,
66+
}
67+
68+
output.bytes += selfSize
69+
}
70+
71+
for (const [name, entry] of Object.entries(schema.types)) {
72+
const fakePath = `schema/${entry.extends}:${name}`
73+
processType(fakePath, entry)
74+
}
75+
76+
return {outputs: {root: output}, inputs}
77+
}

packages/sanity/src/_internal/cli/actions/schema/validateAction.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import {
1010
type ValidateSchemaWorkerResult,
1111
} from '../../threads/validateSchema'
1212
import {formatSchemaValidation, getAggregatedSeverity} from './formatSchemaValidation'
13+
import {generateMetafile} from './metafile'
14+
import {writeFileSync} from 'node:fs'
1315

1416
interface ValidateFlags {
15-
workspace?: string
16-
format?: string
17-
level?: 'error' | 'warning'
17+
'workspace'?: string
18+
'format'?: string
19+
'level'?: 'error' | 'warning'
20+
'debug-metafile-path'?: string
1821
}
1922

2023
export type SchemaValidationFormatter = (result: ValidateSchemaWorkerResult) => string
@@ -70,20 +73,30 @@ export default async function validateAction(
7073
workDir,
7174
level,
7275
workspace: flags.workspace,
76+
debugSerialize: Boolean(flags['debug-metafile-path']),
7377
} satisfies ValidateSchemaWorkerData,
7478
env: process.env,
7579
})
7680

77-
const {validation} = await new Promise<ValidateSchemaWorkerResult>((resolve, reject) => {
78-
worker.addListener('message', resolve)
79-
worker.addListener('error', reject)
80-
})
81+
const {validation, serializedDebug} = await new Promise<ValidateSchemaWorkerResult>(
82+
(resolve, reject) => {
83+
worker.addListener('message', resolve)
84+
worker.addListener('error', reject)
85+
},
86+
)
8187

8288
const problems = validation.flatMap((group) => group.problems)
8389
const errorCount = problems.filter((problem) => problem.severity === 'error').length
8490
const warningCount = problems.filter((problem) => problem.severity === 'warning').length
8591

8692
const overallSeverity = getAggregatedSeverity(validation)
93+
const didFail = overallSeverity === 'error'
94+
95+
if (flags['debug-metafile-path'] && !didFail) {
96+
if (!serializedDebug) throw new Error('serializedDebug should always be produced')
97+
const metafile = generateMetafile(serializedDebug)
98+
writeFileSync(flags['debug-metafile-path'], JSON.stringify(metafile), 'utf8')
99+
}
87100

88101
switch (format) {
89102
case 'ndjson': {
@@ -114,8 +127,18 @@ export default async function validateAction(
114127
output.print()
115128

116129
output.print(formatSchemaValidation(validation))
130+
131+
if (flags['debug-metafile-path']) {
132+
output.print()
133+
if (didFail) {
134+
output.print(`${logSymbols.info} Metafile not written due to validation errors`)
135+
} else {
136+
output.print(`${logSymbols.info} Metafile written to: ${flags['debug-metafile-path']}`)
137+
output.print(` This can be analyzed at https://esbuild.github.io/analyze/`)
138+
}
139+
}
117140
}
118141
}
119142

120-
process.exitCode = overallSeverity === 'error' ? 1 : 0
143+
process.exitCode = didFail ? 1 : 0
121144
}

packages/sanity/src/_internal/cli/commands/schema/validateSchemaCommand.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Options
77
--workspace <name> The name of the workspace to use when validating all schema types.
88
--format <pretty|ndjson|json> The output format used to print schema errors and warnings.
99
--level <error|warning> The minimum level reported out. Defaults to warning.
10+
--debug-metafile-path <path> Optional path where a metafile
1011
1112
Examples
1213
# Validates all schema types in a Sanity project with more than one workspace
@@ -17,6 +18,9 @@ Examples
1718
1819
# Report out only errors
1920
sanity schema validate --level error
21+
22+
# Generate a report which can be analyzed with https://esbuild.github.io/analyze/
23+
sanity schema validate --debug-metafile-path metafile.json
2024
`
2125

2226
const validateDocumentsCommand: CliCommandDefinition = {

packages/sanity/src/_internal/cli/threads/validateSchema.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import {isMainThread, parentPort, workerData as _workerData} from 'node:worker_threads'
22

3+
import {
4+
type EncodableObject,
5+
type EncodableValue,
6+
type SetSynchronization,
7+
} from '@sanity/descriptors'
8+
import {DescriptorConverter} from '@sanity/schema/_internal'
39
import {type SchemaValidationProblem, type SchemaValidationProblemGroup} from '@sanity/types'
410

511
import {getStudioWorkspaces} from '../util/getStudioWorkspaces'
@@ -10,17 +16,43 @@ export interface ValidateSchemaWorkerData {
1016
workDir: string
1117
workspace?: string
1218
level?: SchemaValidationProblem['severity']
19+
debugSerialize?: boolean
1320
}
1421

1522
/** @internal */
1623
export interface ValidateSchemaWorkerResult {
1724
validation: SchemaValidationProblemGroup[]
25+
serializedDebug?: SeralizedSchemaDebug
26+
}
27+
28+
/**
29+
* Contains debug information about the serialized schema.
30+
*
31+
* @internal
32+
**/
33+
export type SeralizedSchemaDebug = {
34+
size: number
35+
parent?: SeralizedSchemaDebug
36+
types: Record<string, SerializedTypeDebug>
37+
}
38+
39+
/**
40+
* Contains debug information about a serialized type.
41+
*
42+
* @internal
43+
**/
44+
export type SerializedTypeDebug = {
45+
size: number
46+
extends: string
47+
fields?: Record<string, SerializedTypeDebug>
48+
of?: Record<string, SerializedTypeDebug>
1849
}
1950

2051
const {
2152
workDir,
2253
workspace: workspaceName,
2354
level = 'warning',
55+
debugSerialize,
2456
} = _workerData as ValidateSchemaWorkerData
2557

2658
async function main() {
@@ -55,6 +87,14 @@ async function main() {
5587
const schema = workspace.schema
5688
const validation = schema._validation!
5789

90+
let serializedDebug: ValidateSchemaWorkerResult['serializedDebug']
91+
92+
if (debugSerialize) {
93+
const conv = new DescriptorConverter({})
94+
const set = conv.get(schema)
95+
serializedDebug = getSeralizedSchemaDebug(set)
96+
}
97+
5898
const result: ValidateSchemaWorkerResult = {
5999
validation: validation
60100
.map((group) => ({
@@ -64,12 +104,79 @@ async function main() {
64104
),
65105
}))
66106
.filter((group) => group.problems.length),
107+
serializedDebug,
67108
}
68109

69110
parentPort?.postMessage(result)
111+
} catch (err) {
112+
console.error(err)
113+
console.error(err.stack)
114+
throw err
70115
} finally {
71116
cleanup()
72117
}
73118
}
74119

120+
function getSeralizedSchemaDebug(set: SetSynchronization<string>): SeralizedSchemaDebug {
121+
let size = 0
122+
const types: Record<string, SerializedTypeDebug> = {}
123+
124+
for (const [id, value] of Object.entries(set.objectValues)) {
125+
const typeName = typeof value.name === 'string' ? value.name : id
126+
if (isEncodableObject(value.typeDef)) {
127+
const debug = getSerializedTypeDebug(value.typeDef)
128+
types[typeName] = debug
129+
size += debug.size
130+
}
131+
}
132+
133+
return {
134+
size,
135+
types,
136+
}
137+
}
138+
139+
function isEncodableObject(val: EncodableValue | undefined): val is EncodableObject {
140+
return typeof val === 'object' && val !== null && !Array.isArray(val)
141+
}
142+
143+
function getSerializedTypeDebug(typeDef: EncodableObject): SerializedTypeDebug {
144+
const ext = typeof typeDef.extends === 'string' ? typeDef.extends : '<unknown>'
145+
let fields: SerializedTypeDebug['fields']
146+
let of: SerializedTypeDebug['of']
147+
148+
if (Array.isArray(typeDef.fields)) {
149+
fields = {}
150+
151+
for (const field of typeDef.fields) {
152+
if (!isEncodableObject(field)) continue
153+
const name = field.name
154+
const fieldTypeDef = field.typeDef
155+
if (typeof name !== 'string' || !isEncodableObject(fieldTypeDef)) continue
156+
157+
fields[name] = getSerializedTypeDebug(fieldTypeDef)
158+
}
159+
}
160+
161+
if (Array.isArray(typeDef.of)) {
162+
of = {}
163+
164+
for (const field of typeDef.of) {
165+
if (!isEncodableObject(field)) continue
166+
const name = field.name
167+
const arrayTypeDef = field.typeDef
168+
if (typeof name !== 'string' || !isEncodableObject(arrayTypeDef)) continue
169+
170+
of[name] = getSerializedTypeDebug(arrayTypeDef)
171+
}
172+
}
173+
174+
return {
175+
size: JSON.stringify(typeDef).length,
176+
extends: ext,
177+
fields,
178+
of,
179+
}
180+
}
181+
75182
void main().then(() => process.exit())

packages/sanity/src/_internal/cli/util/mockBrowserEnvironment.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ export function mockBrowserEnvironment(basePath: string): () => void {
2020
}
2121
}
2222

23+
const btoa = global.btoa
2324
const domCleanup = jsdomGlobal(jsdomDefaultHtml, {url: 'http://localhost:3333/'})
25+
26+
// Don't use jsdom's btoa as it's using the deprecatd `abab` package.
27+
if (typeof btoa === 'function') global.btoa = btoa
28+
2429
const windowCleanup = () => global.window.close()
2530
const globalCleanup = provideFakeGlobals(basePath)
2631
const cleanupFileLoader = addHook(

0 commit comments

Comments
 (0)