Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/structure/NEXT_STEPS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
> If you look at the Structure package code, is there anything you would recommend doing to improve it? It could be anything. Something to make it more
> performant, something to make it easier to work with, something to make it more modern. What's the one thing you'd recommend that'd have to biggest impact?

✦ If I had to recommend one change with the biggest impact on the Structure package, it would be replacing `ts-morph` with a high-performance AST parser like
[`oxc-parser`](https://github.com/oxc-project/oxc) or [`swc`](https://swc.rs/).

Expand Down Expand Up @@ -47,3 +50,48 @@ Why this is better than inheritance:

This approach is essentially how most modern compilers and high-performance tools (like ESLint or rust-analyzer) are built today. It trades "magical
getters" for a transparent, predictable data flow.

✦ To take full advantage of a move to a high-performance parser like oxc, you should eventually update the interface, though you could keep the current one
as a temporary compatibility layer.

Here is the breakdown of why keeping the current interface would actually limit the benefits of the rewrite:

1. The "Compatibility Layer" Tax
The current interface is built on ts-morph, which provides a very high-level, human-friendly API (e.g., sourceFile.getVariableDeclaration('QUERY')).

- The Problem: oxc (and most high-performance parsers) returns a raw, flat AST.
- The Consequence: If you keep the current interface, you have to write a massive "compatibility layer" that mimics ts-morph's API using the raw AST. This
layer would likely be written in JavaScript, adding back some of the overhead you were trying to remove.

2. From "Pull" to "Push" (Performance)

- Current (Pull): The inheritance/lazy-getter model is a "Pull" system. You ask for project.cells, and it triggers a chain of getters that eventually call
ts-morph.
- Optimal (Push): High-performance tools work better as "Push" systems. You run a fast Rust-based scanner over the whole project in one pass, which
"pushes" a plain data object representing the whole structure.
- If you keep the current interface, you are forcing a high-speed stream of data into a slow, stateful object container.

3. Serializability and Concurrency
One of the biggest hidden benefits of oxc is that because it’s so fast and the data it produces is simple, you can easily move the work to Worker Threads.

- The Limitation: You cannot send class instances (like RWProject) across worker boundaries easily.
- The Win: If you update the interface to use Plain Old JavaScript Objects (POJOs), the Structure package could parse 50 files in parallel on 50 threads
and return a single JSON-like object. Other packages would then "query" this data rather than interacting with live class instances.

4. What a "Modern" Interface would look like
Instead of other packages doing this:

1 const project = getProject(path)
2 const routes = project.router.routes // Deep getter chain

They would do this:
1 // The structure package returns a frozen snapshot of the project
2 const projectData = await scanProject(path)
3 const routes = findRoutes(projectData) // Fast lookup in a data structure

Recommendation
If you want to move to oxc, I would recommend a Two-Phase Approach:

1. Phase 1 (Internal): Rewrite the internals to use oxc but keep the RWProject classes as "wrappers" so you don't break the CLI or Telemetry immediately.
2. Phase 2 (Public API): Introduce a new functional API (scanProject) and slowly migrate the CLI to use it. Once the CLI is migrated, you can delete the
inheritance-based classes entirely.
209 changes: 209 additions & 0 deletions packages/structure/benchmark.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import path from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'

import * as RWProjectJS from './src/model/RWProject.js'
import type { RWProject as RWProjectJST } from './src/model/RWProject.js'

// @ts-expect-error tsx makes me do this
const RWProject: typeof RWProjectJST = RWProjectJS.default.RWProject

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const fixturePath = path.resolve(__dirname, '../../__fixtures__/test-project')

const WARMUP_ITERATIONS = 2
const ITERATIONS = 10

// Enable manual GC if running with --expose-gc
const gc = global.gc

async function forceGC() {
if (gc) {
// Run GC multiple times to ensure everything is cleaned up
gc()
gc()
// Small delay to let GC settle
await new Promise((resolve) => setTimeout(resolve, 10))
}
}

function getMemoryMB() {
return process.memoryUsage().heapUsed / 1024 / 1024
}

function calculateStats(values: number[]) {
const sorted = [...values].sort((a, b) => a - b)
const mean = values.reduce((a, b) => a + b, 0) / values.length
const variance =
values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) /
values.length
const stdDev = Math.sqrt(variance)

return {
mean,
median: sorted[Math.floor(sorted.length / 2)],
min: sorted[0],
max: sorted[sorted.length - 1],
stdDev,
p95: sorted[Math.floor(sorted.length * 0.95)],
}
}

async function benchmark() {
console.log('CedarJS Structure Performance Benchmark')
console.log('=======================================\n')
console.log(`Implementation: ts-morph`)
console.log(`Target Project: ${fixturePath}`)
console.log(`Warmup Iterations: ${WARMUP_ITERATIONS}`)
console.log(`Measurement Iterations: ${ITERATIONS}`)
console.log(
`GC Control: ${gc ? 'enabled' : 'disabled (run with --expose-gc for accurate memory stats)'}`,
)
console.log('')

// Track individual measurements
const initTimes: number[] = []
const coldDiagnosticsTimes: number[] = []
const warmDiagnosticsTimes: number[] = []
const memoryDeltas: number[] = []
let diagnosticCount = 0

// Warmup phase (don't measure these)
console.log('Warming up...')
for (let i = 0; i < WARMUP_ITERATIONS; i++) {
const project = new RWProject({ projectRoot: fixturePath })
await project.collectDiagnostics()
await project.collectDiagnostics() // Warm cache
}
forceGC()
console.log('Warmup complete.\n')

// Measurement phase
console.log('Running measurements...')

// Establish a stable baseline
await forceGC()
const baselineMemory = getMemoryMB()

for (let i = 0; i < ITERATIONS; i++) {
// Stabilize memory before measurement
await forceGC()
const memBefore = getMemoryMB()

// 1. Project Initialization (Shallow)
const t0 = performance.now()
const project = new RWProject({ projectRoot: fixturePath })
const t1 = performance.now()
initTimes.push(t1 - t0)

// 2. Full Project Build & Diagnostics (Cold)
const t2 = performance.now()
const diagnosticsCold = await project.collectDiagnostics()
const t3 = performance.now()
coldDiagnosticsTimes.push(t3 - t2)
diagnosticCount = diagnosticsCold.length

// 3. Cached Diagnostics (Warm)
const t4 = performance.now()
await project.collectDiagnostics()
const t5 = performance.now()
warmDiagnosticsTimes.push(t5 - t4)

// 4. Memory Usage - measure peak before GC
const memPeak = getMemoryMB()
const memDelta = memPeak - memBefore

// Only record positive deltas (actual allocations)
// Negative values indicate GC interference
if (memDelta > 0) {
memoryDeltas.push(memDelta)
}

process.stdout.write('.')
}
console.log(' done!\n')

// Calculate statistics
const initStats = calculateStats(initTimes)
const coldStats = calculateStats(coldDiagnosticsTimes)
const warmStats = calculateStats(warmDiagnosticsTimes)
const memStats = calculateStats(memoryDeltas)

// Print Results
console.log('=== Initialization ===')
console.table({
Mean: `${initStats.mean.toFixed(2)} ms`,
Median: `${initStats.median.toFixed(2)} ms`,
Min: `${initStats.min.toFixed(2)} ms`,
Max: `${initStats.max.toFixed(2)} ms`,
'Std Dev': `${initStats.stdDev.toFixed(2)} ms`,
P95: `${initStats.p95.toFixed(2)} ms`,
})

console.log('\n=== Cold Diagnostics (First Run) ===')
console.table({
Mean: `${coldStats.mean.toFixed(2)} ms`,
Median: `${coldStats.median.toFixed(2)} ms`,
Min: `${coldStats.min.toFixed(2)} ms`,
Max: `${coldStats.max.toFixed(2)} ms`,
'Std Dev': `${coldStats.stdDev.toFixed(2)} ms`,
P95: `${coldStats.p95.toFixed(2)} ms`,
})

console.log('\n=== Warm Diagnostics (Cached) ===')
console.table({
Mean: `${warmStats.mean.toFixed(2)} ms`,
Median: `${warmStats.median.toFixed(2)} ms`,
Min: `${warmStats.min.toFixed(2)} ms`,
Max: `${warmStats.max.toFixed(2)} ms`,
'Std Dev': `${warmStats.stdDev.toFixed(2)} ms`,
P95: `${warmStats.p95.toFixed(2)} ms`,
})

console.log('\n=== Memory Usage (Peak per Iteration) ===')
if (memoryDeltas.length > 0) {
console.table({
Mean: `${memStats.mean.toFixed(2)} MB`,
Median: `${memStats.median.toFixed(2)} MB`,
Min: `${memStats.min.toFixed(2)} MB`,
Max: `${memStats.max.toFixed(2)} MB`,
'Std Dev': `${memStats.stdDev.toFixed(2)} MB`,
P95: `${memStats.p95.toFixed(2)} MB`,
Samples: memoryDeltas.length.toString(),
})
} else {
console.log('No valid memory measurements (run with --expose-gc)')
}

console.log('\n=== Summary ===')
console.log(`Total Diagnostics Found: ${diagnosticCount}`)
console.log(
`Total Time per Cold Run: ${(initStats.mean + coldStats.mean).toFixed(2)} ms`,
)
console.log(
`Speedup (Cold → Warm): ${(coldStats.mean / warmStats.mean).toFixed(1)}x`,
)

console.log('\n=== Notes ===')
console.log('- Init: Time to create RWProject instance (lightweight)')
console.log(
'- Cold: Full AST parsing + diagnostics (most relevant for comparison)',
)
console.log('- Warm: Re-running diagnostics with ts-morph internal caching')
console.log(
'- Memory Peak: Heap allocated per iteration (negative deltas filtered out)',
)
console.log(
'\n⚠️ For accurate memory stats, run with: node --expose-gc benchmark.mts',
)
if (!gc) {
console.log(
' Memory measurements are unreliable without GC control enabled.',
)
}
}

benchmark().catch((err) => {
console.error('Benchmark failed:', err)
process.exit(1)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { schemaPath: 'db/schema.prisma' }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const posts = () => []
export const post = ({ id }: { id: number }) => ({ id, title: 'Post ' + id })
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "structure-test-project",
"private": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[web]
port = 8910
apiUrl = "/.redwood/functions"
[api]
port = 8911
[browser]
open = true
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Router, Route, Set } from '@cedarjs/router'

import MainLayout from 'src/layouts/MainLayout'

const Routes = () => {
return (
<Router>
<Set wrap={MainLayout} someProp="value" {...{ spread: 'prop' }}>
<Route path="/" page={HomePage} name="home" />
<Set wrap={MainLayout}>
<Route path="/nested" page={NestedPage} name="nested" />
</Set>
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}

export default Routes
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const QUERY = gql`
query UnusualCell($id: Int!) {
post(id: $id) {
id
title
}
}
`

export const Loading = () => <div>Loading...</div>

// Unusual Success export
const MySuccess = ({ post }) => <div>{post.title}</div>
export { MySuccess as Success }

export const Failure = ({ error }) => <div>{error.message}</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default ({ children }) => <main>{children}</main>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => <h1>Home</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => <h1>Nested</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => <h1>Not Found</h1>
Loading
Loading